コメント - Vibe Coding Showcase

コメント

ユーザー間のディスカッションを可能にするコメントシステム。返信、いいね、削除機能を備えた完全なソリューション

デザインプレビュー

コメントシステム

CU
田中太郎

田中太郎

1時間前

このコンポーネントはとても使いやすいですね!実装もシンプルで、カスタマイズしやすそうです。

SH

鈴木花子

30分前

同感です!特にタイムスタンプの表示が分かりやすくて良いと思います。

山田次郎

山田次郎

1日前

アバターがない場合のイニシャル表示も良いアイデアですね。 改行も きちんと 表示されます。

CU

現在のユーザー

5分前

自分のコメントは削除できるようになっています。

シンプルコメント(返信なし)

田中太郎

田中太郎

1時間前

このコンポーネントはとても使いやすいですね!実装もシンプルで、カスタマイズしやすそうです。

山田次郎

山田次郎

1日前

アバターがない場合のイニシャル表示も良いアイデアですね。 改行も きちんと 表示されます。

読み取り専用コメント

田中太郎

田中太郎

1時間前

このコンポーネントはとても使いやすいですね!実装もシンプルで、カスタマイズしやすそうです。

SH

鈴木花子

30分前

同感です!特にタイムスタンプの表示が分かりやすくて良いと思います。

カスタムスタイル

DM

ダークモードユーザー

たった今

ダークモードでの表示例です。

コメントの特徴

ネスト構造の返信

階層的な会話構造により、議論の流れを明確に表示。最大深度の制御も可能

リアルタイムインタラクション

いいね機能、返信、削除など、ユーザーエンゲージメントを高める機能を完備

スマートなタイムスタンプ

相対的な時間表示(たった今、5分前など)により、会話の新鮮さを直感的に把握

AI活用ガイド

リアルタイムコメント

プロンプト例:

WebSocketを使用したリアルタイムコメントシステムを実装してください。新規コメントの即時表示、タイピング中インジケーター、オンラインユーザー表示、未読通知機能を含めてください。

モデレーション機能

プロンプト例:

AIベースのコメントモデレーションシステムを開発してください。不適切な内容の自動検出、スパムフィルタリング、レポート機能、管理者承認フロー、ユーザー評価システムを実装してください。

リッチテキストコメント

プロンプト例:

マークダウン対応のリッチテキストコメントエディタを作成してください。画像アップロード、コード構文ハイライト、メンション機能、絵文字ピッカー、ドラッグ&ドロップファイル添付を含めてください。

実装コード

ファイルサイズ: 6.8KB TypeScript

コンポーネント実装

import React, { useState } from 'react';

interface User {
  id: string;
  name: string;
  avatar?: string;
  initials?: string;
}

interface Comment {
  id: string;
  user: User;
  content: string;
  timestamp: Date;
  likes: number;
  isLiked?: boolean;
  replies?: Comment[];
}

interface CommentsProps {
  comments: Comment[];
  currentUser?: User;
  onReply?: (commentId: string, content: string) => void;
  onLike?: (commentId: string) => void;
  onDelete?: (commentId: string) => void;
  showReplies?: boolean;
  maxDepth?: number;
  className?: string;
}

