実務1年目駆け出しエンジニアがLaravel&ReactでWebアプリケーション開発に挑戦してみた!(その30)
0. 初めに
こんにちは!
見習いエンジニアの僕がWebアプリケーションを一から開発する様子をお届けしています。
先週は投稿をさぼってしまってすみませんでした。
普通にド忘れしていました...
ストックは結構あるのですが、習慣ができていないようですね。
前回の記事は、かなり長くなってしまって読みにくかったと思います。
今回は、その反省を生かして、少しでも読みやすくできたらなと思います...!!
今回は、マイページを作りたいと思います。
ぜひ、ゆっくりしていってください。(#^.^#)
1. ブランチ運用
いつも通り、developブランチを最新化して新規ブランチを切って作業します。
作業が終わったら、コミットを忘れずにしましょう。
ブランチ名は、feature/frontend/my-pageにします。
2. 画面デザイン
3. 基本情報表示(UI作成)
まず、画面の中央にログイン中のユーザーの基本情報を表示できるようにしましょう。
既にMyPage\Index.jsxを作成済みですが、すべて消してゼロから書き直しましょう。
共通レイアウトを適用する
最初はいつも戻り、AppLayoutをインポートして使いましょう。
import AppLayout from "@/Layouts/AppLayout";
import { Head } from "@inertiajs/react";
const Index = ({ title }) => {
return (
<AppLayout title={title}>
<Head title={title} />
</AppLayout>
);
}
export default Index;
大枠を作る
次に、見出しを付けました。
この後、この「基本情報」の中にユーザーの情報表示を追加してきましょう。
const Index = ({ title }) => {
return (
<AppLayout title={title}>
<Head title={title} />
{/* 基本情報 */}
<h2 className="text-xl font-bold text-black border-b border-black pb-2 w-full">基本情報</h2>
{/* ブックマーク済み研究室 */}
<h2 className="text-xl font-bold text-black border-b border-black pb-2 w-full mt-8">ブックマーク済み研究室</h2>
</AppLayout>
);
}
export default Index;
FieldBarを作成する
ユーザーの情報は、入力フィールドっぽく表示したいです。
そのために、まずはその型となるコンポーネントを作成したいと思います。
/**
* フィールドバーコンポーネント
* @param {Object} props - コンポーネントのprops
* @param {React.ReactNode} [props.left] - 左側に表示する要素
* @param {React.ReactNode} [props.right] - 右側に表示する要素
* @param {React.ReactNode} props.children - 中央に表示するコンテンツ
* @param {string} [props.className=''] - 追加のCSSクラス
* @returns {JSX.Element} コンポーネントのJSX
*/
const FieldBar = ({ left, right, children, className = "" }) => {
return (
<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-1
${className}
`}
>
{left ? (
<div className="mr-0.5 flex-shrink-0">
{left}
</div>
) : null}
<div className="flex-grow min-w-0">
{children}
</div>
{right ? (
<div className="flex items-center">
<div className="h-6 mx-3" />
{right}
</div>
) : null}
</div>
);
};
export default FieldBar;
左右にアイコンを表示できるようにしました(検索バーなどで今後これを再利用する想定です)。
これを使って、ユーザー情報を表示するコンポーネントを作成したいと思います。
UserInfoBarを作成する
/**
* ユーザー情報バーコンポーネント
* @param {Object} props - コンポーネントのprops
* @param {string} props.value - 表示するユーザー情報の値
* @param {Function} props.onOpenEditDialog - 編集モーダルを開くコールバック関数
* @returns {JSX.Element} コンポーネントのJSX
*/
import FieldBar from '../Common/FieldBar.jsx';
import EditIcon from '../../Assets/icons/edit.svg';
const UserInfoBar = ({ value, onOpenEditDialog }) => {
return (
<FieldBar
right={<img src={EditIcon} alt="編集" onClick={onOpenEditDialog} className="cursor-pointer w-4 h-4" />}
>
<div className="truncate text-[#747D8C]">{value}</div>
</FieldBar>
);
}
export default UserInfoBar;
EditIconには、以下をお使いください。
基本情報を表示する
表示したいのは、ニックネーム、e-Mailアドレス、パスワード、利用開始日の四つです。
また、退会用の確認モーダル(今後作成予定です)を開くためのボタンも用意しておきます。
import AppLayout from "@/Layouts/AppLayout";
import { Head } from "@inertiajs/react";
import UserInfoBar from "@/Components/MyPage/UserInfoBar";
const Index = ({ title, user }) => {
return (
<AppLayout title={title}>
<Head title={title} />
{/* 基本情報 */}
<div className="flex items-center justify-between border-b border-black pb-2 w-full">
<h2 className="text-xl font-bold text-black">基本情報</h2>
<span className="text-sm text-[#747D8C]">
利用開始日: {user?.created_at ? new Date(user.created_at).toLocaleDateString('ja-JP') : ''}
</span>
</div>
{/* ニックネーム */}
<div className="mt-6 mb-4 flex items-center">
<h3 className="text-lg font-semibold text-[#747D8C] w-40 shrink-0">ニックネーム</h3>
<div className="w-96">
<UserInfoBar value={user?.name} onOpenEditModal={() => {}} />
</div>
</div>
{/* e-Mailアドレス */}
<div className="mt-6 mb-4 flex items-center">
<h3 className="text-lg font-semibold text-[#747D8C] w-40 shrink-0">e-Mailアドレス</h3>
<div className="w-96">
<UserInfoBar value={user?.email} onOpenEditModal={() => {}} />
</div>
</div>
{/* パスワード */}
<div className="mt-6 mb-4 flex items-center">
<h3 className="text-lg font-semibold text-[#747D8C] w-40 shrink-0">パスワード</h3>
<div className="w-96">
<UserInfoBar value="••••••••" onOpenEditModal={() => {}} />
</div>
</div>
{/* 退会 */}
<div className="mt-6 mb-4 flex items-center">
<button
type="button"
onClick={() => {}}
className="text-lg font-semibold text-[#747D8C] hover:underline cursor-pointer text-left"
>
退会
</button>
</div>
{/* ブックマーク済み研究室 */}
<h2 className="text-xl font-bold text-black border-b border-black pb-2 w-full mt-8">ブックマーク済み研究室</h2>
</AppLayout>
);
}
export default Index;
4. ブックマーク済み表示(UI作成)
次は、ブックマーク済みの研究室を表示できるようにしたいと思います。
コントローラーを修正する
現状だと、マイページにユーザー情報を渡す処理はMyPageController.phpのshoUser()メソッドに書かれています。
しかし、レビューの情報(特に総合評価値)を計算して渡す処理はありません。
それを追加したいと思います。
public function showUser()
{
$user = Auth::user();
// 管理者・一般ユーザー問わず通知を取得
$notifications = $user->notifications()->latest()->get();
// ブックマーク済み研究室とそのレビュー情報・総合評価を取得
$bookmarks = $user->bookmarks()->with(['lab.reviews'])->get()->map(function($bookmark) {
$lab = $bookmark->lab;
if (!$lab) return null;
// 評価項目
$ratingColumns = [
'mentorship_style',
'lab_atmosphere',
'achievement_activity',
'constraint_level',
'facility_quality',
'work_style',
'student_balance',
];
// 各項目の平均値
$averagePerItem = collect($ratingColumns)->mapWithKeys(function ($column) use ($lab) {
return [$column => $lab->reviews->avg($column)];
});
// 総合評価値
$overallAverage = $averagePerItem->avg();
$lab->overall_avg = $overallAverage;
foreach ($ratingColumns as $column) {
$lab->{"avg_{$column}"} = $averagePerItem[$column];
}
// reviews_countも追加
$lab->reviews_count = $lab->reviews->count();
return $lab;
})->filter();
return Inertia::render('MyPage/Index', [
'title' => "{$user->name}さんのマイページ",
'user' => $user,
'notifications' => $notifications,
'bookmarks' => $bookmarks,
]);
}
保存済み研究室を表示する
LabCardをインポートして表示します。
また、研究室名だけだとどこの大学かわかりにくいと思うので、大学名と学部名を表示するようにします。
import AppLayout from "@/Layouts/AppLayout";
import { Head } from "@inertiajs/react";
import UserInfoBar from "@/Components/MyPage/UserInfoBar";
import LabCard from "@/Components/Lab/LabCard";
const Index = ({ title, user, bookmarks = [] }) => {
console.log('ブックマーク一覧', bookmarks);
return (
<AppLayout title={title}>
<Head title={title} />
{/* 基本情報 */}
<div className="flex items-center justify-between border-b border-black pb-2 w-full">
<h2 className="text-xl font-bold text-black">基本情報</h2>
<span className="text-sm text-[#747D8C]">
利用開始日: {user?.created_at ? new Date(user.created_at).toLocaleDateString('ja-JP') : ''}
</span>
</div>
{/* ニックネーム */}
<div className="mt-6 mb-4 flex items-center">
<h3 className="text-lg font-semibold text-[#747D8C] w-40 shrink-0">ニックネーム</h3>
<div className="w-96">
<UserInfoBar value={user?.name} onOpenEditModal={() => {}} />
</div>
</div>
{/* e-Mailアドレス */}
<div className="mt-6 mb-4 flex items-center">
<h3 className="text-lg font-semibold text-[#747D8C] w-40 shrink-0">e-Mailアドレス</h3>
<div className="w-96">
<UserInfoBar value={user?.email} onOpenEditModal={() => {}} />
</div>
</div>
{/* パスワード */}
<div className="mt-6 mb-4 flex items-center">
<h3 className="text-lg font-semibold text-[#747D8C] w-40 shrink-0">パスワード</h3>
<div className="w-96">
<UserInfoBar value="••••••••" onOpenEditModal={() => {}} />
</div>
</div>
{/* 退会 */}
<div className="mt-6 mb-4 flex items-center">
<button
type="button"
onClick={() => {}}
className="text-lg font-semibold text-[#747D8C] hover:underline cursor-pointer text-left"
>
退会
</button>
</div>
{/* ブックマーク済み研究室 */}
<div className="flex items-center justify-between border-b border-black pb-2 w-full mt-8">
<h2 className="text-xl font-bold text-black">ブックマーク済み研究室</h2>
<span className="text-sm text-[#747D8C]">保存済み: {bookmarks.length}件</span>
</div>
<div className="mt-4 grid gap-4 grid-cols-1">
{bookmarks.length === 0 ? (
<p className="text-[#747D8C]">ブックマーク済みの研究室はありません。</p>
) : (
bookmarks.map((lab) => (
<div className="w-full" key={lab.id}>
<div className="flex flex-col items-center w-full">
{/* 大学・学部名の表示 */}
<div className="mb-2 text-lg font-semibold text-[#747D8C] text-center">
{lab.faculty?.university?.name} {lab.faculty?.name}
</div>
<div className="flex justify-center w-full"><div className="max-w-xl w-full"><LabCard lab={lab} /></div></div>
</div>
</div>
))
)}
</div>
</AppLayout>
);
}
export default Index;
横スクロールできるようにする
ブックマークが複数あるときに横スクロールで表示できるようにしたいと思います。
import AppLayout from "@/Layouts/AppLayout";
import { Head } from "@inertiajs/react";
import UserInfoBar from "@/Components/MyPage/UserInfoBar";
import LabCard from "@/Components/Lab/LabCard";
import { useState, useRef } from "react";
const Index = ({ title, user, bookmarks = [] }) => {
// 横スクロール用インデックス
const [currentIndex, setCurrentIndex] = useState(0);
const handlePrev = () => {
setCurrentIndex((prev) => Math.max(prev - 1, 0));
};
const handleNext = () => {
setCurrentIndex((prev) => Math.min(prev + 1, bookmarks.length - 1));
};
// 横スクロールもサポート(スワイプやホイール)
const handleScroll = (e) => {
if (bookmarks.length <= 1) return;
if (e.deltaX > 10 || e.deltaY > 10) {
handleNext();
} else if (e.deltaX < -10 || e.deltaY < -10) {
handlePrev();
}
};
// タッチスワイプ対応
const touchStartX = useRef(0);
const handleTouchStart = (e) => {
touchStartX.current = e.touches[0].clientX;
};
const handleTouchEnd = (e) => {
const touchEndX = e.changedTouches[0].clientX;
if (touchEndX - touchStartX.current > 50) {
handlePrev();
} else if (touchEndX - touchStartX.current < -50) {
handleNext();
}
};
// LabCardを1件だけ表示
const currentLab = bookmarks[currentIndex];
return (
<AppLayout title={title}>
<Head title={title} />
{/* 基本情報 */}
<div className="flex items-center justify-between border-b border-black pb-2 w-full">
<h2 className="text-xl font-bold text-black">基本情報</h2>
<span className="text-sm text-[#747D8C]">
利用開始日: {user?.created_at ? new Date(user.created_at).toLocaleDateString('ja-JP') : ''}
</span>
</div>
{/* ニックネーム */}
<div className="mt-6 mb-4 flex items-center">
<h3 className="text-lg font-semibold text-[#747D8C] w-40 shrink-0">ニックネーム</h3>
<div className="w-96">
<UserInfoBar value={user?.name} onOpenEditModal={() => {}} />
</div>
</div>
{/* e-Mailアドレス */}
<div className="mt-6 mb-4 flex items-center">
<h3 className="text-lg font-semibold text-[#747D8C] w-40 shrink-0">e-Mailアドレス</h3>
<div className="w-96">
<UserInfoBar value={user?.email} onOpenEditModal={() => {}} />
</div>
</div>
{/* パスワード */}
<div className="mt-6 mb-4 flex items-center">
<h3 className="text-lg font-semibold text-[#747D8C] w-40 shrink-0">パスワード</h3>
<div className="w-96">
<UserInfoBar value="••••••••" onOpenEditModal={() => {}} />
</div>
</div>
{/* 退会 */}
<div className="mt-6 mb-4 flex items-center">
<button
type="button"
onClick={() => {}}
className="text-lg font-semibold text-[#747D8C] hover:underline cursor-pointer text-left"
>
退会
</button>
</div>
{/* ブックマーク済み研究室 */}
<div className="flex items-center justify-between border-b border-black pb-2 w-full mt-8">
<h2 className="text-xl font-bold text-black">ブックマーク済み研究室</h2>
<span className="text-sm text-[#747D8C]">保存済み: {bookmarks.length}件</span>
</div>
<div className="mt-4 flex flex-col items-center">
{bookmarks.length === 0 ? (
<p className="text-[#747D8C]">ブックマーク済みの研究室はありません。</p>
) : (
<div
className="flex items-center w-full max-w-2xl"
onWheel={handleScroll}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{/* ≪ボタン */}
<button
type="button"
onClick={handlePrev}
disabled={currentIndex === 0}
className={`text-2xl px-2 text-[#747D8C] hover:text-gray-600 transition ${currentIndex === 0 ? 'opacity-30 cursor-default' : 'cursor-pointer'}`}
>
≪
</button>
<div className="flex-1 flex flex-col items-center">
{/* 大学・学部名の表示 */}
<div className="mb-2 text-lg font-semibold text-[#747D8C] text-center">
{currentLab.faculty?.university?.name} {currentLab.faculty?.name}
</div>
<div className="flex justify-center w-full">
<div className="max-w-xl w-full">
<LabCard lab={currentLab} />
</div>
</div>
<div className="mt-2 text-sm text-[#747D8C] text-center">
{currentIndex + 1} / {bookmarks.length}
</div>
</div>
{/* ≫ボタン */}
<button
type="button"
onClick={handleNext}
disabled={currentIndex === bookmarks.length - 1}
className={`text-2xl px-2 text-[#747D8C] hover:text-gray-600 transition ${currentIndex === bookmarks.length - 1 ? 'opacity-30 cursor-default' : 'cursor-pointer'}`}
>
≫
</button>
</div>
)}
</div>
</AppLayout>
);
};
export default Index;
アニメーションを付けてもよかったのですが、まあ、余裕があればいつかやりたいと思います...
5. 通知アイコン作成(UI作成)
NotificationIconを使用します。
画像は以下からダウンロードしてください。
import AppLayout from "@/Layouts/AppLayout";
import { Head } from "@inertiajs/react";
import UserInfoBar from "@/Components/MyPage/UserInfoBar";
import LabCard from "@/Components/Lab/LabCard";
import { useState, useRef } from "react";
import NotificationIcon from "@/Assets/icons/notification.svg";
const Index = ({ title, user, bookmarks = [] }) => {
// 横スクロール用インデックス
const [currentIndex, setCurrentIndex] = useState(0);
const handlePrev = () => {
setCurrentIndex((prev) => Math.max(prev - 1, 0));
};
const handleNext = () => {
setCurrentIndex((prev) => Math.min(prev + 1, bookmarks.length - 1));
};
// 横スクロールもサポート(スワイプやホイール)
const handleScroll = (e) => {
if (bookmarks.length <= 1) return;
if (e.deltaX > 10 || e.deltaY > 10) {
handleNext();
} else if (e.deltaX < -10 || e.deltaY < -10) {
handlePrev();
}
};
// タッチスワイプ対応
const touchStartX = useRef(0);
const handleTouchStart = (e) => {
touchStartX.current = e.touches[0].clientX;
};
const handleTouchEnd = (e) => {
const touchEndX = e.changedTouches[0].clientX;
if (touchEndX - touchStartX.current > 50) {
handlePrev();
} else if (touchEndX - touchStartX.current < -50) {
handleNext();
}
};
// LabCardを1件だけ表示
const currentLab = bookmarks[currentIndex];
// 通知件数(仮で0。必要に応じてprops化してください)
const notificationCount = 0;
return (
<AppLayout title={title}>
<Head title={title} />
{/* 通知アイコン(右肩に赤丸バッジ) */}
<div className="w-full flex justify-end">
<div className="relative">
<img src={NotificationIcon} alt="通知" className="w-7 h-7 cursor-pointer" />
<span className="absolute -top-2 -right-2 w-5 h-5 bg-red-500 rounded-full z-10 flex items-center justify-center text-xs font-bold text-white">
{0}
</span>
</div>
</div>
<div className="mt-2">
{/* 基本情報 */}
<div className="flex items-center justify-between border-b border-black pb-2 w-full">
<h2 className="text-xl font-bold text-black">基本情報</h2>
<span className="text-sm text-[#747D8C]">
利用開始日: {user?.created_at ? new Date(user.created_at).toLocaleDateString('ja-JP') : ''}
</span>
</div>
{/* ニックネーム */}
<div className="mt-6 mb-4 flex items-center">
<h3 className="text-lg font-semibold text-[#747D8C] w-40 shrink-0">ニックネーム</h3>
<div className="w-96">
<UserInfoBar value={user?.name} onOpenEditModal={() => {}} />
</div>
</div>
{/* e-Mailアドレス */}
<div className="mt-6 mb-4 flex items-center">
<h3 className="text-lg font-semibold text-[#747D8C] w-40 shrink-0">e-Mailアドレス</h3>
<div className="w-96">
<UserInfoBar value={user?.email} onOpenEditModal={() => {}} />
</div>
</div>
{/* パスワード */}
<div className="mt-6 mb-4 flex items-center">
<h3 className="text-lg font-semibold text-[#747D8C] w-40 shrink-0">パスワード</h3>
<div className="w-96">
<UserInfoBar value="••••••••" onOpenEditModal={() => {}} />
</div>
</div>
{/* 退会 */}
<div className="mt-6 mb-4 flex items-center">
<button
type="button"
onClick={() => {}}
className="text-lg font-semibold text-[#747D8C] hover:underline cursor-pointer text-left"
>
退会
</button>
</div>
{/* ブックマーク済み研究室 */}
<div className="flex items-center justify-between border-b border-black pb-2 w-full mt-8">
<h2 className="text-xl font-bold text-black">ブックマーク済み研究室</h2>
<span className="text-sm text-[#747D8C]">保存済み: {bookmarks.length}件</span>
</div>
<div className="mt-4 flex flex-col items-center">
{bookmarks.length === 0 ? (
<p className="text-[#747D8C]">ブックマーク済みの研究室はありません。</p>
) : (
<div
className="flex items-center w-full max-w-2xl"
onWheel={handleScroll}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{/* ≪ボタン */}
<button
type="button"
onClick={handlePrev}
disabled={currentIndex === 0}
className={`text-2xl px-2 text-[#747D8C] hover:text-gray-600 transition ${currentIndex === 0 ? 'opacity-30 cursor-default' : 'cursor-pointer'}`}
>
≪
</button>
<div className="flex-1 flex flex-col items-center">
{/* 大学・学部名の表示 */}
<div className="mb-2 text-lg font-semibold text-[#747D8C] text-center">
{currentLab.faculty?.university?.name} {currentLab.faculty?.name}
</div>
<div className="flex justify-center w-full">
<div className="max-w-xl w-full">
<LabCard lab={currentLab} />
</div>
</div>
<div className="mt-2 text-sm text-[#747D8C] text-center">
{currentIndex + 1} / {bookmarks.length}
</div>
</div>
{/* ≫ボタン */}
<button
type="button"
onClick={handleNext}
disabled={currentIndex === bookmarks.length - 1}
className={`text-2xl px-2 text-[#747D8C] hover:text-gray-600 transition ${currentIndex === bookmarks.length - 1 ? 'opacity-30 cursor-default' : 'cursor-pointer'}`}
>
≫
</button>
</div>
)}
</div>
</div>
</AppLayout>
);
};
export default Index;
通知件数は、検証用に0件にしています。
今後、これをクリックすることで通知一覧が開くようにしたいと思います。
今日のところは以下のようになっていればおkっす!

