浏览代码

added photo thumbnails, command for create thumbnails

Alexander Musikhin 20 小时之前
父节点
当前提交
9e35a4ffc9

+ 1 - 0
.env.example

@@ -47,6 +47,7 @@ DISK_SPACE_WARNING_CACHE_SECONDS=300
 BROADCAST_DRIVER=redis
 CACHE_DRIVER=redis
 FILESYSTEM_DISK=local
+PHOTO_THUMBNAIL_MAX_SIDE=150
 QUEUE_CONNECTION=redis
 SESSION_DRIVER=file
 SESSION_LIFETIME=120

+ 3 - 2
Dockerfile

@@ -17,6 +17,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
     libicu-dev \
     libfreetype6-dev \
     libjpeg-dev \
+    libwebp-dev \
     libpng-dev \
     libxml2-dev \
     unzip \
@@ -28,8 +29,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
 RUN docker-php-ext-install mysqli \
     && docker-php-ext-install pdo_mysql
 
-RUN docker-php-ext-install gd \
-    && docker-php-ext-configure gd \
+RUN docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \
+    && docker-php-ext-install gd \
     &&  docker-php-ext-enable gd
 
 RUN docker-php-ext-install zip \

+ 107 - 0
app/Console/Commands/EnsurePhotoThumbnails.php

@@ -0,0 +1,107 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Console\Commands;
+
+use App\Models\File;
+use App\Services\FileService;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Storage;
+
+class EnsurePhotoThumbnails extends Command
+{
+    protected $signature = 'photos:ensure-thumbnails
+                            {--overwrite : Пересоздать существующие миниатюры}
+                            {--dry-run : Только проверить, без создания миниатюр}';
+
+    protected $description = 'Проверяет наличие миниатюр для загруженных фотографий и создаёт отсутствующие';
+
+    public function handle(FileService $fileService): int
+    {
+        $dryRun = (bool) $this->option('dry-run');
+        $overwrite = (bool) $this->option('overwrite');
+        $checked = 0;
+        $missing = 0;
+        $created = 0;
+        $overwriteCandidates = 0;
+        $overwritten = 0;
+        $skipped = 0;
+        $failed = 0;
+        $missingSources = 0;
+
+        File::query()
+            ->where(function ($query): void {
+                $query->where('mime_type', 'like', 'image/%')
+                    ->orWhere('path', 'like', '%.jpg')
+                    ->orWhere('path', 'like', '%.jpeg')
+                    ->orWhere('path', 'like', '%.png')
+                    ->orWhere('path', 'like', '%.webp');
+            })
+            ->orderBy('id')
+            ->chunkById(100, function ($files) use ($fileService, $dryRun, $overwrite, &$checked, &$missing, &$created, &$overwriteCandidates, &$overwritten, &$skipped, &$failed, &$missingSources): void {
+                foreach ($files as $file) {
+                    if (!$fileService->isImageFile($file) || !$file->path || $fileService->isThumbnailPath($file->path)) {
+                        $skipped++;
+                        continue;
+                    }
+
+                    $checked++;
+
+                    if (!Storage::disk('public')->exists($file->path)) {
+                        $skipped++;
+                        $missingSources++;
+
+                        if ($this->getOutput()->isVerbose()) {
+                            $this->warn("Нет исходного файла: #{$file->id} {$file->path}");
+                        }
+
+                        continue;
+                    }
+
+                    $thumbnailPath = $fileService->thumbnailPath($file->path);
+                    if (Storage::disk('public')->exists($thumbnailPath)) {
+                        if (!$overwrite) {
+                            continue;
+                        }
+
+                        $overwriteCandidates++;
+
+                        if ($dryRun) {
+                            $this->line("Будет перезаписана миниатюра: #{$file->id} {$thumbnailPath}");
+                            continue;
+                        }
+
+                        if ($fileService->ensureThumbnail($file, true)) {
+                            $overwritten++;
+                            $this->line("Перезаписана миниатюра: #{$file->id} {$thumbnailPath}");
+                        } else {
+                            $failed++;
+                            $this->warn("Не удалось перезаписать миниатюру: #{$file->id} {$file->path}");
+                        }
+
+                        continue;
+                    }
+
+                    $missing++;
+
+                    if ($dryRun) {
+                        $this->line("Нет миниатюры: #{$file->id} {$thumbnailPath}");
+                        continue;
+                    }
+
+                    if ($fileService->ensureThumbnail($file)) {
+                        $created++;
+                        $this->line("Создана миниатюра: #{$file->id} {$thumbnailPath}");
+                    } else {
+                        $failed++;
+                        $this->warn("Не удалось создать миниатюру: #{$file->id} {$file->path}");
+                    }
+                }
+            });
+
+        $this->info("Проверено фото: {$checked}. Нет миниатюр: {$missing}. Создано: {$created}. К перезаписи: {$overwriteCandidates}. Перезаписано: {$overwritten}. Пропущено: {$skipped}. Нет исходников: {$missingSources}. Ошибок: {$failed}.");
+
+        return $failed > 0 ? self::FAILURE : self::SUCCESS;
+    }
+}