export const Comments: React.FC<CommentsProps> = ({
  comments,
  currentUser,
  onReply,
  onLike,
  onDelete,
  showReplies = true,
  maxDepth = 3,
  className = ''
}) => {
  const [replyingTo, setReplyingTo] = useState<string | null>(null);
  const [replyContent, setReplyContent] = useState('');

  const handleReply = (commentId: string) => {
    if (replyContent.trim()) {
      onReply?.(commentId, replyContent);
      setReplyContent('');
      setReplyingTo(null);
    }
  };

  const formatTimestamp = (date: Date) => {
    const now = new Date();
    const diff = now.getTime() - date.getTime();
    const minutes = Math.floor(diff / 60000);
    const hours = Math.floor(diff / 3600000);
    const days = Math.floor(diff / 86400000);

    if (minutes < 1) return 'たった今';
    if (minutes < 60) return `${minutes}分前`;
    if (hours < 24) return `${hours}時間前`;
    if (days < 7) return `${days}日前`;
    
    return date.toLocaleDateString('ja-JP');
  };

  const renderComment = (comment: Comment, depth: number = 0) => {
    const isAuthor = currentUser?.id === comment.user.id;
    const canReply = showReplies && depth < maxDepth && onReply;

    return (
      <div key={comment.id} className={`${depth > 0 ? 'ml-12' : ''}`}>
        <div className="flex space-x-3">
          {/* アバター */}
          <div className="flex-shrink-0">
            {comment.user.avatar ? (
              <img
                src={comment.user.avatar}
                alt={comment.user.name}
                className="w-10 h-10 rounded-full"
              />
            ) : (
              <div className="w-10 h-10 rounded-full bg-gray-300 flex items-center justify-center text-gray-600 font-semibold">
                {comment.user.initials || comment.user.name.charAt(0).toUpperCase()}
              </div>
            )}
          </div>

          {/* コメント本体 */}
          <div className="flex-1">
            <div className="bg-gray-50 rounded-lg px-4 py-3">
              <div className="flex items-center justify-between mb-1">
                <h4 className="text-sm font-semibold text-gray-900">
                  {comment.user.name}
                </h4>
                <span className="text-xs text-gray-500">
                  {formatTimestamp(comment.timestamp)}
                </span>
              </div>
              <p className="text-gray-700 text-sm whitespace-pre-wrap">
                {comment.content}
              </p>
            </div>

            {/* アクションボタン */}
            <div className="mt-2 flex items-center space-x-4 text-sm">
              {/* いいねボタン */}
              {onLike && (
                <button
                  onClick={() => onLike(comment.id)}
                  className={`flex items-center space-x-1 transition-colors ${
                    comment.isLiked
                      ? 'text-red-600 hover:text-red-700'
                      : 'text-gray-500 hover:text-gray-700'
                  }`}
                >
                  <svg
                    className="w-4 h-4"
                    fill={comment.isLiked ? 'currentColor' : 'none'}
                    stroke="currentColor"
                    viewBox="0 0 24 24"
                  >
                    <path
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      strokeWidth="2"
                      d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
                    />
                  </svg>
                  <span>{comment.likes > 0 ? comment.likes : 'いいね'}</span>
                </button>
              )}

              {/* 返信ボタン */}
              {canReply && (
                <button
                  onClick={() => setReplyingTo(comment.id)}
                  className="text-gray-500 hover:text-gray-700 transition-colors"
                >
                  返信
                </button>
              )}

              {/* 削除ボタン */}
              {isAuthor && onDelete && (
                <button
                  onClick={() => onDelete(comment.id)}
                  className="text-red-500 hover:text-red-700 transition-colors"
                >
                  削除
                </button>
              )}
            </div>

            {/* 返信フォーム */}
            {replyingTo === comment.id && (
              <div className="mt-3">
                <div className="flex space-x-3">
                  <div className="flex-shrink-0">
                    {currentUser?.avatar ? (
                      <img
                        src={currentUser.avatar}
                        alt={currentUser.name}
                        className="w-8 h-8 rounded-full"
                      />
                    ) : (
                      <div className="w-8 h-8 rounded-full bg-gray-300 flex items-center justify-center text-gray-600 text-sm font-semibold">
                        {currentUser?.initials || currentUser?.name.charAt(0).toUpperCase() || 'U'}
                      </div>
                    )}
                  </div>
                  <div className="flex-1">
                    <textarea
                      value={replyContent}
                      onChange={(e) => setReplyContent(e.target.value)}
                      placeholder="返信を入力..."
                      className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
                      rows={2}
                    />
                    <div className="mt-2 flex justify-end space-x-2">
                      <button
                        onClick={() => {
                          setReplyingTo(null);
                          setReplyContent('');
                        }}
                        className="px-3 py-1 text-sm text-gray-600 hover:text-gray-800"
                      >
                        キャンセル
                      </button>
                      <button
                        onClick={() => handleReply(comment.id)}
                        disabled={!replyContent.trim()}
                        className="px-3 py-1 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
                      >
                        返信
                      </button>
                    </div>
                  </div>
                </div>
              </div>
            )}

            {/* 返信 */}
            {showReplies && comment.replies && comment.replies.length > 0 && (
              <div className="mt-4 space-y-4">
                {comment.replies.map(reply => renderComment(reply, depth + 1))}
              </div>
            )}
          </div>
        </div>
      </div>
    );
  };

  return (
    <div className={`space-y-4 ${className}`}>
      {comments.map(comment => renderComment(comment))}
    </div>
  );
};

