トップページ
Laravel学習サイトLaravelやるばい

目次

summernoteで記事の内容の作成・公開と下書き保存

今回のアプリはブログです。

少しCSSを組みますのでポートフォリを掲載するのに使って貰って大丈夫です。

今まで保存・編集・削除の処理の解説をしていますが新しいアプリのたびに同じ内容を読んでもつまらないと思うので説今まで解説した内容はコードだけ載せます。

説明をどこまで書けばいいかで悩んでいて説明を変える可能性があるので記事の内容が変わっていたらご了承下さい。


難易度

今回の解説はフロントエンドとバックエンドが連携していて難易度が高いです。

だからプログラムを本格的に勉強するのが当サイトが初めての人は今回の記事は訳が分からないかもしれません。

記事を読んで訳が分からないと思ったら今回の記事は読むのをやめてこのサイトの他の記事を読んでLaravelにもっと慣れてからこの記事を読んだ方がいいです。

今の私はこの記事を読んでも普通に感じますがLaravelを学びたての時は訳が分からなかったはずです。

ブログサイトを作ることができたら調べたりAIを駆使して自分が作りたいアプリを作るフェーズになるので個人開発をしましょう。


ブログサイトに登場する新しい機能

今回登場する新しい内容は下記になります。


summernote

summernoteはかなり癖があります。

また情報が少なすぎるのとリファレンスを読んでもよく分からないので今回の内容でとりあえず実装ができればいいと思います。


テーブルに対する処理

今までテーブルに値を保存したり編集・削除をする時は全てコントローラーに記述していました。

その方がLaravelを勉強し始めの人にとってやりやすいと思ったからです。

テーブルにアクセスするのはモデルの役割なのでブログサイトの解説ではテーブルにアクセスする記述はモデルに行います。

その際にPHPのクラスやクラスメソッドを使うのでまだ学習してない人は学習してからこの記事を読んだ方がいいです。


Ajax

削除ボタンを押したら記事を削除できますが画面の下の方にある記事を削除した時に画面の読み込みが発生して画面の一番上に移動してどの記事を削除したかが分からなくなります。

こんな時に画面読み込みをせずに削除するのにAjaxを使います。

jQuery・JavaScript・React.js・Vue.jsのフロントエンドで一番使う記述は恐らくAjaxです。

削除以外でも画面読み込みをしたくない場合が色々あるのでめちゃくちゃよく使います。

だからAjaxはできるようにした方がいいです。


記事の公開・下書き保存

今までは全て作成でしたがブログの場合は今すぐ公開するのではなく下書き保存にする場合があるのでブログサイト作りで解説します。

それでは説明します。

各自Breezeでログイン・ユーザー登録ができるようにします。


postsテーブルの作成

ブログの記事を保存するテーブルをpostsテーブルとします。

カラムは下記になります。

「php artisan migrate」でpostsテーブルを作成したらweb.phpに下記の記述をします。

<?php

use App\Http\Controllers\ProfileController;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\PostController;          //この行を追加

/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider and all of them will
| be assigned to the "web" middleware group. Make something great!
|
*/

Route::get('/', function () {
    return view('welcome');
});

Route::get('/dashboard', function () {
    return view('dashboard');
})->middleware(['auth', 'verified'])->name('dashboard');

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


//ここから追加
Route::middleware('auth')->group(function () {
    Route::controller(PostController::class)->group(function() {
        Route::get('/post/create', 'create')->name('post.create');

        Route::post('/post/store', 'store')->name('post.store');

        Route::post('/post/image', 'uploadImage')->name('post.image');
    });
});
//ここまで追加

require __DIR__.'/auth.php';


summernoteを使った保存の処理

create.blade.phpに下記の記述をします。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">

    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
    <link href="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-bs4.min.css" rel="stylesheet">
    <script src="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-bs4.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/summernote@0.8.20/src/lang/summernote-ja-JP.js"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.0/js/bootstrap.min.js"></script>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">
    <meta name="csrf-token" content="{{ csrf_token() }}">  
    <title>記事の作成</title>
