0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

実務1年目駆け出しエンジニアがLaravel&ReactでWebアプリケーション開発に挑戦してみた!(フロントエンド実装編⑤)~大学検索結果画面作成~

Posted at

実務1年目駆け出しエンジニアがLaravel&ReactでWebアプリケーション開発に挑戦してみた!(その26)

0. 初めに

本シリーズでは、見習いエンジニアの僕が一からWebアプリケーションを開発する過程をお届けしています!

前回からいよいよ各ページを作り始めました。
前回は、ホームページを作成しましたね!

今回は、ホームページで検索した結果のページを作りたいと思います!

1. ブランチ運用

いつも通り、developブランチを最新化して、新規ブランチを切って作業しましょう。

ブランチ名は、feature/frontend/search-result-pageとかにしますかね!

2. 画面デザイン

毎度おなじみの完成のイメージ図の共有です!

前回作ったホームページの検索バーに検索したい大学名を入力して、検索ボタンを押すと、以下のような感じで検索結果の一覧戻るボタンを表示するようにしたいと思います!
image.png

ちなみに、検索結果が一つもヒットしない場合は、以下のようにメッセージ戻るボタンを表示するようにしたいです。
image.png

今日これから作る画面をイメージはできましたか?

3. 修正: テーブル定義

フロントエンド編で、まさかのテーブル定義修正!!

ま、まぁ、ア、アジャイル開発ならよくあることっすよ...!!('◇')ゞ

何を修正するのかは、画面デザインを見たら察した方もいらっしゃるかと思います。
大学名の横に「国」とか「私」とかの区分が用意されています。

現在のテーブル定義ですと、主キーの他には大学名しかないため、このような表示はできません。

現在のUniversitiesテーブル定義
image.png

そのため、以下のようにその大学の区分を表すtypeカラムを追加します。

修正後のUniversitiesテーブル定義
image.png

4. マイグレーション修正

テーブル定義の修正によって、マイグレーションファイルを修正する必要があります。

\project-root\src\database\migrations\2025_05_30_072009_create_universities_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('universities', function (Blueprint $table) {
            $table->id();
            $table->string('name')->unique();
            $table->enum('type', ['national', 'public', 'private']); // 追加
            $table->timestamps();
            $table->softDeletes();
            $table->unsignedBigInteger('version')->default(1);
        });
    }

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

