【開発編Part2】半自動で運営される個人開発集サイト作ってみた

プログラミング

こんにちは!

今回は「半自動で運営される個人開発集サイト作ってみた」シリーズの第4弾ということで、開発編Part2をご紹介していきます。

前回の開発編Part1は以下から確認できます。

【開発編Part1】半自動で運営される個人開発集サイト作ってみた

前回は主にユーザー側の実装を行いました。

今回は管理側の実装ということで、管理画面から記事の閲覧や作成を行えるようにしていきましょう。

管理側はCRUDなどLaravelの基本的な機能を使って作っていくので、Laravelの基礎的な知識を身につけることができると思います😊

それでは早速みていきましょう!

管理側ログイン

ログイン画面

まずはログイン画面から作っていきます。

resources/viewsにadminディレクトリを作成し、login.blade.phpを作成します。

cd resources/views
mkdir admin
cd admin
touch login.blade.php

次に画面を作成します。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>個人開発集 | ログイン画面</title>
    <style>
        .main {
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
        }
        .card {
            padding: 50px;
            border: 1px solid #ccc;
            border-radius: 5px;
        }
        .card h1 {
            text-align: center;
            margin-bottom: 20px;
        }
        .login-form {
            display: flex;
            flex-direction: column;
        }
        .form-group {
            margin-bottom: 20px;
        }
        .form-group label {
            display: block;
            margin-bottom: 5px;
        }
        .form-group input {
            width: 400px;
            padding: 10px;
            border: 1px solid #ccc;
            border-radius: 5px;
        }
        .submit-btn {
            width: 100px;
            padding: 10px;
            border: 1px solid #ccc;
            border-radius: 5px;
            background-color: #fff;
            cursor: pointer;
        }
    </style>
</head>
<body>
    <div class="main">
        <div class="card">
            <h1>ログイン</h1>
            <form action="{{ route('admin.auth.login') }}" method="POST" class="login-form">
                @csrf
                <div class="form-group">
                    <label for="email">メールアドレス</label>
                    <input type="email" id="email" name="email">
                </div>
                <div class="form-group">
                    <label for="password">パスワード</label>
                    <input type="password" id="password" name="password">
                </div>
                <div class="btn-group">
                    <button class="submit-btn" type="submit">ログイン</button>
                </div>
            </form>
        </div>
    </div>
</body>
</html>

formの書き方は普通のLaravelの書き方ですね。

ログイン処理のルーティング名はadmin.auth.loginとします。セキュリティ対策の@csrfは忘れないように書いておきましょう。

ルーティング

次に、ログイン処理のルーティングを設定していきます。

今回、管理側のルーティングはユーザー側と分けたいため、別で作成していきます。

cd routes
touch admin.php

admin.phpを編集します。

<?php
use Illuminate\\Support\\Facades\\Route;

// 管理側
Route::prefix('admin')->group(function () {
    Route::get('/login', function () { return view('admin.login'); })->name('admin.auth');
    Route::post('/login', 'App\\Http\\Controllers\\Admin\\AdminUserController@login')->name('admin.auth.login');
});

そして、このadmin.phpファイルをroutes/web.phpファイル内で読み込みます。

include __DIR__ . '/admin.php';

管理ユーザガード

管理ユーザのガードを作成していきます。

config/auth.phpを開き、以下のように編集します。

'guards' => [
    'web' => [
        'driver' => 'session',
        'provider' => 'users',
    ],
    // 以下を追加
    'admin_users' => [
        'driver' => 'session',
        'provider' => 'admin_users',
    ]
],

'providers' => [
    'users' => [
        'driver' => 'eloquent',
        'model' => App\\Models\\User::class,
    ],
    // 以下を追加
    'admin_users' => [
        'driver' => 'eloquent',
        'model' => App\\Models\\AdminUser::class,
    ]

    // 'users' => [
    //     'driver' => 'database',
    //     'table' => 'users',
    // ],
],

これで管理ユーザ用のガードが作成できました。