通知アイコン、ヘッダーに配置したほうがよかったかな...
デザイン的にちょっと不格好かな...
6. 問題点: 戻るボタンについて
ここで問題が発生しました!
どうしましょう。(笑)
マイページから研究室カードをクリックするとその研究室の詳細ページに移動することができます。
しかし、この状態で戻るボタンを押すとうまく戻り切れません。
というのも、大学の検索結果を経由していないため、クエリパラメータを持っていません。
オーノー!(+_+)
クエリパラメータがnullなので、すべての大学が出てきてしまいました。
しかも、「「null」を含む大学一覧」ってなんやねん!って感じです...
戻るボタン、やめます...!!
色々迷った挙句、戻るボタンをやめることにしました。
代わりの対応を次回やりたいと思います!w
本シリーズでは、画面遷移図を作成していなかったこともあり、設計がやや甘かったです...
そういった部分も反面教師にしてもらえれば、本シリーズの意義もあるかなと思いますので、温かい目で楽しんでもらえればと思います。"(-""-)"
7. (おまけその6)修正: 前回のミス
今回は、少しだけ余裕がありそうなので、おまけコーナー復活ですw
前回作成した、Lab/Show.jsxですが、JSDocのコメントアウト付けるのを忘れました。
以下のように追加しましょー。
/**
* 研究室詳細ページコンポーネント
* @param {Object} props - コンポーネントのprops
* @param {Object} props.lab - 研究室オブジェクト
* @param {Object} props.averagePerItem - 各評価項目の平均値
* @param {number} props.overallAverage - 総合評価の平均値
* @param {Array} props.comments - コメント一覧
* @param {Object} props.auth - 認証情報
* @param {Object|null} props.userReview - ログインユーザーのレビュー
* @param {Object|null} props.userBookmark - ログインユーザーのブックマーク
* @param {number} props.bookmarkCount - ブックマーク数
* @param {string} props.query - 検索クエリ文字列
* @returns {JSX.Element} コンポーネントのJSX
*/
const Show = ({ lab, averagePerItem, overallAverage, comments, auth, userReview, userBookmark, bookmarkCount, query }) => {
8. まとめ・次回予告
今回は、マイページを作成しました。
色々と課題が残る結果となってしまいましたね...
特に戻るボタンの問題は、早めにどうにかしたいと思ったので次回直したいと思います!
乞うご期待。(。◕ˇдˇ◕。)



