ONSEN GOODS 開発記録 No.5
スタイル手法について
私は今までスタイルをつけるときの手法としてはCSS Moduleしか使ったことがなかったのだが、今回はTailwind CSSを使っていこうと思う。今回のアプリは複数ページなのでCSS Moduleのほうが効率的かと思ったのだが、
- 各ページで使いまわしているようなコンポーネントがほとんどない。
- できるだけファイル構成をコンパクトにし、余分なファイルを増やしたくない。
以上の理由からTailwind CSSを選ぶことにした。
ホーム画面
ホーム画面は温泉の一覧ができるだけすっきり見えるよう、<img>タグをなくし、温泉内の設備onsen.facilitiesを追加した。できるだけ小さいスペースで多くの情報を載せたかったという理由が一つと、もう一つは設備についてはこのアプリの中で個人的に重要視しているデータであるからだ。
return (
<div className='p-6 max-w-4xl mx-auto bg-white shadow-xl rounded-xl mt-8'>
<h1 className='text-4xl font-extrabold text-blue-700 mb-8 text-center'>温泉一覧ページ</h1>
{onsenList.length === 0 ? (
<p className="text-center text-gray-600 text-lg py-10">登録されている温泉はまだありません。</p>
):(
<ul className="list-none max-w-2x1 max-auto gap-8">
{onsenList.map((onsen) => (
<li key={onsen.id} className='border border-gray-200 rounded-lg shadow-md hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1 bg-white'>
<Link to={ROUTES.ONSEN_DETAIL.replace(':id', onsen.id)} className="block p-5 text-decoration-none text-gray-800" >
<h2 className="text-2xl font-semibold text-blue-800 mb-2">{onsen.name}</h2>
<p className="text-gray-600 text-base mb-1">場所: {onsen.location}</p>
<p className="text-gray-600 text-base mb-1">設備: {onsen.facilities}</p> {/* 設備を追加 */}
<p className="text-gray-700 text-base font-medium">評価: {onsen.rating ? onsen.rating.toFixed(1) : 'N/A'} / 5</p> {/* 評価の表示を修正 */}
</Link>
</li>
))}
</ul>
)}
</div>
)
完成図は以下の通り

温泉詳細ページ
温泉詳細ページはその温泉についての情報をできるだけ掲示したかったため、ユーザーのコメント一覧も追加することにした。
ただこれははAPIにメソッドを追加しなければならないため、またあとで
とりあえずスタイルをつけて
return (
<div className="p-6 max-w-3xl mx-auto bg-white shadow-xl rounded-xl mt-8 mb-8">
<h1 className="text-4xl font-extrabold text-blue-700 mb-6 text-center">{onsen.name}</h1>
{onsen.image_url && (
<div className="mb-6 text-center">
<img
src={onsen.image_url}
alt={onsen.name}
className="w-full h-64 object-cover rounded-lg shadow-md border border-gray-200 mx-auto"
style={{ maxWidth: '600px' }} // 画像の最大幅を調整
/>
</div>
)}
<div className="space-y-4 text-gray-800 text-lg">
<p><strong className="font-semibold text-gray-700">場所:</strong> {onsen.location}</p>
<p><strong className="font-semibold text-gray-700">評価:</strong> {onsen.rating ? onsen.rating.toFixed(2) : 'N/A'} / 5</p>
<p><strong className="font-semibold text-gray-700">説明:</strong> {onsen.description}</p>
<p><strong className="font-semibold text-gray-700">設備:</strong> {onsen.facilities}</p>
<p className="text-sm text-gray-500 mt-4">最終更新日: {new Date(onsen.updated_at).toLocaleDateString()}</p>
</div>
<div className="mt-8 flex flex-col sm:flex-row justify-center gap-4">
<Link
to={ROUTES.HOME}
className="inline-block px-6 py-3 bg-gray-600 text-white font-semibold rounded-lg shadow-md hover:bg-gray-700 transition-colors duration-300 text-center flex-grow sm:flex-none"
>
← 温泉一覧に戻る
</Link>
<Link
to={ROUTES.REVIEW.replace(':id', id)}
className="inline-block px-6 py-3 bg-green-600 text-white font-semibold rounded-lg shadow-md hover:bg-green-700 transition-colors duration-300 text-center flex-grow sm:flex-none"
>
📝 この温泉を評価する →
</Link>
</div>
</div>
)
完成図は