</head>
<body>
    <div class="container pt-5 pb-5">
        @if(session('message'))
            <p class="pt-2 pb-2 text-center flash">{{ session('message') }}</p>
        @endif
        @if($errors->any())
            <div>
                <p>記事の登録ができません</p>
                <ul>
                    @foreach($errors->all() as $error)
                    <li>{{$error}}</li>
                    @endforeach
                </ul>
            </div>
        @endif
        <h2>問題の作成</h2>
        <form class="mt-2" action="{{ route('post.store') }}" method="post" enctype="multipart/form-data">
            @csrf
            <div>
                <label class="label">タイトル : </label>
                <input type="text" name="title" class="d-inline-block title" value="{{ old('title') }}" required>
            </div>
            <div class="mt-3">
                <textarea name="content" id="summernote" cols="30" rows="10">{{ old('content') }}</textarea>
            </div>
            <div class="eyecatch mt-3">
                <h2>アイキャッチ画像</h2>
                <div class="mt-2">
                    <input type="file" name="eyecatch" accept="image/jpg,image/png">
                </div>
            </div>
            <div class="mt-5">
                <button type="submit" name="status" value="公開" class="border-0">公開</button>
                <button type="submit" name="status" value="下書き" class="border-0">下書き保存</button>
            </div>
        </form>
    </div>
</body>
</html>

<script>
    $(document).ready(function() {
        $('#summernote').summernote({
            placeholder: 'ここにテキストを書きます。画像も入れる事ができます。',
            height: 500,
            lang: 'ja-JP',
            toolbar: [
                ['style', ['style']],
                ['font', ['bold', 'underline', 'clear']],
                ['fontname', ['fontname']],
                ['color', ['color']],
                ['para', ['ul', 'ol', 'paragraph']],
                ['table', ['table']],
                ['insert', ['link', 'picture', 'video']],
                ['view', ['fullscreen', 'codeview', 'help']]
            ],
            callbacks: {
                onImageUpload: function(files) {
                    sendFile(files[0]);
                },
            }
        });

        function sendFile(file) {
            let form_data = new FormData();

            form_data.append('summernote-image', file);
            
            $.ajax({
                headers: {'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')},
                data: form_data,
                type: "POST",
                contentType: 'multipart/form-data',
                url: '/post/image',
                cache: false,
                contentType: false,
                processData: false,
            })
            .done(function(url){
                $('#summernote').summernote('insertImage', url);
            })
            .fail(function(XMLHttpRequest, textStatus, errorThrown){
                console.log(XMLHttpRequest.status);
                console.log(textStatus);
                console.log(errorThrown.message);
            })
            .always(function(url){
                console.log('画像を送りました');
            });
        }

    });
</script>

<style>
    .container{
        width: 70%;
        margin-left:auto;
        margin-right:auto;
        padding-top: 50px;
    }
    .title{
        width: 300px;
    }
    .flash{
        border:1px solid red;
    }
    h2{
        font-size: 18px;
        font-weight: bold;
    }
</style>

「http://localhost:8888/post/create」にアクセスすると下記の見た目になります。

20250102_165456_blog1.jpg

上記赤枠にsummernoteを使います。

summernoteを使うにはbootstrapとjqueryが必要なのでheadタグで読み込みをしています。(下記参照)

<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
<link href="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-bs4.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-bs4.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/summernote@0.8.20/src/lang/summernote-ja-JP.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.0/js/bootstrap.min.js"></script>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">

summernoteのエディタを表示するにはビューでid属性を指定しないといけません。(下記参照)

<textarea name="content" id="summernote" cols="30" rows="10">{{ old('content') }}</textarea>

「id="summernote"」の記述をしていますがjQueryで下記を合わせないといけません。

