■P227

────────────────
sail artisan make:migration createImagesTable
────────────────

●database/migrations/20XX_XX_XX_XXXXXX_create_images_table.php
────────────────
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('images', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('images');
    }
};
────────────────


■P228

────────────────
sail artisan make:migration createTweetImagesTable
────────────────

●database/migrations/20XX_XX_XX_XXXXXX_create_tweet_images_table.php
────────────────
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('tweet_images', function (Blueprint $table) {
            $table->foreignId('tweet_id')->constrained('tweets')->cascadeOnDelete();
            $table->foreignId('image_id')->constrained('images')->cascadeOnDelete();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('tweet_images');
    }
};
────────────────


■P229

────────────────
sail artisan migrate
────────────────


■P230

────────────────
sail artisan make:model Image -f
────────────────

●app/Models/Image.php
────────────────
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Image extends Model
{
    /** @use HasFactory<\Database\Factories\ImageFactory> */
    use HasFactory;
}
────────────────

●app/Models/Image.php
────────────────
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;

#[Appends(['url'])]
class Image extends Model
{
    /** @use HasFactory<\Database\Factories\ImageFactory> */
    use HasFactory;

    public function getUrlAttribute(): string
    {
        return Storage::url('images/'.$this->name);
    }
}
────────────────


■P231

────────────────
sail artisan make:model TweetImage --pivot
────────────────


■P232

────────────────
sail composer require swatty007/faker-image-generator:3.0.0
────────────────

────────────────
sail artisan vendor:publish --provider="Swatty007\FakerImageGenerator\FakerImageGeneratorServiceProvider"
────────────────

●config/faker-image-generator.php
────────────────
//  'directory' => sys_get_temp_dir(),
    'directory' => storage_path('app/public/images'),
────────────────

●database/factories/ImageFactory.php
────────────────
<?php

namespace Database\Factories;

use App\Models\Image;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Storage;
use Swatty007\FakerImageGenerator\Providers\FakerImageGenerationProvider;

/**
 * @extends Factory<Image>
 */
class ImageFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        // ディレクトリがなければ作成する
        if (! Storage::disk('public')->exists('images')) {
            Storage::disk('public')->makeDirectory('images');
        }
        $faker = \Faker\Factory::create();
        $faker->addProvider(new FakerImageGenerationProvider($faker));
        $imagePath = $faker->imageGenerator();

        return [
            'name' => basename($imagePath),
        ];
    }
}
────────────────


■P234

────────────────
sail artisan storage:link
────────────────

●app/Models/Tweet.php
────────────────
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

class Tweet extends Model
{
    use HasFactory;

    /**
     * ツイートを投稿したユーザーとの関係
     */
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    public function images(): BelongsToMany
    {
        return $this->belongsToMany(Image::class, 'tweet_images')
            ->using(TweetImage::class);
    }
}
────────────────


■P235

●database/seeders/TweetsSeeder.php
────────────────
<?php

namespace Database\Seeders;

use App\Models\Image;
use App\Models\Tweet;
use Illuminate\Database\Seeder;

class TweetsSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        Tweet::factory()->count(10)->create()->each(fn (Tweet $tweet) => Image::factory()->count(4)->create()->each(fn (Image $image) => $tweet->images()->attach($image->id)
        )
        );
    }
}
────────────────


────────────────
sail artisan db:seed --class=TweetsSeeder
────────────────


■P236

●app/Services/TweetService.php
────────────────
    public function getTweets()
    {
        return Tweet::with('images')->orderBy('created_at', 'DESC')->get();
    }
────────────────


●app/Http/Controllers/Tweet/IndexController.php
────────────────
        $tweets = $tweetService->getTweets();
        dd($tweets);
────────────────


■P238

●app/Http/Controllers/Tweet/IndexController.php
────────────────
        \DB::enableQueryLog();
        $tweets = $tweetService->getTweets();
        dd(\DB::getQueryLog());
────────────────

────────────────
sail npm install alpinejs@^3.15.8 -D
────────────────


■P239

●resources/js/bootstrap.js
────────────────
import Alpine from 'alpinejs';
window.Alpine = Alpine;
Alpine.start();
────────────────

●resources/views/components/tweet/images.blade.php
────────────────
@props([
    'images' => []
])

