JavaScript + Laravel(Blade)での画像プレビュー & 削除機能のコード：新規登録(create)

# 解説
https://qiita.com/honaki/items/749982f3509949e6d3b7


# サンプル画像
![スクリーンショット 2025-02-23 20.29.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3726068/48a7c18c-7e47-4797-9466-dc3f36401d16.png)


# create.blade.php
```create.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="createForm" action="{{ route('collections.store') }}" method="POST" enctype="multipart/form-data">
                        @csrf
                    <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="{{ old('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">{{ old('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="{{ old('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="{{ old('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="{{ old('url_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" {{ old('is_public') == '0' ? 'checked' : '' }}>非公開
                              <input type="radio" name="is_public" value="1" {{ old('is_public') == '1' ? 'checked' : '' }}>一般公開
                            </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" {{ old('position') == '0' ? 'selected' : '' }}>デフォルト</option>
                                <option value="1" {{ old('position') == '1' ? 'selected' : '' }}>1ページ目</option>
                                <option value="2" {{ old('position') == '2' ? 'selected' : '' }}>topページ</option>
                              </select>
                            </div>
                          </div>

                          <!-- 画像アップロード -->
                          <div class="p-2 w-full">
                            <div class="relative">
                                <label for="image_path" class="leading-7 text-sm text-gray-600">画像</label>
                                <!-- 見えない input -->
                                <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 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">
                                      <!-- 画像プレビューがここに追加される -->
                                    </div>
                                </div>
                                <!-- 大きなプレビュー画像 -->
                                <div id="mainImageContainer" class="flex justify-center mt-4 hidden">
                                    <img id="mainImage" class="w-3/5 h-auto object-cover border rounded-lg" src="" alt="メイン画像">
                                </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>
                        
<script>
// --- UUID(一意の識別子)生成 (1回だけ定義)
window.generateUUID = function() {
    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);
    });
};

// ⭐️画像プレビュー & 削除機能
document.addEventListener("DOMContentLoaded", function() { // これがないと、HTMLの読み込み前にJavaScriptが実行され、エラーになることがある
    // --- 変数の初期化
    let selectedFiles = []; // 選択した画像のデータを保持(JavaScriptでは、input type="file"のfilesを直接変更できないため、selectedFilesにデータを保持しておく)
    const mainImageContainer = document.getElementById("mainImageContainer"); // 「大きなプレビュー画像」div要素
    const mainImage = document.getElementById("mainImage"); // 「大きなプレビュー画像」img要素
    const imageInput = document.getElementById("image_path"); // <input type="file">
    const imagePreviewContainer = document.getElementById("imagePreviewContainer");

    // --- 画像を選択したらプレビューを表示
    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;
        }

        // 画像のサムネイルを表示するためのエリア (<div id="imagePreviewContainer">) を取得
        const imagePreviewContainer = document.getElementById('imagePreviewContainer');

        // 複数ファイルを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を追加

        Array.from(files).forEach((file) => { // files(選択されたファイルのリスト)を配列に変換してforEach()で処理
            const reader = new FileReader(); // FileReader = ファイルの内容を読み取る
            reader.onload = function(e) { // onload = ファイルの読み込みが完了したときに実行される | e =「イベントオブジェクト」
                const imageId = "image_" + Date.now(); // 一意のIDを生成、削除時このIDを使って特定の画像を識別
                const fileName = file.name.trim(); // 空白削除(uniqueIdを生成時、無駄なスペースが混ざらないように)
                const uniqueId = fileName + '_' + generateUUID(); // UUID

                // `selectedFiles`を更新(新しい画像を追加)
                selectedFiles.push({ id: imageId, uniqueId, file: file, src: e.target.result }); // file = input.filesで取得したFileオブジェクト(forEachで回している) | e.target.result = 読み込んだファイルのデータが入る{今回は、画像のデータURL(reader.readAsDataURL(file);で作る)} | e =「イベントオブジェクト」 | reader.onload = 「ファイルの読み込みが完了したら実行する関数」

                // `DataTransfer`に新しく選択した画像を追加(こうすることで、新しい画像を選択しても、前の画像が消えないようにする)
                dataTransfer.items.add(file);

                // サムネイルを表示する要素を作成
                const imageWrapper = document.createElement("div");
                imageWrapper.classList.add("relative", "w-24", "h-24");
                imageWrapper.dataset.imageId = imageId; // dataset にIDをセット
                imageWrapper.dataset.fileName = fileName;  // `fileName` をセット
                imageWrapper.dataset.uniqueId = uniqueId;  // `uniqueId` をセット

                // <img> タグを作成し、画像を設定する
                const img = document.createElement("img");
                img.src = e.target.result;
                img.classList.add("w-full", "h-full", "object-cover", "object-center", "rounded", "cursor-pointer");
                img.id = imageId;
                img.onclick = function() {
                    changeMainImage(e.target.result); // 画像をクリックするとメイン画像を変更
                };

                // 削除ボタンの作成
                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 = function() {
                    removeImage(imageId);
                };

                imageWrapper.appendChild(img); // img要素をimageWrapperに追加。これでimageWrapperの中に画像が表示される。
                imageWrapper.appendChild(removeButton); // 画像の横に削除ボタンが表示される
                imagePreviewContainer.appendChild(imageWrapper); // 画面上にプレビューが表示される

                // 追加ごとに大きなプレビューを追加画像に変更
                changeMainImage(e.target.result);
                mainImageContainer.classList.remove("hidden");
            };

            reader.readAsDataURL(file); // FileReaderを使ってfileをbase64形式(画像のデータURL)に変換する
        });

        // input.filesを更新（画像を保持）
        input.files = dataTransfer.files;
    }

    // --- 画像を削除
    function removeImage(imageId) {
        console.log(`画像 ${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)); // 配列 selectedFilesに保存されているファイルを、DataTransferに追加

        // `input.files`を更新
        imageInput.files = dataTransfer.files;

        // DOMから該当の画像を削除
        const imageElement = document.getElementById(imageId);
        if (imageElement) {
            imageElement.parentElement.remove();
        }

        // メイン画像のリセット（リストの最初の画像をメインにする or 非表示）
        if (selectedFiles.length > 0) {
            changeMainImage(selectedFiles[0].src);
        } else {
            mainImage.src = "";
            mainImageContainer.classList.add("hidden");
        }
    }

    // --- メインプレビュー変更
    function changeMainImage(src) {
        mainImage.src = src; // メイン画像を変更 (mainImage.src = src)。
        mainImageContainer.classList.remove("hidden"); // メイン画像エリアを表示 (classList.remove("hidden"))。
    }

    // --- 画像が選択された時だけプレビューを表示
    document.getElementById("image_path").addEventListener("change", previewImages); // 「ファイルが選択されたときに実行」なのでchange(監視イベント) | previewImages()にするとページが読み込まれた瞬間に即実行となるためNG
});
</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 fileName = div.dataset.fileName;
        const uniqueId = div.dataset.uniqueId;
            if (uniqueId) {
                imageOrder.push({fileName, uniqueId, position: index});
            }
    });

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

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

    const form = document.getElementById("createForm");
    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 creating a new resource.
     *
     * @return \Illuminate\Http\Response
     */
    public function create()
    {
        return view('admin.collections.create');
    }

    /**
     * Store a newly created resource in storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function store(CollectionRequest $request)
    {
        // $requestの新規作成(画像以外)
        $collection = CollectionService::storeRequest($request);

        // 画像を保存 画像順番保存
        CollectionService::storeRequestImage($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
{
  // ------ store ------
  public static function storeRequest($request) {
    $collection = Collection::create([
      'title' => $request->title,
      'description' => $request->description,
      'url_qiita' => $request->url_qiita,
      'url_webapp' => $request->url_webapp,
      'url_github' => $request->url_github,
      'is_public' => $request->is_public,
      'position' => $request->position,
      'user_id' => Auth::id(),
    ]);

    return $collection;
  }

  public static function storeRequestImage($request, $collection) {
    if($request->hasFile('image_path')) {
        $uploadedFiles = $request->file('image_path');
        $orderData = json_decode($request->input('image_order'), true);

        foreach($uploadedFiles as $index => $imagePath) {
            // 画像positionを設定する準備
            $fileName = trim($imagePath->getClientOriginalName()); // ファイル名
            $order = collect($orderData)->first(fn($item) => str_starts_with($item['uniqueId'], $fileName)); // first() = 条件に合致する最初の要素を返す | str_starts_with($item['uniqueId'], $fileName) = uniqueIdがfileNameで始まるかどうかをチェック

            // 画像を保存
            $imageName = time() . '_' . uniqid() . '.' . $imagePath->getClientOriginalExtension();
            $imagePath->storeAs('public/collection_images', $imageName);

            // データベースに保存
            $image = CollectionImage::create([
                'collection_id' => $collection->id,
                'image_path' => $imageName,
                'position' => $order ? $order['position'] : 0
            ]);
        }
    }
  }
```