モダンUIコンポーネントの実装パターン
実装ガイドReact、Vue、Angularで共通して使えるUIパターンを解説
ユーザー間のディスカッションを可能にするコメントシステム。返信、いいね、削除機能を備えた完全なソリューション
このコンポーネントはとても使いやすいですね!実装もシンプルで、カスタマイズしやすそうです。
同感です!特にタイムスタンプの表示が分かりやすくて良いと思います。
アバターがない場合のイニシャル表示も良いアイデアですね。 改行も きちんと 表示されます。
自分のコメントは削除できるようになっています。
このコンポーネントはとても使いやすいですね!実装もシンプルで、カスタマイズしやすそうです。
アバターがない場合のイニシャル表示も良いアイデアですね。 改行も きちんと 表示されます。
このコンポーネントはとても使いやすいですね!実装もシンプルで、カスタマイズしやすそうです。
同感です!特にタイムスタンプの表示が分かりやすくて良いと思います。
階層的な会話構造により、議論の流れを明確に表示。最大深度の制御も可能
いいね機能、返信、削除など、ユーザーエンゲージメントを高める機能を完備
相対的な時間表示(たった今、5分前など)により、会話の新鮮さを直感的に把握
プロンプト例:
WebSocketを使用したリアルタイムコメントシステムを実装してください。新規コメントの即時表示、タイピング中インジケーター、オンラインユーザー表示、未読通知機能を含めてください。
プロンプト例:
AIベースのコメントモデレーションシステムを開発してください。不適切な内容の自動検出、スパムフィルタリング、レポート機能、管理者承認フロー、ユーザー評価システムを実装してください。
プロンプト例:
マークダウン対応のリッチテキストコメントエディタを作成してください。画像アップロード、コード構文ハイライト、メンション機能、絵文字ピッカー、ドラッグ&ドロップファイル添付を含めてください。
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>
);
}
ダークモードユーザー
たった今ダークモードでの表示例です。