$('#summernote').summernote({     //←の「#summernote」
    placeholder: 'ここにテキストを書きます。画像も入れる事ができます。',
    height: 500,
    lang: 'ja-JP',
    toolbar: [
        ['style', ['style']],
        ['font', ['bold', 'underline', 'clear']],
        ['fontname', ['fontname']],
        ['color', ['color']],
        ['para', ['ul', 'ol', 'paragraph']],
        ['table', ['table']],
        ['insert', ['link', 'picture', 'video']],
        ['view', ['fullscreen', 'codeview', 'help']]
    ],
});

エディタのボタンがあります。(下記赤枠)

20250102_170937_blog3.jpg

それを設定しているのが下記の記述です。

toolbar: [
    ['style', ['style']],
    ['font', ['bold', 'underline', 'clear']],
    ['fontname', ['fontname']],
    ['color', ['color']],
    ['para', ['ul', 'ol', 'paragraph']],
    ['table', ['table']],
    ['insert', ['link', 'picture', 'video']],
    ['view', ['fullscreen', 'codeview', 'help']]
],

画像をエディタ内に使うのは下記画像の赤枠でできます。

20250102_170021_blog2.jpg

画像を選択してエディタの中に画像が表示されるようにする為の記述が下記です。

$('#summernote').summernote({
           ・
       ・
    callbacks: {
        onImageUpload: function(files) {
            sendFile(files[0]);
        },
    }
});

function sendFile(file) {
    let form_data = new FormData();

    form_data.append('summernote-image', file);
    
    $.ajax({
        headers: {'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')},
        data: form_data,
        type: "POST",
        contentType: 'multipart/form-data',
        url: '/post/image',
        cache: false,
        contentType: false,
        processData: false,
    })
    .done(function(url){
        $('#summernote').summernote('insertImage', url);
    });
}

下記のコードはsummernoteの書き方なのでそんなもんと思っていいと思います。

callbacks: {
    onImageUpload: function(files) {
        sendFile(files[0]);
    },
}

画像を表示するのをsendFileメソッドで行っていてその中でAjaxを使っています。

Ajaxを使った時の挙動はまずアクションにデータを送信してアクションからAjaxにデータが返って来るのですがsummernoteを使っている場合は動作が特殊です。

Ajaxでアクションに送信する為の記述は下記になります。

$.ajax({
    headers: {'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')},
    data: form_data,
    type: "POST",
    contentType: 'multipart/form-data',
    url: '/post/image',
    cache: false,
    contentType: false,
    processData: false,
})

アクションから返ってきてからの処理は下記になります。

.done(function(url){
    $('#summernote').summernote('insertImage', url);
})

この記述でsummernoteのエディタの中に画像が表示されます。

アクションに送信する記述で大事な部分を見ていきます。

下記のコードがアクションに送信するデータです、アクションに送信するのはform_dataです。

data: form_data

送信する時はURLを設定しないといけません、それが下記のコードです。

url: '/post/image'

URLを設定したのでそれに対するweb.phpの設定をしないといけないのですが下記になります。

Route::post('/post/image', 'uploadImage')->name('post.image');

Ajaxから送信するデータ(form_data)を受け取る為のアクションのuploadImageをPostController.phpに記述します。

public function uploadImage(Request $request)
{
    if ($request->file('summernote-image')->isValid()) {

        $original_file_name = request()->file('summernote-image')->getClientOriginalName();

        $file_name = date('Ymd_His') . '_' . $original_file_name;

        request()->file('summernote-image')->move('storage/images', $file_name);

        echo '/storage/images/' . $file_name;
    }
}

「summernote-image」はAjaxで送信するデータのform_dataに含まれています。(下記参照)

form_data.append('summernote-image', file);

コードの中に「storage」がありますがあとで説明しますので待って下さい。

Ajaxを使った場合はuploadImageアクションの中にビューに返す記述があるのですがsummernoteを使う場合はその記述が必要ないみたいです。


作成・下書き保存の処理

記事を作成・下書き保存するページの表示と記事を保存する為の処理をPostControllerに記述します。

//ここから追加
public function create()
{
    return view('post.create');
}

public function store(Post $post, Request $request)
{
    $post->postStore($request);

    return back()->with('message', '記事を作成しました');
}
//ここまで追加