いや~、このファイル作ったの5月か~。懐かしいっすね。(*´ω`)

5. モデル修正

モデルにも影響が出ますので、修正しましょう。

\project-root\src\app\Models\University.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;

class University extends Model
{
    use SoftDeletes; // 論理削除

    protected $fillable = [
        'name',
        'type', // 追加
    ];

    protected static function boot()
    {
        parent::boot();

        static::deleting(function ($university) {
            // 論理削除時に関連する学部も論理削除
            $university->faculties()->get()->each->delete();
        });
    }

    // リレーションの定義
    // ユーザーとのリレーション(多対多)
    // 中間テーブル名を明示的に指定
    public function users()
    {
        return $this->belongsToMany(User::class, 'university_edit_histories')->withTimestamps();
    }

    // 学部とのリレーション(一対多)
    public function faculties()
    {
        return $this->hasMany(Faculty::class);
    }

    // 削除依頼とのポリモーフィックリレーション(一対多)
    public function deletionRequests()
    {
        return $this->morphMany(DeletionRequest::class, 'target');
    }

    // 作成者とのリレーション(多対一)
    public function creator()
    {
        return $this->belongsTo(User::class, 'created_by');
    }
}

こちらももはや懐かしのファイルです。
業務では最近フロントエンドを担当させていただくことがほとんどなので、PHPのファイルを見ること自体が懐かしいですw

6. シーディング修正

当然、影響はシーディングにも及びますね。

ついでに、テストデータを増やしておきました。

\project-root\src\database\seeders\UniversitySeeder.php
<?php

namespace Database\Seeders;

use App\Models\University;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;

class UniversitySeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        //  作成者を管理者に設定
        $adminId = User::where('email', 'admin@example.com')->value('id')
            ?? User::first()->id; // 念のため、管理者がいなければ最初のユーザーを使用
        // テストデータを修正
        University::create([
            'name' => 'テストA国立大学',
            'type' => 'national',
            'created_by' => $adminId,
        ]);

        University::create([
            'name' => 'テストB国立大学',
            'type' => 'national',
            'created_by' => $adminId,
        ]);

        University::create([
            'name' => 'テストC国立大学',
            'type' => 'national',
            'created_by' => $adminId,
        ]);

        University::create([
            'name' => 'テストD国立大学',
            'type' => 'national',
            'created_by' => $adminId,
        ]);

        University::create([
            'name' => 'テストE県立大学',
            'type' => 'public',
            'created_by' => $adminId,
        ]);

        University::create([
            'name' => 'テストF県立大学',
            'type' => 'public',
            'created_by' => $adminId,
        ]);

        University::create([
            'name' => 'テストG市立大学',
            'type' => 'public',
            'created_by' => $adminId,
        ]);

        University::create([
            'name' => 'テストH市立大学',
            'type' => 'public',
            'created_by' => $adminId,
        ]);

        University::create([
            'name' => 'テストI私立大学',
            'type' => 'private',
            'created_by' => $adminId,
        ]);

        University::create([
            'name' => 'テストJ私立大学',
            'type' => 'private',
            'created_by' => $adminId,
        ]);

        University::create([
            'name' => 'テストK私立大学',
            'type' => 'private',
            'created_by' => $adminId,
        ]);

        University::create([
            'name' => 'テストL私立大学',
            'type' => 'private',
            'created_by' => $adminId,
        ]);
    }
}

修正出来たら、以下をDockerコンテナ内で実行してください。

実行コマンド

/var/www
$ php artisan migrate:fresh --seed

7. コントローラー修正

\project-root\src\app\Http\Controllers\UniversityController.php
    public function index(Request $request)
    {
        $query = $request->input('query', '');

        $universities = University::query()
            ->when($query, function ($queryBuilder) use ($query) {
                $queryBuilder->where('name', 'like', '%' . $query . '%');
            })
            ->orderBy('name')
            ->paginate(10) // 10件ずつ表示に変更
            ->withQueryString();

        return Inertia::render('University/Index', [
            'universities' => $universities,
            'query' => $query,
        ]);
    }

バックエンド編の時に、ページネーションうまくいっているかを確認するために、1ページ当たりの表示数を少なくしていました。

何件にするのがベストなのかが正直良くわからないのですが、とりあえず10件にしておきます。
動作が分かりにくい時は、適宜変えてみてください。

8. Reactコンポーネント作成

一応フロントエンド編なので、ここからが今日のメインです!

University/Indexを作成する

まずは、ページを表示する担当のコンポーネントを作成します。
既に、University/Index.jsxというファイルがありますが、こちらは一旦すべて消して、一から作り直したいと思います!!

\project-root\src\resources\js\Pages\University\Index.jsx
// 最初から書き直すぜベイベー♪

まずは、必要そうなモジュールのインポートからです!

\project-root\src\resources\js\Pages\University\Index.jsx
import { Head } from "@inertiajs/react";
import AppLayout from '@/Layouts/AppLayout';
import UniversityCard from '../../Components/University/UniversityCard';
import BackButton from '../../Components/Common/BackButton';
import Pagination from "../../Components/Common/Pagination";

コンポーネントたちは、この後作成していきます!

propsには、コントローラーから受け取るデータを指定します。
大学の情報が格納されているuniversities、ユーザーの検索文字列であるqueryの二つですね。

また、大学が1件以上あるかどうかで表示内容を少し変化させたいので、hasResultsという真偽値型の変数を用意しておきます。

\project-root\src\resources\js\Pages\University\Index.jsx
import { Head } from "@inertiajs/react";
import AppLayout from '@/Layouts/AppLayout';
import UniversityCard from '../../Components/University/UniversityCard';
import BackButton from '../../Components/Common/BackButton';
import Pagination from "../../Components/Common/Pagination";

const Index = ({ universities, query}) => {
  const hasResults = universities.data.length > 0;
}

export default Index;

jsxの部分には、このhasResultsを三項演算子を用いて利用します。

三項演算子は、このシリーズでも何度も登場してきています。
ここまで読んでくださっているあなたなら、もうバッチのはずです!(^^♪

\project-root\src\resources\js\Pages\University\Index.jsx
import { Head } from "@inertiajs/react";
import AppLayout from '@/Layouts/AppLayout';
import UniversityCard from '../../Components/University/UniversityCard';
import BackButton from '../../Components/Common/BackButton';
import Pagination from "../../Components/Common/Pagination";

const Index = ({ universities, query}) => {
  const hasResults = universities.data.length > 0;

  return (
    <AppLayout title={`「${query}」を含む大学一覧`}>
      <Head title={`「${query}」を含む大学一覧`} />

      {hasResults ? (
        // 1件以上の場合:コンテンツが少なければ戻るボタンは画面下部、多ければスクロール後に表示
        <div className="flex flex-col items-center min-h-full">
          <div className="w-full flex justify-end">
            <p className="text-[#747D8C]">{universities.total}件の検索結果</p>
          </div>
          <div className="w-full max-w-xl space-y-6 mt-8">
            {universities.data.map(university => (
              <UniversityCard key={university.id} university={university} />
            ))}
          </div>

          {/* ページネーション */}
          <Pagination paginator={universities} />
          
          <div className="mt-auto pt-8 pb-12">
            <BackButton routerName="home" />
          </div>
        </div>
      ) : (
        // 0件の場合:メッセージを画面中央に、戻るボタンは下部に固定
        <div className="flex flex-col items-center min-h-full">
          <div className="flex-1 flex items-center justify-center">
            <p className="text-[#747D8C]">0件の検索結果</p>
          </div>
          <div className="pt-8 pb-12">
            <BackButton routerName="home" />
          </div>
        </div>
      )}
    </AppLayout>
  )
}

export default Index;

例によって、共通レイアウトAppLayout.jsxを適用しています。

また、各コンポーネントに渡すpropsの定義は、この後しますので、今はすべて理解できなくても大丈夫ですヨ!

UniversityCardを作成する

ここからは、University/Index.jsxでインポートする各コンポーネントについて実装していきたいと思います。

まずは、検索結果の大学一つ一つを表すUniversityCardです!

\project-root\src\resources\js\Components\University\UniversityCard.jsx
import { router } from "@inertiajs/react";
import TypeBadge from "./TypeBadge";

const UniversityCard = ({ university }) => {
  return (
    <div
      className="bg-[#EEF7FB] rounded-lg shadow-md px-4 py-3 hover:shadow-lg transition-shadow cursor-pointer flex items-center gap-4"
      onClick={() => router.get(`/universities/${university.id}/faculties`)}
    >
      <TypeBadge type={university.type} className="flex-shrink-0 text-3xl" />
      <span className="text-2xl font-bold text-[#747D8C]">
        {university.name}
      </span>
    </div>
  );
}

export default UniversityCard;

例によって、Inertiaのrouterを使って、カードが検索されたときにその詳細ページに遷移できるようにしています。

また、TypeBadgeは大学の区分を表すもので、この後すぐ作成します。

TypeBadgeを作成する

次に、TypeBadgeです。

\project-root\src\resources\js\Components\University\TypeBadge.jsx
import NationalBadge from '../../Assets/icons/university/national.svg';
import PublicBadge from '../../Assets/icons/university/public.svg';
import PrivateBadge from '../../Assets/icons/university/private.svg';

const BADGE_MAP = {
  national: NationalBadge,
  public: PublicBadge,
  private: PrivateBadge,
};

const TypeBadge = ({ type }) => {
  const Icon = BADGE_MAP[type];
  return (
    <img
      src={Icon}
      alt={type}
      className='w-12 h-12 object-contain'
    />
  );
}

export default TypeBadge;

アイコンたちの画像ファイルは、以下に共有していますので、例によってダウンロードして指定のフォルダに格納してください。

typeBadge

BackButtonを作成する

続いて、一番下に表示する「戻るボタン」を構成するコンポーネントを作りましょう。

\project-root\src\resources\js\Components\Common\BackButton.jsx
import { router } from "@inertiajs/react";

const BackButton = ({ routerName, params = {}}) => {
  return (
    <button
        className="px-16 py-2 bg-[#EEF7FB] text-[#747D8C] shadow-md font-bold rounded-md hover:shadow-lg transition-shadow cursor-pointer"
        onClick={() => router.get(route(routerName, params))}
      >
        戻る
    </button>
  );
}

export default BackButton;

こちらも、routerを使って、propsで受け取った遷移先にクリック時に移動できるようにしています。

このようにpropsで遷移先を渡せるようにしておくことで、他のページでもこの戻るボタンを使いまわすことができます!

Paginationを作成する

最後は、ページネーションです。

こちら、画面デザインを作っているときに、バックエンド編でページネーションを作ったことをすっかり忘れておりました。💦

以下のようにしてみましょう。

\project-root\src\resources\js\Components\Common\Pagination.jsx
import { Link } from '@inertiajs/react';

const Pagination = ({ paginator }) => {
  const canGoPrev = paginator.current_page > 1;
  const canGoNext = paginator.current_page < paginator.last_page;

  // 1ページしかないときはそもそも出さない
  if (paginator.last_page <= 1) return null;

  return (
    <div className="mt-8 flex items-center justify-center text-sm">
      <div className="w-8 flex justify-end">
        {canGoPrev && (
          <Link
            href={paginator.prev_page_url}
            preserveScroll
            className="text-2xl text-[#747D8C] hover:text-gray-600"
          ></Link>
        )}
      </div>

      <span className="mx-4 text-[#747D8C]">
        {paginator.current_page} / {paginator.last_page}
      </span>

      <div className="w-8 flex justify-start">
        {canGoNext && (
          <Link
            href={paginator.next_page_url}
            preserveScroll
            className="text-2xl text-[#747D8C] hover:text-gray-600"
          ></Link>
        )}
      </div>
    </div>
  );
}

export default Pagination;

University/Index.jsxでpropsとして、paginatoruniversitesを渡します。
これはコントローラーで加工されたページネーションオブジェクトになっています。

Laravelのページネーションについて、軽く見てみましょう。

ペジネーターからのJSONは、total、current_page、last_pageなどのメタ情報を持っています。結果レコードは、JSON配列のdataキーを介して利用できます。ルートからペジネーションインスタンスを返すことによって作成されたJSONの例を以下で紹介します。

{
   "total": 50,
   "per_page": 15,
   "current_page": 1,
   "last_page": 4,
   "first_page_url": "http://laravel.app?page=1",
   "last_page_url": "http://laravel.app?page=4",
   "next_page_url": "http://laravel.app?page=2",
   "prev_page_url": null,
   "path": "http://laravel.app",
   "from": 1,
   "to": 15,
   "data":[
        {
            // レコード…
        },
        {
            // レコード…
        }
   ]
}

current_pageは現在のページ、last_pageは最後のページですね。

JSON形式なので、JavaScriptで簡単に取得することができます。
(まあ、このシリーズではあんまりJSONとか意識していなかったので、よくわからない方はスルーで大丈夫ですw)

これらを使って、ページの移動ができるかどうかを表す変数canGoPrev, canGoNextを用意しました。
これらを三項演算子に用いることで、ページを開けるかどうかに応じて表示内容をけることができるようにしています。

preserveScrollは、InertiaのLinkに付けることで、ページを移動してもスクロールの位置を保存することができます。

これにより、ページネーションで次のページに行ったときでも(特に戻ってきたときに効果を発しますが)上からスクロールしなおさずに済みます。
詳しくは、以下をご覧ください。

動作確認をする

これにて、必要なコンポーネントがすべてそろったはずなので、動作確認をしましょう!

適当な文字列で検索をしてみましょう。
表示件数によって、レイアウトが若干変化する様子をお楽しみください。

image.png
image.png
image.png

「カードをクリックして詳細ページに移動できるか」と「戻るボタンで正しく戻れるか」、「ページネーションをクリックしてページ遷移が正しくできるか」などを適宜ご確認ください。

9. (おまけその1)修正: 関数定義の書き方統一

今日の実装は完了なのですが、これまでのフロントエンド編で書き方が微妙だった箇所がいくつか見つかったので、「おまけ」ということで修正してみたいと思います。

それが、関数の定義の書き方の統一感がなかったことです。

以下の二つのファイルを比べて見てください。
どちらもこれまで作成してきたファイルです。

\project-root\src\resources\js\Layouts\AppLayout.jsx
import { Head, usePage } from '@inertiajs/react';
import { useState, useCallback, useEffect } from 'react';
import Header from "../Components/Header";
import Sidebar from "../Components/Sidebar";
import HamburgerMenu from '@/Components/HamburgerMenu';

export default function AppLayout({ children, title, mode='default' }) {
  // ユーザーの認証状態を管理
  const { props } = usePage();
  const isLoggedIn = !!props?.auth?.user;

  // サイドバーの開閉状態を管理
  const [isSidebarOpen, setIsSidebarOpen] = useState(false);
  const setSidebarOpen = useCallback((open) => setIsSidebarOpen(Boolean(open)), []);

  // ホームページかどうか
  const isHome = mode === 'home';

  // サイドバーが開いている間は、背景のスクロールを防止
  useEffect(() => {
    if (isSidebarOpen) document.body.classList.add('overflow-hidden');
    else document.body.classList.remove('overflow-hidden');
    return () => document.body.classList.remove('overflow-hidden');
  }, [isSidebarOpen]);

  // Escキーでサイドバーを閉じる
  useEffect(() => {
    const onKey = (e) => e.key === 'Escape' && setIsSidebarOpen(false);
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, []);

  return (
    <div className="min-h-dvh flex flex-col bg-[#EEF5F9]">
      <Head title={title} />

      {/* ホームモード: ハンバーガーアイコンのみを固定表示(サイドバー非表示時のみ) */}
      {isHome ? (
        !isSidebarOpen && (
          <div className="fixed top-4 right-6 z-50">
            <HamburgerMenu onOpenSidebar={() => setSidebarOpen(true)} />
          </div>
        )
      ) : (
        /* デフォルト: フルヘッダー表示 */
        <Header title={title} onOpenSidebar={() => setIsSidebarOpen(true)} />
      )}

      {/* メインコンテンツ領域 */}
      <main className="flex-1 bg-transparent flex">
        <div
          className="
            mx-auto
            w-full
            max-w-container
            px-[clamp(16px,4vw,32px)]
            py-6
            bg-[#EEF5F9]
            flex-1
          "
        >
          {children || <p className="text-gray-400 text-center">メインコンテンツ領域</p>}
        </div>
      </main>
      {/* サイドバー */}
      <Sidebar isOpen={isSidebarOpen} onClose={() => setSidebarOpen(false)} isLoggedIn={isLoggedIn} />
    </div>
  );
}
\project-root\src\resources\js\Components\Common\Pagination.jsx
import { Link } from '@inertiajs/react';

const Pagination = ({ paginator }) => {
  const canGoPrev = paginator.current_page > 1;
  const canGoNext = paginator.current_page < paginator.last_page;

  // 1ページしかないときはそもそも出さない
  if (paginator.last_page <= 1) return null;

  return (
    <div className="mt-8 flex items-center justify-center text-sm">
      <div className="w-8 flex justify-end">
        {canGoPrev && (
          <Link
            href={paginator.prev_page_url}
            preserveScroll
            className="text-2xl text-[#747D8C] hover:text-gray-600"
          ></Link>
        )}
      </div>

      <span className="mx-4 text-[#747D8C]">
        {paginator.current_page} / {paginator.last_page}
      </span>

      <div className="w-8 flex justify-start">
        {canGoNext && (
          <Link
            href={paginator.next_page_url}
            preserveScroll
            className="text-2xl text-[#747D8C] hover:text-gray-600"
          ></Link>
        )}
      </div>
    </div>
  );
}

export default Pagination;

AppLayout.jsxは、フロントエンド編の最初で作りましたね。
こっちは、関数の定義をfunctionキーワードで書いています。

一方で、今日作ったPagination.jsxは、アロー関数で書いています。

...どっちかにそろえたほうが気分が良い気がするので、統一します。

皆さんは、どちらがおすきですか?

僕は、アロー関数に統一したいなと思った(特に深い理由はありません)ので、AppLayout.jsxの方を修正したいと思います。

修正

\project-root\src\resources\js\Layouts\AppLayout.jsx
import { Head, usePage } from '@inertiajs/react';
import { useState, useCallback, useEffect } from 'react';
import Header from "../Components/Header";
import Sidebar from "../Components/Sidebar";
import HamburgerMenu from '@/Components/HamburgerMenu';

const AppLayout = ({ children, title, mode='default' }) => {
  // ユーザーの認証状態を管理
  const { props } = usePage();
  const isLoggedIn = !!props?.auth?.user;

  // サイドバーの開閉状態を管理
  const [isSidebarOpen, setIsSidebarOpen] = useState(false);
  const setSidebarOpen = useCallback((open) => setIsSidebarOpen(Boolean(open)), []);

  // ホームページかどうか
  const isHome = mode === 'home';

  //  サイドバーが開いている間は、背景のスクロールを防止
  useEffect(() => {
    if (isSidebarOpen) document.body.classList.add('overflow-hidden');
    else document.body.classList.remove('overflow-hidden');
    return () => document.body.classList.remove('overflow-hidden');
  }, [isSidebarOpen]);

  //  Escキーでサイドバーを閉じる
  useEffect(() => {
    const onKey = (e) => e.key === 'Escape' && setIsSidebarOpen(false);
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, []);

  return (
    <div className="min-h-dvh flex flex-col bg-[#EEF5F9]">
      <Head title={title} />

      {/* 修正: ホームモード: ハンバーガーアイコンのみを固定表示(サイドバー非表示時のみ) */}
      {isHome ? (
        !isSidebarOpen && (
          <div className="fixed top-4 right-6 z-50">
            <HamburgerMenu onOpenSidebar={() => setSidebarOpen(true)} />
          </div>
        )
      ) : (
        /* デフォルト: フルヘッダー表示 */
        <Header title={title} onOpenSidebar={() => setIsSidebarOpen(true)} />
      )}

      {/* メインコンテンツ領域 */}
      <main className="flex-1 bg-transparent flex">
        <div
          className="
            mx-auto
            w-full
            max-w-container
            px-[clamp(16px,4vw,32px)]
            py-6
            bg-[#EEF5F9]
            flex-1
          "
        >
          {children || <p className="text-gray-400 text-center">メインコンテンツ領域</p>}
        </div>
      </main>
      {/* サイドバー */}
      <Sidebar isOpen={isSidebarOpen} onClose={() => setSidebarOpen(false)} isLoggedIn={isLoggedIn} />
    </div>
  );
}

export default AppLayout;

これに伴って、他のファイルも修正します!

\project-root\src\resources\js\Components\Sidebar.jsx
import { Link } from '@inertiajs/react';
import close from '../Assets/icons/sidebar/close.svg';
import home from '../Assets/icons/sidebar/home.svg';
import mypage from '../Assets/icons/sidebar/mypage.svg';
import bookmark from '../Assets/icons/sidebar/bookmark.svg';
import ranking from '../Assets/icons/sidebar/ranking.svg';
import logout from '../Assets/icons/sidebar/logout.svg';
import login from '../Assets/icons/sidebar/login.svg';
import register from '../Assets/icons/sidebar/register.svg';

const Sidebar = ({ isOpen, onClose, isLoggedIn }) => {
  // メニュー定義
  const items = isLoggedIn
    ? [
        { label: 'ホーム', href: route('home'), icon: home },
        { label: 'マイページ', href: route('mypage.index'), icon: mypage },
        { label: 'ブックマーク', href: route('mypage.bookmarks'), icon: bookmark },
        { label: 'ランキング', href: null, icon: ranking },
        { label: 'ログアウト', href: route('logout'), method: 'post', icon: logout },
      ]
    : [
        { label: 'ホーム', href: route('home'), icon: home },
        { label: '新規登録', href: route('register'), icon: register },
        { label: 'ログイン', href: route('login'), icon: login },
        { label: 'ランキング', href: null, icon: ranking },
      ];

  return (
    <>
      <style>{`
        @keyframes text-expand {
          0% {
            letter-spacing: 0;
          }
          100% {
            letter-spacing: 0.05em;
          }
        }
        .text-expand:hover {
          animation: text-expand 0.3s ease-out forwards;
        }
      `}</style>
      {/* オーバーレイ */}
      <div
        onClick={onClose}
        className={`
          fixed inset-0 bg-black/40 transition-opacity duration-300
          ${isOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}
        `}
        aria-hidden="true"
      />

      {/* 本体 */}
      <aside
        role="dialog"
        aria-modal="true"
        className={`
          fixed right-0 top-0 h-dvh w-[270px] max-w-[90vw] bg-[#EEF5F9] shadow-2xl
          transform transition-transform duration-300
          ${isOpen ? 'translate-x-0' : 'translate-x-full'}
          flex flex-col
        `}
      >
        {/* ヘッダー(バツ印) */}
        <div className="flex items-center justify-end px-4 h-14">
          <button
            type="button"
            onClick={onClose}
            className="p-2"
            aria-label="メニューを閉じる"
            autoFocus
          >
            <img src={close} alt="" className="h-7 w-7 hover:opacity-80 transition" />
          </button>
        </div>

        {/* リスト */}
        <nav className="p-2">
          <ul className="space-y-1">
            {items.map(it => (
              <li key={it.label}>
                {it.href ? (
                  <Link
                    href={it.href}
                    method={it.method}
                    as={it.method ? 'button' : 'a'}
                    className="
                      text-expand text-lg w-full text-left flex items-center gap-3 px-4 py-3 rounded-lg
                      hover: transition
                      text-[#747D8C] font-medium
                    "
                    onClick={onClose}
                  >
                    <img src={it.icon} alt="" className="h-8 w-8" />
                    {it.label}
                  </Link>
                ) : (
                  <span
                    className="
                      w-full text-left flex items-center gap-3 px-4 py-3 rounded-lg
                      text-gray-400 font-medium cursor-not-allowed
                    "
                  >
                    <img
                      src={it.icon}
                      alt=""
                      className={`${it.label === 'ランキング' ? 'h-8 w-8' : 'h-5 w-5'} opacity-40`}
                    />
                    {it.label}
                  </span>
                )}
              </li>
            ))}
          </ul>
        </nav>
      </aside>
    </>
  );
}

export default Sidebar;
\project-root\src\resources\js\Components\HamburgerMenu.jsx
import hamburgerIcon from '../Assets/icons/hamburger.svg';

const HamburgerMenu = ({ onOpenSidebar }) => {
  return (
    <button
      onClick={onOpenSidebar}
      className="ml-auto flex items-center"
      aria-label="メニューを開く"
    >
      <img
        src={hamburgerIcon}
        alt="メニュー"
        className="h-7 w-7 hover:opacity-80 transition"
      />
    </button>
  );
}

export default HamburgerMenu;
\project-root\src\resources\js\Components\SearchBar.jsx
import searchInputIcon from '../Assets/icons/search/icon-search-input.svg';
import searchButtonIcon from '../Assets/icons/search/icon-search-button.svg';

const SearchBar = ({
  value,
  onChange,
  onSubmit,
  placeholder = '大学名を入力...',
}) => {
  const isDisabled = !value || !value.trim();

  const handleSubmit = (e) => {
    e.preventDefault();
    if (isDisabled) return;
    onSubmit?.(e);
  };

  return (
    <form
      onSubmit={handleSubmit}
      className='w-full max-w-xl mx-auto'
    >
      <div className='
        flex items-center
        rounded-lg
        bg-[#E2EDF6]
        border border-[#E2EDF6]
        shadow-[inset_0_2px_4px_rgba(0,0,0,0.15)]
        px-4 py-0
      '>
        {/* 左虫眼鏡アイコン */}
        <div className='mr-0.5 flex-shrink-0'>
          <img src={searchInputIcon} alt="" className='w-5 h-5' />
        </div>
        {/* 入力フィールド */}
        <input
          type="text"
          value={value}
          onChange={onChange}
          placeholder={placeholder}
          className="flex-grow bg-transparent border-none focus:outline-none focus:ring-0 text-black placeholder-[#747D8C]"
        />
        {/* 区切り線 + 右側の検索ボタン */}
        <div className='flex items-center'>
          <div className='
            h-6
            border-l border-[#747D8C]
            mx-3
          '></div>
          <button
            type="submit"
            disabled={isDisabled}
            className={`
              flex items-center justify-center
              w-6 h-6
              rounded-md
              focus:outline-none focus:ring-2 focus:ring-blue-500
              ${isDisabled ? 'opacity-50 cursor-not-allowed' : ''}
            `}
          >
            <img src={searchButtonIcon} alt="検索" className='w-6 h-6' />
          </button>
        </div>
      </div>
    </form>
  )
}

export default SearchBar;
\project-root\src\resources\js\Pages\Home.jsx
import { useState } from "react";
import { router, Head } from "@inertiajs/react";
import AppLayout from "@/Layouts/AppLayout";
import logo from "../Assets/logo/Home.svg";
import SearchBar from "../Components/SearchBar";

const Home = ({ query=''}) => {
  const [search, setSearch] = useState(query || '');

  // 検索フォームを送信する関数
  const handleSubmit = e => {
    e.preventDefault();
    router.get('/universities', { query: search });
  }

  return (
    <AppLayout mode="home"> {/* ホームモードをpropsで渡す */}
      <div className="flex flex-col items-center gap-8 pt-24">

        {/* ロゴ */}
        <img src={logo} alt="App Logo" className="h-32 w-auto" />

        {/* 検索フォーム */}
        <SearchBar
          value={search}
          onChange={e => setSearch(e.target.value)}
          onSubmit={handleSubmit}
        />

        {/* 検索メッセージ */}
        <p className="text-[#747D8C]">まずは大学を検索してみましょう。</p>
      </div>
    </AppLayout>
  )
};

export default Home;

使い分けをする方もいるそうです。
以下の動画であれこれ議論されていますのでよかったらどうぞ。(笑)
https://youtu.be/yu5cGmYM0dM?si=LkkLSinwZkTyC-jG&t=47

10. (おまけその2)修正: ページタイトルについて

今まで、テキトーに流していましたが、ページタイトルもちゃんとしておきましょう。

前回は、ホームページを作ったので、Home.jsxHeadタグを用意して、titleを設定しておきましょう。

\project-root\src\resources\js\Pages\Home.jsx
import { useState } from "react";
import { router, Head } from "@inertiajs/react";
import AppLayout from "@/Layouts/AppLayout";
import logo from "../Assets/logo/Home.svg";
import SearchBar from "../Components/SearchBar";

const Home = ({ query=''}) => {
  const [search, setSearch] = useState(query || '');

  // 検索フォームを送信する関数
  const handleSubmit = e => {
    e.preventDefault();
    router.get('/universities', { query: search });
  }

  return (
    <AppLayout mode="home"> {/* ホームモードをpropsで渡す */}
      <Head title="ホーム" /> {/* 追加! */}
      <div className="flex flex-col items-center gap-8 pt-24">

        {/* ロゴ */}
        <img src={logo} alt="App Logo" className="h-32 w-auto" />

        {/* 検索フォーム */}
        <SearchBar
          value={search}
          onChange={e => setSearch(e.target.value)}
          onSubmit={handleSubmit}
        />

        {/* 検索メッセージ */}
        <p className="text-[#747D8C]">まずは大学を検索してみましょう。</p>
      </div>
    </AppLayout>
  )
};

export default Home;

さらに、.envファイルを修正します。

\project-root\src.env
APP_NAME=LabChart

これで、ブラウザのタブの部分に表示される文字列が変わりましたね!
image.png

本当はあと一つ直したいことがあるのですが、直す箇所がたくさんあるので、次回!!

11. まとめ・次回予告

お疲れ様でした!!

今日は、検索結果画面を作りましたね。
テーブル定義の修正から始まり、使用するコンポーネントもたくさんあった疲れたと思います。

ゆっくり休んで、また明日から頑張って行きましょうね!(≧◇≦)

次回は、大学詳細・学部一覧画面を作っていきたいと思います!

今日よりは軽めに終われたらいいな...

ありがとうございました。

これまでの記事一覧

☆要件定義・設計編

☆環境構築編

☆バックエンド実装編

☆フロントエンド実装編

参考

軽く宣伝

YouTubeを始めました(というか始めてました)。
内容としては、Webエンジニアの生活や稼げるようになるまでの成長記録などを発信していく予定です。

現在、まったく再生されておらず、落ち込みそうなので、見てくださる方はぜひ高評価を教えもらえると励みになると思います。"(-""-)"

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?