diff --git a/CLAUDE.md b/CLAUDE.md index 87c978f..5a43c84 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,6 +87,8 @@ pnpm --filter @blog-study/bot init-rounds # 회차 초기화 - **아이콘**: lucide-react - **다크모드**: next-themes (시스템 연동) - **폰트**: Pretendard Variable +- **기본 아바타**: DiceBear `fun-emoji` 스타일 (`getDefaultAvatar()` in `utils.ts`) +- **아바타 리소스**: [DiceBear](https://www.dicebear.com/styles/) - 30+ 스타일, seed 기반 결정적 아바타 생성, API: `https://api.dicebear.com/9.x/{style}/svg?seed={seed}` - **상세 스펙**: `docs/UI-DESIGN-SYSTEM.md` 참조 ## 에이전트 활용 가이드 diff --git a/packages/bot/src/services/member.service.ts b/packages/bot/src/services/member.service.ts index 7e8e1de..fe83352 100644 --- a/packages/bot/src/services/member.service.ts +++ b/packages/bot/src/services/member.service.ts @@ -91,6 +91,7 @@ export class MemberService { discordId: input.discordId, discordUsername: input.discordUsername, name: input.name, + nickname: input.name, part: input.part, blogUrl: urlValidation.normalizedUrl || input.blogUrl, rssUrl: input.rssUrl || null, diff --git a/packages/shared/src/db/schema.ts b/packages/shared/src/db/schema.ts index bad4889..57b1720 100644 --- a/packages/shared/src/db/schema.ts +++ b/packages/shared/src/db/schema.ts @@ -72,7 +72,8 @@ export const members = pgTable( id: uuid('id').primaryKey().defaultRandom(), discordId: varchar('discord_id', { length: 20 }).notNull().unique(), discordUsername: varchar('discord_username', { length: 100 }).notNull(), - name: varchar('name', { length: 100 }).notNull(), + 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 }), @@ -82,6 +83,10 @@ export const members = pgTable( interests: text('interests').array(), resolution: varchar('resolution', { length: 300 }), onboardingCompleted: boolean('onboarding_completed').default(false), + // 소셜 링크 + githubUrl: varchar('github_url', { length: 500 }), + linkedinUrl: varchar('linkedin_url', { length: 500 }), + instagramUrl: varchar('instagram_url', { length: 500 }), // 상태 관리 status: varchar('status', { length: 20 }).notNull().default(MemberStatus.ACTIVE), dormantStartRound: integer('dormant_start_round'), diff --git a/packages/web/src/app/(user)/members/[id]/page.tsx b/packages/web/src/app/(user)/members/[id]/page.tsx index e717c47..49b6c32 100644 --- a/packages/web/src/app/(user)/members/[id]/page.tsx +++ b/packages/web/src/app/(user)/members/[id]/page.tsx @@ -2,18 +2,20 @@ import { useEffect, useState } from 'react'; import { useParams, useRouter } from 'next/navigation'; -import { ArrowLeft, User, FileText, CheckCircle, Calendar, ExternalLink } from 'lucide-react'; +import { ArrowLeft, User, FileText, ExternalLink, Github, Linkedin, Instagram } from 'lucide-react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Separator } from '@/components/ui/separator'; import { getPartStyle, getPartLabel } from '@/lib/part-config'; +import { getDefaultAvatar } from '@/lib/utils'; interface MemberInfo { id: string; discordUsername: string; name: string; + nickname: string; part: string; blogUrl: string; profileImageUrl: string | null; @@ -22,15 +24,9 @@ interface MemberInfo { resolution: string | null; status: string; joinedAt: string; -} - -interface Stats { - postCount: number; - totalRounds: number; - submittedRounds: number; - lateRounds: number; - absentRounds: number; - attendanceRate: number; + githubUrl: string | null; + linkedinUrl: string | null; + instagramUrl: string | null; } interface Post { @@ -42,7 +38,6 @@ interface Post { interface MemberProfileData { member: MemberInfo; - stats: Stats; recentPosts: Post[]; } @@ -114,7 +109,14 @@ export default function MemberProfilePage() { ); } - const { member, stats, recentPosts } = data; + const { member, recentPosts } = data; + + 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 (
@@ -123,7 +125,7 @@ export default function MemberProfilePage() {
-

{member.name}

+

{member.nickname}

스터디원 프로필

@@ -142,15 +144,16 @@ export default function MemberProfilePage() {
- + - {member.name.slice(0, 2).toUpperCase()} + {member.nickname.slice(0, 2).toUpperCase()}
-

{member.name}

-

@{member.discordUsername}

+

{member.nickname}

+

{member.name}

+

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

{getPartLabel(member.part)} @@ -189,18 +192,6 @@ export default function MemberProfilePage() {
-
-

블로그

- - {member.blogUrl} - - -

가입일

@@ -208,49 +199,29 @@ export default function MemberProfilePage() {

- - - {/* Stats */} -
- - - 작성 글 - - - -
{stats.postCount}개
-
-
- - - - 출석률 - - - -
{stats.attendanceRate}%
-

- {stats.submittedRounds}/{stats.totalRounds}회 -

-
-
- - - - 지각/결석 - - - -
- {stats.lateRounds + stats.absentRounds}회 + {/* Social Links */} + {socialLinks.length > 0 && ( +
+ {socialLinks.map((link) => { + const Icon = link.icon; + return ( + + + {link.label} + + ); + })}
-

- 지각 {stats.lateRounds}회 / 결석 {stats.absentRounds}회 -

- - -
+ )} +
+
{/* Recent Posts */} diff --git a/packages/web/src/app/(user)/members/page.tsx b/packages/web/src/app/(user)/members/page.tsx new file mode 100644 index 0000000..749901e --- /dev/null +++ b/packages/web/src/app/(user)/members/page.tsx @@ -0,0 +1,213 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { UsersRound, ExternalLink, Github, Linkedin, Instagram } from 'lucide-react'; +import { Card, CardContent } from '@/components/ui/card'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; +import { PART_OPTIONS, getPartStyle, getPartLabel } from '@/lib/part-config'; +import { getDefaultAvatar } from '@/lib/utils'; + +interface Member { + id: string; + name: string; + nickname: string; + discordUsername: string; + part: string; + blogUrl: string; + profileImageUrl: string | null; + bio: string | null; + status: string; + githubUrl: string | null; + linkedinUrl: string | null; + instagramUrl: string | null; + postCount: number; + attendanceRate: number; + joinedAt: string; +} + +interface MembersData { + members: Member[]; + total: number; +} + +export default function MembersPage() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [filterPart, setFilterPart] = useState(null); + + useEffect(() => { + const fetchMembers = async () => { + try { + const response = await fetch('/api/members?status=active'); + if (!response.ok) { + throw new Error('Failed to fetch members'); + } + const result = await response.json(); + setData(result); + } catch (err) { + setError('스터디원 목록을 불러오는데 실패했습니다.'); + console.error(err); + } finally { + setLoading(false); + } + }; + + fetchMembers(); + }, []); + + if (loading) { + return ( +
+
로딩 중...
+
+ ); + } + + if (error) { + return ( +
+
{error}
+
+ ); + } + + return ( +
+ {/* Page header */} +
+

+ Members +

+
+

스터디원 목록

+ + {filterPart + ? `${data?.members.filter((m) => m.part === filterPart).length ?? 0}명` + : `총 ${data?.total ?? 0}명`} + +
+
+ + {/* Part filter */} + {data?.members && data.members.length > 0 && ( +
+ + {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 partStyle = getPartStyle(member.part); + + 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.discordUsername.replace(/#0$/, '')} +

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

+ {member.bio} +

+ )} + + {/* Social link chips */} + {socialLinks.length > 0 && ( +
+ {socialLinks.map((link) => { + const Icon = link.icon; + return ( + { + e.preventDefault(); + window.open(link.url!, '_blank', 'noopener,noreferrer'); + }} + > + + {link.label} + + ); + })} +
+ )} +
+
+ + ); + })} +
+ ) : ( +
+ +

아직 등록된 스터디원이 없습니다.

+
+ )} +
+ ); +} diff --git a/packages/web/src/app/(user)/profile/edit/page.tsx b/packages/web/src/app/(user)/profile/edit/page.tsx index 5cb3c4d..3be015c 100644 --- a/packages/web/src/app/(user)/profile/edit/page.tsx +++ b/packages/web/src/app/(user)/profile/edit/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { ArrowLeft, Save, Image, FileText, Heart, Target, User } from 'lucide-react'; +import { ArrowLeft, Save, Image, FileText, Heart, Target, User, Link2 } from 'lucide-react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -32,12 +32,16 @@ interface ProfileData { }; member: { name: string; + nickname: string; part: string; profileImageUrl: string | null; bio: string | null; interests: string[] | null; resolution: string | null; onboardingCompleted: boolean; + githubUrl: string | null; + linkedinUrl: string | null; + instagramUrl: string | null; } | null; } @@ -50,12 +54,17 @@ export default function ProfileEditPage() { // Form state const [userId, setUserId] = useState(''); + const [name, setName] = useState(''); + const [nickname, setNickname] = useState(''); const [selectedPart, setSelectedPart] = useState(''); const [customPart, setCustomPart] = useState(''); const [profileImageUrl, setProfileImageUrl] = useState(''); const [bio, setBio] = useState(''); const [interests, setInterests] = useState([]); const [resolution, setResolution] = useState(''); + const [githubUrl, setGithubUrl] = useState(''); + const [linkedinUrl, setLinkedinUrl] = useState(''); + const [instagramUrl, setInstagramUrl] = useState(''); useEffect(() => { const fetchProfile = async () => { @@ -74,6 +83,8 @@ export default function ProfileEditPage() { // Pre-fill existing data if (data.user?.id) setUserId(data.user.id); + if (data.member.name) setName(data.member.name); + if (data.member.nickname) setNickname(data.member.nickname); if (data.member.part) { const isPreset = PART_OPTIONS.some((o) => o.value === data.member!.part); if (isPreset) { @@ -87,6 +98,9 @@ export default function ProfileEditPage() { if (data.member.bio) setBio(data.member.bio); if (data.member.interests) setInterests(data.member.interests); if (data.member.resolution) setResolution(data.member.resolution); + if (data.member.githubUrl) setGithubUrl(data.member.githubUrl); + if (data.member.linkedinUrl) setLinkedinUrl(data.member.linkedinUrl); + if (data.member.instagramUrl) setInstagramUrl(data.member.instagramUrl); } catch (err) { console.error(err); router.push('/profile'); @@ -120,11 +134,16 @@ export default function ProfileEditPage() { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ + name: name.trim() || null, + nickname: nickname.trim() || null, part: part || null, profileImageUrl: profileImageUrl || null, bio: bio || null, interests: interests.length > 0 ? interests : null, resolution: resolution || null, + githubUrl: githubUrl || null, + linkedinUrl: linkedinUrl || null, + instagramUrl: instagramUrl || null, }), }); @@ -170,6 +189,42 @@ export default function ProfileEditPage() {
+ {/* Name & Nickname */} + + +
+ + 이름 & 닉네임 +
+
+ +
+ + setName(e.target.value)} + maxLength={50} + /> +
+
+ + setNickname(e.target.value)} + maxLength={100} + /> +
+
+
+ {/* Part */} @@ -318,6 +373,51 @@ export default function ProfileEditPage() { + {/* Social Links */} + + +
+ + 소셜 링크 +
+ + 다른 스터디원들이 볼 수 있는 소셜 링크를 입력하세요. (선택) + +
+ +
+ + setGithubUrl(e.target.value)} + maxLength={500} + /> +
+
+ + setLinkedinUrl(e.target.value)} + maxLength={500} + /> +
+
+ + setInstagramUrl(e.target.value)} + maxLength={500} + /> +
+
+
+ {/* Messages */} {error && (

{error}

@@ -331,7 +431,7 @@ export default function ProfileEditPage() { - diff --git a/packages/web/src/app/(user)/profile/onboarding/page.tsx b/packages/web/src/app/(user)/profile/onboarding/page.tsx index ad1d544..f15bcfe 100644 --- a/packages/web/src/app/(user)/profile/onboarding/page.tsx +++ b/packages/web/src/app/(user)/profile/onboarding/page.tsx @@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Badge } from '@/components/ui/badge'; import { AvatarUpload } from '@/components/avatar-upload'; +import { Separator } from '@/components/ui/separator'; import { PART_OPTIONS } from '@/lib/part-config'; const INTEREST_OPTIONS = [ @@ -37,6 +38,7 @@ export default function OnboardingPage() { // Form state const [name, setName] = useState(''); + const [nickname, setNickname] = useState(''); const [selectedPart, setSelectedPart] = useState(''); const [customPart, setCustomPart] = useState(''); const [blogUrl, setBlogUrl] = useState(''); @@ -45,6 +47,9 @@ export default function OnboardingPage() { const [profileImageUrl, setProfileImageUrl] = useState(''); const [resolution, setResolution] = useState(''); const [userId, setUserId] = useState(''); + const [githubUrl, setGithubUrl] = useState(''); + const [linkedinUrl, setLinkedinUrl] = useState(''); + const [instagramUrl, setInstagramUrl] = useState(''); useEffect(() => { const fetchUserInfo = async () => { @@ -64,7 +69,8 @@ export default function OnboardingPage() { // Discord 정보로 pre-fill if (data.id) setUserId(data.id); - if (data.discordUsername) setName(data.discordUsername); + if (data.nickname) setNickname(data.nickname); + else if (data.discordUsername) setNickname(data.discordUsername); if (data.avatarUrl) setProfileImageUrl(data.avatarUrl); } catch { router.push('/login'); @@ -87,7 +93,7 @@ export default function OnboardingPage() { }; const part = selectedPart === 'other' ? customPart.trim() : selectedPart; - const isStep1Valid = name.trim().length > 0 && part.length > 0 && blogUrl.trim().length > 0; + const isStep1Valid = name.trim().length > 0 && nickname.trim().length > 0 && part.length > 0 && blogUrl.trim().length > 0; const isStep2Valid = interests.length >= 3 && interests.length <= 6 && bio.trim().length >= 100; const isStep3Valid = resolution.trim().length > 0; @@ -112,12 +118,16 @@ export default function OnboardingPage() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name.trim(), + nickname: nickname.trim(), part, blogUrl: blogUrl.trim(), profileImageUrl: profileImageUrl || null, bio: bio.trim(), interests, resolution: resolution.trim(), + githubUrl: githubUrl.trim() || null, + linkedinUrl: linkedinUrl.trim() || null, + instagramUrl: instagramUrl.trim() || null, }), }); @@ -197,13 +207,26 @@ export default function OnboardingPage() {
setName(e.target.value)} + maxLength={50} + /> +
+ +
+ + setNickname(e.target.value)} maxLength={100} />
@@ -253,6 +276,46 @@ export default function OnboardingPage() { Velog, Tistory, Medium 등 블로그 주소를 입력하세요.

+ + + +
+

소셜 링크 (선택)

+

다른 스터디원들이 볼 수 있는 소셜 링크를 입력하세요.

+
+ +
+ + setGithubUrl(e.target.value)} + maxLength={500} + /> +
+ +
+ + setLinkedinUrl(e.target.value)} + maxLength={500} + /> +
+ +
+ + setInstagramUrl(e.target.value)} + maxLength={500} + /> +
)} diff --git a/packages/web/src/app/(user)/profile/page.tsx b/packages/web/src/app/(user)/profile/page.tsx index a682b90..816d5cc 100644 --- a/packages/web/src/app/(user)/profile/page.tsx +++ b/packages/web/src/app/(user)/profile/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, useCallback } from 'react'; import { useRouter } from 'next/navigation'; -import { User, Link2, FileText, CheckCircle, Calendar, Wallet, ExternalLink } from 'lucide-react'; +import { User, Link2, FileText, CheckCircle, Calendar, Wallet, ExternalLink, Github, Linkedin, Instagram } from 'lucide-react'; import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -21,6 +21,7 @@ interface MemberInfo { discordId: string; discordUsername: string; name: string; + nickname: string; part: string; blogUrl: string; rssUrl: string | null; @@ -32,6 +33,9 @@ interface MemberInfo { status: string; dormantUsed: boolean; joinedAt: string; + githubUrl: string | null; + linkedinUrl: string | null; + instagramUrl: string | null; } interface Stats { @@ -131,13 +135,16 @@ export default function ProfilePage() { - {(data?.member?.name || data?.user?.email || 'U').slice(0, 2).toUpperCase()} + {(data?.member?.nickname || data?.user?.email || 'U').slice(0, 2).toUpperCase()}

- {data?.member?.name || data?.user?.email?.split('@')[0]} + {data?.member?.nickname || data?.user?.email?.split('@')[0]}

+ {data?.member?.name && ( +

{data.member.name}

+ )}

{data?.user?.email}

@@ -175,7 +182,7 @@ export default function ProfilePage() {

Discord

-

{data.member.discordUsername}

+

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

파트

@@ -235,6 +242,37 @@ export default function ProfilePage() {

“{data.member.resolution}”

)} + + {/* Social Links */} + {(data.member.githubUrl || data.member.linkedinUrl || data.member.instagramUrl) && ( +
+

소셜 링크

+
+ {[ + { url: data.member.blogUrl, icon: ExternalLink, label: '블로그' }, + { url: data.member.githubUrl, icon: Github, label: 'GitHub' }, + { url: data.member.linkedinUrl, icon: Linkedin, label: 'LinkedIn' }, + { url: data.member.instagramUrl, icon: Instagram, label: 'Instagram' }, + ] + .filter((link) => link.url) + .map((link) => { + const Icon = link.icon; + return ( + + + {link.label} + + ); + })} +
+
+ )} diff --git a/packages/web/src/app/(user)/ranking/page.tsx b/packages/web/src/app/(user)/ranking/page.tsx index 0e52da2..de6925f 100644 --- a/packages/web/src/app/(user)/ranking/page.tsx +++ b/packages/web/src/app/(user)/ranking/page.tsx @@ -7,6 +7,7 @@ import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { getDefaultAvatar } from '@/lib/utils'; import { Table, TableBody, @@ -19,6 +20,7 @@ import { interface RankingMember { id: string; name: string; + nickname: string; discordUsername: string; profileImageUrl: string | null; postCount: number; @@ -150,14 +152,14 @@ export default function RankingPage() { className="flex items-center gap-3 hover:opacity-75 transition-opacity" > - + - {(member.name || member.discordUsername).slice(0, 2).toUpperCase()} + {(member.nickname || member.discordUsername).slice(0, 2).toUpperCase()}

- {member.name || member.discordUsername} + {member.nickname || member.discordUsername}

@@ -247,13 +249,13 @@ export default function RankingPage() { className="flex items-center gap-2.5 hover:opacity-75 transition-opacity" > - + - {(member.name || member.discordUsername).slice(0, 2).toUpperCase()} + {(member.nickname || member.discordUsername).slice(0, 2).toUpperCase()} - {member.name || member.discordUsername} + {member.nickname || member.discordUsername} diff --git a/packages/web/src/app/api/admin/members/route.ts b/packages/web/src/app/api/admin/members/route.ts index 28052b9..ec99c29 100644 --- a/packages/web/src/app/api/admin/members/route.ts +++ b/packages/web/src/app/api/admin/members/route.ts @@ -176,6 +176,7 @@ export const POST = withAdminAuth(async (request: NextRequest, _adminAuth) => { .insert(members) .values({ name: name.trim(), + nickname: name.trim(), part: part.trim(), discordId: discordId.trim(), discordUsername: discordUsername?.trim() || discordId.trim(), diff --git a/packages/web/src/app/api/auth/me/route.ts b/packages/web/src/app/api/auth/me/route.ts index 33e9f70..a6e0a17 100644 --- a/packages/web/src/app/api/auth/me/route.ts +++ b/packages/web/src/app/api/auth/me/route.ts @@ -53,7 +53,8 @@ export async function GET() { avatarUrl: user.user_metadata?.avatar_url, memberId: memberData?.id ?? null, profileImageUrl: memberData?.profileImageUrl ?? user.user_metadata?.avatar_url, - name: memberData?.name ?? user.user_metadata?.full_name, + name: memberData?.name ?? null, + nickname: memberData?.nickname ?? user.user_metadata?.full_name, discordId, hasMemberRecord: !!memberData, onboardingCompleted: memberData?.onboardingCompleted ?? false, diff --git a/packages/web/src/app/api/members/[id]/route.ts b/packages/web/src/app/api/members/[id]/route.ts index 256c42e..c0bb5bb 100644 --- a/packages/web/src/app/api/members/[id]/route.ts +++ b/packages/web/src/app/api/members/[id]/route.ts @@ -77,6 +77,7 @@ export async function GET( id: member.id, discordUsername: member.discordUsername, name: member.name, + nickname: member.nickname, part: member.part, blogUrl: member.blogUrl, profileImageUrl: member.profileImageUrl, @@ -85,6 +86,9 @@ export async function GET( resolution: member.resolution, status: member.status, joinedAt: member.joinedAt, + githubUrl: member.githubUrl, + linkedinUrl: member.linkedinUrl, + instagramUrl: member.instagramUrl, }, stats: { postCount: postCount?.count ?? 0, diff --git a/packages/web/src/app/api/members/route.ts b/packages/web/src/app/api/members/route.ts index 883b451..b935a40 100644 --- a/packages/web/src/app/api/members/route.ts +++ b/packages/web/src/app/api/members/route.ts @@ -58,11 +58,15 @@ export async function GET(request: NextRequest) { id: member.id, discordUsername: member.discordUsername, name: member.name, + nickname: member.nickname, part: member.part, blogUrl: member.blogUrl, profileImageUrl: member.profileImageUrl, bio: member.bio, status: member.status, + githubUrl: member.githubUrl, + linkedinUrl: member.linkedinUrl, + instagramUrl: member.instagramUrl, postCount, attendanceRate, joinedAt: member.joinedAt, diff --git a/packages/web/src/app/api/profile/edit/route.ts b/packages/web/src/app/api/profile/edit/route.ts index da6091f..06f1cbb 100644 --- a/packages/web/src/app/api/profile/edit/route.ts +++ b/packages/web/src/app/api/profile/edit/route.ts @@ -48,7 +48,7 @@ export async function PUT(request: NextRequest) { } const body = await request.json(); - const { part, profileImageUrl, bio, interests, resolution } = body; + const { name, nickname, part, profileImageUrl, bio, interests, resolution, githubUrl, linkedinUrl, instagramUrl } = body; if (part && (typeof part !== 'string' || part.length > 50)) { return NextResponse.json( @@ -113,11 +113,16 @@ export async function PUT(request: NextRequest) { await database .update(members) .set({ + ...(name && typeof name === 'string' && name.trim().length > 0 ? { name: name.trim() } : {}), + ...(nickname && typeof nickname === 'string' && nickname.trim().length > 0 ? { nickname: nickname.trim() } : {}), ...(part ? { part } : {}), profileImageUrl: profileImageUrl || null, bio: bio || null, interests: interests || null, resolution: resolution || null, + githubUrl: githubUrl || null, + linkedinUrl: linkedinUrl || null, + instagramUrl: instagramUrl || null, updatedAt: new Date(), }) .where(eq(members.id, memberData.id)); diff --git a/packages/web/src/app/api/profile/onboarding/route.ts b/packages/web/src/app/api/profile/onboarding/route.ts index aecc78d..19009db 100644 --- a/packages/web/src/app/api/profile/onboarding/route.ts +++ b/packages/web/src/app/api/profile/onboarding/route.ts @@ -35,10 +35,17 @@ export async function POST(request: NextRequest) { } const body = await request.json(); - const { name, part, blogUrl, profileImageUrl, bio, interests, resolution } = body; + const { name, nickname, part, blogUrl, profileImageUrl, bio, interests, resolution, githubUrl, linkedinUrl, instagramUrl } = body; // 필수 필드 검증 if (!name || typeof name !== 'string' || name.trim().length === 0) { + return NextResponse.json( + { message: '이름(실명)은 필수입니다.' }, + { status: 400 } + ); + } + + if (!nickname || typeof nickname !== 'string' || nickname.trim().length === 0) { return NextResponse.json( { message: '닉네임은 필수입니다.' }, { status: 400 } @@ -129,7 +136,9 @@ export async function POST(request: NextRequest) { } const database = db(); - const discordUsername = user.user_metadata?.name || user.user_metadata?.full_name || ''; + const rawUsername = user.user_metadata?.name || user.user_metadata?.full_name || ''; + // Discord 새 유저네임 시스템에서 discriminator가 0이면 #0 제거 + const discordUsername = rawUsername.replace(/#0$/, ''); // 기존 멤버 조회 const [existingMember] = await database @@ -144,12 +153,16 @@ export async function POST(request: NextRequest) { .update(members) .set({ name: name.trim(), + nickname: nickname.trim(), part, blogUrl, profileImageUrl: profileImageUrl || null, bio: bio.trim(), interests, resolution: resolution.trim(), + githubUrl: githubUrl || null, + linkedinUrl: linkedinUrl || null, + instagramUrl: instagramUrl || null, onboardingCompleted: true, updatedAt: new Date(), }) @@ -162,12 +175,16 @@ export async function POST(request: NextRequest) { discordId, discordUsername, name: name.trim(), + nickname: nickname.trim(), part, blogUrl, profileImageUrl: profileImageUrl || null, bio: bio.trim(), interests, resolution: resolution.trim(), + githubUrl: githubUrl || null, + linkedinUrl: linkedinUrl || null, + instagramUrl: instagramUrl || null, onboardingCompleted: true, status: 'active', }); diff --git a/packages/web/src/app/api/profile/route.ts b/packages/web/src/app/api/profile/route.ts index ea7df60..a442e5b 100644 --- a/packages/web/src/app/api/profile/route.ts +++ b/packages/web/src/app/api/profile/route.ts @@ -94,6 +94,7 @@ export async function GET() { discordId: memberData.discordId, discordUsername: memberData.discordUsername, name: memberData.name, + nickname: memberData.nickname, part: memberData.part, blogUrl: memberData.blogUrl, rssUrl: memberData.rssUrl, @@ -105,6 +106,9 @@ export async function GET() { status: memberData.status, dormantUsed: memberData.dormantUsed, joinedAt: memberData.joinedAt, + githubUrl: memberData.githubUrl, + linkedinUrl: memberData.linkedinUrl, + instagramUrl: memberData.instagramUrl, } : null, stats, }); diff --git a/packages/web/src/app/api/ranking/route.ts b/packages/web/src/app/api/ranking/route.ts index 95d82ae..55ea913 100644 --- a/packages/web/src/app/api/ranking/route.ts +++ b/packages/web/src/app/api/ranking/route.ts @@ -23,6 +23,7 @@ export async function GET(request: NextRequest) { .select({ id: members.id, name: members.name, + nickname: members.nickname, discordUsername: members.discordUsername, profileImageUrl: members.profileImageUrl, postCount: count(posts.id), @@ -63,6 +64,7 @@ export async function GET(request: NextRequest) { return { id: member.id, name: member.name, + nickname: member.nickname, discordUsername: member.discordUsername, profileImageUrl: member.profileImageUrl, postCount: member.postCount, diff --git a/packages/web/src/components/avatar-upload.tsx b/packages/web/src/components/avatar-upload.tsx index a21e303..4410103 100644 --- a/packages/web/src/components/avatar-upload.tsx +++ b/packages/web/src/components/avatar-upload.tsx @@ -1,8 +1,21 @@ 'use client'; import { useCallback, useRef, useState } from 'react'; -import { Upload, Loader2, X } from 'lucide-react'; +import { Upload, Loader2, X, Dices, RefreshCw } from 'lucide-react'; import { uploadAvatar } from '@/lib/storage'; +import { Button } from '@/components/ui/button'; + +const DICEBEAR_STYLES = ['fun-emoji', 'adventurer', 'bottts', 'thumbs', 'lorelei'] as const; + +function generateRandomAvatars(): string[] { + const avatars: string[] = []; + for (let i = 0; i < 8; i++) { + const style = DICEBEAR_STYLES[Math.floor(Math.random() * DICEBEAR_STYLES.length)]; + const seed = Math.random().toString(36).substring(2, 10); + avatars.push(`https://api.dicebear.com/9.x/${style}/svg?seed=${seed}`); + } + return avatars; +} interface AvatarUploadProps { currentImageUrl: string; @@ -15,13 +28,14 @@ export function AvatarUpload({ currentImageUrl, onUploadComplete, userId }: Avat const [error, setError] = useState(null); const [dragOver, setDragOver] = useState(false); const [previewUrl, setPreviewUrl] = useState(null); + const [randomAvatars, setRandomAvatars] = useState(null); const inputRef = useRef(null); const handleFile = useCallback(async (file: File) => { setError(null); setUploading(true); + setRandomAvatars(null); - // Local preview const objectUrl = URL.createObjectURL(file); setPreviewUrl(objectUrl); @@ -39,7 +53,6 @@ export function AvatarUpload({ currentImageUrl, onUploadComplete, userId }: Avat const handleChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) handleFile(file); - // Reset so the same file can be re-selected e.target.value = ''; }; @@ -57,10 +70,21 @@ export function AvatarUpload({ currentImageUrl, onUploadComplete, userId }: Avat const handleDragLeave = () => setDragOver(false); + const handleRandomGenerate = () => { + setRandomAvatars(generateRandomAvatars()); + }; + + const handleSelectRandom = (url: string) => { + setPreviewUrl(url); + onUploadComplete(url); + setRandomAvatars(null); + }; + const displayUrl = previewUrl || currentImageUrl; return (
+ {/* Upload area */}
+ {/* Random avatar generator */} +
+ + + {randomAvatars && ( +
+ {randomAvatars.map((url, idx) => ( + + ))} +
+ )} +
+ {error && (
diff --git a/packages/web/src/components/layout/header.tsx b/packages/web/src/components/layout/header.tsx index ec0cbdd..4b6cfc6 100644 --- a/packages/web/src/components/layout/header.tsx +++ b/packages/web/src/components/layout/header.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useRef } from 'react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useTheme } from 'next-themes'; -import { Menu, LogOut, Moon, Sun, UserPen } from 'lucide-react'; +import { Menu, LogOut, Moon, Sun, UserCircle } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; @@ -147,17 +147,17 @@ export function Header({ user, onMenuClick, onLogout }: HeaderProps) {

{user.email}

- {/* Profile edit */} + {/* Profile */} {/* Logout */} diff --git a/packages/web/src/components/layout/sidebar.tsx b/packages/web/src/components/layout/sidebar.tsx index ae7e319..6980b4d 100644 --- a/packages/web/src/components/layout/sidebar.tsx +++ b/packages/web/src/components/layout/sidebar.tsx @@ -8,6 +8,7 @@ import { FileText, Trophy, Newspaper, + UsersRound, Users, CalendarCheck, Banknote, @@ -37,10 +38,11 @@ interface NavItem { // ─── Navigation data ────────────────────────────────────────────────────────── const userNavItems: NavItem[] = [ - { title: '대시보드', href: '/dashboard', icon: LayoutDashboard }, - { title: '글 목록', href: '/posts', icon: FileText }, - { title: '랭킹', href: '/ranking', icon: Trophy }, - { title: '큐레이션', href: '/curation', icon: Newspaper }, + { title: '대시보드', href: '/dashboard', icon: LayoutDashboard }, + { title: '글 목록', href: '/posts', icon: FileText }, + { title: '랭킹', href: '/ranking', icon: Trophy }, + { title: '큐레이션', href: '/curation', icon: Newspaper }, + { title: '스터디원 목록', href: '/members', icon: UsersRound }, ]; const adminNavItems: NavItem[] = [ diff --git a/packages/web/src/lib/utils.ts b/packages/web/src/lib/utils.ts index 9ad0df4..d4fdac7 100644 --- a/packages/web/src/lib/utils.ts +++ b/packages/web/src/lib/utils.ts @@ -4,3 +4,11 @@ import { twMerge } from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +/** + * DiceBear 기본 아바타 URL 생성 + * profileImageUrl이 없을 때 seed 기반으로 일관된 아바타 반환 + */ +export function getDefaultAvatar(seed: string): string { + return `https://api.dicebear.com/9.x/fun-emoji/svg?seed=${encodeURIComponent(seed)}`; +}