評価ページ
評価ページは特に気にするとことはないためちゃっちゃと行く
return (
<div className="p-6 max-w-xl mx-auto bg-white shadow-xl rounded-xl mt-8 mb-8">
<h1 className="text-3xl font-extrabold text-blue-700 mb-6 text-center">「{onsenName}」への評価</h1>
{submitSuccess && (
<p className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded relative mb-4 text-center">
投稿完了!! 詳細ページへ移動します...
</p>
)}
{errorSubmit && (
<p className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-4 text-center">
エラー: {errorSubmit.message || '投稿に失敗しました。'}
</p>
)}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor='rating' className="block text-gray-700 text-sm font-bold mb-2">評価 (1.0 - 5.0):</label>
<input
type="number"
id="rating"
min="1.0"
max="5.0"
step="0.1"
value={rating}
onChange={(e) => setRating(parseFloat(e.target.value))}
required
className="shadow appearance-none border rounded w-full py-3 px-4 text-gray-700 leading-tight focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
/>
</div>
<div>
<label htmlFor='comment' className="block text-gray-700 text-sm font-bold mb-2">コメント:</label>
<textarea
placeholder='コメントを入力してください'
id="comment"
value={comment}
onChange={(e) => setComment(e.target.value)}
rows="5"
className="shadow appearance-none border rounded w-full py-3 px-4 text-gray-700 leading-tight focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200 resize-y"
></textarea>
</div>
<button
type='submit'
disabled={loadingSubmit}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-4 rounded-lg focus:outline-none focus:shadow-outline transition-colors duration-300 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loadingSubmit ? '送信中...' : '評価を投稿する'}
</button>
</form>
<div className="mt-8 flex flex-col sm:flex-row justify-between gap-4">
<Link
to={ROUTES.ONSEN_DETAIL.replace(':id', id)}
className="inline-block px-6 py-3 bg-gray-600 text-white font-semibold rounded-lg shadow-md hover:bg-gray-700 transition-colors duration-300 text-center flex-grow sm:flex-none"
>
←温泉詳細ページに戻る
</Link>
<Link
to={ROUTES.HOME}
className="inline-block px-6 py-3 bg-gray-600 text-white font-semibold rounded-lg shadow-md hover:bg-gray-700 transition-colors duration-300 text-center flex-grow sm:flex-none"
>
←温泉一覧に戻る
</Link>
</div>
</div>
)
思ったよりも長くなった
完成図はこう

スタイルはいったんここまで
機能追加
まずはGETメソッドでratingsテーブルのデータをとってこられるようにするためにbackend/controlleers/onsenController.jsに以下を追加
// 2-1 特定の温泉に対する評価とコメントを取得するAPI
exports.getRatingByOnsenId = (req, res) => {
const { id } = req.params; // URLパスから温泉IDを取得
const sql = 'SELECT * FROM ratings WHERE onsen_id = ?';
db.all(sql, [id], (err, rows) => {
if (err) {
// サーバーエラー (500 Internal Server Error)
console.error('温泉評価取得エラー:', err.message); // デバッグ用
res.status(500).json({ error: '温泉評価の取得中にエラーが発生しました。'});
return;
}
if (rows.length === 0) {
// 評価が見つからない場合、404 Not Found
res.status(404).json({ message: '指定された温泉の評価が見つかりませんでした。'});
return;
}
// 成功した場合、200 OKとともに取得した評価のリストをJSONで返す
res.status(200).json(rows);
});
}
次に/backend/routes/onsen.jsに以下を追加してエンドポイントを設定していく。
// 特定の温泉に対する評価とコメントを取得するエンドポイント
router.get('/:id/rating', onsenController.getRatingByOnsenId);
一旦ここまで書いたらPostmanでhttp://localhost:3000/api/onsen/1/ratingに対してGETをリクエストしてratingsテーブルの中がしっかりとってこれているか確認。
そしてfrontend/pages/onsenDetails.jsxに表示するための文を追加。
{/* 評価一覧 */}
<div className="mt-10">
<h2 className="text-2xl font-bold mb-4 text-blue-800">ユーザーの評価・コメント</h2>
{ratingsLoading ? (
<div>評価を読み込み中...</div>
) : ratings.length === 0 ? (
<div className="text-gray-500">まだ評価がありません。</div>
) : (
<ul className="space-y-4">
{ratings.map((r, i) => (
<li key={r.id || i} className="border rounded-lg p-4 shadow-sm bg-gray-50">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-yellow-600">★ {Number(r.rating_value).toFixed(1)} / 5.0</span>
<span className="text-xs text-gray-400 ml-2">{r.username || '匿名'}・{new Date(r.created_at).toLocaleDateString()}</span>
</div>
<div className="text-gray-800">{r.comment}</div>
</li>
))}
</ul>
)}
</div>
r.rating_valueをr.ratingと書いていて評価の数値が撮ってこれていなかった。データベースと照らし合わせながら作業するのが大事。
また、r.rating_valueはしっかりNumberで囲わないとtoFixedが使えなかったので注意。
完成図は以下の通り

今後の方針
今の状態でデプロイに向けた作業に移るか、機能をもう少し追加するかで迷い中、若干機能追加したい気持ちが強いのでそうするかも
to be continued =>{
Discussion