0
1

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-4【Django + React】

Last updated at Posted at 2025-02-07

初めに

今日の分を進めていくとこういう仕上がりになります。

image.png

image.png

目次

1. ユーザーのプロフィール情報を表示・編集

2. ツイートの投稿や一覧表示の実践(フロント)

3. Like機能の実装

4. Comment機能の実装

1.ユーザーのプロフィール情報を表示・編集

:computer: .staticとmediaの作成


backend/              <-- プロジェクトルート(BASE_DIR)
├── manage.py
├── static/
│   └── images/
│         └── default_profile.png
├── media/            <-- ユーザーアップロード画像用
└── backend/          <-- settings.py 等があるディレクトリ


:computer: 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" フォルダがあることを確認
]

:computer: backend/urls.pyに追記


if settings.DEBUG:
    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

:computer: .envでBASE_URLの設定


# frontend/.env.local

REACT_APP_HUST_URL=http://localhost:8000

:computer: 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;

:pencil2: 解説

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.ツイートの投稿や一覧表示の実践(フロント)

:computer: 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;

:pencil2: 解説

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を一覧表示
  • onDeletehandleDeleteTweetを渡すことで、削除が実行されたときに一覧を更新できる

:computer: 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;

:pencil2: 解説

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()に格納

:computer: 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機能の実装

:computer: 1. python manage.py startapp likes

:computer: 2. settings.pyでアプリを認識させる

:computer: 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}"

:computer: 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はクライアントから送信される必要がないため、読み取り専用に設定。

:computer: 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)

:pencil2: 解説

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として受け取り「いいね」の情報を表示できるようになる。

:computer: 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"),
]

:computer: 7. プロジェクト全体のbackend/urls.pyにlikes/urls.pyをインクルード。

:computer: 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機能の実装

:computer: 1. python manage.py startapp comments

:computer: 2. settings.pyでアプリを認識させる

:computer: 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}"

:computer: 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"]

:computer: 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()

:pencil2: 解説 : 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_querysetget_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のModelViewSetDefaultRouterで「/api/comments/」を自動生成するのではなく、手動でルーティングして/api/tweets/<id>/comments/というURL構造に合わせます。

:computer: 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"),
]

:computer: 7. プロジェクト全体のbackend/urls.pyにlikes/urls.pyをインクルード。

:computer: 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;

:computer: 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;

:computer: 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;

今日はここまで:smile:

0
1
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
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?