public function uploadImage(Request $request)
{
    if ($request->file('summernote-image')->isValid()) {

        $original_file_name = request()->file('summernote-image')->getClientOriginalName();

        $file_name = date('Ymd_His') . '_' . $original_file_name;

        request()->file('summernote-image')->move('storage/images', $file_name);

        echo '/storage/images/' . $file_name;
    }
}

今まではstoreアクションで保存の処理をしていますがモデルで行いそれをコントローラーで使います。

コントローラーとモデルを連携する時のコントローラーの記述は下記になります。

public function store(Post 変数名)
{
    変数名->モデルのメソッド;
}

変数名は「$post」でモデルのメソッドは「postStore」です。

Post.phpに下記の記述をします。

<?php

namespace App\Models;

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

class Post extends Model
{
    use HasFactory;

    protected $fillable = [
        'title',
        'content',
        'eyecatch',
        'delete_flag',
        'status'
    ];

    protected $attributes = [
        'delete_flag' => 0
    ];

    public function postStore($request) {
        $inputs_validation = $request->validate([
            'title' => 'required',
            'content' => 'required',
            'eyecatch' => 'required',
            'status' => 'required',
        ]);

        $inputs_validation['eyecatch'] = $this->uploadEyecatch($request);

        $post = Post::create([
            'title' => $inputs_validation['title'],
            'content' => $inputs_validation['content'],
            'eyecatch' => $inputs_validation['eyecatch'],
            'status' => $inputs_validation['status'],
        ]);

        return $post;
    }

    private function uploadEyecatch($request) {
        if ($request->file('eyecatch')) {
            $original_file_name = $request->file('eyecatch')->getClientOriginalName();

            $store_file_name = date('Ymd_His') . '_' . $original_file_name;

            $request->file('eyecatch')->move('storage/eyecatchs', $store_file_name);

            return $store_file_name;
        }
    }
}

下記の記述はデフォルト値を設定する為にあります。

protected $attributes = [
    'delete_flag' => 0
];

delete_flagは削除のフラグです。

記事を直接削除せずdelete_flagの値を0から1に切り替えることで削除した扱いにします。(論理削除と言います)

今まではレコードを削除する物理削除を使っていましたが実際は削除せず削除の扱いにする論理削除の方が何かと便利だと思うし仕事ではよく使います。

uploadEyecatchメソッドはアイキャッチ画像を登録する為のメソッドです。

uploadEyecatchメソッドをpostStoreメソッドの中に記述せず1つ目のメソッドにしている理由は更新する時のメソッドでも使う為です。

ちなみにテーブルに値を保存する時に画像を受け付けることができるようにする為にビューに下記の記述をしています。

<form class="mt-2" action="{{ route('post.store') }}" method="post" enctype="multipart/form-data">    //←enctype="multipart/form-data"のこと
    @csrf
    <div>
        <label class="label">タイトル : </label>
        <input type="text" name="title" class="d-inline-block title" value="{{ old('title') }}" required>
    </div>

次はstorageについて説明します。


シンボリックリンク

Laravelでページを表示する時は「public」フォルダの中身が表示されます。

画像を表示する時はpublicフォルダの中に設置した画像を使いますが直接設置すると何らかの方法で第三者から画像を触られる可能性があるので間接的に画像を配置します。

シンボリックリンクという物を使いますが下記のコマンドを叩けばいいです。

php artisan storage:link

これでpublicの外にstorageフォルダが作成されてそこからpublicフォルダにアクセスできるようになります。

20250102_192602_blog4.jpg

imageフォルダとeyecatchesフォルダは自動的に作成されるので作成しないくていいです。

最後に動作確認をします。


記事の作成または下書き保存

タイトル・内容・アイキャッチ画像を入力した状態で公開・下書き保存をした時のpostsテーブルを確認します。

20250112_133626_blog-site5.jpg

公開・下書き保存をした時にレコードに違いがあるのを確認できると思います。

20250112_134003_blog-site6.jpg













 

戻る