laravel + livewire + tailwind で webカンファレンス - 画像を記録

(2025-01-03)

今度は、画像を記録して閲覧できるようにします。

途中からやると却って混乱するので最初から作ります。

出来上がりはこんな感じです。

データベース設計

users
   id: 主キー
   name: ユーザー名
   email: メールアドレス
   password: パスワード
   role: 一般ユーザーか管理者か

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 laraconf
composer require livewire/livewire

tailwind を設定します。

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

Breeze のインストール

composer require laravel/breeze --dev
php artisan breeze:install

migration テーブルの作成と実行

マイグレーションは順番が重要なようで、作成時のタイムスタンプの順番にマイグレートされるので 2 つに分けてテーブルを作成します。

php artisan make:migration add_role_to_users_table --table=users
php artisan make:migration create_themes_table

残りのマイグレーションテーブル作成。

php artisan make:migration create_messages_table
php artisan make:migration create_zip_files_table
php artisan make:migration create_images_table

マイグレーションテーブルを編集。

database/migration/2024_12_30_155645_create_themes_table.php
<?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

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

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

2024_12_30_160420_create_messages_table.php
<?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

2025_01_01_160704_add_role_to_users_table.php
<?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 Image
php artisan make:model Message
php artisan make:model Theme
php artisan make:model ZipFile

app/Model/Image.php

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

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

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

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

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'];
}

livewire コンポーネントの作成

php artisan make:livewire ThemeCreate
php artisan make:livewire ThemeImage
php artisan make:livewire ThemeList
php artisan make:livewire ThemeShow
php artisan make:livewire ZipImage

app/Livewire/ThemeCreate.php

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

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

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

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

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,
]);
}
}

blade 編集

resources/views/livewire/theme-create.blade.php

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

"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

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

"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

"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 するので新規作成します。

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>

dashboard の編集

resources/views/dashboard.blade.php
<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>

zip ファイルのアップロード先の変更

app/config/filesystems.php を以下のように編集します。

app/config/filesystems.php
'local' => [
'driver' => 'local',
'root' => storage_path('app'),
'serve' => true,
'throw' => false,
],

画像のシンボリックリンク

php artisan storage:link

タイムゾーン設定

.env
APP_TIMEZONE=Asia/Tokyo
APP_LOCALE=ja
APP_FALLBACK_LOCALE=ja
APP_FAKER_LOCALE=ja

ルーティング

routes/web.php
<?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';

tailwind のビルドとサーバー起動

cd ~/laraconf
php artisan config:clear
php artisan cache:clear
php artisan view:clear
php artisan route:clear
npm run build
php artisan serve