diff --git a/CLAUDE.md b/CLAUDE.md index f5756be..9d07a52 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ deploy/ |------|------| | Runtime | Node.js 22, TypeScript 5.x | | Bot | discord.js v14, feedsmith (RSS 파서), pg-boss (PostgreSQL 잡 큐), Sentry (에러 모니터링) | -| Web | Next.js 16 App Router, React 19, shadcn/ui, Tailwind CSS v4, Tiptap (리치 에디터), sonner (토스트), Framer Motion (랜딩 애니메이션), Firebase (FCM 푸시 알림), Sentry (에러 모니터링) | +| Web | Next.js 16 App Router, React 19, shadcn/ui, Tailwind CSS v4, Tiptap (리치 에디터), sonner (토스트), Framer Motion (랜딩 애니메이션), Firebase (FCM 푸시 알림), Sentry (에러 모니터링), Cloudflare R2 (이미지 스토리지) | | DB | Supabase PostgreSQL + Drizzle ORM (Transaction Pooler, `prepare: false`) | | Auth | Supabase Auth (Discord OAuth) + `@supabase/ssr` | | 배포 | AWS EC2 Docker (bot), Vercel (web), Supabase (DB + Auth) | @@ -64,6 +64,15 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이) - **블로그 URL 수정**: 프로필 수정 시 blogUrl 변경 가능, 변경 시 rssUrl을 null 초기화 후 `after()`로 RSS 비동기 재감지 - **Discord 알림**: 웹에서 직접 Discord REST API 호출 시 `discord-notify.ts` 유틸 사용, 사용자 입력은 `escapeDiscordMarkdown()` 적용, `allowed_mentions: { parse: [] }` 필수 - **댓글 길이**: 최대 5000자 제한 (API에서 검증) +- **이미지 업로드**: Cloudflare R2 (`board-images/{userId}/{uuid}.{ext}`), 5MB 제한, rate limit 20회/분/유저 +- **포스트 수동등록**: 2단계 UX (URL→미리보기→편집→등록), OG HTML 엔티티 자동 디코딩, Discord 알림 토글 +- **포스트 수정**: 본인 또는 관리자만 제목/설명 수정 가능 (`PATCH /api/posts/[id]`) +- **공지 알림**: 게시판 공지 작성 시 FCM 푸시 + Discord 공지채널(`notice_channel_id`) `@everyone` + 웹 딥링크 버튼 +- **벌금 DM**: 계좌 정보 포함 (3333333114501 카카오뱅크), 납부완료 시 관리자 채널 알림 +- **D-Day 계산**: KST 기준 (`+09:00` 명시), 제출률은 active 유저만 카운트 +- **랭킹**: active + OB + dormant 전원 표시, 웹 페이지 4위부터 (포디움과 분리), 주간랭킹 전원 나열 +- **RSS 수집**: active + OB (rssConsent=true만), 포스트 점수는 active만 부여 +- **Discord 버튼**: `discord-notify.ts`에 `components` (Link Button) + `allowEveryone` 옵션 지원 - **백그라운드 작업**: API route에서 푸시 알림/점수 부여 등 fire-and-forget 작업은 `after()` from `next/server` 사용 (Vercel 서버리스 종료 방지) - **비밀댓글 알림**: 비밀댓글(`isSecret`)의 푸시 알림은 내용 마스킹 (`'비밀 댓글이 달렸습니다.'`), 포스트/게시판 댓글 모두 적용 - **비밀댓글 isSecret 토글**: PATCH 시 본인만 변경 가능 (관리자도 타인 비밀 상태 변경 불가) @@ -126,7 +135,12 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이) | `packages/bot/src/scripts/setup-channels.ts` | 디스코드 채널 일괄 생성 스크립트 | | `packages/bot/src/scripts/list-channels.ts` | 서버 채널 구조 조회 스크립트 | | `packages/bot/src/api-server.ts` | 봇 HTTP API 서버 (Express, 수동 트리거 엔드포인트, rate limit 10/min) | -| `packages/web/src/lib/discord-notify.ts` | Discord REST API 채널 메시지 전송 유틸 (웹→Discord 직접 알림) | +| `packages/web/src/lib/discord-notify.ts` | Discord REST API 채널 메시지 전송 유틸 (웹→Discord 직접 알림, 버튼/embed/allowEveryone 지원) | +| `packages/web/src/lib/r2.ts` | Cloudflare R2 업로드/삭제 유틸 (`uploadToR2`, `deleteFromR2`) | +| `packages/web/src/app/api/board/image/route.ts` | 게시판 이미지 업로드 API (R2, 5MB, JPEG/PNG/GIF/WebP) | +| `packages/web/src/app/api/posts/preview/route.ts` | 포스트 URL OG 미리보기 API (제목/설명/썸네일 추출) | +| `packages/web/src/components/board/image-block.tsx` | Tiptap ImageBlock 커스텀 노드 (리사이즈, 삭제, 캡션) | +| `packages/web/src/components/board/image-drop-plugin.ts` | Tiptap 이미지 드래그앤드롭 + 붙여넣기 플러그인 | | `packages/bot/src/services/round.service.ts` | 회차 관리 + ConfigKeys (announcement/notice/curation/admin_notification 채널) | | `packages/web/src/components/landing/landing-client.tsx` | 랜딩 페이지 클라이언트 (7섹션: Hero, Stats, Bento, HowItWorks, Marquee, CTA, Footer) | | `packages/web/src/components/landing/motion.tsx` | 랜딩 애니메이션 컴포넌트 (FadeUp, StaggerContainer, CountUp, DrawLine) | diff --git a/packages/bot/src/handlers/dm-handler.ts b/packages/bot/src/handlers/dm-handler.ts index e37dec3..968e9b2 100644 --- a/packages/bot/src/handlers/dm-handler.ts +++ b/packages/bot/src/handlers/dm-handler.ts @@ -161,7 +161,7 @@ async function handleButtonInteraction(interaction: Interaction): Promise .where(eq(rounds.id, paidFine.roundId)) .limit(1); - const displayName = member.nickname || member.name; + const displayName = member.name || member.nickname; const reason = formatFineReason(paidFine.type as 'late' | 'absent'); const roundText = round ? `${round.roundNumber}회차` : ''; @@ -170,7 +170,7 @@ async function handleButtonInteraction(interaction: Interaction): Promise const channel = await interaction.client.channels.fetch(logChannelId).catch(() => null); if (channel && channel.isTextBased() && !channel.isDMBased()) { await (channel as TextChannel).send( - `💰 **${displayName}**님이 ${roundText} ${reason} 벌금 ${paidFine.amount.toLocaleString()}원 납부를 완료했습니다. 확인해주세요.` + `💰 **${displayName}**님이 ${roundText} ${reason} 벌금 ${paidFine.amount.toLocaleString()}원 납부를 완료했습니다.` ); } } @@ -219,6 +219,7 @@ export async function sendFineNotification( ``, `💰 **금액**: ${amount.toLocaleString()}원`, `📝 **사유**: ${reason}`, + `🏦 **계좌**: 3333333114501 (카카오뱅크)`, ``, `납부 완료 후 아래 버튼을 클릭해주세요.`, ].join('\n'); @@ -277,6 +278,7 @@ export async function sendFineReminder( `(${daysSinceCreation}일 경과)`, ``, `💰 **금액**: ${amount.toLocaleString()}원`, + `🏦 **계좌**: 3333333114501 (카카오뱅크)`, ``, `납부 완료 후 아래 버튼을 클릭해주세요.`, ].join('\n'); diff --git a/packages/bot/src/scheduler-registry.ts b/packages/bot/src/scheduler-registry.ts index 770d237..a28c8c4 100644 --- a/packages/bot/src/scheduler-registry.ts +++ b/packages/bot/src/scheduler-registry.ts @@ -123,13 +123,15 @@ export async function registerAllJobs(boss: PgBoss, client: Client): Promise"'&]/g, '').slice(0, 200); - await scoreService.grantScore( - member.id, - ActivityScoreType.BLOG_POST, - `블로그 포스트: ${safeTitle}`, - ); + // 블로그 포스트 점수 부여 (+30점, 일일 2편 상한) — active 유저만 + if (member.status === 'active') { + const safeTitle = item.title.replace(/[<>"'&]/g, '').slice(0, 200); + await scoreService.grantScore( + member.id, + ActivityScoreType.BLOG_POST, + `블로그 포스트: ${safeTitle}`, + ); + } await notificationService.sendPostNotification({ member, diff --git a/packages/bot/src/schedulers/rss-poller.property.test.ts b/packages/bot/src/schedulers/rss-poller.property.test.ts index 7e54726..61ebbb1 100644 --- a/packages/bot/src/schedulers/rss-poller.property.test.ts +++ b/packages/bot/src/schedulers/rss-poller.property.test.ts @@ -101,25 +101,33 @@ describe('RSS Poller Property Tests', () => { ) ); - // Filter to only active members (what the service should return) + // Filter to active and OB members (what the service should return) const activeMembers = allMembers.filter( m => m.status === MemberStatus.ACTIVE ); + const obMembers = allMembers.filter( + m => m.status === MemberStatus.OB + ); - // Mock the service to return only active members - mockGetAllByStatus.mockResolvedValue(activeMembers); + // Mock the service to return active and OB members separately + mockGetAllByStatus.mockImplementation(async (status: string) => { + if (status === MemberStatus.ACTIVE) return activeMembers; + if (status === MemberStatus.OB) return obMembers; + return []; + }); // Get members to poll const membersToPoll = await poller.getMembersToPoll(); - // Verify: all returned members should be active AND have RSS URL + // Verify: all returned members should be active or OB AND have RSS URL for (const member of membersToPoll) { - expect(member.status).toBe(MemberStatus.ACTIVE); + expect([MemberStatus.ACTIVE, MemberStatus.OB]).toContain(member.status); expect(member.rssUrl).not.toBeNull(); } - // Verify: the service was called with ACTIVE status + // Verify: the service was called with both ACTIVE and OB expect(mockGetAllByStatus).toHaveBeenCalledWith(MemberStatus.ACTIVE); + expect(mockGetAllByStatus).toHaveBeenCalledWith(MemberStatus.OB); } ), { numRuns: 100 } @@ -148,7 +156,10 @@ describe('RSS Poller Property Tests', () => { ) ); - mockGetAllByStatus.mockResolvedValue(activeMembers); + mockGetAllByStatus.mockImplementation(async (status: string) => { + if (status === MemberStatus.ACTIVE) return activeMembers; + return []; // OB returns empty for this test + }); const membersToPoll = await poller.getMembersToPoll(); diff --git a/packages/bot/src/schedulers/rss-poller.ts b/packages/bot/src/schedulers/rss-poller.ts index 06f134c..e816688 100644 --- a/packages/bot/src/schedulers/rss-poller.ts +++ b/packages/bot/src/schedulers/rss-poller.ts @@ -51,14 +51,17 @@ export class RssPoller { /** * Get members that should be polled - * Requirements: 6.2 - Only active members should be polled + * Active + OB members with RSS consent */ async getMembersToPoll(): Promise { const memberService = getMemberService(); - const activeMembers = await memberService.getAllByStatus(MemberStatus.ACTIVE); - + const [activeMembers, obMembers] = await Promise.all([ + memberService.getAllByStatus(MemberStatus.ACTIVE), + memberService.getAllByStatus(MemberStatus.OB), + ]); + // Filter to only members with RSS URLs and RSS consent - return activeMembers.filter(member => member.rssUrl && member.rssConsent !== false); + return [...activeMembers, ...obMembers].filter(member => member.rssUrl && member.rssConsent !== false); } /** diff --git a/packages/bot/src/schedulers/weekly-ranking.ts b/packages/bot/src/schedulers/weekly-ranking.ts index 7f6b3c6..0f9749c 100644 --- a/packages/bot/src/schedulers/weekly-ranking.ts +++ b/packages/bot/src/schedulers/weekly-ranking.ts @@ -4,7 +4,7 @@ */ import { bold, Client, EmbedBuilder } from 'discord.js'; -import { count, eq, sql } from 'drizzle-orm'; +import { count, eq, inArray, sql } from 'drizzle-orm'; import logger from '../lib/logger'; import { activityScores, ActivityScoreType, getDb, members, MemberStatus, posts } from '@blog-study/shared/db'; import { ConfigKeys, getConfigValue } from '../services/round.service'; @@ -53,7 +53,7 @@ async function getMemberRankings(): Promise { }) .from(members) .leftJoin(posts, eq(members.id, posts.memberId)) - .where(eq(members.status, MemberStatus.ACTIVE)) + .where(inArray(members.status, [MemberStatus.ACTIVE, MemberStatus.OB, MemberStatus.DORMANT])) .groupBy(members.id); // 전체 누적 점수 (주간 필터 없음 — 웹 랭킹과 동일) @@ -127,28 +127,45 @@ function createRankingEmbed(rankings: MemberRanking[]): EmbedBuilder { }); } - // 전체 랭킹 (Top 15) - const displayRankings = rankings.slice(0, 15); - let rankingText = ''; - - for (const r of displayRankings) { + // 전체 랭킹 (전원 표시, 1024자 제한 시 여러 field로 분할) + const rankingLines: string[] = []; + for (const r of rankings) { const rankDisplay = r.rank <= 3 ? ['🥇', '🥈', '🥉'][r.rank - 1] ?? `${r.rank}.` : `${r.rank}.`; const activity = r.webActivityScore > 0 ? ` | 활동 ${r.webActivityScore}pt` : ''; - rankingText += `${rankDisplay} <@${r.discordId}> — ${r.totalScore}pt (포스트 ${r.postCount}개${activity})\n`; + rankingLines.push(`${rankDisplay} <@${r.discordId}> — ${r.totalScore}pt (포스트 ${r.postCount}개${activity})`); } - if (rankings.length > 15) { - rankingText += `\n_외 ${rankings.length - 15}명_`; + if (rankingLines.length === 0) { + embed.addFields({ + name: `# 📊 전체 랭킹`, + value: '등록된 멤버가 없습니다.', + inline: false, + }); + } else { + // 1024자 제한 대응: 여러 field로 분할 + let chunk = ''; + let isFirst = true; + for (const line of rankingLines) { + if (chunk.length + line.length + 1 > 1000) { + embed.addFields({ + name: isFirst ? `# 📊 전체 랭킹 (${rankings.length}명)` : '\u200b', + value: chunk, + inline: false, + }); + chunk = ''; + isFirst = false; + } + chunk += (chunk ? '\n' : '') + line; + } + if (chunk) { + embed.addFields({ + name: isFirst ? `# 📊 전체 랭킹 (${rankings.length}명)` : '\u200b', + value: chunk, + inline: false, + }); + } } - embed.addFields({ - name: `# 📊 전체 랭킹 (${rankings.length}명)`, - value: (rankingText || '등록된 멤버가 없습니다.').length > 1024 - ? rankingText.substring(0, 1021) + '...' - : rankingText || '등록된 멤버가 없습니다.', - inline: false, - }); - return embed; } diff --git a/packages/bot/src/services/ranking.service.ts b/packages/bot/src/services/ranking.service.ts index 4670f2c..16bbf9a 100644 --- a/packages/bot/src/services/ranking.service.ts +++ b/packages/bot/src/services/ranking.service.ts @@ -4,14 +4,8 @@ * 주간/월간 랭킹, 포디움 추출 */ -import { and, count, eq, inArray, sql } from 'drizzle-orm'; -import { - getDb, - members, - posts, - activityScores, - MemberStatus, -} from '@blog-study/shared/db'; +import { and, count, inArray, sql } from 'drizzle-orm'; +import { activityScores, getDb, members, MemberStatus, posts, } from '@blog-study/shared/db'; /** * 블로그 포스트 점수 (포스트 1개당 부여되는 점수) @@ -61,7 +55,7 @@ export class RankingService { part: members.part, }) .from(members) - .where(eq(members.status, MemberStatus.ACTIVE)); + .where(inArray(members.status, [MemberStatus.ACTIVE, MemberStatus.OB, MemberStatus.DORMANT])); if (activeMembers.length === 0) { return []; diff --git a/packages/shared/src/db/schema.ts b/packages/shared/src/db/schema.ts index 9ca2ed1..e7e43e7 100644 --- a/packages/shared/src/db/schema.ts +++ b/packages/shared/src/db/schema.ts @@ -91,19 +91,19 @@ export const members = pgTable( name: varchar('name', { length: 50 }).notNull(), nickname: varchar('nickname', { length: 100 }).notNull(), part: varchar('part', { length: 50 }).notNull(), - blogUrl: varchar('blog_url', { length: 500 }).notNull(), - rssUrl: varchar('rss_url', { length: 500 }), + blogUrl: varchar('blog_url', { length: 2000 }).notNull(), + rssUrl: varchar('rss_url', { length: 2000 }), // 프로필 정보 (온보딩) - profileImageUrl: varchar('profile_image_url', { length: 500 }), + profileImageUrl: varchar('profile_image_url', { length: 2000 }), bio: varchar('bio', { length: 200 }), interests: text('interests').array(), resolution: varchar('resolution', { length: 300 }), onboardingCompleted: boolean('onboarding_completed').default(false), rssConsent: boolean('rss_consent').default(true), // 소셜 링크 - githubUrl: varchar('github_url', { length: 500 }), - linkedinUrl: varchar('linkedin_url', { length: 500 }), - instagramUrl: varchar('instagram_url', { length: 500 }), + githubUrl: varchar('github_url', { length: 2000 }), + linkedinUrl: varchar('linkedin_url', { length: 2000 }), + instagramUrl: varchar('instagram_url', { length: 2000 }), // 상태 관리 status: varchar('status', { length: 20 }).notNull().default(MemberStatus.ACTIVE), dormantStartRound: integer('dormant_start_round'), @@ -143,11 +143,11 @@ export const posts = pgTable( .notNull() .references(() => members.id), roundId: integer('round_id').references(() => rounds.id), - title: varchar('title', { length: 500 }).notNull(), - url: varchar('url', { length: 1000 }).notNull().unique(), + title: varchar('title', { length: 2000 }).notNull(), + url: varchar('url', { length: 2000 }).notNull().unique(), publishedAt: timestamp('published_at', { withTimezone: true }).notNull(), description: text('description'), - thumbnailUrl: varchar('thumbnail_url', { length: 1000 }), + thumbnailUrl: varchar('thumbnail_url', { length: 2000 }), commentCount: integer('comment_count').default(0), collectedAt: timestamp('collected_at', { withTimezone: true }).defaultNow(), }, @@ -232,10 +232,10 @@ export const keywords = pgTable('keywords', { */ export const curationSources = pgTable('curation_sources', { id: uuid('id').primaryKey().defaultRandom(), - url: varchar('url', { length: 500 }).notNull().unique(), + url: varchar('url', { length: 2000 }).notNull().unique(), name: varchar('name', { length: 200 }).notNull(), category: varchar('category', { length: 50 }).notNull(), - rssUrl: varchar('rss_url', { length: 500 }), + rssUrl: varchar('rss_url', { length: 2000 }), tags: text('tags').array(), isActive: boolean('is_active').default(true), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), @@ -250,10 +250,10 @@ export const curationItems = pgTable( { id: uuid('id').primaryKey().defaultRandom(), sourceId: uuid('source_id').references(() => curationSources.id), - title: varchar('title', { length: 500 }).notNull(), - url: varchar('url', { length: 1000 }).notNull().unique(), + title: varchar('title', { length: 2000 }).notNull(), + url: varchar('url', { length: 2000 }).notNull().unique(), description: text('description'), - thumbnailUrl: varchar('thumbnail_url', { length: 1000 }), + thumbnailUrl: varchar('thumbnail_url', { length: 2000 }), publishedAt: timestamp('published_at', { withTimezone: true }), category: varchar('category', { length: 50 }).notNull(), tags: text('tags').array(), diff --git a/packages/web/package.json b/packages/web/package.json index 13328a5..3aaf86d 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -13,6 +13,7 @@ "test:watch": "vitest" }, "dependencies": { + "@aws-sdk/client-s3": "^3.1013.0", "@blog-study/shared": "workspace:*", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-avatar": "^1.1.11", diff --git a/packages/web/src/app/(user)/dashboard/page.tsx b/packages/web/src/app/(user)/dashboard/page.tsx index 81540b4..7abbf65 100644 --- a/packages/web/src/app/(user)/dashboard/page.tsx +++ b/packages/web/src/app/(user)/dashboard/page.tsx @@ -36,10 +36,12 @@ interface Post { interface DashboardData { nickname: string | null; + myStatus: string | null; currentRound: RoundInfo | null; recentPosts: Post[]; totalMembers: number; totalPosts: number; + memberBreakdown: { active: number; ob: number; dormant: number }; } interface ScoreProgress { @@ -130,7 +132,7 @@ function getDdayLabel(days: number, isGrace: boolean): string { function getAttendanceChip( status: string | null, - isGracePeriod: boolean, + isGracePeriod: boolean ): { icon: string; label: string; className: string } { switch (status) { case 'SUBMITTED': @@ -283,42 +285,56 @@ export default function DashboardPage() { - {/* My Attendance Status */} - {attendanceChip && ( -
- 나의 출석 - - {attendanceChip.icon} {attendanceChip.label} - + {/* My Status Message (OB/Dormant) or Attendance */} + {data?.myStatus === 'ob' ? ( +
+ 글을 필수로 작성할 필요는 없어요. 자유롭게 활동해주세요!
+ ) : data?.myStatus === 'dormant' ? ( +
+ 푹 쉬다가 돌아오세요 😌 +
+ ) : ( + <> + {attendanceChip && ( +
+ 나의 출석 + + {attendanceChip.icon} {attendanceChip.label} + +
+ )} + )} - {/* Submission Progress */} -
-
-