@if(count($images) > 0)
    <div x-data="{}" class="px-2">
        <div class="flex justify-center -mx-2">
            @foreach($images as $image)
                <div class="w-1/6 px-2 mt-5">
                    <div class="bg-gray-400">
                        <a @click="$dispatch('img-modal', {  imgModalSrc: '{{ $image->url }}' })" class="cursor-pointer">
                            <img alt="{{ $image->name }}" class="object-fit w-full" src="{{ $image->url }}">
                        </a>
                    </div>
                </div>
            @endforeach
        </div>
    </div>
@endif
────────────────


■P240

●resources/views/components/tweet/images.blade.php
────────────────
@once
    <div x-data="{ imgModal : false, imgModalSrc : '' }"
         @img-modal.window="imgModal = true; imgModalSrc = $event.detail.imgModalSrc;">
        <div
            x-cloak
            x-show="imgModal"
            x-transition:enter="transition ease-out duration-300"
            x-transition:enter-start="opacity-0"
            x-transition:enter-end="opacity-100"
            x-transition:leave="transition ease-in duration-300"
            x-transition:leave-start="opacity-100"
            x-transition:leave-end="opacity-0"
            @click="imgModal = false"
            class="fixed w-full h-full inset-0 z-50 overflow-hidden flex justify-center items-center bg-black/75">
            <div @click.stop class="flex flex-col max-w-full max-h-full overflow-auto p-4">
                <div class="z-50">
                    <button @click="imgModal = false" class="float-right p-2 outline-none focus:outline-none">
                        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6 text-white">
                            <path fill-rule="evenodd" d="M5.47 5.47a.75.75 0 0 1 1.06 0L12 10.94l5.47-5.47a.75.75 0 1 1 1.06 1.06L13.06 12l5.47 5.47a.75.75 0 1 1-1.06 1.06L12 13.06l-5.47 5.47a.75.75 0 0 1-1.06-1.06L10.94 12 5.47 6.53a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
                        </svg>
                    </button>
                </div>
                <div class="flex justify-center items-center flex-1">
                    <img
                        class="object-contain max-w-full max-h-full"
                        :alt="imgModalSrc"
                        :src="imgModalSrc">
                </div>
            </div>
        </div>
    </div>
    @push('css')
        <style>
            [x-cloak] { display: none !important; }
        </style>
    @endpush
@endonce
────────────────


■P241

●resources/views/components/tweet/list.blade.php
────────────────
@props([
    'tweets' => []
])
<div class="bg-white rounded-md shadow-lg mt-5 mb-5">
    <ul>
        @foreach($tweets as $tweet)
            <li class="border-b last:border-b-0 border-gray-200 p-4 flex items-start justify-between">
                <div>
                    <span class="inline-block rounded-full text-gray-600 bg-gray-100 px-2 py-1 text-xs mb-2">
                        {{ $tweet->user->name }}
                    </span>
                    <p class="text-gray-600">{!! nl2br(e($tweet->content)) !!}</p>
                    <x-tweet.images :images="$tweet->images"/>
                </div>
                <div>
                    <x-tweet.options :tweetId="$tweet->id" :userId="$tweet->user_id"></x-tweet.options>
                </div>
            </li>
        @endforeach
    </ul>
</div>
────────────────


■P242

────────────────
sail npm run dev
────────────────


■P243

●app/Http/Requests/Tweet/CreateRequest.php
────────────────
<?php

namespace App\Http\Requests\Tweet;

use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;

class CreateRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            'tweet' => 'required|max:140',
            'images' => 'array|max:4',
            'images.*' => 'required|image|mimes:jpeg,png,jpg,gif|max:2048',
        ];
    }

    public function userId(): int
    {
        return $this->user()->id;
    }

    public function tweet(): string
    {
        return $this->input('tweet');
    }

    public function images(): array
    {
        return $this->file('images', []);
    }
}
────────────────


■P244

●app/Services/TweetService.php
────────────────
use App\Models\Image;
use App\Models\Tweet;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;