ログイン機能

それでは、実際にログインの処理を作っていきましょう。

Admin/AdminUserController.phpを作成します。

php artisan make:controller Admin/AdminUserController

AdminUserControllerを編集します。

<?php

namespace App\\Http\\Controllers\\Admin;

use App\\Http\\Controllers\\Controller;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\Auth;

class AdminUserController extends Controller
{
    public function login(Request $request)
    {
        $credentials = $request->only('email', 'password');
        if (Auth::guard('admin_users')->attempt($credentials)) {
            $request->session()->regenerate(); // セッションIDを再生成
            return redirect()->route('admin.articles.index');
        }
        return back()->withErrors([
            'email' => 'メールアドレスかパスワードが間違っています。',
        ]);
    }
}

以下が肝で、この一文でEメールとパスワードが問題ないかを確認しています

Auth::guard('admin_users')->attempt($credentials)

また、デフォルトでは”usersテーブルの”Eメールとパスワードを参照してしまうのですが、管理ユーザガードを使用しているため、admin_usersテーブルを参照してくれるようになっていることもポイントです。

管理側ログアウト

続けてログアウト機能も作っておきましょう。

ログアウト機能

AdminUserControllerを編集します。

public function logout(Request $request)
{
    Auth::guard('admin_users')->logout();
    $request->session()->invalidate(); // セッションを無効化
    $request->session()->regenerateToken(); // CSRFトークンを再生成
    return redirect()->route('admin.auth');
}

単にログアウトするだけではなく、セッションを無効化してCSRFトークンを再生成している点に注意してください。これはセキュリティ対策のために実装しています。

この辺りについては、深くは言及しませんが、ベストプラクティス等を確認したい場合は公式ドキュメントを読みましょう。

認証 9.x Laravel

ルーティング

次にログアウト用のルーティングを追加します。

Route::post('/logout', 'App\\Http\\Controllers\\Admin\\AdminUserController@logout')->name('admin.auth.logout');

これでログアウトは完成です!

管理側記事一覧

次に管理側の記事一覧画面を作成していきます。

レイアウト

早速記事一覧画面を作っていきたいところですが、先に管理側全体のレイアウトを作っていきましょう。

resources/viewsに入ってlayoutsディレクトリを作成し、その中にadmin.blade.phpを作成します。

cd resources/views
mkdir layouts && touch layouts/admin.blade.php

admin.blade.phpを編集します。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>個人開発 | @yield('title')</title>
    <style>
        body {
            margin: 0;
            padding: 0;
        }
        .main {
            padding: 3rem 0;
        }
        .nav {
            display: flex;
            justify-content: space-between;
            align-items: center;
            background-color: lightblue;
            padding: 0 2rem;
        }
        .nav p {
            font-size: 1.5rem;
            margin-left: 1rem;
        }
        .nav ul {
            display: flex;
            gap: 1rem;
            list-style: none;
        }
        .nav ul li {
            display: flex;
            align-items: center;
            jusitfy-content: center;
        }
        .logout-btn {
            cursor: pointer;
            padding: 0.5rem 1rem;
            border: none;
            border-radius: 5px;
            background-color: #5271FF;
            color: white;
        }
    </style>
    @yield('style')
</head>
<body>
    <nav class="nav">
        <p>管理画面</p>
        <ul>
            <li><a href="{{ route('admin.articles.index') }}">記事一覧</a></li>
            <form action="{{ route('admin.auth.logout') }}" method="POST">
                @csrf
                <button class="logout-btn" type="submit">ログアウト</button>
            </form>
        </ul>
    </nav>
    <div class="main">
        @yield('content')
    </div>
    @yield('script')
</body>

レイアウトのポイントは、@yieldです。@yieldの箇所は、各画面で好きな値を入れることができます。

記事一覧画面

レイアウトができたので、記事一覧画面を作っていきます。

resources/views/adminにarticlesディレクトリを作成し、index.blade.phpを作成します。

