JavaScript + Laravel(Blade)での画像プレビュー & 削除機能のコード：編集(edit)

# サンプル画像
![スクリーンショット 2025-02-26 22.55.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3726068/50a330ab-3af1-4f07-8e7f-0acdc214bbd6.png)

# 機能
・画像追加(複数OK)
・サムネイルの順序変更(ドラッグ&ドロップ)
・画像削除

# edit.blade.php
```edit.php
<x-app-layout>
  <x-slot name="header">
      <h2 class="font-semibold text-xl text-gray-800 leading-tight">
          ポートフォリオ編集
      </h2>
  </x-slot>

  <div class="py-12">
      <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
          <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
              <div class="p-6 text-gray-900">
                <section class="text-gray-600 body-font relative">

                    {{-- フォーム --}}
                    <form id="editForm" action="{{ route('collections.update', ['collection' => $collection->id ]) }}" method="POST" enctype="multipart/form-data">
                        @csrf
                        @method('PUT')
                    <div class="container px-5 mx-auto">
                      <div class="lg:w-1/2 md:w-2/3 mx-auto">
                        <div class="flex flex-wrap -m-2">
                          <div class="p-2 w-full">
                            <div class="relative">
                              <x-input-error :messages="$errors->get('title')" class="mt-2" />
                              <label for="title" class="leading-7 text-sm text-gray-600">タイトル</label>
                              <input type="text" id="title" name="title" value="{{ $collection->title }}" class="w-full bg-gray-100 bg-opacity-50 rounded border border-gray-300 focus:border-indigo-500 focus:bg-white focus:ring-2 focus:ring-indigo-200 text-base outline-none text-gray-700 py-1 px-3 leading-8 transition-colors duration-200 ease-in-out">
                            </div>
                          </div>
                          <div class="p-2 w-full">
                            <div class="relative">
                              <x-input-error :messages="$errors->get('description')" class="mt-2" />
                              <label for="description" class="leading-7 text-sm text-gray-600">アプリ解説</label>
                              <textarea id="description" name="description" class="w-full bg-gray-100 bg-opacity-50 rounded border border-gray-300 focus:border-indigo-500 focus:bg-white focus:ring-2 focus:ring-indigo-200 h-32 text-base outline-none text-gray-700 py-1 px-3 resize-none leading-6 transition-colors duration-200 ease-in-out">{{ $collection->description }}</textarea>
                            </div>
                          </div>
                          <div class="p-2 w-full">
                            <div class="relative">
                              <x-input-error :messages="$errors->get('url_qiita')" class="mt-2" />
                              <label for="url_qiita" class="leading-7 text-sm text-gray-600">Qiita URL</label>
                              <input type="url" id="url_qiita" name="url_qiita" value="{{ $collection->url_qiita }}" class="w-full bg-gray-100 bg-opacity-50 rounded border border-gray-300 focus:border-indigo-500 focus:bg-white focus:ring-2 focus:ring-indigo-200 text-base outline-none text-gray-700 py-1 px-3 leading-8 transition-colors duration-200 ease-in-out">
                            </div>
                          </div>
                          <div class="p-2 w-full">
                            <div class="relative">
                              <x-input-error :messages="$errors->get('url_webapp')" class="mt-2" />
                              <label for="url_webapp" class="leading-7 text-sm text-gray-600">WebApp URL</label>
                              <input type="url" id="url_webapp" name="url_webapp" value="{{ $collection->url_webapp }}" class="w-full bg-gray-100 bg-opacity-50 rounded border border-gray-300 focus:border-indigo-500 focus:bg-white focus:ring-2 focus:ring-indigo-200 text-base outline-none text-gray-700 py-1 px-3 leading-8 transition-colors duration-200 ease-in-out">
                            </div>
                          </div>
                          <div class="p-2 w-full">
                            <div class="relative">
                              <x-input-error :messages="$errors->get('url_github')" class="mt-2" />
                              <label for="url_github" class="leading-7 text-sm text-gray-600">GitHub URL</label>
                              <input type="url" id="url_github" name="url_github" value="{{ $collection->github }}" class="w-full bg-gray-100 bg-opacity-50 rounded border border-gray-300 focus:border-indigo-500 focus:bg-white focus:ring-2 focus:ring-indigo-200 text-base outline-none text-gray-700 py-1 px-3 leading-8 transition-colors duration-200 ease-in-out">
                            </div>
                          </div>
                          <div class="p-2 w-full">
                            <div class="relative">
                              <x-input-error :messages="$errors->get('is_public')" class="mt-2" />
                              <label for="is_public" class="leading-7 text-sm text-gray-600">公開種別</label>
                              <input type="radio" name="is_public" value="0" @if($collection->is_public === 0) checked @endif>非公開
                              <input type="radio" name="is_public" value="1" @if($collection->is_public === 1) checked @endif>一般公開
                            </div>
                          </div>
                          <div class="p-2 w-full">
                            <div class="relative">
                              <x-input-error :messages="$errors->get('position')" class="mt-2" />
                              <label for="position" class="leading-7 text-sm text-gray-600">表示優先度</label>
                              <select name="position" id="position" class="rounded-md">
                                <option value="">選択してください</option>
                                <option value="0" @if($collection->position === 0) selected @endif>デフォルト</option>
                                <option value="1" @if($collection->position === 1) selected @endif>1ページ目</option>
                                <option value="2" @if($collection->position === 2) selected @endif>topページ</option>
                              </select>
                            </div>
                          </div>
                          {{-- 画像 --}}
                          <div class="p-2 w-full">
                            <div class="relative">
                                <label class="leading-7 text-sm text-gray-600">画像</label>
                                {{-- 大きなプレビュー画像 --}}
                                <div id="mainImageContainer" class="flex justify-center mt-4 {{ $collection->collection_image->isNotEmpty() ? '' : 'hidden' }}">
                                    <img id="mainImage" class="w-4/5 lg:w-3/5 h-auto object-cover border rounded-lg"
                                         src="{{ $collection->collection_image->isNotEmpty() ? asset('storage/collection_images/' . $collection->collection_image->first()->image_path) : asset('storage/collection_images/noImage.jpg') }}"
                                         alt="メイン画像">
                                </div>
                                {{-- サムネイル一覧 --}}
                                <div class="relative mt-4">
                                    <label class="leading-7 text-sm text-gray-600">サムネイル一覧：</label>
                                    <div id="imagePreviewContainer" class="grid grid-cols-3 gap-3 md:grid-cols-4 lg:grid-cols-5 md:gap-4 w-full place-items-center">
                                        @foreach ($collection->collection_image as $image)
                                            <div class="relative w-24 h-24" data-image-id="{{ $image->id }}">
                                                <img src="{{ asset('storage/collection_images/' . $image->image_path) }}"
                                                     class="w-full h-full object-cover cursor-pointer border border-gray-300 rounded-lg hover:border-indigo-500 transition">
                                            </div>
                                        @endforeach
                                    </div>
                                </div>
                                {{-- 新しい画像アップロード --}}
                                <div class="relative mt-4">
                                    <label class="leading-7 text-sm text-gray-600">新しい画像を追加：</label>
                                    <input multiple type="file" id="image_path" name="image_path[]" class="hidden" accept="image/*">
                                    <br>
                                    <label for="image_path" class="file-upload-btn inline-block px-4 py-1 text-sm text-gray-800 bg-gray-100 border border-gray-300 rounded-md shadow-sm cursor-pointer hover:bg-gray-200 active:bg-gray-300 transition">
                                        ファイルを選択
                                    </label>
                                </div>
                            </div>
                          </div>
                        

                          <div class="w-full mt-8">
                            <button class="flex mx-auto text-white bg-indigo-500 border-0 py-2 px-8 focus:outline-none hover:bg-indigo-600 rounded text-lg">更新</button>
                          </div>
                        </div>
                      </div>
                    </div>
                    </form>

                  </section>
              </div>
          </div>
      </div>
  </div>

{{----------- グローバル関数として定義(2つのscriptタグで使用するため) -----------}}
<script>
function generateUUID() {
    return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
        var r = Math.random() * 16 | 0,
        v = c === 'x' ? r : (r & 0x3 | 0x8);
        return v.toString(16);
      });
}
</script>

{{----------- 画像プレビューの追加、削除、アップロード -----------}}
<script>
document.addEventListener("DOMContentLoaded", function () { // DOMContentLoaded = イベントを監視して処理を実行 | JavaScriptの実行が早すぎてimagePreviewContainerがnullになるのを防ぐ(JavaScriptはデフォルトでHTMLの読み込み中に実行される→まだHTMLのimagePreviewContainerが読み込まれていない場合、nullになってしまう)
    // --- 変数の初期化
    let selectedFiles = [];
    const mainImageContainer = document.getElementById("mainImageContainer");
    const mainImage = document.getElementById("mainImage");
    const imageInput = document.getElementById("image_path");
    const imagePreviewContainer = document.getElementById("imagePreviewContainer");
    const noImageSrc = "/storage/collection_images/noImage.jpg";

    // --- 既存画像の設定(クリックイベント & 削除ボタン追加)
    function setupExistingImages() {
        document.querySelectorAll("#imagePreviewContainer div").forEach(imageWrapper => { // imagePreviewContainer内のすべての<div>を取得
            const imageId = imageWrapper.dataset.imageId; // dataset.imageId → data-image-id属性の値を取得
            const img = imageWrapper.querySelector("img"); // imageWrapper内の<img>要素を取得
            const imageSrc = img.src;

            // メイン画像を変更するときに使用
            img.addEventListener("click", function () {
                changeMainImage(imageSrc);
            });

            // 削除ボタン追記
            if (!imageWrapper.querySelector("button")) {
                const removeButton = createDeleteButton(() => { // createDeleteButton関数 = 削除ボタン生成
                    removeExistingImage(imageWrapper, imageId, imageSrc); // removeExistingImage関数 = 既存画像の削除
                });
                imageWrapper.appendChild(removeButton);
            }
        });
    }

    // --- 画像プレビュー表示(新規アップロード時)
    function previewImages(event) {
        console.log("画像選択イベント発火");
        const input = event.target; // どの要素(input type="file")でイベントが発生したかを取得
        const files = input.files; // 選択されたファイルリストを取得。FileListは、input type="file"でユーザーが選択したファイルの一覧を表すオブジェクト。input.filesを取得すると、その中にFileListが入っている。

        if (!files || files.length === 0) {
            console.log("ファイルが選択されていません");
            return;
        }

        // 複数ファイルをinput.filesに保持するための特別なオブジェクト。
        // → DataTransferを使うと「選択済みのファイルに、新しいファイルを追加OK」「削除したいファイルを除外してinput.filesを更新OK」
        // → 通常のinput type="file"では「新しいファイルを選択すると、以前のファイルが上書きされてしまう」「複数のファイルを選択した状態を保持できない」
        let dataTransfer = new DataTransfer();
        // すでに選択されているファイルを`DataTransfer`に追加
        // → 初回は、previewImages()が実行された時点ではselectedFiles(=過去に選択された画像のリスト)は空
        // → 2回目以降のpreviewImages()実行時には、すでに選択されたファイルがselectedFilesに入っている(下にあるselectedFiles.pushで入る)
        selectedFiles.forEach(fileObj => dataTransfer.items.add(fileObj.file)); // fileObj = selectedFilesの各要素(オブジェクト) | fileObj.file = fileObjの中にあるファイル情報(input.files に入れるデータ) | dataTransfer.items.add(fileObj.file) = dataTransferにfileObj.fileを追加

        // 選択されたファイルを配列に変換し、1つずつ処理
        Array.from(files).forEach((file, index) => { // filesは配列のようなオブジェクト(FileList)なので、直接forEach()やmap()を使えないことがある。Array.from(files)を使うとfilesを本物の配列に変換 できる。 | index = 現在の要素が何番目か(0 から始まるインデックス番号)が入る。
            const reader = new FileReader(); // FileReader = ファイルの内容を読み取る
            reader.onload = function(e) { // onload = ファイルの読み込みが完了したときに実行される | e =「イベントオブジェクト」 | e.target.resultにBase64形式のデータが格納される
                const imageId = "new_" + Date.now();
                const fileName = file.name.trim(); // 空白削除(uniqueIdを生成時、無駄なスペースが混ざらないように)
                const uniqueId = fileName + '_' + generateUUID(); // UUID
                selectedFiles.push({ id: imageId, file: file, src: e.target.result }); // e.target.result = 読み込んだファイルのデータが入る{今回は、画像のデータURL(reader.readAsDataURL(file);で作る)} | e =「イベントオブジェクト」 | reader.onload = 「ファイルの読み込みが完了したら実行する関数」
                dataTransfer.items.add(file);

                // サムネイルを表示する要素を作成
                const imageWrapper = document.createElement("div");
                imageWrapper.classList.add("relative", "w-24", "h-24");

                // <img> タグを作成し、画像を設定する
                const img = document.createElement("img");
                img.src = e.target.result; // e.target.result = 読み込んだファイルのデータが入る{画像のデータURL(reader.readAsDataURL(file);で作る)}
                img.classList.add("w-full", "h-full", "object-cover", "object-center", "rounded", "cursor-pointer");
                img.onclick = function () {
                    changeMainImage(e.target.result); // メイン画像を変更するときに使用
                };

                // 削除ボタン(×)生成
                const removeButton = createDeleteButton(() => {
                    removeNewImage(imageId, imageWrapper);// 新規アップロード画像の削除
                });

                // サムネイル画像作成
                imageWrapper.appendChild(img);
                imageWrapper.appendChild(removeButton);
                imagePreviewContainer.appendChild(imageWrapper);
                // 追加画像をsaveImageOrder()へ送る準備
                imageWrapper.dataset.fileName = fileName;
                imageWrapper.dataset.uniqueId = uniqueId;
                imageWrapper.dataset.imageId = null; // 新規画像なので`null`

                if (selectedFiles.length === 1 || index === 0) { // selectedFiles.length === 1 → 最初の画像 | index === 0 → このループで処理されている最初の画像
                    changeMainImage(e.target.result);
                    mainImageContainer.classList.remove("hidden");
                }

                saveImageOrder(); // 画像が追加された時に `image_order` を更新
            };
            // readAsDataURL(file) → 画像データをBase64(URL)に変換
            reader.readAsDataURL(file); // これにより、ファイルをサーバーにアップロードせずにブラウザ上でプレビューできる
        });

        input.files = dataTransfer.files;
    }

    // --- 削除ボタン生成(共通)
    function createDeleteButton(removeFunction) {
        const removeButton = document.createElement("button");
        removeButton.textContent = "×";
        removeButton.classList.add("absolute", "top-0", "right-0", "bg-black", "bg-opacity-50", "text-white", "px-2", "py-1", "text-xs", "rounded-full", "hover:bg-opacity-70");
        removeButton.onclick = removeFunction; // removeFunction = 「関数を引数として受け取るための変数」 | removeNewImage()やremoveExistingImage()を入れる箱
        return removeButton;
    }

    // --- 新規アップロード画像の削除
    function removeNewImage(imageId, imageWrapper) {
        console.log(`削除する画像 ID: ${imageId}`);
        // `selectedFiles`から対象の画像以外で再構成(=対象画像を削除)
        selectedFiles = selectedFiles.filter(image => image.id !== imageId); // filter() = 配列の中身を条件で絞り込むメソッド | selectedFilesをimageに代入して、selectedFilesのidを取得しているイメージ

        // `DataTransfer`を作成し、削除後のリストをセット
        let dataTransfer = new DataTransfer();
        selectedFiles.forEach(image => dataTransfer.items.add(image.file));
        imageInput.files = dataTransfer.files;

        imageWrapper.remove(); // imageWrapper = サムネイルと削除ボタンを含むHTML要素
        resetMainImage();
    }

    // --- 既存画像の削除
    function removeExistingImage(imageWrapper, imageId, imageSrc) {
        console.log(`既存画像 ID ${imageId} を削除`);
        imageWrapper.remove();

        // `<form>` を正しく取得
        const form = imageInput.closest("form"); // closest("form") = imageInputから一番近いformを取得 | document.querySelector("form")だと、上から順に見てあったものを取得してしまうため
        if (!form) {
            console.error("❌ フォームが見つかりません！");
            return;
        }

        // 削除する画像のIDが既にhidden input(<input type="hidden">)があるかチェック
        let existingInput = form.querySelector(`input[name="delete_images[]"][value="${imageId}"]`); // querySelector(`input[name="delete_images[]"][value="${imageId}"]`) = 条件に合うもの限定で取得
        if (!existingInput) {
            // hidden inputを追加
            const deleteInput = document.createElement("input");
            deleteInput.type = "hidden";
            deleteInput.name = "delete_images[]";
            deleteInput.value = imageId;

            // `setTimeout()`で確実に追加(フォームの更新タイミングによってはhidden inputが消えてしまう → タイミングを固定させる = 「0ミリ秒後に実行」= 「今の処理(removeExistingImage関数)が終わったらすぐに実行」)
            setTimeout(() => form.appendChild(deleteInput), 0); // setTimeout(…, 0) = 指定した時間後に処理を実行する
            console.log("✅ Hidden input を追加:", deleteInput);
        } else {
            console.log("⚠️ 既にhidden inputがあるため追加しませんでした");
        }

        // 削除した画像がメイン画像ならリセット
        if (mainImage.src === imageSrc) {
            resetMainImage();
        }
    }

    // --- メイン画像のリセット
    function resetMainImage() {
        const allImages = document.querySelectorAll("#imagePreviewContainer img"); // #imagePreviewContainer内にあるすべてのimgタグを取得
        if (allImages.length > 0) { // allImages.length > 0 → サムネイル画像が1つ以上ある場合
            changeMainImage(allImages[0].src);
        } else {
            changeMainImage(noImageSrc);
        }
    }

    // --- メイン画像変更
    function changeMainImage(src) {
        console.log("changeMainImage が実行されました: ", src);
        if (mainImage) {
            mainImage.src = src;
        }
    }

    // 初期設定
    setupExistingImages();
    document.getElementById("image_path").addEventListener("change", previewImages);
});
</script>


{{----------- サムネイル移動、順番確定 -----------}}
<!-- SortableJSのCDNを追加 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.0/Sortable.min.js"></script>
<script>
// --- 画像の並び順を保存
function saveImageOrder() { // 画像の並び順を保存する関数
    let imageOrder = []; // 画像の順番を格納するための空配列を作成

    // 画像の順番を格納するための空配列へ順番に保存
    document.querySelectorAll("#imagePreviewContainer div").forEach((div, index) => { // #imagePreviewContainer内のすべての<div>(画像ラッパー)を取得 | indexは0から順番につく
        const imageId = div.dataset.imageId || null; // 既存画像は `imageId` を取得、新規画像は `null`
        const fileName = div.dataset.fileName || "new_image";
        const uniqueId = div.dataset.uniqueId || generateUUID(); // 新規画像の場合は `uniqueId` を生成

        if(imageId) {
            imageOrder.push({ fileName, uniqueId, id: imageId, position: index });
        }
    });

    console.log("🚀 送信する並び順:", imageOrder);

    // 既存のhidden inputを削除(重複を防いで、最新の画像順序データだけを送信)
    document.querySelectorAll("input[name='image_order']").forEach(input => input.remove());

    const form = document.getElementById("editForm");
    if (!form) {
        console.error("❌ フォームが見つかりません！");
        return;
    }

    // フォームにhidden inputを追加
    const hiddenInput = document.createElement("input");
    hiddenInput.type = "hidden";
    hiddenInput.name = "image_order";
    hiddenInput.value = JSON.stringify(imageOrder); // オブジェクト配列を文字列化 | valueは文字列しかセットできないので、オブジェクトを文字列にする必要がある
    form.appendChild(hiddenInput);

    console.log("✅ hidden input に保存:", hiddenInput.value);
}


// ----------- SortableJS(ドラッグ&ドロップ)を適用 ----------- 
document.addEventListener("DOMContentLoaded", function () {
  const imagePreviewContainer = document.getElementById("imagePreviewContainer");

  if (!imagePreviewContainer) {
      console.error("❌ imagePreviewContainer が見つかりません！");
      return;
  }

  // --- SortableJS(ドラッグ&ドロップ)を適用
  const sortable = new Sortable(imagePreviewContainer, { // new Sortable()を使ってimagePreviewContainer内の要素をドラッグ&ドロップ可能にする
      animation: 150, // スムーズなアニメーション
      ghostClass: "sortable-ghost", // ドラッグ中のスタイルを変更
      onEnd: function () { // onEndイベント = 要素の移動が確定したときに発火
          saveImageOrder();
      },
  });
});
</script>
</x-app-layout>
```

