Skip to content
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

## 핵심 파일 위치
Expand All @@ -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`) |
Expand Down
84 changes: 84 additions & 0 deletions packages/shared/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 }) => ({
Expand Down Expand Up @@ -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 }) => ({
Expand Down Expand Up @@ -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 }) => ({
Expand Down Expand Up @@ -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)
// ============================================
Expand Down Expand Up @@ -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;

Expand Down
27 changes: 27 additions & 0 deletions packages/web/src/app/(user)/board/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -88,6 +89,9 @@ export default function BoardDetailPage() {
const [post, setPost] = useState<Post | null>(null);
const [comments, setComments] = useState<Comment[]>([]);
const [polls, setPolls] = useState<Poll[]>([]);
const [reactions, setReactions] = useState<
Record<string, { count: number; members: { id: string; nickname: string }[]; reacted: boolean }>
>({});
const [currentUser, setCurrentUser] = useState<CurrentUser>({
memberId: null,
isAdmin: false,
Expand Down Expand Up @@ -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) {
Expand All @@ -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 {
Expand Down Expand Up @@ -320,6 +338,15 @@ export default function BoardDetailPage() {
<div className="px-6 py-6">
<TiptapRenderer content={post.content} />
</div>

{/* Reactions */}
<div className="px-6 pb-5">
<ReactionBar
postId={postId}
reactions={reactions}
onUpdate={refreshReactions}
/>
</div>
</div>

{/* ── Polls section ── */}
Expand Down
28 changes: 27 additions & 1 deletion packages/web/src/app/(user)/posts/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

// ─────────────────────────────────────────────
Expand Down Expand Up @@ -613,6 +614,9 @@ export default function PostDetailPage() {

const [comments, setComments] = useState<Comment[]>([]);
const [commentCount, setCommentCount] = useState(0);
const [reactions, setReactions] = useState<
Record<string, { count: number; members: { id: string; nickname: string }[]; reacted: boolean }>
>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [newComment, setNewComment] = useState('');
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -672,7 +687,8 @@ export default function PostDetailPage() {
}
fetchPostInfo();
fetchComments();
}, [postId, fetchComments]);
fetchReactions();
}, [postId, fetchComments, fetchReactions]);

const handleSubmitComment = async () => {
if (!newComment.trim()) return;
Expand Down Expand Up @@ -773,6 +789,16 @@ export default function PostDetailPage() {
</Card>
)}

{/* Reactions */}
<div className="px-1">
<ReactionBar
postId={postId}
apiPath="posts"
reactions={reactions}
onUpdate={fetchReactions}
/>
</div>

{/* Comments section */}
<Card className="border-border/60 shadow-none">
<CardContent className="p-4">
Expand Down
Loading
Loading