Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
36.54% covered (danger)
36.54%
95 / 260
36.36% covered (danger)
36.36%
4 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
ImageGalleriesController
36.54% covered (danger)
36.54%
95 / 260
36.36% covered (danger)
36.36%
4 / 11
492.85
0.00% covered (danger)
0.00%
0 / 1
 index
100.00% covered (success)
100.00%
42 / 42
100.00% covered (success)
100.00%
1 / 1
6
 view
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 add
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 edit
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
5.01
 delete
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 manageImages
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 addImages
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
72
 removeImage
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
12
 updateImageOrder
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
20
 picker
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
6
 _processUploadsAndSetFlash
10.71% covered (danger)
10.71%
3 / 28
0.00% covered (danger)
0.00%
0 / 1
41.88
1<?php
2declare(strict_types=1);
3
4namespace App\Controller\Admin;
5
6use App\Controller\AppController;
7use App\Controller\Component\MediaPickerTrait;
8use App\Service\ImageProcessingService;
9use App\Utility\ArchiveExtractor;
10use Cake\Http\Response;
11
12/**
13 * ImageGalleries Controller
14 *
15 * @property \App\Model\Table\ImageGalleriesTable $ImageGalleries
16 */
17class ImageGalleriesController extends AppController
18{
19    use MediaPickerTrait;
20
21    /**
22     * Index method
23     *
24     * @return \Cake\Http\Response|null|void Renders view
25     */
26    public function index(): ?Response
27    {
28        $session = $this->request->getSession();
29        $viewType = $this->request->getQuery('view');
30
31        // Handle view switching with session persistence
32        if ($viewType) {
33            $session->write('ImageGalleries.viewType', $viewType);
34        } else {
35            $viewType = $session->read('ImageGalleries.viewType', 'grid'); // Default to grid for galleries
36        }
37
38        $query = $this->ImageGalleries->find()
39            ->select([
40                'ImageGalleries.id',
41                'ImageGalleries.name',
42                'ImageGalleries.slug',
43                'ImageGalleries.description',
44                'ImageGalleries.preview_image',
45                'ImageGalleries.is_published',
46                'ImageGalleries.created',
47                'ImageGalleries.modified',
48                'ImageGalleries.created_by',
49                'ImageGalleries.modified_by',
50            ]);
51
52        // Load images for both views - grid needs all for slideshow, list needs thumbnails
53        $query->contain([
54            'Images' => function ($q) {
55                return $q->orderBy(['ImageGalleriesImages.position' => 'ASC']);
56                // Load all images so slideshow shows complete gallery in grid view
57                // and list view has images for thumbnails and popovers
58            },
59        ]);
60
61        // Handle status filter
62        $statusFilter = $this->request->getQuery('status');
63        if ($statusFilter !== null) {
64            $query->where(['ImageGalleries.is_published' => (bool)$statusFilter]);
65        }
66
67        // Handle search
68        $search = $this->request->getQuery('search');
69        if (!empty($search)) {
70            $query->where([
71                'OR' => [
72                    'ImageGalleries.name LIKE' => '%' . $search . '%',
73                    'ImageGalleries.slug LIKE' => '%' . $search . '%',
74                    'ImageGalleries.description LIKE' => '%' . $search . '%',
75                ],
76            ]);
77        }
78
79        $imageGalleries = $this->paginate($query);
80
81        // Handle AJAX requests
82        if ($this->request->is('ajax')) {
83            $this->set(compact('imageGalleries', 'viewType', 'search', 'statusFilter'));
84            $this->viewBuilder()->setLayout('ajax');
85
86            return $this->render('search_results');
87        }
88
89        $this->set(compact('imageGalleries', 'viewType'));
90
91        // Return appropriate template based on view type
92        return $this->render($viewType === 'grid' ? 'index_grid' : 'index');
93    }
94
95    /**
96     * View method
97     *
98     * @param string|null $id Image Gallery id.
99     * @return \Cake\Http\Response|null|void Renders view
100     * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
101     */
102    public function view(?string $id = null): ?Response
103    {
104        $imageGallery = $this->ImageGalleries->get($id, contain: [
105            'Images' => [
106                'sort' => ['ImageGalleriesImages.position' => 'ASC'],
107            ],
108            'Slugs',
109        ]);
110        $this->set(compact('imageGallery'));
111
112        return null;
113    }
114
115    /**
116     * Add method
117     *
118     * @return \Cake\Http\Response|null|void Redirects on successful add, renders view otherwise.
119     */
120    public function add(): ?Response
121    {
122        $imageGallery = $this->ImageGalleries->newEmptyEntity();
123        if ($this->request->is('post')) {
124            $imageGallery = $this->ImageGalleries->patchEntity($imageGallery, $this->request->getData());
125            if ($this->ImageGalleries->save($imageGallery)) {
126                // Handle file uploads if provided
127                $uploadedFiles = $this->request->getUploadedFiles();
128                $this->_processUploadsAndSetFlash($uploadedFiles, $imageGallery->id, 'saved');
129
130                return $this->redirect(['action' => 'index']);
131            }
132            $this->Flash->error(__('The image gallery could not be saved. Please, try again.'));
133        }
134        $images = $this->ImageGalleries->Images->find('list', limit: 200)->all();
135        $this->set(compact('imageGallery', 'images'));
136
137        return null;
138    }
139
140    /**
141     * Edit method
142     *
143     * @param string|null $id Image Gallery id.
144     * @return \Cake\Http\Response|null|void Redirects on successful edit, renders view otherwise.
145     * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
146     */
147    public function edit(?string $id = null): ?Response
148    {
149        $imageGallery = $this->ImageGalleries->get($id, contain: ['Images']);
150        if ($this->request->is(['patch', 'post', 'put'])) {
151            $imageGallery = $this->ImageGalleries->patchEntity($imageGallery, $this->request->getData());
152
153            // Handle file uploads if provided
154            $uploadedFiles = $this->request->getUploadedFiles();
155            if (!empty($uploadedFiles['image_files'])) {
156                $this->_processUploadsAndSetFlash($uploadedFiles, $imageGallery->id, 'updated');
157            }
158
159            if ($this->ImageGalleries->save($imageGallery)) {
160                if (empty($uploadedFiles['image_files'])) {
161                    $this->Flash->success(__('The image gallery has been saved.'));
162                }
163
164                return $this->redirect(['action' => 'index']);
165            }
166            $this->Flash->error(__('The image gallery could not be saved. Please, try again.'));
167        }
168        $images = $this->ImageGalleries->Images->find('list', limit: 200)->all();
169        $this->set(compact('imageGallery', 'images'));
170
171        return null;
172    }
173
174    /**
175     * Delete method
176     *
177     * @param string|null $id Image Gallery id.
178     * @return \Cake\Http\Response|null Redirects to index.
179     * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
180     */
181    public function delete(?string $id = null): ?Response
182    {
183        $this->request->allowMethod(['post', 'delete']);
184        $imageGallery = $this->ImageGalleries->get($id);
185        if ($this->ImageGalleries->delete($imageGallery)) {
186            $this->Flash->success(__('The image gallery has been deleted.'));
187        } else {
188            $this->Flash->error(__('The image gallery could not be deleted. Please, try again.'));
189        }
190
191        return $this->redirect($this->referer(['action' => 'index']));
192    }
193
194    /**
195     * Manage images in a gallery - drag and drop interface
196     *
197     * @param string|null $id Gallery id.
198     * @return \Cake\Http\Response|null|void Renders view
199     * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
200     */
201    public function manageImages(?string $id = null): ?Response
202    {
203        $imageGallery = $this->ImageGalleries->get($id, contain: [
204            'ImageGalleriesImages' => [
205                'finder' => 'ordered',
206                'Images' => [
207                    'conditions' => [
208                        'Images.image IS NOT' => null,
209                        'Images.image !=' => '',
210                    ],
211                ],
212            ],
213        ]);
214
215        $this->set(compact('imageGallery'));
216
217        return null;
218    }
219
220    /**
221     * Add images to a gallery (AJAX endpoint)
222     *
223     * @param string|null $id Gallery id.
224     * @return \Cake\Http\Response JSON response
225     * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
226     */
227    public function addImages(?string $id = null): Response
228    {
229        $this->request->allowMethod(['post']);
230
231        $imageIds = $this->request->getData('image_ids', []);
232
233        if (empty($imageIds)) {
234            // For AJAX requests, return JSON
235            if ($this->request->is('ajax')) {
236                $response = [
237                    'success' => false,
238                    'message' => __('No images selected'),
239                ];
240
241                return $this->getResponse()
242                    ->withType('application/json')
243                    ->withStatus(400)
244                    ->withStringBody(json_encode($response));
245            }
246
247            // For regular requests, redirect with flash message
248            $this->Flash->error(__('No images selected'));
249
250            return $this->redirect(['action' => 'manageImages', $id]);
251        }
252
253        $galleriesImagesTable = $this->fetchTable('ImageGalleriesImages');
254
255        $added = 0;
256        foreach ($imageIds as $imageId) {
257            // Check if image is already in gallery
258            $exists = $galleriesImagesTable->exists([
259                'image_gallery_id' => $id,
260                'image_id' => $imageId,
261            ]);
262
263            if (!$exists) {
264                $position = $galleriesImagesTable->getNextPosition($id);
265                $galleryImage = $galleriesImagesTable->newEntity([
266                    'image_gallery_id' => $id,
267                    'image_id' => $imageId,
268                    'position' => $position,
269                ]);
270
271                if ($galleriesImagesTable->save($galleryImage)) {
272                    $added++;
273                }
274            }
275        }
276
277        // For AJAX requests, return JSON
278        if ($this->request->is('ajax')) {
279            $response = [
280                'success' => true,
281                'message' => __('Added {0} images to gallery', $added),
282                'added_count' => $added,
283            ];
284
285            return $this->getResponse()
286                ->withType('application/json')
287                ->withStringBody(json_encode($response));
288        }
289
290        // For regular requests, redirect with flash message
291        if ($added > 0) {
292            $this->Flash->success(__('Added {0} images to gallery', $added));
293        } else {
294            $this->Flash->warning(__('No new images were added (they may already be in the gallery)'));
295        }
296
297        return $this->redirect(['action' => 'manageImages', $id]);
298    }
299
300    /**
301     * Remove image from gallery (AJAX endpoint)
302     *
303     * @param string|null $id Gallery id.
304     * @param string|null $imageId Image id.
305     * @return \Cake\Http\Response JSON response
306     */
307    public function removeImage(?string $id = null, ?string $imageId = null): Response
308    {
309        $this->request->allowMethod(['delete']);
310
311        $galleriesImagesTable = $this->fetchTable('ImageGalleriesImages');
312
313        $galleryImage = $galleriesImagesTable->find()
314            ->where([
315                'image_gallery_id' => $id,
316                'image_id' => $imageId,
317            ])
318            ->first();
319
320        if (!$galleryImage) {
321            $response = [
322                'success' => false,
323                'message' => __('Image not found in gallery'),
324            ];
325
326            return $this->getResponse()
327                ->withType('application/json')
328                ->withStatus(404)
329                ->withStringBody(json_encode($response));
330        }
331
332        if ($galleriesImagesTable->delete($galleryImage)) {
333            $response = [
334                'success' => true,
335                'message' => __('Image removed from gallery'),
336            ];
337        } else {
338            $response = [
339                'success' => false,
340                'message' => __('Failed to remove image from gallery'),
341            ];
342        }
343
344        return $this->getResponse()
345            ->withType('application/json')
346            ->withStringBody(json_encode($response));
347    }
348
349    /**
350     * Update image order in gallery (AJAX endpoint)
351     *
352     * @return \Cake\Http\Response JSON response
353     */
354    public function updateImageOrder(): Response
355    {
356        $this->request->allowMethod(['post']);
357
358        $galleryId = $this->request->getData('gallery_id');
359        $imageIds = $this->request->getData('image_ids', []);
360
361        if (empty($galleryId) || empty($imageIds)) {
362            $response = [
363                'success' => false,
364                'message' => __('Invalid data provided'),
365            ];
366
367            return $this->getResponse()
368                ->withType('application/json')
369                ->withStatus(400)
370                ->withStringBody(json_encode($response));
371        }
372
373        $galleriesImagesTable = $this->fetchTable('ImageGalleriesImages');
374
375        if ($galleriesImagesTable->reorderImages($galleryId, $imageIds)) {
376            $response = [
377                'success' => true,
378                'message' => __('Image order updated'),
379            ];
380        } else {
381            $response = [
382                'success' => false,
383                'message' => __('Failed to update image order'),
384            ];
385        }
386
387        return $this->getResponse()
388            ->withType('application/json')
389            ->withStringBody(json_encode($response));
390    }
391
392    /**
393     * Gallery picker for selecting galleries to insert into content
394     * Uses MediaPickerTrait for DRY implementation
395     *
396     * @return \\Cake\\Http\\Response|null|void Renders view
397     */
398    public function picker(): ?Response
399    {
400        // Build query with trait helper
401        $selectFields = [
402            'ImageGalleries.id',
403            'ImageGalleries.name',
404            'ImageGalleries.slug',
405            'ImageGalleries.description',
406            'ImageGalleries.preview_image',
407            'ImageGalleries.is_published',
408            'ImageGalleries.created',
409            'ImageGalleries.modified',
410        ];
411
412        $query = $this->buildPickerQuery($this->ImageGalleries, $selectFields, [
413            'contain' => [
414                'Images' => function ($q) {
415                    return $q->select(['Images.id', 'Images.name', 'Images.image', 'Images.dir', 'Images.alt_text'])
416                             ->limit(4) // Show first 4 images for preview
417                             ->orderBy(['ImageGalleriesImages.position' => 'ASC']);
418                },
419            ],
420        ]);
421
422        // Handle search with trait helper
423        $search = $this->request->getQuery('search');
424        $searchFields = [
425            'ImageGalleries.name',
426            'ImageGalleries.slug',
427            'ImageGalleries.description',
428        ];
429        $query = $this->handlePickerSearch($query, $search, $searchFields);
430
431        // Setup pagination with trait helper
432        $limit = $this->getRequestLimit(8, 24);
433        $page = $this->getRequestPage();
434
435        $galleries = $this->paginate($query, [
436            'limit' => $limit,
437            'page' => $page,
438        ]);
439
440        // Set variables for template (template expects $results)
441        $results = $galleries;
442        $this->set(compact('results', 'search'));
443        $this->set('_serialize', ['results', 'search']);
444
445        // Check if this is a search request that should only return results HTML
446        $galleryOnly = $this->request->getQuery('gallery_only');
447        if ($galleryOnly) {
448            // For search requests, only return the results portion to avoid flicker
449            $this->viewBuilder()->setTemplate('picker_results');
450        } else {
451            // For initial load, return the full template with search form
452            $this->viewBuilder()->setTemplate('picker');
453        }
454
455        // Use AJAX view for modal content
456        $this->viewBuilder()->setLayout('ajax');
457
458        return null;
459    }
460
461    /**
462     * Process uploaded files and set appropriate flash messages
463     *
464     * @param array $uploadedFiles Array of uploaded files
465     * @param string $galleryId Gallery ID to associate files with
466     * @param string $action Action being performed (saved|updated)
467     * @return void
468     */
469    private function _processUploadsAndSetFlash(array $uploadedFiles, string $galleryId, string $action): void
470    {
471        if (empty($uploadedFiles['image_files'])) {
472            $this->Flash->success(__('The image gallery has been {0}.', $action));
473
474            return;
475        }
476
477        $uploadService = new ImageProcessingService(
478            $this->fetchTable('Images'),
479            $this->fetchTable('ImageGalleriesImages'),
480            new ArchiveExtractor(),
481        );
482
483        $result = $uploadService->processUploadedFiles($uploadedFiles['image_files'], $galleryId);
484
485        // Set flash message based on results
486        if ($result['success_count'] > 0 && $result['error_count'] === 0) {
487            $this->Flash->success(__(
488                'Gallery {0} with {1} image(s) uploaded successfully.',
489                $action,
490                $result['success_count'],
491            ));
492        } elseif ($result['success_count'] > 0 && $result['error_count'] > 0) {
493            $this->Flash->success(__(
494                'Gallery {0} with {1} image(s) uploaded. {2} failed to upload.',
495                $action,
496                $result['success_count'],
497                $result['error_count'],
498            ));
499        } elseif ($result['error_count'] > 0) {
500            $this->Flash->warning(__(
501                'Gallery {0}, but all {1} image(s) failed to upload.',
502                $action,
503                $result['error_count'],
504            ));
505        }
506    }
507}