제출률

-

- {round.submissionRate >= 100 && 🎊} - {round.submissionRate}% -

-
-
-
= 100 - ? 'bg-emerald-500' - : round.submissionRate >= 70 - ? 'bg-primary' - : round.submissionRate >= 40 - ? 'bg-amber-500' - : 'bg-destructive' - }`} - style={{ width: `${Math.min(round.submissionRate, 100)}%` }} - /> + {/* Submission Progress (active만 표시) */} + {data?.myStatus !== 'ob' && data?.myStatus !== 'dormant' && ( +
+
+

제출률

+

+ {round.submissionRate >= 100 && 🎊} + {round.submissionRate}% +

+
+
+
= 100 + ? 'bg-emerald-500' + : round.submissionRate >= 70 + ? 'bg-primary' + : round.submissionRate >= 40 + ? 'bg-amber-500' + : 'bg-destructive' + }`} + style={{ width: `${Math.min(round.submissionRate, 100)}%` }} + /> +
-
+ )} ) : ( @@ -340,7 +356,14 @@ export default function DashboardPage() { {data?.totalMembers ?? 0}

-

🏃🏻 함께 성장하는 중

+ {data?.memberBreakdown ? ( +

+ 활성 {data.memberBreakdown.active} · OB {data.memberBreakdown.ob} · 휴면{' '} + {data.memberBreakdown.dormant} +

+ ) : ( +

🏃🏻 함께 성장하는 중

+ )}
@@ -406,6 +429,8 @@ export default function DashboardPage() { {scoreData.todayProgress.map((p) => { const isFull = p.earned >= p.dailyCap; const pct = Math.min((p.earned / p.dailyCap) * 100, 100); + const isPostScore = p.type === 'blog_post'; + const isNonActive = data?.myStatus === 'ob' || data?.myStatus === 'dormant'; return (
-
-
-
-
- - {p.earned}/{p.dailyCap} - - {isFull && } -
+ {isPostScore && isNonActive ? ( +

+ 활성 스터디원만 포스트 등록 점수를 받을 수 있어요. +

+ ) : ( + <> +
+
+
+
+ + {p.earned}/{p.dailyCap} + + {isFull && } +
+ + )}
); diff --git a/packages/web/src/app/(user)/members/page.tsx b/packages/web/src/app/(user)/members/page.tsx index 2c8277e..c3c5cbf 100644 --- a/packages/web/src/app/(user)/members/page.tsx +++ b/packages/web/src/app/(user)/members/page.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; import { useEffect, useState } from 'react'; -import { UsersRound, ExternalLink, Github, Linkedin, Instagram } from 'lucide-react'; +import { ExternalLink, Github, Instagram, Linkedin, UsersRound } from 'lucide-react'; import { Card, CardContent } from '@/components/ui/card'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; @@ -29,6 +29,7 @@ interface Member { postCount: number; attendanceRate: number; joinedAt: string; + isAdmin: boolean; } interface MembersData { @@ -102,9 +103,8 @@ export default function MembersPage() { > 전체 - {PART_OPTIONS - .filter((opt) => data.members.some((m) => m.part === opt.value)) - .map((opt) => ( + {PART_OPTIONS.filter((opt) => data.members.some((m) => m.part === opt.value)).map( + (opt) => ( - ))} + ) + )}
)} {/* Members grid */} {data?.members && data.members.length > 0 ? (
- {data.members.filter((m) => !filterPart || m.part === filterPart).map((member) => { - const socialLinks = [ - { url: member.blogUrl, icon: ExternalLink, label: '블로그' }, - { url: member.githubUrl, icon: Github, label: 'GitHub' }, - { url: member.linkedinUrl, icon: Linkedin, label: 'LinkedIn' }, - { url: member.instagramUrl, icon: Instagram, label: 'Instagram' }, - ].filter((link) => link.url); + {data.members + .filter((m) => !filterPart || m.part === filterPart) + .map((member) => { + const socialLinks = [ + { url: member.blogUrl, icon: ExternalLink, label: '블로그' }, + { url: member.githubUrl, icon: Github, label: 'GitHub' }, + { url: member.linkedinUrl, icon: Linkedin, label: 'LinkedIn' }, + { url: member.instagramUrl, icon: Instagram, label: 'Instagram' }, + ].filter((link) => link.url); - return ( - - - {/* Top: Avatar + Name + Part */} -
- - - - {member.nickname.slice(0, 2).toUpperCase()} - - -
-
-

{member.nickname}

- {member.status !== 'active' && ( - - {MEMBER_STATUS_CONFIG[member.status]?.label ?? member.status} - - )} -
-
-

- @{member.discordUsername.replace(/#0$/, '')} -

- + return ( + + + {/* Top: Avatar + Name + Part */} +
+ + + + {member.nickname.slice(0, 2).toUpperCase()} + + +
+
+

{member.nickname}

+ {member.isAdmin && ( + + 관리자 + + )} + {member.status !== 'active' && ( + + {MEMBER_STATUS_CONFIG[member.status]?.label ?? member.status} + + )} +
+
+

+ @{member.discordUsername.replace(/#0$/, '')} +

+ +
-
- {/* Bio */} - {member.bio && ( -

- {member.bio} -

- )} + {/* Bio */} + {member.bio && ( +

+ {member.bio} +

+ )} - {/* Social link chips */} - {socialLinks.length > 0 && ( -
- {socialLinks.map((link) => { - const Icon = link.icon; - return ( - e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - className="inline-flex items-center gap-1 rounded-full border border-border/60 bg-muted/40 px-2 py-0.5 text-[10px] font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1" - > - - {link.label} - - ); - })} + {/* Social link chips */} + {socialLinks.length > 0 && ( + + )} +
+
- )} -
- -
- - - ); - })} + + + ); + })}
) : (
diff --git a/packages/web/src/app/(user)/posts/[id]/page.tsx b/packages/web/src/app/(user)/posts/[id]/page.tsx index d294ad2..040de17 100644 --- a/packages/web/src/app/(user)/posts/[id]/page.tsx +++ b/packages/web/src/app/(user)/posts/[id]/page.tsx @@ -626,6 +626,8 @@ export default function PostDetailPage() { authorAvatar: string; authorId: string | null; publishedAt: string; + thumbnailUrl: string | null; + description: string | null; } | null>(null); const fetchComments = useCallback(async () => { @@ -659,6 +661,8 @@ export default function PostDetailPage() { authorAvatar: post.memberProfileImageUrl || getDefaultAvatar(name), authorId: post.memberId, publishedAt: post.publishedAt, + thumbnailUrl: post.thumbnailUrl || null, + description: post.description || null, }); } } @@ -748,6 +752,23 @@ export default function PostDetailPage() { + {postInfo.description && ( +

+ {postInfo.description} +

+ )} + {postInfo.thumbnailUrl && ( +
+ {postInfo.title} { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> +
+ )} )} diff --git a/packages/web/src/app/(user)/posts/page.tsx b/packages/web/src/app/(user)/posts/page.tsx index 6473c25..cbcab8a 100644 --- a/packages/web/src/app/(user)/posts/page.tsx +++ b/packages/web/src/app/(user)/posts/page.tsx @@ -805,14 +805,18 @@ function PostCard({ onView, onCommentCountChange, onDelete, + onEdit, canDelete, + canEdit, rank, }: { post: Post; onView: (id: string) => void; onCommentCountChange: (postId: string, delta: number) => void; onDelete: (postId: string) => void; + onEdit: (postId: string, title: string, description: string | null) => void; canDelete: boolean; + canEdit: boolean; rank?: number; // 1, 2, 3 for medal styling }) { const authorName = post.memberNickname || post.memberDiscordUsername; @@ -827,6 +831,37 @@ function PostCard({ const [showComments, setShowComments] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); const [deleting, setDeleting] = useState(false); + const [editOpen, setEditOpen] = useState(false); + const [editTitle, setEditTitle] = useState(post.title); + const [editDescription, setEditDescription] = useState(post.description || ''); + const [editing, setEditing] = useState(false); + + const handleEditSubmit = async () => { + if (!editTitle.trim()) return; + setEditing(true); + try { + const res = await fetch(`/api/posts/${post.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: editTitle.trim(), + description: editDescription.trim() || null, + }), + }); + if (!res.ok) { + const json = await res.json(); + toast.error(json.error?.message || '수정에 실패했습니다.'); + return; + } + toast.success('수정되었습니다.'); + onEdit(post.id, editTitle.trim(), editDescription.trim() || null); + setEditOpen(false); + } catch { + toast.error('서버 오류가 발생했습니다.'); + } finally { + setEditing(false); + } + }; const fetchComments = useCallback(async () => { try { @@ -1016,15 +1051,30 @@ function PostCard({ {post.commentCount} - {canDelete && ( - - )} +
+ {canEdit && ( + + )} + {canDelete && ( + + )} +
{/* 삭제 확인 다이얼로그 */} @@ -1061,6 +1111,57 @@ function PostCard({ + {/* 수정 다이얼로그 */} + + + + 포스트 수정 + 제목과 설명을 수정할 수 있습니다. + +
+
+ + setEditTitle(e.target.value)} + /> +
+
+ +