初めに
今日の分を進めていくとこういう仕上がりになります。
目次
1. ユーザーのプロフィール情報を表示・編集
2. ツイートの投稿や一覧表示の実践(フロント)
3. Like機能の実装
4. Comment機能の実装
1.ユーザーのプロフィール情報を表示・編集
.staticとmediaの作成
backend/ <-- プロジェクトルート(BASE_DIR)
├── manage.py
├── static/
│ └── images/
│ └── default_profile.png
├── media/ <-- ユーザーアップロード画像用
└── backend/ <-- settings.py 等があるディレクトリ
settings.pyの設定
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles" # 本番用:collectstatic によって集約される場所
# 開発時に参照する static ファイルのディレクトリ(例)
STATICFILES_DIRS = [
BASE_DIR / "static", # ここに default_profile.png を含む "images" フォルダがあることを確認
]
backend/urls.pyに追記
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
.envでBASE_URLの設定
# frontend/.env.local
REACT_APP_HUST_URL=http://localhost:8000
Profile.jsxの作成
ユーザーのプロフィール情報(プロフィール画像や自己紹介)を表示・編集するページを作成します。
// frontend/src/pages/Profile.jsx
import React, { useContext, useState, useEffect } from "react";
import { AuthContext } from "../contexts/AuthContext";
import API from "../api";
function Profile() {
const { user, accessToken, logout } = useContext(AuthContext);
const [bio, setBio] = useState(user ? user.bio : "");
const [profileImage, setProfileImage] = useState(null);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(null);
useEffect(() => {
if (user) {
setBio(user.bio);
}
}, [user]);
const handleSubmit = async (e) => {
e.preventDefault();
setError(null);
setSuccess(null);
try {
const formData = new FormData();
formData.append("bio", bio);
if (profileImage) {
formData.append("profile_image", profileImage);
}
const res = await API.patch("profile/me/", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
// 更新されたユーザー情報をContextに反映
// ここでは再度プロフィールを取得する方法もあります
// または、AuthContextにsetUserを追加して直接更新することも可能
setSuccess("Profile updated successfully!");
} catch (err) {
console.error("Profile update failed", err);
setError("Failed to update profile. Please try again.");
}
};
const handleImageChange = (e) => {
if (e.target.files && e.target.files[0]) {
setProfileImage(e.target.files[0]);
}
};
const imageUrl = user?.profile_image
? user.profile_image.startsWith("http")
? user.profile_image
: `${REACT_APP_HOST_URL}${user.profile_image}`
: `${REACT_APP_HOST_URL}static/images/default_profile.png`;
// キャッシュバスター(例: 現在のタイムスタンプ)を付与
const finalImageUrl = `${imageUrl}?v=${new Date().getTime()}`;
return (
<div className="flex justify-center items-center min-h-screen bg-gray-100">
<form onSubmit={handleSubmit} className="bg-white p-6 rounded shadow-md w-full max-w-md">
<h2 className="text-2xl font-bold mb-4 text-center">Your Profile</h2>
{error && <div className="bg-red-100 text-red-700 p-2 mb-4 rounded">{error}</div>}
{success && <div className="bg-green-100 text-green-700 p-2 mb-4 rounded">{success}</div>}
<div className="mb-4 flex flex-col items-center">
<img
src={finalImageUrl}
alt="Profile"
className="w-24 h-24 rounded-full object-cover mb-2"
/>
<input type="file" accept="image/*" onChange={handleImageChange} />
</div>
<div className="mb-4">
<label className="block text-gray-700">Bio</label>
<textarea
className="w-full px-3 py-2 border rounded focus:outline-none focus:ring focus:border-blue-300"
value={bio}
onChange={(e) => setBio(e.target.value)}
placeholder="Tell us about yourself"
rows="3"
></textarea>
</div>
<button
type="submit"
className="w-full bg-blue-500 text-white py-2 rounded hover:bg-blue-600 transition duration-200"
>
Update Profile
</button>
<button
type="button"
onClick={logout}
className="w-full mt-2 bg-red-500 text-white py-2 rounded hover:bg-red-600 transition duration-200"
>
Logout
</button>
</form>
</div>
);
}
export default Profile;
解説
1. Contextの利用
- useContext(AuthContext)で現在のユーザー情報とlogout関数を取得。
2. Stateの管理
-
bio:
ユーザーの自己紹介文。 -
profileImage:
新しくアップロードするプロフィール画像。 -
error
,success:
エラーメッセージと成功メッセージの表示管理。
3. フォームのハンドリング
- handleSubmit: フォーム送信時にプロフィール情報を更新。
- FormDataを使用して、テキストと画像を一緒に送信。
- 成功時にはsuccessメッセージを表示。
- 失敗時にはerrorメッセージを表示。
- handleImageChange: 画像ファイルが選択されたときにprofileImageを更新。
3. デザイン
- Tailwind CSSを使用して、フォームを中央に配置し、整ったデザインにしています。
- プロフィール画像が存在しない場合はデフォルト画像を表示。
4. エラーハンドリング
-
try-catchブロック:
APIリクエスト時にエラーが発生した場合、catchブロックでエラーメッセージを設定し、ユーザーに通知。 - フォーム入力のバリデーション: HTMLのrequired属性を使用し、必須入力フィールドを強制。
2.ツイートの投稿や一覧表示の実践(フロント)
TweetList.jsxの作成
// frontend/src/pages/TweetList.jsx
import React, { useContext, useState, useEffect } from "react";
import { AuthContext } from "../contexts/AuthContext";
import API from "../api";
import TweetForm from "../components/TweetForm";
import TweetItem from "../components/TweetItem";
function TweetList() {
const { accessToken } = useContext(AuthContext);
const [tweets, setTweets] = useState([]);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
console.log("Access token:", accessToken);
// ツイート一覧を取得
const fetchTweets = async () => {
setLoading(true);
setError(null);
try {
const res = await API.get("tweets/");
setTweets(res.data);
} catch (err) {
console.error("Failed to fetch tweets", err);
setError("Failed to load tweets.Please try again.")
}
setLoading(false);
};
useEffect(() => {
fetchTweets();
}, []);
const handleNewTweet = (newTweet) => {
setTweets([newTweet, ...tweets]);
};
// ツイート削除時のハンドラー
const handleDeleteTweet = (deletedTweetId) => {
setTweets(tweets.filter(tweet => tweet.id !== deletedTweetId))
};
return (
<div className="container mx-auto mt-4 px-4">
<h2 className="text-2xl font-bold mb-4">Tweets</h2>
{accessToken && (
<div className="mb-6">
<TweetForm onTweetCreated={handleNewTweet} />
</div>
)}
{loading && <p>Loading tweets...</p>}
{error && <div className="bg-red-100 text-red-700 p-2 mb-4 rounded">{error}</div>}
{!loading && tweets.length === 0 && <p>No tweets available.</p>}
<div className="space-y-4">
{tweets.map((tweet) => (
<TweetItem key={tweet.id} tweet={tweet} onDelete={handleDeleteTweet}/>
))}
</div>
</div>
);
}
export default TweetList;
解説
1. TweetListコンポーネントの定義
- accessToken → AuthContext から認証トークンを取得(ログイン状態の確認に使う)
- tweets → ツイート一覧のデータ(配列)
- error → API 取得時のエラー情報を格納
- loading → データをロード中かどうかの状態
2. fetchTweetsでツイート一覧を取得
- loading を true に設定(データ取得中であることを UI に反映)
- エラー (error) をリセット
- API.get("tweets/") を実行して、サーバーからツイート一覧を取得
- 取得したデータ (res.data) を setTweets で tweets ステートに保存
- もしエラーが発生した場合は、エラーメッセージを setError に格納
- loading を false に変更し、データ取得終了
3. 初回レンダリング時にツイートを取得
useEffect(() => {
fetchTweets();
}, []);
-
useEffect
を使うことで、コンポーネントが マウントされたとき ([]
のため一度だけ)にfetchTweets()
を実行 - これにより、ページが表示されたときにAPIからツイート一覧を取得する
4. 新しいツイートを追加
const handleNewTweet = (newTweet) => {
setTweets([newTweet, ...tweets]);
};
-
TweetForm
でツイートが作成されたら、新しいツイート(newTweet)
をtweets
の先頭に追加 - これにより、リアルタイムでツイート一覧が更新される
5. ツイート削除時のハンドラー
const handleDeleteTweet = (deletedTweetId) => {
setTweets(tweets.filter(tweet => tweet.id !== deletedTweetId));
};
ツイートを削除したら、そのid
に一致しないツイートだけをsetTweets
で更新
これにより、削除後に一覧から即座に消える
6. UIのレンダリング
{accessToken && (
<div className="mb-6">
<TweetForm onTweetCreated={handleNewTweet} />
</div>
)}
-
accessToken
がある場合のみTweetForm
を表示(ログインしているとツイートできる)
<div className="space-y-4">
{tweets.map((tweet) => (
<TweetItem key={tweet.id} tweet={tweet} onDelete={handleDeleteTweet} />
))}
</div>
-
tweets.map()
を使ってTweetItem
を一覧表示 -
onDelete
にhandleDeleteTweet
を渡すことで、削除が実行されたときに一覧を更新できる
TweetForm.jsx作成
// frontend/src/components/TweetForm.jsx
import React, { useState, useContext } from "react";
import { AuthContext } from "../contexts/AuthContext";
import API from "../api";
function TweetForm({ onTweetCreated }) {
const{ accessToken } = useContext(AuthContext);
const [content, setContent] = useState("");
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setError(null);
// バリデーション、空白は送信しない
if (!content.trim()) {
setError("Tweet content cannot be empty!");
return;
}
setLoading(true);
try {
const res = await API.post(
"tweets/",
{ content },
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
);
onTweetCreated(res.data);
setContent("");
} catch (err) {
console.error("Failed to post tweet", err);
setError("Failed to post tweet.please try again.");
}
setLoading(false);
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
{error && <div className="bg-red-100 text-red-700 p-2 rounded">{error}</div>}
<textarea
className="w-full px-3 py-2 border rounded focus:outline-none focus:ring focus:border-blue-300"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="What's happening?"
rows="3"
required
></textarea>
<button
type="submit"
disabled={loading}
className={`w-full bg-blue-500 text-white py-2 rounded hover:bg-blue-600 transition duration-200${
loading ? "opacity-50 cursor-not-allowed" : ""
}`}
>
{loading ? "Posting..." : "Tweet"}
</button>
</form>
);
}
export default TweetForm;
解説
1. e.preventDefault();
→ フォームのデフォルトの送信を防ぐ
2. setError(null);
→ 送信前にエラーメッセージをリセット
3. バリデーション
-
ontent.trim()
を使って、空白のツイートを防止 - 空の場合は
setError()
でエラーメッセージを表示
4. APIリクエスト
-
API.post("tweets/", { content }, { headers })
を実行し、新しいツイートを送信 - 成功したら
onTweetCreated(res.data);
を実行し、新しいツイートを親コンポーネント (TweetList.jsx) に渡す -
setContent("");
で入力欄をクリア -
catch (err) {}
でエラーが発生した場合にエラーメッセージをetError()
に格納
TweetItem.jsxの作成
// frontend/src/components/TweetItem.jsx
// src/components/TweetItem.jsx
import React, { useState } from "react";
import CommentList from "./CommentList"; ←後で作ります
import LikeToggle from "./LikeToggle"; ←後で作ります
const TweetItem = ({ tweet, onDelete }) => {
const [showComments, setShowComments] = useState(false);
const handleDeleteTweet = () => {
if (!window.confirm("Delete this tweet?")) return;
// 親から受け取った onDelete があれば呼ぶ
if (onDelete) {
onDelete(tweet.id);
}
};
return (
<div className="border p-4 mb-4">
<p className="font-bold">{tweet.username}</p>
<p>{tweet.content}</p>
<small className="text-gray-500">
{new Date(tweet.created_at).toLocaleString()}
</small>
<div className="mt-2 flex items-center gap-4">
<LikeToggle
tweetId={tweet.id}
initialLiked={tweet.user_has_liked}
initialLikeCount={tweet.likes_count}
/>
<button
onClick={() => setShowComments(!showComments)}
className="text-blue-500 mr-2"
>
{showComments ? "Hide Comments" : "Show Comments"}
</button>
<button
onClick={handleDeleteTweet}
className="text-red-500"
>
Delete Tweet
</button>
</div>
{showComments && (
<CommentList tweetId={tweet.id} />
)}
</div>
);
};
export default TweetItem;
3.Like機能の実装
1. python manage.py startapp likes
2. settings.pyでアプリを認識させる
3. Likeモデルの設計
# backend/likes/models.py
from django.db import models
from django.conf import settings
from tweets.models import Tweet
User = settings.AUTH_USER_MODEL
class Like(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="likes")
tweet = models.ForeignKey(Tweet, on_delete=models.CASCADE, related_name="likes")
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ('user', 'tweet') # 同じユーザーが同じツイートを複数回いいねできないように
def __str__(self):
return f"{self.user} likes Tweet {self.tweet.id}"
4. シリアライザの作成
# backend/likes/serializers.py
from rest_framework import serializers
from.models import Like
class LikeSerializer(serializers.ModelSerializer):
class Meta:
model = Like
fields = ["id", "user", "tweet", "created_at"]
read_only_fields = ["id", "tweet", "created_at"]
- Read-only Fields: id, user, created_atはクライアントから送信される必要がないため、読み取り専用に設定。
5. ビューの作成
# backend/likes/views.py
from rest_framework.views import APIView
from rest_framework import permissions, status
from rest_framework.response import Response
from tweets.models import Tweet
from.serializers import LikeSerializer
from.models import Like
class LikeToggleAPIView(APIView):
permission_classes = [permissions.IsAuthenticated]
def post(self, request, tweet_id):
try:
tweet = Tweet.objects.get(id=tweet_id)
except Tweet.DoesNotExist:
return Response({"error": "Tweet not found."}, status=status.HTTP_404_NOT_FOUND)
like, created = Like.objects.get_or_create(user=request.user, tweet=tweet)
if created:
serializer = LikeSerializer(like)
return Response({
"message": "Tweet liked successfully.",
"likes_count": tweet.likes.count(),
"like": serializer.data
}, status=status.HTTP_201_CREATED)
else:
return Response({"detail": "Already liked."}, status=status.HTTP_200_OK)
def delete(self, request, tweet_id):
try:
tweet = Tweet.objects.get(id=tweet_id)
like = Like.objects.get(user=request.user, tweet=tweet)
like.delete()
return Response({
"message":"Like removed.",
"likes_count": tweet.likes.count(),
}, status=status.HTTP_200_OK)
except Tweet.DoesNotExist:
return Response({"error": "Tweet not found."}, status=status.HTTP_404_NOT_FOUND)
except Like.DoesNotExist:
return Response({"error": "Like does not exist."}, status=status.HTTP_400_BAD_REQUEST)
解説
1. LikeSerializer(like) でシリアライズとは?
シリアライズ(Serialization)とは、 PythonのオブジェクトをJSON形式などに変換する処理 のこと。
serializer = LikeSerializer(like)
このコードでは、Like
モデルのインスタンス (like)
をLikeSerializer
を使って JSON形式に変換している。
2. なぜシリアライズが必要なのか?
Django REST Framework (DRF) では、モデルのデータをそのままレスポンスとして返せない。
レスポンスをクライアント(フロントエンド)に送るためには、
Pythonオブジェクト → JSON
に変換する必要がある。
return Response({
"message": "Tweet liked successfully.",
"likes_count": tweet.likes.count(),
"like": serializer.data
}, status=status.HTTP_201_CREATED)
このserializer.data
が、
LikeSerializerによってJSON形式の辞書(Pythonのdict)に変換されたデータになる。
3. 例
LikeSerializer
がどのように動作するかを理解するために、
serializers.py
にあるLikeSerializer
を想定してみる。
from rest_framework import serializers
from .models import Like
class LikeSerializer(serializers.ModelSerializer):
class Meta:
model = Like
fields = ["id", "user", "tweet", "created_at"] # どのフィールドをシリアライズするか指定
このLikeSerializer
を使うことで、
モデルのデータがJSONに変換される。
例えば、
like = Like.objects.create(user=request.user, tweet=tweet)
をシリアライズすると
{
"id": 3,
"user": 5,
"tweet": 12,
"created_at": "2025-02-02T12:00:00Z"
}
これでクライアント側(Reactなど)が、
データをJSONとして受け取り「いいね」の情報を表示できるようになる。
6. ルーティングの設定
# backend/likes/urls.py
from django.urls import path
from.views import LikeToggleAPIView
urlpatterns = [
path("tweets/<int:tweet_id>/like/", LikeToggleAPIView.as_view(), name="tweet_like"),
path("tweets/<int:tweet_id>/unlike/", LikeToggleAPIView.as_view(), name="tweet_unlike"),
]
7. プロジェクト全体のbackend/urls.pyにlikes/urls.pyをインクルード。
8. LikeToggle.jsxを作成してTweetItem.jsxに埋め込む(上記ではすでに埋め込んでいます)
// frontend/src/components/LikeToggle.jsx
import React, { useState } from "react";
import API from "../api";
// TweetItem.jsxからprops(tweetID, initialLiked, initialLikeCount)を受け取る
const LikeToggle = ({ tweetId, initialLiked, initialLikeCount }) => {
const [liked, setLiked] = useState(initialLiked);
const [likeCount, setLikeCount] = useState(initialLikeCount || 0);
const handleLike = async () => {
try {
const res = await API.post(`tweets/${tweetId}/like/`);
console.log("Like Response:", res.data);
setLiked(true);
setLikeCount((prev) => prev + 1);
} catch (error) {
console.error("Error liking tweet:", error);
}
};
const handleUnlike = async () => {
try {
const res = await API.delete(`tweets/${tweetId}/unlike/`);
console.log("Unlike Response:", res.data)
setLiked(false);
setLikeCount((prev) => Math.max(prev - 1, 0));
} catch (error) {
console.error("Error unliking tweet:", error);
}
};
return (
<div>
<span>{likeCount} Likes</span>
{liked ? (
<button onClick={handleUnlike} className="text-red-500">
Unlike
</button>
) : (
<button onClick={handleLike} className="text-blue-500">
Like
</button>
)}
</div>
);
};
export default LikeToggle;
3.Comment機能の実装
1. python manage.py startapp comments
2. settings.pyでアプリを認識させる
3. Commentsモデルの設計
# backend/comments/models.py
from django.db import models
from django.conf import settings
from tweets.models import Tweet
User = settings.AUTH_USER_MODEL
class Comment(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="comments")
tweet = models.ForeignKey(Tweet, on_delete=models.CASCADE, related_name="comments")
content = models.TextField(max_length=280)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.user.username} commented on {self.tweet.id}"
4. シリアライザの作成
# backend/comments/serializers.py
from rest_framework import serializers
from .models import Comment
from tweets.models import Tweet
class CommentSerializer(serializers.ModelSerializer):
class Meta:
model = Comment
fields = ["id", "tweet", "user", "content", "created_at"]
read_only_fields = ["user", "tweet", "created_at"]
5. ビューの作成
# backend/comments/views.py
from rest_framework import generics, permissions
from rest_framework.response import Response
from django.shortcuts import get_object_or_404
from.models import Comment
from tweets.models import Tweet
from.serializers import CommentSerializer
class TweetCommentViewSet(generics.ListCreateAPIView):
# /api/tweets/<tweet_id>/comments/ に完全一致
# GET: 特定ツイートのコメント一覧
# POST: 特定ツイートにコメント追加
serializer_class = CommentSerializer
permission_classes = [permissions.IsAuthenticated]
# URLパラメータの tweet_id をもとにフィルタリング
def get_queryset(self):
tweet_id = self.kwargs["tweet_id"]
return Comment.objects.filter(tweet_id=tweet_id).order_by("-created_at")
def perform_create(self, serializer):
# POST時にTweetを自動で紐付け、ユーザーも自動セット
tweet_id = self.kwargs["tweet_id"]
tweet = get_object_or_404(Tweet, id=tweet_id)
serializer.save(usesr=self.request.user, tweet=tweet)
class TweetCommentDetailView(generics.RetrieveUpdateDestroyAPIView):
# GET /api/tweets/<tweet_id>/comments/<comment_id>/ → 単一コメント詳細
# PATCH /api/tweets/<tweet_id>/comments/<comment_id>/ → コメント編集
# DELETE /api/tweets/<tweet_id>/comments/<comment_id>/ → コメント削除
def get_queryset(self):
tweet_id = self.kwargs["tweet_id"]
return Comment.objects.filter(tweet_id=tweet_id)
def get_object(self):
queryset = self.get_queryset()
comment_id = self.kwargs["comment_id"]
return get_object_or_404(queryset, id=comment_id)
def perform_update(self, serializer):
serializer.save()
解説 : APIView
と 汎用ビュークラス
の違い**
likes/views.py (LikeToggleAPIView)
このクラスは APIView
を継承しており、メソッドのシグネチャで直接tweet_idを受け取るように定義。
つまり、URL定義で <int:tweet_id>
として渡されるtweet_id
が、post(self, request, tweet_id)
の引数として自動的にセットされます。
そのため、メソッド内で tweet_id
を直接使えます。
comments/views.py (TweetCommentViewSet, TweetCommentDetailView)
こちらは 汎用ビュークラス
(ListCreateAPIView
, RetrieveUpdateDestroyAPIView
) を使っているため、URLパラメータはビューインスタンスの self.kwargs
に格納されます。
そのため、get_queryset
や get_object
メソッド内では、tweet_id = self.kwargs["tweet_id"]
として取得します。
Response と status について
汎用ビューは、リクエストを処理した結果を自動的に Response
オブジェクトにラップし、適切な HTTP ステータスコード(例: 200, 201 など)を設定して返す仕組みが組み込まれています。そのため、ビュー内で個別に Response()
を呼び出す必要がありません。
例えば、GET リクエストでコメント一覧を返す場合、get_queryset()
の戻り値がシリアライザを通して Response
に変換されます。
同様に、POST でコメント作成時は、perform_create()
メソッド内で serializer.save()
を呼び出した後、汎用ビューがシリアライズされたデータを Response
として返すように処理します。
一方で、likes/views.py の LikeToggleAPIView
のように、APIView
を直接継承した場合は、各メソッド内で Response
を明示的に返す必要があります。
エンドポイント
-
/api/tweets/<id>/comments/
に完全一致するURLで、特定のツイートに紐づくコメント一覧取得 + コメント投稿を行いたいという要件を実現するには、カスタムView + 独自URLを用意すると分かりやすいです。 -
DRFの
ModelViewSet
やDefaultRouter
で「/api/comments/」を自動生成するのではなく、手動でルーティングして/api/tweets/<id>/comments/
というURL構造に合わせます。
6. ルーティングの設定
# backend/comments/urls.py
from django.urls import path
from.views import TweetCommentViewSet,TweetCommentDetailView
urlpatterns = [
path("tweets/<int:tweet_id>/comments/", TweetCommentViewSet.as_view(), name="tweet_comments_list_create"),
path("tweets/<int:tweet_id>/comments/<int:comment_id>/", TweetCommentDetailView.as_view(), name="tweet_comment_detail"),
]
7. プロジェクト全体のbackend/urls.pyにlikes/urls.pyをインクルード。
8. CommentList.jsxの作成
// frontend/src/components/CommentList.jsx
import React, { useState, useEffect } from "react";
import API from "../api";
import CommentItem from "./CommentItem";
import CommentForm from "./CommentForm";
const CommentList = ({ tweetId }) => {
const [comments, setComments] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
if (!tweetId) return;
setLoading(true);
setError("");
// GET /api/tweets/<tweetId>/comments/
API.get(`tweets/${tweetId}/comments/`)
.then((res) => {
setComments(res.data);
})
.catch((err) => {
console.error("Error fetching comments:", err);
setError("Failed to load comments.");
})
.finally(() => {
setLoading(false);
});
}, [tweetId]);
// 新規コメントが追加されたとき
const handleCommentAdded = (newComment) => {
setComments((prev) => [newComment, ...prev]);
};
// コメントが更新されたとき
const handleCommentUpdate = (updatedComment) => {
setComments((prev) =>
prev.map((c) => (c.id === updatedComment.id ? updatedComment : c))
);
};
// コメントが削除されたとき
const handleCommentDelete = (deletedId) => {
setComments((prev) => prev.filter((c) => c.id !== deletedId));
};
if (loading) {
return <p>Loading comments...</p>;
}
if (error) {
return <p className="text-red-500">{error}</p>;
}
return (
<div className="mt-4 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg shadow">
<h4 className="font-bold mb-2">Comments ({comments.length})</h4>
{/* 新規投稿フォーム */}
<CommentForm tweetId={tweetId} onCommentAdded={handleCommentAdded} />
{comments.length > 0 ? (
comments.map((comment) => (
<CommentItem
key={comment.id}
tweetId={tweetId}
comment={comment}
onUpdate={handleCommentUpdate}
onDelete={handleCommentDelete}
/>
))
) : (
<p className="text-gray-500">No comments yet.</p>
)}
</div>
);
};
export default CommentList;
9. CommentItem.jsxの作成
// frontend/src/components/CommentItem.jsx
import React, { useState } from "react";
import API from "../api";
const CommentItem = ({tweetId, comment, onUpdate, onDelete }) => {
const [editing, setEditing] = useState(false);
const [editContent, setEditContent] = useState(comment.content);
// コメント編集
const handleEditSave = async () => {
try {
const res = await API.patch(`tweets/${tweetId}/comments/${comment.id}/`, {
content: editContent,
});
onUpdate(res.data);
setEditing(false);
} catch (err) {
console.error("Failed to edit comment:", err);
alert("Failed to edit comment.");
}
};
const handleDelete = async () => {
if (!window.confirm("Are you sure that deleting this comment?")) return;
try {
await API.delete(`tweets/${tweetId}/comments/${comment.id}/`);
onDelete(comment.id);
} catch (err) {
console.error("Failed to delete comment:", err);
alert("Failed to delete comment.");
}
};
return (
<div className="border-b p-2 flex justify-between items-center">
{editing ? (
// 編集モード
<>
<textarea
className="w-full border"
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
/>
<div>
<button onClick={handleEditSave} className="text-green-500">Save</button>
<button onClick={() => setEditing(false)} className="text-gray-500">Cancel</button>
</div>
</>
) : (
// 表示モード
<>
<div>
<p>
<strong>{comment.user.username}</strong>: {comment.content}
</p>
<small>{new Date(comment.created_at).toLocaleString()}</small>
</div>
<div>
<button onClick={() => setEditing(true)} className="text-blue-500">Edit</button>
<button onClick={handleDelete} className="text-red-500">Delete</button>
</div>
</>
)}
</div>
);
};
export default CommentItem;
10. CommentForm.jsxの作成
// frontend/src/components/CommentForm.jsx
import React, { useState } from "react";
import API from "../api";
const CommentForm = ({tweetId, onCommentAdded }) => {
const [content, setContent] = useState("");
const [error, setError] = useState("");
const handleSubmit = async (e) => {
e.preventDefault();
setError("");
if (!content.trim()) {
setError("Comment cannot be empty. Please try again.");
return;
}
console.log("POST to /api/tweets/<tweetId>/comments/:", { content });
try {
const res = await API.post (`tweets/${tweetId}/comments/`, { content });
console.log("Commented Successfull.:", res.data);
// 親コンポーネントへ新しいコメントを通知
if (onCommentAdded) {
onCommentAdded(res.data);
}
setContent(""); //入力フォームをクリア
} catch (err) {
console.error("Failed to post comment.:", err);
setError("Failed to post comment. Please try again.");
}
};
return (
<div className="mt-4 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg shadow">
<form onSubmit={handleSubmit} className="flex flex-col">
{error && <p className="text-red-500 text-sm">a{error}</p>}
<textarea
className="w-full p-2 border rounded dark:text-black"
placeholder="Write comment..."
value={content}
onChange={(e) => setContent(e.target.value)}
rows="2"
required
/>
<button
type="submit"
className="mt-2 bg-blue-500 hover:bg-blue-600 text-white py-1 px-4 rounded"
>
Comment
</button>
</form>
</div>
);
};
export default CommentForm;