解説
サンプル画像
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
]);
}
}
}