cd resources/views/admin
mkdir articles && touch articles/index.blade.php

index.blade.phpを編集します。

@extends('layouts.admin')
@section('title', '管理側記事一覧')

@section('style')
@endsection

@section('content')
    <h1>記事一覧</h1>
@endsection

@section('script')
@endsection

先に作っておいたレイアウトを使いたいので、1行目の@extends(’layouts.admin’)を忘れずに書いておきましょう。

あとは@sectionでレイアウトの@yieldに要素を入れていきます。

最初はこれくらいにして、まずはログインが機能するか確認しましょう。

ログイン機能の動作確認

まずはtinkerを使ってテストユーザを作ります。

php artisan tinker
> use App\\Models\\AdminUser;
> use Illuminate\\Support\\Facades\\Hash;
> $user = new AdminUser();
> $user->name = 'testuser';
> $user->email = 'testuser@example.com';
> $user->password = Hash::make('testpass');
> $user->save();

ちなみにテストデータを作る方法は他にもSeederなど色々方法があります。どれを選んでもOKです。

次に、ログイン画面に入って、テストユーザで実際にログインを行ってみましょう。

先ほど作成したユーザーのEメールとパスワードを入力し、ログインできたら成功です。

コントローラ

ここからは、本格的に記事一覧機能を作っていきます。

まずはコントローラを作成します。

php artisan make:controller Admin/ArticleController

次に、indexアクションを実装していきます。

<?php

namespace App\\Http\\Controllers\\Admin;

use App\\Http\\Controllers\\Controller;
use Illuminate\\Http\\Request;
use App\\Models\\Article;

class ArticleController extends Controller
{
    const PAGINATE = 20;

    public function index(Request $request)
    {
        $query = Article::orderBy('article_updated_at', 'desc');
        if ($request->keyword) {
            $query->where('title', 'like', '%' . $request->keyword . '%');
        }
        $articles = $query->paginate(self::PAGINATE);
        return view('admin.articles.index', ['articles' => $articles]);
    }
}

記事は最新記事の20件分を取得して取得しています。

$query->paginate(self::PAGINATE)でページネーションを実装し、1ページに20件ずつ表示するようにしています。

また、タイトル検索機能をつけたいので、以下でタイトルフォームに入力されたキーワードでlike検索の処理を入れています。

if ($request->keyword) {
    $query->where('title', 'like', '%' . $request->keyword . '%');
}

モデル

Articleモデルも作成しておきましょう。

php artisan make:model Article

App\Models\Articleモデルが作成されるので、こちらを編集していきます。

第一回目の記事でも書きましたが、今回はソフトデリートする方針なので、モデルにSoftDeletesトレイトを入れておきます。

use HasFactory, SoftDeletes;

次に、更新する予定のフィールドを保護しておきます。

protected $fillable = [
    'title',
    'url',
    'source',
    'author_name',
    'author_profile_image_url',
    'article_created_at',
    'article_updated_at',
];

ルーティング

次に、app/routes/admin.phpを開いてルーティングを追加します。

Route::middleware('auth:admin_users')->group(function () {
    Route::get('/articles', 'App\\Http\\Controllers\\Admin\\ArticleController@index')->name('admin.articles.index');
});

記事一覧画面から先は認証を通過したユーザのみ入れるようにしたいので、ミドルウェアを設置しておきます。

記事一覧画面続き

ここまで設定できたら、記事一覧画面の続きを作っていきましょう。

resources/views/admin/articles/index.blade.phpを開いて編集します。