# CollectionController.php
```CollectionController.php
<?php

namespace App\Http\Controllers\Admin;

use App\Models\User;
use App\Models\Collection;
use App\Http\Controllers\Controller;
use App\Service\Admin\CollectionService;
use App\Http\Requests\CollectionRequest;
use App\Models\CollectionImage;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;

class CollectionController extends Controller
{
    /**
     * Show the form for editing the specified resource.
     *
     * @param  int  $id
     * @return \Illuminate\Http\Response
     */
    public function edit($id)
    {
        // ログインユーザーの(コレクション&コレクション画像)テーブルを取得
        $collection = CollectionService::getCollectionImage($id);

        return view('admin.collections.edit', compact('collection'));
    }

    /**
     * Update the specified resource in storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  int  $id
     * @return \Illuminate\Http\Response
     */
    public function update(CollectionRequest $request, $id)
    {
        $collection = Auth::user()
        ->collections()
        ->with('collection_image')
        ->findOrFail($id);

        // save(画像以外)
        CollectionService::updateRequest($collection, $request);

        // 削除リクエストがある場合、該当画像を削除
        CollectionService::deleteRequestImage($request);

        // 追加画像保存、既存画像position変更
        CollectionService::updateRequestImage($request, $collection);

        return to_route('collections.index');
    }
}
```

