diff --git a/CLAUDE.md b/CLAUDE.md index de8c604..5d9e108 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,6 +78,8 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이) - **비밀댓글 알림**: 비밀댓글(`isSecret`)의 푸시 알림은 내용 마스킹 (`'비밀 댓글이 달렸습니다.'`), 포스트/게시판 댓글 모두 적용 - **비밀댓글 isSecret 토글**: PATCH 시 본인만 변경 가능 (관리자도 타인 비밀 상태 변경 불가) - **포스트 삭제**: 본인 또는 관리자만 가능, 트랜잭션으로 댓글/조회기록/활동점수(blog_post) 일괄 삭제 +- **이모지 리액션**: 게시판 글 + 포스트에 고정 6종 이모지 (👍👀🔥💡😂✅) 토글, `ReactionBar` 공용 컴포넌트 (`apiPath` prop으로 board/posts 구분), 호버(PC)/클릭(모바일) 시 닉네임 팝오버, 복수 선택 가능, 활동 점수/알림 없음 +- **인기글 점수**: `댓글×3 + 조회수×2 + 리액션×1` - **스터디원 목록**: active + dormant + ob 모두 표시, 상태 칩으로 구분 (OB: 황금 파스텔, 휴면: secondary) ## 핵심 파일 위치 @@ -104,6 +106,7 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이) | `packages/web/src/lib/score.ts` | 웹 활동 점수 부여 (board_post, post_comment, board_comment, post_view) | | `packages/web/src/lib/score-config.ts` | 활동 점수 타입별 메타데이터 (Single Source of Truth: 라벨, 이모지, 배점, 뱃지 컬러) | | `packages/web/src/app/(user)/profile/activity/page.tsx` | 활동 내역 페이지 (타입별 필터, 무한 로드) | +| `packages/web/src/components/board/reaction-bar.tsx` | 이모지 리액션 바 공용 컴포넌트 (`apiPath` prop) | | `packages/web/src/lib/board-auth.ts` | 게시판 인증 헬퍼 (`getBoardAuth`) | | `packages/web/src/lib/board-config.ts` | 게시판 카테고리/뱃지 설정 | | `packages/web/src/lib/api-error.ts` | API 표준 응답/에러 헬퍼 (`successResponse`, `Errors`, `withCache`) | diff --git a/packages/shared/src/db/schema.ts b/packages/shared/src/db/schema.ts index 2de3788..92bc3f4 100644 --- a/packages/shared/src/db/schema.ts +++ b/packages/shared/src/db/schema.ts @@ -499,6 +499,61 @@ export const boardPollVotes = pgTable( }) ); +// ── Board Post Reactions ────────────────────────────────────────────────── + +export const REACTION_EMOJIS = ['👍', '👀', '🔥', '💡', '😂', '✅'] as const; +export type ReactionEmoji = (typeof REACTION_EMOJIS)[number]; + +export const boardPostReactions = pgTable( + 'board_post_reactions', + { + id: uuid('id').primaryKey().defaultRandom(), + postId: uuid('post_id') + .notNull() + .references(() => boardPosts.id, { onDelete: 'cascade' }), + memberId: uuid('member_id') + .notNull() + .references(() => members.id, { onDelete: 'cascade' }), + emoji: varchar('emoji', { length: 10 }).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), + }, + (table) => ({ + postIdIdx: index('idx_board_post_reactions_post_id').on(table.postId), + memberIdIdx: index('idx_board_post_reactions_member_id').on(table.memberId), + uniqueReaction: unique('unique_post_member_emoji').on( + table.postId, + table.memberId, + table.emoji + ), + }) +); + +// ── Post Reactions ──────────────────────────────────────────────────────── + +export const postReactions = pgTable( + 'post_reactions', + { + id: uuid('id').primaryKey().defaultRandom(), + postId: uuid('post_id') + .notNull() + .references(() => posts.id, { onDelete: 'cascade' }), + memberId: uuid('member_id') + .notNull() + .references(() => members.id, { onDelete: 'cascade' }), + emoji: varchar('emoji', { length: 10 }).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), + }, + (table) => ({ + postIdIdx: index('idx_post_reactions_post_id').on(table.postId), + memberIdIdx: index('idx_post_reactions_member_id').on(table.memberId), + uniqueReaction: unique('unique_post_reaction').on( + table.postId, + table.memberId, + table.emoji + ), + }) +); + // ── FCM Tokens ───────────────────────────────────────────────────────────── export const fcmTokens = pgTable( @@ -565,6 +620,8 @@ export const membersRelations = relations(members, ({ many }) => ({ boardComments: many(boardComments), fcmTokens: many(fcmTokens), notificationPreferences: many(notificationPreferences), + boardPostReactions: many(boardPostReactions), + postReactions: many(postReactions), })); export const fcmTokensRelations = relations(fcmTokens, ({ one }) => ({ @@ -598,6 +655,7 @@ export const postsRelations = relations(posts, ({ one, many }) => ({ }), views: many(postViews), comments: many(postComments), + reactions: many(postReactions), })); export const attendanceRelations = relations(attendance, ({ one }) => ({ @@ -669,6 +727,7 @@ export const boardPostsRelations = relations(boardPosts, ({ one, many }) => ({ }), comments: many(boardComments), polls: many(boardPolls), + reactions: many(boardPostReactions), })); export const boardCommentsRelations = relations(boardComments, ({ one, many }) => ({ @@ -720,6 +779,28 @@ export const boardPollVotesRelations = relations(boardPollVotes, ({ one }) => ({ }), })); +export const boardPostReactionsRelations = relations(boardPostReactions, ({ one }) => ({ + post: one(boardPosts, { + fields: [boardPostReactions.postId], + references: [boardPosts.id], + }), + member: one(members, { + fields: [boardPostReactions.memberId], + references: [members.id], + }), +})); + +export const postReactionsRelations = relations(postReactions, ({ one }) => ({ + post: one(posts, { + fields: [postReactions.postId], + references: [posts.id], + }), + member: one(members, { + fields: [postReactions.memberId], + references: [members.id], + }), +})); + // ============================================ // Type Exports (for use in application code) // ============================================ @@ -775,6 +856,9 @@ export type NewBoardPollOption = typeof boardPollOptions.$inferInsert; export type BoardPollVote = typeof boardPollVotes.$inferSelect; export type NewBoardPollVote = typeof boardPollVotes.$inferInsert; +export type BoardPostReaction = typeof boardPostReactions.$inferSelect; +export type NewBoardPostReaction = typeof boardPostReactions.$inferInsert; + export type FcmToken = typeof fcmTokens.$inferSelect; export type NewFcmToken = typeof fcmTokens.$inferInsert; diff --git a/packages/web/src/app/(user)/board/[id]/page.tsx b/packages/web/src/app/(user)/board/[id]/page.tsx index ccca0e9..79ff5e5 100644 --- a/packages/web/src/app/(user)/board/[id]/page.tsx +++ b/packages/web/src/app/(user)/board/[id]/page.tsx @@ -9,6 +9,7 @@ import { type Poll, PollDisplay } from '@/components/board/poll-display'; import { type Comment, CommentTree } from '@/components/board/comment-tree'; import { CommentForm } from '@/components/board/comment-form'; import { DeletePostDialog } from '@/components/board/delete-post-dialog'; +import { ReactionBar } from '@/components/board/reaction-bar'; import { categoryBadgeConfig } from '@/lib/board-config'; import { MemberAvatar } from '@/components/ui/member-avatar'; import { Button } from '@/components/ui/button'; @@ -88,6 +89,9 @@ export default function BoardDetailPage() { const [post, setPost] = useState(null); const [comments, setComments] = useState([]); const [polls, setPolls] = useState([]); + const [reactions, setReactions] = useState< + Record + >({}); const [currentUser, setCurrentUser] = useState({ memberId: null, isAdmin: false, @@ -145,6 +149,7 @@ export default function BoardDetailPage() { setPost(postResult.data.post); setComments(postResult.data.comments); + setReactions(postResult.data.reactions || {}); // Fetch polls (non-critical) if (pollsRes.ok) { @@ -169,12 +174,25 @@ export default function BoardDetailPage() { if (!res.ok) return; const result = await res.json(); setComments(result.data.comments); + setReactions(result.data.reactions || {}); setPost((prev) => (prev ? { ...prev, commentCount: result.data.post.commentCount } : prev)); } catch { // Fail silently — user can manually refresh } }, [postId]); + // Refresh reactions + const refreshReactions = useCallback(async () => { + try { + const res = await fetch(`/api/board/${postId}`); + if (!res.ok) return; + const result = await res.json(); + setReactions(result.data.reactions || {}); + } catch { + // Fail silently + } + }, [postId]); + // Refresh polls after voting const refreshPolls = useCallback(async () => { try { @@ -320,6 +338,15 @@ export default function BoardDetailPage() {
+ + {/* Reactions */} +
+ +
{/* ── Polls section ── */} diff --git a/packages/web/src/app/(user)/posts/[id]/page.tsx b/packages/web/src/app/(user)/posts/[id]/page.tsx index 040de17..121ece4 100644 --- a/packages/web/src/app/(user)/posts/[id]/page.tsx +++ b/packages/web/src/app/(user)/posts/[id]/page.tsx @@ -34,6 +34,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; +import { ReactionBar } from '@/components/board/reaction-bar'; import { cn, getDefaultAvatar } from '@/lib/utils'; // ───────────────────────────────────────────── @@ -613,6 +614,9 @@ export default function PostDetailPage() { const [comments, setComments] = useState([]); const [commentCount, setCommentCount] = useState(0); + const [reactions, setReactions] = useState< + Record + >({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [newComment, setNewComment] = useState(''); @@ -645,6 +649,17 @@ export default function PostDetailPage() { } }, [postId]); + const fetchReactions = useCallback(async () => { + try { + const res = await fetch(`/api/posts/${postId}/reactions`); + if (!res.ok) return; + const result = await res.json(); + setReactions(result.data.reactions || {}); + } catch { + // Non-critical + } + }, [postId]); + useEffect(() => { async function fetchPostInfo() { try { @@ -672,7 +687,8 @@ export default function PostDetailPage() { } fetchPostInfo(); fetchComments(); - }, [postId, fetchComments]); + fetchReactions(); + }, [postId, fetchComments, fetchReactions]); const handleSubmitComment = async () => { if (!newComment.trim()) return; @@ -773,6 +789,16 @@ export default function PostDetailPage() { )} + {/* Reactions */} +
+ +
+ {/* Comments section */} diff --git a/packages/web/src/app/(user)/posts/page.tsx b/packages/web/src/app/(user)/posts/page.tsx index cbcab8a..9b33472 100644 --- a/packages/web/src/app/(user)/posts/page.tsx +++ b/packages/web/src/app/(user)/posts/page.tsx @@ -20,6 +20,7 @@ import { Plus, Reply, Search, + SmilePlus, Trash2, TrendingUp, X, @@ -32,6 +33,7 @@ import { Switch } from '@/components/ui/switch'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Textarea } from '@/components/ui/textarea'; import { PartBadge } from '@/components/ui/part-badge'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { MemberAvatar } from '@/components/ui/member-avatar'; import { PageError, PostsListSkeleton } from '@/components/ui/page-state'; import { @@ -56,6 +58,9 @@ import { import { cn, getDefaultAvatar } from '@/lib/utils'; import { getPartStyle, PART_OPTIONS } from '@/lib/part-config'; +// Synced with REACTION_EMOJIS in packages/shared/src/db/schema.ts (server validates) +const REACTION_EMOJIS = ['👍', '👀', '🔥', '💡', '😂', '✅'] as const; + // ───────────────────────────────────────────── // Types // ───────────────────────────────────────────── @@ -113,6 +118,7 @@ interface Post { viewCount: number; viewers: Viewer[]; totalViewers: number; + reactionCount: number; } interface PostsData { @@ -800,6 +806,79 @@ function PostThumbnail({ ); } +// ───────────────────────────────────────────── +// ReactionChip (이모지 카운트 + 호버/클릭 시 닉네임) +// ───────────────────────────────────────────── + +function ReactionChip({ + emoji, + count, + reacted, + members, + loading, + onToggle, +}: { + emoji: string; + count: number; + reacted: boolean; + members: { id: string; nickname: string }[]; + loading: boolean; + onToggle: () => void; +}) { + const [open, setOpen] = useState(false); + + return ( + + +
setOpen(true)} + onMouseLeave={() => setOpen(false)} + > + +
+
+ e.preventDefault()} + onPointerDownOutside={() => setOpen(false)} + onMouseEnter={() => setOpen(true)} + onMouseLeave={() => setOpen(false)} + > +
+ {members.map((m) => ( +

+ {m.nickname} +

+ ))} +
+
+
+ ); +} + +// ───────────────────────────────────────────── +// PostCard with inline comments +// ───────────────────────────────────────────── + function PostCard({ post, onView, @@ -835,6 +914,48 @@ function PostCard({ const [editTitle, setEditTitle] = useState(post.title); const [editDescription, setEditDescription] = useState(post.description || ''); const [editing, setEditing] = useState(false); + const [reactions, setReactions] = useState< + Record + >({}); + const [reactionPickerOpen, setReactionPickerOpen] = useState(false); + const [reactionLoading, setReactionLoading] = useState(null); + + const fetchReactions = useCallback(async () => { + try { + const res = await fetch(`/api/posts/${post.id}/reactions`); + if (!res.ok) return; + const result = await res.json(); + setReactions(result.data.reactions || {}); + } catch { /* non-critical */ } + }, [post.id]); + + useEffect(() => { + fetchReactions(); + }, [fetchReactions]); + + const toggleReaction = useCallback(async (emoji: string) => { + if (reactionLoading) return; + setReactionLoading(emoji); + setReactionPickerOpen(false); + try { + const res = await fetch(`/api/posts/${post.id}/reactions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ emoji }), + }); + if (!res.ok) { + toast.error('리액션 처리에 실패했습니다.'); + return; + } + fetchReactions(); + } catch { + toast.error('리액션 처리에 실패했습니다.'); + } finally { + setReactionLoading(null); + } + }, [post.id, reactionLoading, fetchReactions]); + + const activeEmojis = REACTION_EMOJIS.filter((e) => (reactions[e]?.count ?? 0) > 0); const handleEditSubmit = async () => { if (!editTitle.trim()) return; @@ -1010,7 +1131,7 @@ function PostCard({ })()} {/* Footer: viewers + comments toggle */} -
+
{post.viewers.length > 0 ? (
@@ -1051,6 +1172,58 @@ function PostCard({ {post.commentCount} + + {/* Reactions — 이모지별 카운트, 호버/클릭 시 닉네임 */} + {activeEmojis.map((emoji) => { + const r = reactions[emoji]!; + return ( + toggleReaction(emoji)} + /> + ); + })} + {/* 리액션 추가 버튼 */} + + + + + e.preventDefault()} + > +
+ {REACTION_EMOJIS.map((emoji) => ( + + ))} +
+
+
+
{canEdit && ( + + ); + })} + + {/* 리액션 추가 피커 */} + + + + + e.preventDefault()} + > +
+ {EMOJIS.map((emoji) => ( + + ))} +
+
+
+
+ ); +}