+ 3 - 3
app/Http/Controllers/OrderController.php

@@ -588,17 +588,17 @@ class OrderController extends Controller
     public function deletePhoto(Order $order, File $file, FileService $fileService)
     {
         $order->photos()->detach($file);
-        Storage::disk('public')->delete($file->path);
+        $fileService->deleteFileWithThumbnail($file);
         $file->delete();
         return redirect()->route('order.show', $order);
     }
 
-    public function deleteAllPhotos(Order $order)
+    public function deleteAllPhotos(Order $order, FileService $fileService)
     {
         $files = $order->photos;
         $order->photos()->detach();
         foreach ($files as $file) {
-            Storage::disk('public')->delete($file->path);
+            $fileService->deleteFileWithThumbnail($file);
             $file->delete();
         }
         return redirect()->route('order.show', $order);

+ 2 - 2
app/Http/Controllers/ReclamationController.php

@@ -282,7 +282,7 @@ class ReclamationController extends Controller
         $this->ensureCanViewReclamation($reclamation);
 
         $reclamation->photos_before()->detach($file);
-        Storage::disk('public')->delete($file->path);
+        $fileService->deleteFileWithThumbnail($file);
         $file->delete();
         return $this->redirectToReclamationShow($request, $reclamation);
     }
@@ -293,7 +293,7 @@ class ReclamationController extends Controller
         $this->ensureCanViewReclamation($reclamation);
 
         $reclamation->photos_after()->detach($file);
-        Storage::disk('public')->delete($file->path);
+        $fileService->deleteFileWithThumbnail($file);
         $file->delete();
         return $this->redirectToReclamationShow($request, $reclamation);
     }

+ 35 - 0
app/Models/File.php

@@ -6,6 +6,8 @@ use Illuminate\Database\Eloquent\Casts\Attribute;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Str;
 
 class File extends Model
 {
@@ -39,4 +41,37 @@ class File extends Model
         );
     }
 
+    public function thumbnailPath(): Attribute
+    {
+        return Attribute::make(
+            get: function () {
+                if (!$this->path || Str::contains(pathinfo($this->path, PATHINFO_FILENAME), '.thumbnail')) {
+                    return $this->path;
+                }
+
+                $directory = pathinfo($this->path, PATHINFO_DIRNAME);
+                $filename = pathinfo($this->path, PATHINFO_FILENAME);
+                $extension = pathinfo($this->path, PATHINFO_EXTENSION);
+                $thumbnailName = $filename . '.thumbnail' . ($extension !== '' ? '.' . $extension : '');
+
+                return $directory === '.' ? $thumbnailName : $directory . '/' . $thumbnailName;
+            },
+        );
+    }
+
+    public function thumbnailLink(): Attribute
+    {
+        return Attribute::make(
+            get: function () {
+                $thumbnailPath = $this->thumbnail_path;
+
+                if ($thumbnailPath && Storage::disk('public')->exists($thumbnailPath)) {
+                    return url('/storage/' . $thumbnailPath);
+                }
+
+                return $this->link;
+            },
+        );
+    }
+
 }

+ 118 - 1
app/Services/FileService.php

@@ -30,13 +30,130 @@ class FileService
             throw new RuntimeException('Не удалось сохранить файл. Проверьте имя файла и повторите попытку.', 0, $e);
         }
 