# CollectionService.php
```CollectionService.php
<?php 
namespace App\Service\Admin;

use App\Models\Collection;
use App\Models\CollectionImage;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

class CollectionService
{
  // ------ update ------
  public static function updateRequest($collection, $request) {
    $collection->title = $request->title;
    $collection->description = $request->description;
    $collection->url_qiita = $request->url_qiita;
    $collection->url_webapp = $request->url_webapp;
    $collection->url_github = $request->url_github;
    $collection->is_public = $request->is_public;
    $collection->position = $request->position;
    $collection->save();
  }

  public static function deleteRequestImage($request) {
    if($request->delete_images) {
      foreach($request->delete_images as $imageId) {
          $image = CollectionImage::find($imageId);
          if($image) {
              Storage::delete('public/collection_images/' . $image->image_path);
              $image->delete();
          }
      }
    }
  }

  public static function updateRequestImage($request, $collection) {
    // --- 追加画像あり
    if($request->hasFile('image_path')) {
      // 初期設定
      $uploadedFiles = $request->file('image_path');
      $orderData = json_decode($request->input('image_order'), true);
      $imageIdMap = [];
      
      // 追加画像のループ
      foreach($uploadedFiles as $index => $imagePath) {
        // 追加画像のファイル名を生成、publicに保存
        $imageName = time() . '_' . uniqid() . '.' . $imagePath->getClientOriginalExtension();
        $imagePath->storeAs('public/collection_images', $imageName);

        // 追加画像のposition確定
        $fileName = trim($imagePath->getClientOriginalName()); // ファイル名
        $order = collect($orderData)->first(fn($item) => str_starts_with($item['uniqueId'], $fileName));
        
        // データベースに保存
        $image = CollectionImage::create([
          'collection_id' => $collection->id,
          'image_path' => $imageName,
          'position' => $order ? $order['position'] : 0
        ]);
      }

      // 既存画像position更新
      if(isset($orderData)) {
        foreach($orderData as $order) {
          // 既存画像か新規画像かを判、既存画像ならテーブルidがある、新規の場合は'null'が入ってる
          $imageId = ($order['id'] === "null") ? null : $order['id']; // JavaScript側で文字列化のため → hiddenInput.value = JSON.stringify(imageOrder); // オブジェクト配列を文字列化 | valueは文字列しかセットできないので、オブジェクトを文字列にする必要がある
  
            if ($imageId !== null) {
              CollectionImage::where('id', $imageId)
              ->update(['position' => $order['position']]);
            }
        }
      }
    }


    // --- 追加画像なし、既存position更新
    if(!$request->hasFile('image_path') && $request->filled('image_order')) {
      $imageOrders = json_decode($request->input('image_order'), true); // JSONを配列に変換
      if(is_array($imageOrders)) {
          foreach ($imageOrders as $order) {
              CollectionImage::where('id', $order['id'])
              ->update(['position' => $order['position']]);
          }
      }
    }
  }
}
?>
```