diff --git a/packages/bot/src/commands/index.ts b/packages/bot/src/commands/index.ts index f660544..5dfceb9 100644 --- a/packages/bot/src/commands/index.ts +++ b/packages/bot/src/commands/index.ts @@ -6,7 +6,6 @@ import type { CommandHandler } from '../bot'; // Import user commands import { 핑Command } from './ping'; -import { 참가Command } from './join'; import { 탈퇴Command } from './leave'; import { 내정보Command } from './my-info'; import { 참가자목록Command } from './member-list'; @@ -21,7 +20,6 @@ import { getAdminCommands as getAdminCommandsFromDir } from './admin'; // User commands array const userCommands: CommandHandler[] = [ 핑Command, - 참가Command, 탈퇴Command, 내정보Command, 참가자목록Command, diff --git a/packages/bot/src/commands/join.ts b/packages/bot/src/commands/join.ts deleted file mode 100644 index db9a94b..0000000 --- a/packages/bot/src/commands/join.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * /참가 명령어 - 스터디 참가 등록 - * 블로그 URL로 스터디에 참가 등록합니다. - * Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6 - */ - -import { - SlashCommandBuilder, - type ChatInputCommandInteraction, - type GuildMember, -} from 'discord.js'; -import type { CommandHandler } from '../bot'; -import { getMemberService, MemberError, MemberErrorCodes } from '../services/member.service'; -import { STUDY_ROLE_NAME } from './constants'; - -// Welcome message template -const WELCOME_MESSAGE = ` -🎉 **스터디 참가를 환영합니다!** - -📋 **스터디 규칙 안내** -• 총 10회차로 진행됩니다 (각 회차는 2주) -• 월요일 시작 → 2주 뒤 일요일 마감 -• 마감 다음날(월요일) 제출 시 지각 (벌금 3,000원) -• 마감 다음날 이후(화요일~) 미제출 시 결석 (벌금 5,000원) -• 휴면은 1회만 가능, 최대 4회차(8주) 동안 면제 - -📝 **명령어 안내** -• \`/내정보\` - 내 참가 정보 확인 -• \`/현황\` - 현재 회차 출석 현황 -• \`/랭킹\` - 포스트 수 랭킹 -• \`/탈퇴\` - 스터디 탈퇴 - -열심히 글 써봐요! 💪 -`; - -export const 참가Command: CommandHandler = { - data: new SlashCommandBuilder() - .setName('참가') - .setDescription('블로그 URL로 스터디에 참가 등록합니다.') - .addStringOption((option) => - option - .setName('블로그url') - .setDescription('블로그 주소 (예: https://velog.io/@username)') - .setRequired(true) - ) - .addStringOption((option) => - option - .setName('이름') - .setDescription('실명 (예: 홍길동)') - .setRequired(true) - ) - .addStringOption((option) => - option - .setName('파트') - .setDescription('담당 파트') - .setRequired(true) - .addChoices( - { name: 'Frontend', value: 'frontend' }, - { name: 'Backend', value: 'backend' }, - { name: 'Fullstack', value: 'fullstack' }, - { name: 'Design', value: 'design' }, - { name: 'PM', value: 'pm' }, - { name: 'DevOps', value: 'devops' }, - { name: 'Other', value: 'other' } - ) - ) - .addStringOption((option) => - option - .setName('rss_url') - .setDescription('RSS 피드 URL (자동 감지 실패 시 직접 입력)') - .setRequired(false) - ) as SlashCommandBuilder, - - async execute(interaction: ChatInputCommandInteraction): Promise { - const blogUrl = interaction.options.getString('블로그url', true); - const name = interaction.options.getString('이름', true); - const part = interaction.options.getString('파트', true); - const rssUrl = interaction.options.getString('rss_url') || undefined; - - const memberService = getMemberService(); - - try { - // Register the member - const member = await memberService.register({ - discordId: interaction.user.id, - discordUsername: interaction.user.username, - name, - part, - blogUrl, - rssUrl, - }); - - // Try to assign the study role - let roleAssigned = false; - if (interaction.guild && interaction.member) { - try { - const role = interaction.guild.roles.cache.find( - (r) => r.name === STUDY_ROLE_NAME - ); - if (role && interaction.member instanceof Object && 'roles' in interaction.member) { - const guildMember = interaction.member as GuildMember; - await guildMember.roles.add(role); - roleAssigned = true; - } - } catch (roleError) { - console.warn('Failed to assign study role:', roleError); - } - } - - // Build response message - let response = `✅ **스터디 참가 등록 완료!**\n\n`; - response += `📝 **등록 정보**\n`; - response += `• 이름: ${member.name}\n`; - response += `• 파트: ${member.part}\n`; - response += `• 블로그: ${member.blogUrl}\n`; - if (member.rssUrl) { - response += `• RSS: ${member.rssUrl}\n`; - } else { - response += `• RSS: 자동 감지 예정\n`; - } - if (roleAssigned) { - response += `• 역할: ${STUDY_ROLE_NAME} 부여됨\n`; - } - response += WELCOME_MESSAGE; - - await interaction.reply({ - content: response, - }); - } catch (error) { - if (error instanceof MemberError) { - let errorMessage = `❌ ${error.userMessage}`; - - if (error.code === MemberErrorCodes.INVALID_URL) { - errorMessage += '\n\n💡 올바른 블로그 URL 형식:\n'; - errorMessage += '• Velog: `https://velog.io/@username`\n'; - errorMessage += '• Tistory: `https://blogname.tistory.com`\n'; - errorMessage += '• Medium: `https://medium.com/@username`\n'; - errorMessage += '• 기타: `https://your-blog.com`'; - } - - await interaction.reply({ - content: errorMessage, - ephemeral: true, - }); - } else { - console.error('Error in /참가 command:', error); - await interaction.reply({ - content: '❌ 참가 등록 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', - ephemeral: true, - }); - } - } - }, - - adminOnly: false, -}; diff --git a/packages/web/src/app/(admin)/admin/members/page.tsx b/packages/web/src/app/(admin)/admin/members/page.tsx index 57cc430..24e4e1c 100644 --- a/packages/web/src/app/(admin)/admin/members/page.tsx +++ b/packages/web/src/app/(admin)/admin/members/page.tsx @@ -23,6 +23,7 @@ import { TableHeader, TableRow, } from '@/components/ui/table'; +import { getPartStyle, getPartLabel } from '@/lib/part-config'; import { MemberFormDialog } from './member-form-dialog'; import { DeleteMemberDialog } from './delete-member-dialog'; @@ -285,7 +286,11 @@ export default function AdminMembersPage() { filteredMembers.map((member) => ( {member.name} - {member.part} + + + {getPartLabel(member.part)} + + {member.discordUsername} diff --git a/packages/web/src/app/(user)/layout.tsx b/packages/web/src/app/(user)/layout.tsx index e44c258..644fc3a 100644 --- a/packages/web/src/app/(user)/layout.tsx +++ b/packages/web/src/app/(user)/layout.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useState, useCallback } from 'react'; -import { useRouter } from 'next/navigation'; +import { useRouter, usePathname } from 'next/navigation'; import { MainLayout } from '@/components/layout'; interface UserInfo { @@ -16,15 +16,23 @@ export default function UserLayout({ children: React.ReactNode; }) { const router = useRouter(); + const pathname = usePathname(); const [user, setUser] = useState(null); + const [onboardingChecked, setOnboardingChecked] = useState(false); useEffect(() => { - // Get user info from cookie or API const fetchUser = async () => { try { const response = await fetch('/api/auth/me'); if (response.ok) { const data = await response.json(); + + // 온보딩 미완료 시 리다이렉트 (온보딩 페이지 자체는 예외) + if (!data.onboardingCompleted && pathname !== '/profile/onboarding') { + router.push('/profile/onboarding'); + return; + } + setUser({ name: data.name || data.discordUsername || data.email?.split('@')[0] || '', email: data.email || '', @@ -33,10 +41,12 @@ export default function UserLayout({ } } catch { // User not authenticated, middleware will handle redirect + } finally { + setOnboardingChecked(true); } }; fetchUser(); - }, []); + }, [pathname, router]); const handleLogout = useCallback(async () => { try { @@ -48,6 +58,15 @@ export default function UserLayout({ } }, [router]); + // 온보딩 체크 중에는 로딩 표시 (무한루프 방지를 위해 온보딩 페이지는 바로 표시) + if (!onboardingChecked && pathname !== '/profile/onboarding') { + return ( +
+
로딩 중...
+
+ ); + } + return ( {children} diff --git a/packages/web/src/app/(user)/members/[id]/page.tsx b/packages/web/src/app/(user)/members/[id]/page.tsx index c5b42cf..e717c47 100644 --- a/packages/web/src/app/(user)/members/[id]/page.tsx +++ b/packages/web/src/app/(user)/members/[id]/page.tsx @@ -8,6 +8,7 @@ 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'; interface MemberInfo { id: string; @@ -151,7 +152,9 @@ export default function MemberProfilePage() {

{member.name}

@{member.discordUsername}

- {member.part} + + {getPartLabel(member.part)} + @@ -159,7 +162,7 @@ export default function MemberProfilePage() { <>
-

한줄 소개

+

자기소개

{member.bio}

diff --git a/packages/web/src/app/(user)/profile/edit/page.tsx b/packages/web/src/app/(user)/profile/edit/page.tsx index b74bc16..5cb3c4d 100644 --- a/packages/web/src/app/(user)/profile/edit/page.tsx +++ b/packages/web/src/app/(user)/profile/edit/page.tsx @@ -2,32 +2,37 @@ import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { ArrowLeft, Save, Image, FileText, Heart, Target } from 'lucide-react'; +import { ArrowLeft, Save, Image, FileText, Heart, Target, User } 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'; import { Label } from '@/components/ui/label'; import { Badge } from '@/components/ui/badge'; -import { Separator } from '@/components/ui/separator'; +import { AvatarUpload } from '@/components/avatar-upload'; +import { PART_OPTIONS } from '@/lib/part-config'; const INTEREST_OPTIONS = [ - 'Frontend', - 'Backend', - 'DevOps', - 'Mobile', - 'AI/ML', - 'Data', - 'Security', - 'Cloud', - 'Design', - 'PM', - 'Startup', - 'Career', + // 개발 + '프론트엔드', '백엔드', '풀스택', '모바일', 'DevOps', '클라우드', + '데이터 엔지니어링', '보안', '시스템 설계', '데이터베이스', '테스팅', + // AI/트렌드 + 'AI/ML', 'LLM', '데이터 사이언스', 'Web3', + // 디자인/기획 + 'UX/UI', '프로덕트 매니지먼트', '서비스 기획', '브랜딩', '디자인 시스템', + // 커리어/성장 + '커리어 성장', '사이드 프로젝트', '스타트업', '오픈소스', '기술 블로그', + // 인문/일상 + '독서', '글쓰기', '생산성', '자기계발', '인문학', '심리학', + '경제/재테크', '건강/운동', '여행', '일상 기록', ]; interface ProfileData { + user: { + id: string; + }; member: { name: string; + part: string; profileImageUrl: string | null; bio: string | null; interests: string[] | null; @@ -44,10 +49,12 @@ export default function ProfileEditPage() { const [success, setSuccess] = useState(false); // Form state + const [userId, setUserId] = useState(''); + const [selectedPart, setSelectedPart] = useState(''); + const [customPart, setCustomPart] = useState(''); const [profileImageUrl, setProfileImageUrl] = useState(''); const [bio, setBio] = useState(''); const [interests, setInterests] = useState([]); - const [customInterest, setCustomInterest] = useState(''); const [resolution, setResolution] = useState(''); useEffect(() => { @@ -66,6 +73,16 @@ export default function ProfileEditPage() { } // Pre-fill existing data + if (data.user?.id) setUserId(data.user.id); + if (data.member.part) { + const isPreset = PART_OPTIONS.some((o) => o.value === data.member!.part); + if (isPreset) { + setSelectedPart(data.member.part); + } else { + setSelectedPart('other'); + setCustomPart(data.member.part); + } + } if (data.member.profileImageUrl) setProfileImageUrl(data.member.profileImageUrl); if (data.member.bio) setBio(data.member.bio); if (data.member.interests) setInterests(data.member.interests); @@ -82,18 +99,13 @@ export default function ProfileEditPage() { }, [router]); const toggleInterest = (interest: string) => { - setInterests((prev) => - prev.includes(interest) - ? prev.filter((i) => i !== interest) - : [...prev, interest] - ); - }; - - const addCustomInterest = () => { - if (customInterest.trim() && !interests.includes(customInterest.trim())) { - setInterests((prev) => [...prev, customInterest.trim()]); - setCustomInterest(''); - } + setInterests((prev) => { + if (prev.includes(interest)) { + return prev.filter((i) => i !== interest); + } + if (prev.length >= 6) return prev; + return [...prev, interest]; + }); }; const handleSubmit = async (e: React.FormEvent) => { @@ -103,10 +115,12 @@ export default function ProfileEditPage() { setSuccess(false); try { + const part = selectedPart === 'other' ? customPart.trim() : selectedPart; const response = await fetch('/api/profile/edit', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ + part: part || null, profileImageUrl: profileImageUrl || null, bio: bio || null, interests: interests.length > 0 ? interests : null, @@ -156,6 +170,43 @@ export default function ProfileEditPage() {
+ {/* Part */} + + +
+ + 파트 +
+
+ + + + {selectedPart === 'other' && ( + setCustomPart(e.target.value)} + maxLength={50} + /> + )} + +
+ {/* Profile Image */} @@ -168,27 +219,11 @@ export default function ProfileEditPage() { -
- - setProfileImageUrl(e.target.value)} - /> -
- {profileImageUrl && ( -
- Preview { - (e.target as HTMLImageElement).style.display = 'none'; - }} - /> -
- )} + setProfileImageUrl(url)} + userId={userId} + />
@@ -197,20 +232,32 @@ export default function ProfileEditPage() {
- 한줄 소개 + 자기소개
+ + 어떤 일을 하고 있는지, 스터디에서 어떤 글을 쓸 계획인지 자유롭게 소개해주세요. +
- - + 자기소개 * + + (최소 100자, 최대 200자) + + +