-        return File::query()->create([
+        $fileModel = File::query()->create([
             'link' => url('/storage/' . $relativePath),
             'path' => $relativePath,
             'user_id' => auth()->user()->id,
             'original_name' => $originalName,
             'mime_type' => $file->getClientMimeType(),
         ]);
+
+        $this->ensureThumbnail($fileModel);
+
+        return $fileModel;
+    }
+
+    public function ensureThumbnail(File $file, bool $overwrite = false): bool
+    {
+        if (!$this->isImageFile($file) || !$file->path || $this->isThumbnailPath($file->path)) {
+            return false;
+        }
+
+        $disk = Storage::disk('public');
+        if (!$disk->exists($file->path)) {
+            return false;
+        }
+
+        $thumbnailPath = $this->thumbnailPath($file->path);
+        if ($disk->exists($thumbnailPath) && !$overwrite) {
+            return true;
+        }
+
+        return $this->createThumbnail($file->path, $thumbnailPath);
+    }
+
+    public function deleteFileWithThumbnail(File $file): void
+    {
+        if ($file->path) {
+            Storage::disk('public')->delete($file->path);
+
+            if (!$this->isThumbnailPath($file->path)) {
+                Storage::disk('public')->delete($this->thumbnailPath($file->path));
+            }
+        }
+    }
+
+    public function thumbnailPath(string $path): string
+    {
+        $directory = pathinfo($path, PATHINFO_DIRNAME);
+        $filename = pathinfo($path, PATHINFO_FILENAME);
+        $extension = pathinfo($path, PATHINFO_EXTENSION);
+        $thumbnailName = $filename . '.thumbnail' . ($extension !== '' ? '.' . $extension : '');
+
+        return $directory === '.' ? $thumbnailName : $directory . '/' . $thumbnailName;
+    }
+
+    public function isThumbnailPath(string $path): bool
+    {
+        return Str::contains(pathinfo($path, PATHINFO_FILENAME), '.thumbnail');
+    }
+
+    public function isImageFile(File $file): bool
+    {
+        if (in_array((string) $file->mime_type, ['image/jpeg', 'image/png', 'image/webp'], true)) {
+            return true;
+        }
+
+        return in_array(Str::lower(pathinfo((string) $file->path, PATHINFO_EXTENSION)), ['jpg', 'jpeg', 'png', 'webp'], true);
+    }
+
+    private function createThumbnail(string $sourcePath, string $thumbnailPath): bool
+    {
+        if (!extension_loaded('gd')) {
+            return false;
+        }
+
+        $disk = Storage::disk('public');
+        $sourceFullPath = $disk->path($sourcePath);
+        $imageInfo = @getimagesize($sourceFullPath);
+
+        if (!$imageInfo || empty($imageInfo[0]) || empty($imageInfo[1])) {
+            return false;
+        }
+
+        [$width, $height, $type] = $imageInfo;
+        $source = match ($type) {
+            IMAGETYPE_JPEG => function_exists('\imagecreatefromjpeg') ? @\imagecreatefromjpeg($sourceFullPath) : false,
+            IMAGETYPE_PNG => function_exists('\imagecreatefrompng') ? @\imagecreatefrompng($sourceFullPath) : false,
+            IMAGETYPE_WEBP => function_exists('\imagecreatefromwebp') ? @\imagecreatefromwebp($sourceFullPath) : false,
+            default => false,
+        };
+
+        if (!$source) {
+            return false;
+        }
+
+        $maxSide = $this->thumbnailMaxSide();
+        $scale = min(1, $maxSide / max($width, $height));
+        $thumbnailWidth = max(1, (int) round($width * $scale));
+        $thumbnailHeight = max(1, (int) round($height * $scale));
+        $thumbnail = \imagecreatetruecolor($thumbnailWidth, $thumbnailHeight);
+
+        if (in_array($type, [IMAGETYPE_PNG, IMAGETYPE_WEBP], true)) {
+            \imagealphablending($thumbnail, false);
+            \imagesavealpha($thumbnail, true);
+        }
+
+        \imagecopyresampled($thumbnail, $source, 0, 0, 0, 0, $thumbnailWidth, $thumbnailHeight, $width, $height);
+        $disk->makeDirectory(dirname($thumbnailPath));
+        $thumbnailFullPath = $disk->path($thumbnailPath);
+
+        $saved = match ($type) {
+            IMAGETYPE_JPEG => function_exists('\imagejpeg') && \imagejpeg($thumbnail, $thumbnailFullPath, 82),
+            IMAGETYPE_PNG => function_exists('\imagepng') && \imagepng($thumbnail, $thumbnailFullPath, 7),
+            IMAGETYPE_WEBP => function_exists('\imagewebp') && \imagewebp($thumbnail, $thumbnailFullPath, 82),
+            default => false,
+        };
+
+        \imagedestroy($source);
+        \imagedestroy($thumbnail);
+
+        return (bool) $saved;
+    }
+
+    private function thumbnailMaxSide(): int
+    {
+        return max(1, (int) config('filesystems.photo_thumbnail_max_side', 150));
     }
 
     private function sanitizeOriginalName(string $originalName): string

+ 2 - 0
config/filesystems.php

@@ -15,6 +15,8 @@ return [
 
     'default' => env('FILESYSTEM_DISK', 'local'),
 
+    'photo_thumbnail_max_side' => (int) env('PHOTO_THUMBNAIL_MAX_SIDE', 150),
+
     /*
     |--------------------------------------------------------------------------
     | Filesystem Disks

+ 18 - 0
resources/js/custom.js

@@ -772,6 +772,23 @@ $(document).ready(function () {
         }
     }
 
+    function initLazyCollapseImages() {
+        function loadImages(container) {
+            container.querySelectorAll('img[data-src]').forEach(function (image) {
+                image.setAttribute('src', image.dataset.src);
+                image.removeAttribute('data-src');
+            });
+        }
+
+        document.querySelectorAll('[data-lazy-images].show').forEach(loadImages);
+
+        document.addEventListener('shown.bs.collapse', function (event) {
+            if (event.target.matches('[data-lazy-images]')) {
+                loadImages(event.target);
+            }
+        });
+    }
+
     function getNotificationBadge() {
         return document.getElementById('notification-badge');
     }
@@ -861,6 +878,7 @@ $(document).ready(function () {
     cleanupStaleModalState();
     initQueuedUploads();
     initMobileDatePicker();
+    initLazyCollapseImages();
     window.addEventListener('pageshow', cleanupStaleModalState);
 
     if ($('.main-alert').length) {

+ 2 - 2
resources/views/orders/show.blade.php

@@ -201,12 +201,12 @@
                             @method('DELETE')
                         </form>
                     @endif
-                    <div class="row my-2 g-1 collapse" id="photos">
+                    <div class="row my-2 g-1 collapse" id="photos" data-lazy-images>
                         @foreach($order->photos as $photo)
                             <div class="col-4">
                                 <a href="{{ $photo->link }}"
                                    data-toggle="lightbox" data-gallery="photos" data-size="fullscreen">
-                                    <img class="img-thumbnail" src="{{ $photo->link }}" alt="">
+                                    <img class="img-thumbnail" data-src="{{ $photo->thumbnail_link }}" alt="">
                                 </a>
                                 @if(hasRole('admin,manager'))
                                     <i class="bi bi-x-circle-fill fs-6 text-danger cursor-pointer rm-but"

+ 4 - 4
resources/views/reclamations/edit.blade.php

@@ -611,12 +611,12 @@
                                    name="photo[]" class="form-control form-control-sm" accept=".jpg,.jpeg,.png,.webp">
                         </form>
                     @endif
-                    <div class="row my-2 g-1 collapse" id="photos_before">
+                    <div class="row my-2 g-1 collapse" id="photos_before" data-lazy-images>
                         @foreach($reclamation->photos_before as $photo)
                             <div class="col-4">
                                 <a href="{{ $photo->link }}"
                                    data-toggle="lightbox" data-gallery="photos-before" data-size="fullscreen">
-                                    <img class="img-thumbnail" src="{{ $photo->link }}" alt="">
+                                    <img class="img-thumbnail" data-src="{{ $photo->thumbnail_link }}" alt="">
                                 </a>
                                 @if(hasRole('admin,manager'))
                                     <i class="bi bi-x-circle-fill fs-6 text-danger cursor-pointer rm-but"
@@ -658,12 +658,12 @@
                                    name="photo[]" class="form-control form-control-sm" accept=".jpg,.jpeg,.png,.webp">
                         </form>
                     @endif
-                    <div class="row my-2 g-1 collapse" id="photos_after">
+                    <div class="row my-2 g-1 collapse" id="photos_after" data-lazy-images>
                         @foreach($reclamation->photos_after as $photo)
                             <div class="col-4">
                                 <a href="{{ $photo->link }}"
                                    data-toggle="lightbox" data-gallery="photos-after" data-size="fullscreen">
-                                    <img class="img-thumbnail" src="{{ $photo->link }}" alt="">
+                                    <img class="img-thumbnail" data-src="{{ $photo->thumbnail_link }}" alt="">
                                 </a>
                                 @if(hasRole('admin,manager'))
                                     <i class="bi bi-x-circle-fill fs-6 text-danger cursor-pointer rm-but"