// 略

    public function saveTweet(int $userId, string $content, array $images): void
    {
        DB::transaction(function () use ($userId, $content, $images) {
            $tweet = new Tweet;
            $tweet->user_id = $userId;
            $tweet->content = $content;
            $tweet->save();
            /** @var \Illuminate\Http\UploadedFile $image */
            foreach ($images as $image) {
                Storage::disk('public')->putFile('images', $image);
                $imageModel = new Image;
                $imageModel->name = $image->hashName();
                $imageModel->save();
                $tweet->images()->attach($imageModel->id);
            }
        });
    }
────────────────


■P246

●app/Http/Controllers/Tweet/CreateController.php
────────────────
use App\Http\Controllers\Controller;
use App\Http\Requests\Tweet\CreateRequest;
use App\Services\TweetService;

class CreateController extends Controller
{
    public function __invoke(CreateRequest $request, TweetService $tweetService)
    {
        $tweetService->saveTweet(
            $request->userId(),
            $request->tweet(),
            $request->images()
        );

        return redirect()->route('tweet.index');
    }
}
────────────────

●resources/views/components/tweet/form/images.blade.php
────────────────
<div x-data="{
    fields: [],
    addField() {
        const i = this.fields.length;
        this.fields.push({
            file: '',
            id: `input-image-${i}`
        });
    },
    removeField(index) {
        this.fields.splice(index, 1);
    }
}" class="my-2">
    <template x-for="(field, i) in fields" :key="i">
        <div class="w-full flex my-2">
            <label :for="field.id" class="border border-gray-300 rounded-md p-2 w-full bg-white cursor-pointer">
                <input type="file" accept="image/*" class="sr-only" :id="field.id" name="images[]" @change="fields[i].file = $event.target.files[0]">
                <span x-text="field.file ? field.file.name : '画像を選択'" class="text-gray-700"></span>
            </label>
            <button type="reset" @click="removeField(i)" class="p-2">
                <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="h-5 w-5 text-red-500 hover:text-red-700">
                    <path stroke-linecap="round" stroke-linejoin="round" d="m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
                </svg>
            </button>
        </div>
    </template>

    <template x-if="fields.length < 4">
        <button type="button" @click="addField()" class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-gray-500 hover:bg-gray-600">
            <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="h-5 w-5 mr-2">
                <path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
            </svg>
            <span>画像を追加</span>
        </button>
    </template>
    @error('images')
        <x-form.error>{{ $message }}</x-form.error>
    @enderror
    @error('images.*')
        <x-form.error>{{ $message }}</x-form.error>
    @enderror
</div>
────────────────


■P248

●resources/views/components/tweet/form/post.blade.php
────────────────
@auth
    <div class="p-4">
        <form action="{{ route('tweet.create') }}" method="post" enctype="multipart/form-data">
            @csrf
            <x-form.textarea
                name="tweet"
                rows="3"
                placeholder="つぶやきを入力"
                helpText="140文字まで"
                required
            />
            <x-tweet.form.images/>
            <div class="flex flex-wrap justify-end mt-4">
                <x-element.button>
                    つぶやく
                </x-element.button>
            </div>
        </form>
    </div>
@endauth
────────────────


■P250

●app/Services/TweetService.php
────────────────
    public function deleteTweet(int $tweetId): void
    {
        DB::transaction(function () use ($tweetId) {
            $tweet = Tweet::query()->where('id', $tweetId)->firstOrFail();
            $tweet->images()->each(function ($image) use ($tweet) {
                $filePath = 'images/' . $image->name;
                $storage = Storage::disk('public');
                if ($storage->exists($filePath)) {
                    $storage->delete($filePath);
                }
                $tweet->images()->detach($image->id);
                $image->delete();
            });

            $tweet->delete();
        });
    }
────────────────


■P251

●app/Http/Controllers/Tweet/DeleteController.php
────────────────
<?php

namespace App\Http\Controllers\Tweet;

use App\Http\Controllers\Controller;
use App\Services\TweetService;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;

class DeleteController extends Controller
{
    /**
     * Handle the incoming request.
     */
    public function __invoke(Request $request, TweetService $tweetService)
    {
        $tweetId = (int) $request->route('tweetId');
        if (! $tweetService->checkOwnTweet($request->user()->id, $tweetId)) {
            throw new AccessDeniedHttpException;
        }
        $tweetService->deleteTweet($tweetId);

        return redirect()
            ->route('tweet.index')
            ->with('feedback.success', 'つぶやきを削除しました');
    }
}
────────────────