@extends('layouts.admin')
@section('title', '管理側記事一覧')
@section('style')
<style>
    .main {
        padding: 3rem 0;
        width: 90%;
        margin: 0 auto;
    }
    .card {
        display: flex;
    }
    .articles {
        display: flex;
        flex-direction: column;
        justify-content: center;
        align-items: center;
        gap: 50px;
    }
    table{
        table-layout: fixed;
        border-collapse: collapse;
        border-spacing: 0;
        width: 100%;
    }

    table tr{
        border-bottom: solid 1px #eee;
        cursor: pointer;
    }

    table tr:hover{
        background-color: #d4f0fd;
    }

    table th,table td{
        text-align: center;
        padding: 15px 0;
        overflow: hidden;
        white-space: nowrap;
        text-overflow: ellipsis;
    }

    .pagination {
        display: flex;
        gap: 10px;
    }
    .pagination a {
        display: flex;
        justify-content: center;
        align-items: center;
        border: 1px solid gray;
        border-radius: 5px;
        background-color: white;
        width: 40px;
        height: 40px;
        font-size: 1.2rem;
        text-decoration: none;
        color: black;
    }
    button {
        cursor: pointer;
        width: 100%;
        padding: 5px;
        border: 1px solid #ccc;
        border-radius: 5px;
        background-color: #fff;
    }
    .keyword-form {
        width: 300px;
        padding: 10px;
        border: 1px solid #ccc;
        border-radius: 5px;
    }
    .search-btn {
        width: 50px;
        padding: 5px 10px;
        border: 1px solid #ccc;
        border-radius: 5px;
        background-color: #fff;
        cursor: pointer;
    }
</style>
@endsection
@section('content')
    <h1>記事一覧</h1>
    <p>現在のページ: {{ $articles->currentPage() }}</p>
    <form action="{{ route('admin.articles.index') }}">
        <input type="text" name="keyword" class="keyword-form" placeholder="検索ワードを入力">
        <input type="submit" class="search-btn" value="検索" />
    </form>
    <div class="articles">
        <table>
            <thead>
                <tr>
                    <th style="width: 20%;">タイトル</th>
                    <th style="width: 30%;">URL</th>
                    <th style="width: 15%;">著者名</th>
                    <th style="width: 15%;">公開日</th>
                    <th style="width: 10%;">データ元</th>
                </tr>
            </thead>
            <tbody>
                @foreach ($articles as $article)
                <tr>
                    <td>{{ $article['title'] }}</td>
                    <td><a href="{{ $article['url'] }}" target="blank">{{ $article['url'] }}</a></td>
                    <td>{{ $article['author_name'] }}</td>
                    <td>{{ $article['article_updated_at'] }}</td>
                    <td>{{ $article['source'] }}</>
                </tr>
                @endforeach
            </tbody>
        </table>
        <div class="pagination">
            <a class="prev-btn" href="{{ $articles->previousPageUrl() }}"><</a>
            @if ($articles->currentPage() - 1 > 0)
            <a class="page" href="{{ $articles->url($articles->currentPage() - 1) }}" data-page="{{ $articles->currentPage() - 1 }}">{{ $articles->currentPage() - 1 }}</a>
            @endif
            <a class="page" href="{{ $articles->url($articles->currentPage()) }}" data-page="{{ $articles->currentPage() }}">{{ $articles->currentPage() }}</a>
            @if ($articles->currentPage() + 1 <= $articles->lastPage())
            <a class="page" href="{{ $articles->url($articles->currentPage() + 1) }}" data-page="{{ $articles->currentPage() + 1 }}">{{ $articles->currentPage() + 1 }}</a>
            @endif
            <a class="next-btn" href="{{ $articles->nextPageUrl() }}">></a>
        </div>
    </div>
@endsection
@section('script')
<script>
    const pageElements = document.querySelectorAll('.page');
    pageElements.forEach((elem) => {
        if (elem.dataset.page == {{ $articles->currentPage() }}) {
            elem.style.border = '1px solid #5271FF';
            elem.style.backgroundColor = '#5271FF';
            elem.style.color = 'white';
        }
    })
</script>
@endsection

今は記事の一覧表示機能しかないですが、後ほど記事編集や記事削除などの機能を追加していく予定です。

ここまでで、一旦ログインから記事一覧画面に入れるところまでを実装できました!

次回からは、記事作成から記事編集、記事削除までを説明していこうと思います。

お楽しみに😊

コメント

タイトルとURLをコピーしました