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() {
-
가입일
@@ -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 (
+
+ );
+ }
+
+ 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() {
+
+
+
+
+
소셜 링크 (선택)
+
다른 스터디원들이 볼 수 있는 소셜 링크를 입력하세요.
+
+
+
+
+ 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 (
+ {/* 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)}`;
+}