From 1d3a72207b6bdba04f3c4c8fb79e46f426d5d423 Mon Sep 17 00:00:00 2001 From: Hyeonjun0527 Date: Sun, 18 Jan 2026 20:41:23 +0900 Subject: [PATCH 01/49] =?UTF-8?q?fix:=20=EB=A1=9C=EC=BB=AC=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EC=97=90=EC=84=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=95=88=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EB=B0=8F=20?= =?UTF-8?q?=20manifest.json=20=EC=97=90=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/manifest.json | 22 ++++++++++++++++++++++ src/features/auth/model/types.ts | 2 ++ src/features/auth/ui/sign-up-modal.tsx | 14 ++++++++++++-- src/hooks/common/use-auth.ts | 19 +++++++++++++------ 4 files changed, 49 insertions(+), 8 deletions(-) create mode 100644 public/manifest.json diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 00000000..f2c27928 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,22 @@ +{ + "name": "ZERO-ONE - 개발자 스터디 플랫폼", + "short_name": "ZERO-ONE", + "description": "1:1, 그룹 스터디, 멘토링 등 다양한 방식의 학습을 지원하는 개발자 전문 스터디 플랫폼", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#000000", + "icons": [ + { + "src": "/favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "/icons/logo.svg", + "sizes": "any", + "type": "image/svg+xml" + } + ] +} + diff --git a/src/features/auth/model/types.ts b/src/features/auth/model/types.ts index c9be65f3..f891d496 100644 --- a/src/features/auth/model/types.ts +++ b/src/features/auth/model/types.ts @@ -2,6 +2,8 @@ export interface SignUpResponse { content: { generatedMemberId: string; + accessToken: string; + refreshToken: string; }; status: number; message: string; diff --git a/src/features/auth/ui/sign-up-modal.tsx b/src/features/auth/ui/sign-up-modal.tsx index 878615f0..c4bba626 100644 --- a/src/features/auth/ui/sign-up-modal.tsx +++ b/src/features/auth/ui/sign-up-modal.tsx @@ -111,9 +111,13 @@ export default function SignupModal({ signUp.mutate(signUpPayload, { onSuccess: async (data) => { - const memberId = data.content.generatedMemberId; - if (memberId) { + const { generatedMemberId: memberId, accessToken, refreshToken } = data.content; + + if (memberId && accessToken && refreshToken) { setCookie('memberId', memberId); + setCookie('accessToken', accessToken); + // refreshToken도 쿠키에 저장 (필요 시 secure, httpOnly 설정 등 고려) + setCookie('refresh_token', refreshToken); // 이미지 업로드 if (signupData.file) { @@ -147,6 +151,12 @@ export default function SignupModal({ }; const handleNext = () => { + // goal 단계에서는 바로 완료 처리 + if (currentStep === 'goal') { + handleComplete(); + return; + } + const currentIndex = STEPS.indexOf(currentStep); if (currentIndex < STEPS.length - 1) { setCurrentStep(STEPS[currentIndex + 1]); diff --git a/src/hooks/common/use-auth.ts b/src/hooks/common/use-auth.ts index c639294e..a0a7f673 100644 --- a/src/hooks/common/use-auth.ts +++ b/src/hooks/common/use-auth.ts @@ -3,13 +3,13 @@ import { useMemo } from 'react'; import { getCookie } from '@/api/client/cookie'; import { decodeJwt } from '@/utils/jwt'; -type RoleId = 'ROLE_MEMBER' | 'ROLE_ADMIN' | 'ROLE_MENTOR'; +type RoleId = 'ROLE_MEMBER' | 'ROLE_ADMIN' | 'ROLE_MENTOR' | 'ROLE_GUEST'; type AuthVendor = 'GOOGLE' | 'KAKAO'; interface DecodedToken { roleIds: RoleId[]; authVendor: AuthVendor; - memberId: number; + memberId: number | null; } interface UseAuthReturn { @@ -25,15 +25,20 @@ function isDecodedToken(value: unknown): value is DecodedToken { // roleIds가 배열이고, 모든 요소가 유효한 RoleId인지 확인 if (!Array.isArray(obj.roleIds)) return false; - const validRoles: RoleId[] = ['ROLE_MEMBER', 'ROLE_ADMIN', 'ROLE_MENTOR']; + const validRoles: RoleId[] = ['ROLE_MEMBER', 'ROLE_ADMIN', 'ROLE_MENTOR', 'ROLE_GUEST']; if (!obj.roleIds.every((role) => validRoles.includes(role))) return false; // authVendor가 유효한 값인지 확인 const validVendors: AuthVendor[] = ['GOOGLE', 'KAKAO']; if (!validVendors.includes(obj.authVendor as AuthVendor)) return false; - // memberId가 숫자인지 확인 - if (typeof obj.memberId !== 'number') return false; + // memberId가 숫자이거나 null인지 확인 + if ( + typeof obj.memberId !== 'number' && + obj.memberId !== null && + obj.memberId !== undefined + ) + return false; return true; } @@ -61,9 +66,11 @@ export function useAuth(): UseAuthReturn { } }, [accessToken]); + const isGuest = decodedToken?.roleIds.includes('ROLE_GUEST'); + return { accessToken, data: decodedToken, - isAuthenticated: !!accessToken && !!decodedToken, + isAuthenticated: !!accessToken && !!decodedToken && !isGuest, }; } From f09e31a2250b2af999ec9f6a44988b1db8468a9d Mon Sep 17 00:00:00 2001 From: Hyeonjun0527 Date: Mon, 19 Jan 2026 23:54:36 +0900 Subject: [PATCH 02/49] =?UTF-8?q?feat:=20=EC=8A=A4=ED=84=B0=EB=94=94=20?= =?UTF-8?q?=EB=9E=AD=ED=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 4 +- src/app/(service)/home/page.tsx | 2 + src/app/(service)/insights/page.tsx | 11 + src/app/(service)/insights/weekly/page.tsx | 395 ++++++++ src/app/(service)/one-on-one/page.tsx | 944 ++++++++++++++++++++ src/app/(service)/one-on-one/shared-data.ts | 66 ++ src/components/weekly/admin-pick-button.tsx | 46 + src/features/auth/ui/sign-up-modal.tsx | 13 +- src/widgets/home/sidebar.tsx | 12 + src/widgets/home/weekly-pick.tsx | 65 ++ yarn.lock | 52 +- 11 files changed, 1601 insertions(+), 9 deletions(-) create mode 100644 src/app/(service)/insights/weekly/page.tsx create mode 100644 src/app/(service)/one-on-one/page.tsx create mode 100644 src/app/(service)/one-on-one/shared-data.ts create mode 100644 src/components/weekly/admin-pick-button.tsx create mode 100644 src/widgets/home/weekly-pick.tsx diff --git a/package.json b/package.json index c6c9358f..10b4ea05 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "date-fns": "^4.1.0", "dayjs": "^1.11.18", "embla-carousel-react": "^8.6.0", + "framer-motion": "^12.26.2", "googleapis": "^164.1.0", "lucide-react": "^0.475.0", "next": "15.2.8", @@ -106,8 +107,7 @@ "typescript-eslint": "^8.24.0", "vitest": "^3.1.1" }, - "resolutions": { + "resolutions": { "strip-ansi": "6.0.1" } - } diff --git a/src/app/(service)/home/page.tsx b/src/app/(service)/home/page.tsx index 0e2b8815..51a3b585 100644 --- a/src/app/(service)/home/page.tsx +++ b/src/app/(service)/home/page.tsx @@ -3,6 +3,7 @@ import StudyCard from '@/features/study/schedule/ui/study-card'; import { generateMetadata as generateSEOMetadata } from '@/utils/seo'; import Banner from '@/widgets/home/banner'; import Sidebar from '@/widgets/home/sidebar'; +import WeeklyPick from '@/widgets/home/weekly-pick'; export const metadata: Metadata = generateSEOMetadata({ title: '홈 - ZERO-ONE', @@ -18,6 +19,7 @@ export default async function Home() {
+
diff --git a/src/app/(service)/insights/page.tsx b/src/app/(service)/insights/page.tsx index c725e109..abcb8fea 100644 --- a/src/app/(service)/insights/page.tsx +++ b/src/app/(service)/insights/page.tsx @@ -92,6 +92,17 @@ export default async function BlogPage({ searchParams }: BlogPageProps) { {category.name} ))} + + + 위클리 + + NEW + + +
{/* 아티클 목록 */} diff --git a/src/app/(service)/insights/weekly/page.tsx b/src/app/(service)/insights/weekly/page.tsx new file mode 100644 index 00000000..a2ada154 --- /dev/null +++ b/src/app/(service)/insights/weekly/page.tsx @@ -0,0 +1,395 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { + Star, + MessageCircle, + Heart, + Plus, + Send, + Trash2, + X, + Clock, + Users, + Eye, + Loader2 +} from 'lucide-react'; +import { cn } from '@/components/ui/(shadcn)/lib/utils'; +import { MOCK_WEEKLY_DATA, WeeklyPost, WeeklyComment } from '@/app/(service)/one-on-one/shared-data'; +import AdminPickButton from '@/components/weekly/admin-pick-button'; + +export default function WeeklyPage() { + const [posts, setPosts] = useState([]); + const [isWriting, setIsWriting] = useState(false); + const [selectedPost, setSelectedPost] = useState(null); + const [newTitle, setNewTitle] = useState(''); + const [newContent, setNewContent] = useState(''); + const [newComment, setNewComment] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [isAdmin, setIsAdmin] = useState(true); // Mock admin status + + useEffect(() => { + // 데이터 로딩 시뮬레이션 + const timer = setTimeout(() => { + setPosts(MOCK_WEEKLY_DATA); + setIsLoading(false); + }, 800); + return () => clearTimeout(timer); + }, []); + + const managerPick = posts.find(post => post.isManagerPick); + const regularPosts = posts.filter(post => !post.isManagerPick); + + const handleCreatePost = () => { + if (!newTitle.trim() || !newContent.trim()) return; + + const newPost: WeeklyPost = { + id: Date.now(), + title: newTitle, + content: newContent, + author: 'User_Me', + date: new Date().toISOString().split('T')[0].replace(/-/g, '.'), + isManagerPick: false, + likes: 0, + comments: [] + }; + + setPosts([newPost, ...posts]); + setIsWriting(false); + setNewTitle(''); + setNewContent(''); + }; + + const handleLike = (postId: number) => { + setPosts(prev => prev.map(post => + post.id === postId + ? { ...post, likes: post.likes + 1 } + : post + )); + }; + + const handleAddComment = () => { + if (!newComment.trim() || !selectedPost) return; + + const comment: WeeklyComment = { + id: Date.now(), + author: 'User_Me', + content: newComment, + date: new Date().toISOString().split('T')[0].replace(/-/g, '.') + }; + + const updatedPost = { + ...selectedPost, + comments: [...selectedPost.comments, comment] + }; + + setPosts(prev => prev.map(post => + post.id === selectedPost.id ? updatedPost : post + )); + setSelectedPost(updatedPost); + setNewComment(''); + }; + + const handleTogglePick = (postId: number) => { + setPosts(prev => prev.map(post => { + if (post.id === postId) { + return { ...post, isManagerPick: !post.isManagerPick }; + } + // 다른 게시글의 픽 상태를 해제 (한 번에 하나만 픽 가능) + return { ...post, isManagerPick: false }; + })); + }; + + if (isLoading) { + return ( +
+
+ +

+ 데이터를 불러오는 중입니다... +

+
+
+ ); + } + + return ( +
+
+ {/* Left Sidebar */} + + + {/* Main Content Area */} +
+
+ + {/* Header */} +
+

위클리 소통 공간

+ +
+ + {/* Manager's Pick */} + {managerPick && ( +
+
+
+
+ + Manager's Pick +
+
+ +
setSelectedPost(managerPick)} + > +

+ {managerPick.title} +

+

+ {managerPick.content} +

+ +
+
+
+
+ {managerPick.author.charAt(0)} +
+ {managerPick.author} +
+
+ + {managerPick.date} +
+
+ +
+ + +
+ + {managerPick.comments.length} +
+
+
+
+
+
+ )} + + {/* Regular Posts */} +
+ {regularPosts.length > 0 ? ( +
+ {regularPosts.map((post) => ( +
setSelectedPost(post)} + > +
+
+
+ {post.author.charAt(0)} +
+
+ {post.author} + + {post.date} +
+
+ +

+ {post.title} +

+

+ {post.content} +

+
+ +
+ handleTogglePick(post.id)} + isAdmin={isAdmin} + /> + +
+
+ + {post.likes} +
+
+ + {post.comments.length} +
+
+
+
+ ))} +
+ ) : ( +
+ +

아직 게시글이 없습니다.

+

첫 번째 이야기를 시작해보세요.

+
+ )} +
+ +
+
+
+ + {/* Write Modal */} + {isWriting && ( +
+
+
+

글쓰기

+ +
+
+ setNewTitle(e.target.value)} + className="w-full p-200 rounded-100 border border-border-subtle focus:border-border-strong outline-none font-designer-15m" + /> +