// コメント入力フォーム
interface CommentFormProps {
  onSubmit: (content: string) => void;
  currentUser?: User;
  placeholder?: string;
  buttonText?: string;
}

export const CommentForm: React.FC<CommentFormProps> = ({
  onSubmit,
  currentUser,
  placeholder = 'コメントを入力...',
  buttonText = 'コメントする'
}) => {
  const [content, setContent] = useState('');

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (content.trim()) {
      onSubmit(content);
      setContent('');
    }
  };

  return (
    <form onSubmit={handleSubmit} className="flex space-x-3">
      <div className="flex-shrink-0">
        {currentUser?.avatar ? (
          <img
            src={currentUser.avatar}
            alt={currentUser.name}
            className="w-10 h-10 rounded-full"
          />
        ) : (
          <div className="w-10 h-10 rounded-full bg-gray-300 flex items-center justify-center text-gray-600 font-semibold">
            {currentUser?.initials || currentUser?.name.charAt(0).toUpperCase() || 'U'}
          </div>
        )}
      </div>
      <div className="flex-1">
        <textarea
          value={content}
          onChange={(e) => setContent(e.target.value)}
          placeholder={placeholder}
          className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
          rows={3}
        />
        <div className="mt-2 flex justify-end">
          <button
            type="submit"
            disabled={!content.trim()}
            className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
          >
            {buttonText}
          </button>
        </div>
      </div>
    </form>
  );
};

使用例

// 基本的な使用例
import { Comments, CommentForm } from './comments';

function App() {
  const [comments, setComments] = useState([
    {
      id: '1',
      user: {
        id: 'user1',
        name: '田中太郎',
        avatar: '/avatars/tanaka.jpg'
      },
      content: 'とても参考になりました!',
      timestamp: new Date('2024-01-01'),
      likes: 5,
      isLiked: false,
      replies: []
    }
  ]);

  const currentUser = {
    id: 'current',
    name: '現在のユーザー',
    initials: 'CU'
  };

  const handleNewComment = (content) => {
    const newComment = {
      id: Date.now().toString(),
      user: currentUser,
      content,
      timestamp: new Date(),
      likes: 0,
      isLiked: false,
      replies: []
    };
    setComments([newComment, ...comments]);
  };

  const handleReply = (commentId, content) => {
    // 返信ロジック
  };

  const handleLike = (commentId) => {
    // いいねロジック
  };

  return (
    <div>
      {/* コメント投稿フォーム */}
      <CommentForm
        currentUser={currentUser}
        onSubmit={handleNewComment}
        placeholder="コメントを投稿..."
      />

      {/* コメント一覧 */}
      <Comments
        comments={comments}
        currentUser={currentUser}
        onReply={handleReply}
        onLike={handleLike}
        onDelete={handleDelete}
        showReplies={true}
        maxDepth={3}
      />

      {/* 返信なしのシンプル版 */}
      <Comments
        comments={comments}
        currentUser={currentUser}
        onLike={handleLike}
        showReplies={false}
      />

      {/* 読み取り専用 */}
      <Comments
        comments={comments}
      />
    </div>
  );
}