今度は、画像を記録して閲覧できるようにします。
途中からやると却って混乱するので最初から作ります。
出来上がりはこんな感じです。
themes
id: 主キー
user_id: 外部キー (users.id)
title: テーマのタイトル名
presen: テーマの説明
messages
id: 主キー
user_id: 外部キー (users.id)
theme_id: 外部キー (theme.id)
content: メッセージ内容
zip_files
id: 主キー
file_name: 外部キー (users.id)
themeid: 外部キー (theme.id)
hash: zip ファイルのハッシュ値
images
id: 主キー
themeid: 外部キー (theme.id)
filename: 画像名
まずはプロジェクトを作成します。
composer create-project --prefer-dist laravel/laravel laraconf
livewire を設定します。
cd laraconfcomposer require livewire/livewire
tailwind を設定します。
npm install -D tailwindcss postcss autoprefixernpx tailwindcss init -p
Breeze のインストール
composer require laravel/breeze --devphp artisan breeze:install
マイグレーションは順番が重要なようで、作成時のタイムスタンプの順番にマイグレートされるので 2 つに分けてテーブルを作成します。
php artisan make:migration add_role_to_users_table --table=usersphp artisan make:migration create_themes_table
残りのマイグレーションテーブル作成。
php artisan make:migration create_messages_tablephp artisan make:migration create_zip_files_tablephp artisan make:migration create_images_table
マイグレーションテーブルを編集。
<?php
use Illuminate\Database\Migrations\Migration;use Illuminate\Database\Schema\Blueprint;use Illuminate\Support\Facades\Schema;
class CreateThemesTable extends Migration{ public function up() { Schema::create('themes', function (Blueprint $table) { $table->id(); $table->foreignId('user_id')->constrained('users')->onDelete('cascade'); $table->string('title'); $table->string('presen'); $table->timestamps(); }); }
public function down() { Schema::dropIfExists('themes'); }}
database/migration/2024_12_30_155837_create_images_table.php
<?php
use Illuminate\Database\Migrations\Migration;use Illuminate\Database\Schema\Blueprint;use Illuminate\Support\Facades\Schema;
class CreateImagesTable extends Migration{ public function up() { Schema::create('images', function (Blueprint $table) { $table->id(); $table->string('filename'); $table->timestamps(); $table->foreignId('theme_id')->nullable()->constrained('themes'); }); }
public function down() { Schema::dropIfExists('images'); }}
database/migration/2025_01_03_010159__create_zip_files_table.php
<?php
use Illuminate\Database\Migrations\Migration;use Illuminate\Database\Schema\Blueprint;use Illuminate\Support\Facades\Schema;
class CreateZipFilesTable extends Migration{ public function up() { Schema::create('zip_files', function (Blueprint $table) { $table->id(); $table->string('filename'); $table->timestamps(); $table->foreignId('theme_id')->constrained('themes')->onDelete('cascade'); $table->string('hash'); $table->unique(['theme_id', 'hash']); // theme_id と hash の組み合わせに対する UNIQUE 制約 }); }
public function down() { Schema::dropIfExists('zip_files'); }}
2024_12_30_160420_create_messages_table
<?php
use Illuminate\Database\Migrations\Migration;use Illuminate\Database\Schema\Blueprint;use Illuminate\Support\Facades\Schema;
class CreateMessagesTable extends Migration{ public function up() { Schema::create('messages', function (Blueprint $table) { $table->id(); $table->foreignId('theme_id')->constrained('themes')->onDelete('cascade'); $table->foreignId('user_id')->constrained('users')->onDelete('cascade'); $table->text('content'); $table->timestamps(); }); }
public function down() { Schema::dropIfExists('messages'); }}
2025_01_01_160704_add_role_to_users_table
<?php
use Illuminate\Database\Migrations\Migration;use Illuminate\Database\Schema\Blueprint;use Illuminate\Support\Facades\Schema;
return new class extends Migration{ public function up() { Schema::table('users', function (Blueprint $table) { $table->string('role')->default('user'); // 'user' がデフォルトの役割 }); }
public function down() { Schema::table('users', function (Blueprint $table) { $table->dropColumn('role'); }); }
};
マイグレーション。
php artisan migrate
マイグレーションのリセットは、
php artisan migrate:reset
php artisan make:model Imagephp artisan make:model Messagephp artisan make:model Themephp artisan make:model ZipFile
app/Model/Image.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;use Illuminate\Database\Eloquent\Model;
class Image extends Model{ use HasFactory;
protected $fillable = [ 'filename', 'theme_id', ];}
app/Model/Message.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;use Illuminate\Database\Eloquent\Model;
class Message extends Model{ use HasFactory;
protected $fillable = ['user_id', 'theme_id', 'content'];
// メッセージが所属するテーマ public function theme() { return $this->belongsTo(Theme::class); }
// メッセージを送信したユーザー public function user() { return $this->belongsTo(User::class); }}
app/Model/Theme.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;use Illuminate\Database\Eloquent\Model;
class Theme extends Model{ use HasFactory;
protected $fillable = ['user_id', 'title', 'presen'];
public function user() { return $this->belongsTo(User::class); }
public function messages() { return $this->hasMany(Message::class); }
}
app/Model/User.php
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;use Illuminate\Database\Eloquent\Factories\HasFactory;use Illuminate\Foundation\Auth\User as Authenticatable;use Illuminate\Notifications\Notifiable;
class User extends Authenticatable{ /** @use HasFactory<\Database\Factories\UserFactory> */ use HasFactory, Notifiable;
/** * The attributes that are mass assignable. * * @var list<string> */ protected $fillable = [ 'name', 'email', 'password', ];
/** * The attributes that should be hidden for serialization. * * @var list<string> */ protected $hidden = [ 'password', 'remember_token', ];
/** * Get the attributes that should be cast. * * @return array<string, string> */ protected function casts(): array { return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', ]; }
public function messages() { return $this->hasMany(Message::class); }}
app/Model/ZipFile.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;use Illuminate\Database\Eloquent\Model;
class ZipFile extends Model{ use HasFactory;
protected $fillable = ['filename', 'theme_id', 'hash'];
}
php artisan make:livewire ThemeCreatephp artisan make:livewire ThemeImagephp artisan make:livewire ThemeListphp artisan make:livewire ThemeShowphp artisan make:livewire ZipImage
app/Livewire/ThemeCreate.php
<?php
namespace App\Livewire;
use Livewire\Component;use App\Models\Theme;use Illuminate\Support\Facades\Auth;
class ThemeCreate extends Component{ public $title; public $presen;
public function render() { return view('livewire.theme-create'); }
public function saveTheme() { $this->validate([ 'title' => 'required|max:255', 'presen' => 'required|max:2000', ]);
Theme::create([ 'user_id' => Auth::id(), 'title' => $this->title, 'presen' => $this->presen, ]);
$this->reset('title'); // フォームをリセット $this->reset('presen'); session()->flash('success', 'テーマが作成されました!'); }}
app/Livewire/ThemeImage.php
<?php
namespace App\Livewire;
use Livewire\Component;use App\Models\Image;
class ThemeImage extends Component{ public $theme_id; public $folderName; public $images;
public function mount($theme_id, $folderName) { $this->theme_id = $theme_id; $this->folderName = $folderName;
// 特定のテーマ ID とサブディレクトリに一致する画像を取得 $this->images = Image::where('theme_id', $theme_id) ->where('filename', 'LIKE', "%/{$folderName}/%") ->get(); }
public function render() { return view('livewire.theme-image'); }}
app/Livewire/ThemeList.php
<?php
namespace App\Livewire;use Livewire\Component;use App\Models\Theme;
class ThemeList extends Component{ public $themes;
public function mount() { $this->themes = Theme::with('user')->orderBy('created_at', 'desc')->get(); }
public function render() { return view('livewire.theme-list'); }}
app/Livewire/ThemeShow.php
<?php
namespace App\Livewire;
use Livewire\Component;use App\Models\Theme;use App\Models\Image;use App\Models\Message;
class ThemeShow extends Component{ public $theme; public $content; public $imagesByFolder = []; public $modalImages = [];
public function mount($theme_id) { // テーマデータを取得 $this->theme = Theme::with('messages.user')->findOrFail($theme_id);
// サブディレクトリごとにグループ化された画像を取得 $images = Image::where('theme_id', $this->theme->id)->get();
// サブディレクトリごとに最初の画像を抽出 foreach ($images as $image) { $folderName = basename(dirname($image->filename)); if (!isset($this->imagesByFolder[$folderName])) { $this->imagesByFolder[$folderName] = $image; // 最初の画像を保存 } } }
public function saveMessage() { $this->validate([ 'content' => 'required|string|max:1000', ]);
// メッセージを保存 Message::create([ 'user_id' => auth()->id(), 'theme_id' => $this->theme->id, 'content' => $this->content, ]);
// 入力をリセット $this->reset('content');
// コンポーネントを再レンダリング(リストを更新) $this->theme->load('messages.user'); }
public function render() { return view('livewire.theme-show', [ 'imagesByFolder' => $this->imagesByFolder, 'messages' => $this->theme->messages->sortByDesc('created_at'), ]); }}
app/Livewire/ZipImage.php
<?php
namespace App\Livewire;
use Livewire\Component;use Livewire\WithFileUploads;use App\Models\Image;use App\Models\ZipFile;use App\Models\Theme;use Illuminate\Support\Facades\Storage;use ZipArchive;use Illuminate\Support\Facades\Log;
class ZipImage extends Component{ use WithFileUploads;
public $zip = []; // 複数ファイルに対応するため配列に変更 public $theme_id; public $themes;
public function mount() { // テーマ一覧を取得 $this->themes = Theme::orderBy('created_at', 'desc')->get(); }
public function save() { // バリデーション $this->validate([ 'theme_id' => 'required|integer|exists:themes,id', 'zip.*' => 'required|mimes:zip|max:10240', // 各ファイル最大10MB ]);
$uploadedZips = []; $duplicateZips = [];
foreach ($this->zip as $zipFile) { // ZIPファイルの一時保存 $zipPath = $zipFile->store('temp', 'local'); $zipFilePath = storage_path('app/' . $zipPath); $zip = new ZipArchive;
// ZIPファイルのハッシュを計算 $zipHash = md5_file($zipFilePath);
// 重複チェック if (ZipFile::where('hash', $zipHash)->where('theme_id', $this->theme_id)->exists()) { $duplicateZips[] = $zipFile->getClientOriginalName(); continue; }
// ZIPファイルを開く if ($zip->open($zipFilePath) === true) { // ZIPファイル名を取得 $zipFileName = pathinfo($zipFile->getClientOriginalName(), PATHINFO_FILENAME);
// ZIPファイル情報を保存 $zipFileRecord = ZipFile::create([ 'filename' => $zipFileName, 'theme_id' => $this->theme_id, 'hash' => $zipHash, ]);
// ZIP内の画像を処理 for ($i = 0; $i < $zip->numFiles; $i++) { $fileName = $zip->getNameIndex($i);
// 画像ファイルのみを処理 if (preg_match('/\.(jpg|jpeg|png|gif)$/i', $fileName)) { $fileContent = $zip->getFromName($fileName);
// ファイルを保存 $baseFolder = "images/theme_{$this->theme_id}/{$zipFileName}/"; $storagePath = $baseFolder . uniqid() . '-' . basename($fileName); // ユニークなファイル名 Storage::disk('public')->put($storagePath, $fileContent);
// データベースに記録 Image::create([ 'filename' => $storagePath, 'theme_id' => $this->theme_id, ]); } } $zip->close(); $uploadedZips[] = $zipFile->getClientOriginalName(); }
// 一時ファイルを削除 Storage::delete($zipPath); }
// フィードバックメッセージ if (count($uploadedZips) > 0) { session()->flash('message', '以下のZIPファイル内の画像が保存されました: ' . implode(', ', $uploadedZips)); }
if (count($duplicateZips) > 0) { session()->flash('message', '以下のZIPファイルは既にアップロードされています: ' . implode(', ', $duplicateZips)); } }
public function render() { return view('livewire.zip-image', [ 'themes' => $this->themes, ]); }}
resources/views/livewire/theme-create.blade.php
<div class="p-4">
<h1 class="text-xl font-bold mb-4 border border-gray-500 bg-slate-200 p-2">テーマを作成</h1>
@if (session()->has('success')) <div class="p-4 mb-4 text-green-700 bg-green-100 rounded"> {{ session('success') }} </div> @endif
<p class="border border-slate-300 p-2 rounded shadow-md bg-gray-100 leading-7"> テーマを作成します。<br> テーマのタイトルは「症例報告:回盲部の潰瘍性病変」とか、「自己免疫性肝炎疑診例の1例」とか何でも結構です。<br> テーマに関しての説明は、症例検討であれば経過を書いて下さい。上限は 2,000 文字です。 </p>
<div class="my-5">
<form wire:submit.prevent="saveTheme">
<div class="mb-4"> <label for="title" class="block text-lg font-medium">テーマのタイトル</label> <input type="text" id="title" wire:model="title" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" placeholder="テーマのタイトルを入力してください。"> @error('title') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror </div>
<div class="mb-4"> <label for="presen" class="block text-lg font-medium">テーマの説明</label> <textarea id="presen" rows="8" wire:model="presen" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" placeholder="テーマに関しての説明を入力して下さい。上限は 2,000 文字です。"></textarea> @error('presen') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror </div>
<div> <button type="submit" class="px-4 py-1 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"> 保存 </button> </div>
</form>
</div>
<a href="{{ route('dashboard') }}" class="px-4 py-2 bg-orange-900 text-white rounded hover:bg-orange-700">トップへ</a>
</div>
resources/views/livewire/theme-image.blade.php
<div class="p-4 bg-gray-200"> @if ($images->isNotEmpty()) <div class="grid grid-cols-5 gap-4"> @foreach ($images as $image) <div class="relative"> <!-- サムネイル画像 --> <img src="{{ asset('storage/' . $image->filename) }}" alt="Image" class="w-full h-auto rounded shadow-md cursor-pointer" onclick="openModal('{{ asset('storage/' . $image->filename) }}')" > </div> @endforeach </div>
<!-- モーダルウィンドウ --> <div id="imageModal" class="fixed inset-0 bg-gray-900 bg-opacity-90 flex items-center justify-center hidden" onclick="closeModal()"> <img id="modalImage" src="" alt="Expanded Image" class="max-w-full max-h-full"> <button class="absolute top-4 right-4 text-white text-3xl font-bold bg-gray-800 bg-opacity-50 px-4 py-2 rounded hover:bg-opacity-75" onclick="closeModal()" > ✖ </button> </div> @else <p>このテーマに関連する画像はありません。</p> @endif</div>
<script> // モーダルウィンドウを開く function openModal(imageUrl) { const modal = document.getElementById('imageModal'); const modalImage = document.getElementById('modalImage'); modalImage.src = imageUrl; modal.classList.remove('hidden'); }
// モーダルウィンドウを閉じる function closeModal() { const modal = document.getElementById('imageModal'); modal.classList.add('hidden'); }</script>
resources/views/livewire/theme-show.blade.php
<div class="p-4">
<!-- テーマ --> <h1 class="text-xl font-bold mb-4 border border-gray-500 bg-slate-200 px-4 py-2">テーマ:{{ $theme->title }}</h1>
<div class="border border-gray-400 p-2 bg-gray-200 rounded my-5 shadow-md">
<p class="border border-slate-200 p-3 rounded mb-3 shadow-md bg-white">{!! nl2br(e($theme->presen)) !!}</p>
<!-- サブディレクトリごとの最初の画像を表示 -->
<div class="flex"> @foreach ($imagesByFolder as $folderName => $image) <div class="w-48 mr-3"> <div class="text-md font-medium">{{ $folderName }}</div> <a href="{{ route('theme.image', ['theme_id' => $theme->id, 'folderName' => $folderName]) }}" target="_blank"> <img src="{{ asset('storage/' . $image->filename) }}" alt="First Image" class="w-48 cursor-pointer"> </a> </div> @endforeach </div>
<p class="mt-2 text-sm text-gray-500">(作成者: {{ $theme->user->name }} <span class="ml-5">作成日時: {{ $theme->created_at->format('Y-m-d H:i') }})</span></p>
</div>
<a href="{{ route('dashboard') }}" class="px-4 py-2 bg-orange-900 text-white rounded hover:bg-orange-700">トップへ</a>
<!-- メッセージ入力フォーム --> <h2 class="text-xl font-bold mt-8 mb-3 border border-gray-500 bg-slate-200 p-2">メッセージ入力</h2>
<div class="border border-gray-400 p-2 bg-gray-200 rounded my-5 shadow-md"> <form wire:submit.prevent="saveMessage"> <div class="mb-2"> <textarea wire:model="content" class="w-full p-2 border rounded" rows="2" placeholder="このテーマに関して、ここからメッセージ書き込むことができます。送信ボタンを押すとメッセージ一覧にリアルタイムで反映されます。"></textarea> @error('content') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror </div> <button type="submit" class="px-4 py-1 bg-blue-600 text-white rounded hover:bg-blue-700"> 送信 </button> </form>
</div>
<!-- メッセージ一覧 --> <h2 class="text-xl font-bold mt-8 mb-3 border border-gray-500 bg-slate-200 p-2">メッセージ一覧</h2> <div class="border border-gray-400 p-2 bg-gray-200 rounded my-5 shadow-md"> <div class="bg-white p-4 shadow-md rounded border border-slate-300"> @foreach ($messages as $message) <div class="mb-4"> <p><span class="font-semibold">{{ $message->user->name }}</span>:<span class="text-xs text-gray-600">{{ $message->created_at->format('Y-m-d H:i') }}</span></p> <p>{!! nl2br(e($message->content)) !!}</p> </div> @endforeach </div> </div>
</div>
resources/views/livewire/theme-list.blade.php
<div class="p-4"> <h1 class="text-xl font-bold mb-4">テーマ一覧</h1>
@foreach ($themes as $theme) <div class="mb-4 p-2 bg-white shadow-md rounded border border-slate-200"> <h3 class="text-lg font-semibold"> <!-- テーマIDをURLパラメータとして渡してテーマ詳細ページへ遷移 --> <a href="{{ route('theme.show', ['theme_id' => $theme->id]) }}" class="text-blue-600 hover:text-blue-800"> {{ $theme->title }} </a> </h3> <p class="text-sm text-gray-500">作成者: {{ $theme->user->name }} <span class="ml-5">作成日時: {{ $theme->created_at->format('Y-m-d H:i') }}</span></p> </div> @endforeach
@if ($themes->isEmpty()) <p class="text-gray-500">まだテーマが作成されていません。</p> @endif</div>
resources/views/livewire/zip-image.blade.php
<div class="p-4">
<h1 class="text-xl font-bold mb-4 border border-gray-400 bg-slate-200 p-2">画像アップロード</h1>
<div class="border border-gray-300 bg-slate-100 p-2 rounded mb-5">
<p class="border border-slate-300 p-2 rounded shadow-md bg-white leading-7 mb-3"> 画像ファイルをアップロードします。<br> 画像ファイルの拡張子は「jpg, png, jpeg」です。zip ファイルに圧縮したものを使用します。容量は最大で 10 MB です。<br> 最初に以下のテーマ一覧からテーマを選択すると、テーマ番号入力欄に自動的に数字が入力されます。<br> それから zip ファイルを選択してアップロードして下さい。複数選択も可能です。 </p>
<form wire:submit.prevent="save">
<label for="theme_id">テーマ番号</label> <input type="number" id="theme_id" wire:model="theme_id" class="border rounded px-2 py-1 w-24" readonly>
<!-- ZIPファイルのアップロード --> <div class="mt-4"> <label for="zip">ZIPファイル</label> <input type="file" id="zip" wire:model="zip" accept=".zip" class="border border-slate-300 bg-white rounded p-2" multiple required> <button type="submit" class="bg-blue-500 text-white rounded p-2 mt-4">アップロード</button> </div>
</form>
</div>
<a href="{{ route('dashboard') }}" class="px-4 py-2 bg-orange-900 text-white rounded hover:bg-orange-700">トップへ</a>
<h2 class="text-lg font-bold border border-gray-400 p-2 bg-blue-50 my-5">テーマ一覧</h2>
<table class="border-collapse border border-gray-300"> <thead> <tr> <th class="border border-gray-300 bg-sky-900 text-white px-5 py-2">ID</th> <th class="border border-gray-300 bg-sky-900 text-white px-5 p-2">タイトル</th> </tr> </thead> <tbody> @foreach ($themes as $theme) <tr class="cursor-pointer hover:bg-gray-100" wire:click="$set('theme_id', {{ $theme->id }})"> <td class="border border-gray-300 px-2 py-1 text-right">{{ $theme->id }}</td> <td class="border border-gray-300 px-2 py-1 ">{{ $theme->title }}</td> </tr> @endforeach </tbody> </table>
<!-- 成功メッセージ --> @if (session()->has('message')) <div class="text-green-900 mt-4">{{ session('message') }}</div> @endif
</div>
すべてデフォルトの resources/views/components/layouts/app.blade.php するので新規作成します。
<!DOCTYPE html><html lang="ja"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> @vite('resources/css/app.css') <title>laravel</title></head><body> {{ $slot }}</body></html>
<x-app-layout> <x-slot name="header"> <h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">{{ __('Menu') }}</h2> <ul class="list-disc leading-10 mx-10 px-2"> <li class="text-blue-800 hover:text-orange-800 delay-150"><a href="{{ route('theme.list') }}">テーマ一覧</a></li> <li class="text-blue-800 hover:text-orange-800 delay-150"><a href="{{ route('theme.create') }}">テーマ作成</a></li> <li class="text-blue-800 hover:text-orange-800 delay-150"><a href="{{ route('zip.image') }}">画像アップロード</a></li> </ul> </x-slot></x-app-layout>
app/config/filesystems.php を以下のように編集します。
'local' => [ 'driver' => 'local', 'root' => storage_path('app'), 'serve' => true, 'throw' => false, ],
php artisan storage:link
APP_TIMEZONE=Asia/TokyoAPP_LOCALE=jaAPP_FALLBACK_LOCALE=jaAPP_FAKER_LOCALE=ja
<?php
use Illuminate\Support\Facades\Route;use App\Livewire\ThemeCreate;use App\Livewire\ThemeList;use App\Livewire\ThemeShow;use App\Livewire\ZipImage;use App\Livewire\ThemeImage;
Route::get('/', function () { return view('welcome');})->name('home');
Route::get('/dashboard', function () { return view('dashboard');})->middleware(['auth', 'verified'])->name('dashboard');
Route::middleware('auth')->group(function () { Route::get('/theme/create', ThemeCreate::class)->name('theme.create'); Route::get('/theme/list', ThemeList::class)->name('theme.list'); Route::get('/theme/{theme_id}', ThemeShow::class)->name('theme.show'); Route::get('/zip-image', ZipImage::class)->name('zip.image'); Route::get('/theme/{theme_id}/images/{folderName?}', ThemeImage::class)->name('theme.images');});
// プロフィール関連ルートRoute::middleware('auth')->group(function () { Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');});
require __DIR__.'/auth.php';
cd ~/laraconfphp artisan config:clearphp artisan cache:clearphp artisan view:clearphp artisan route:clearnpm run buildphp artisan serve