diff --git a/src/app/(service)/home/home-content.tsx b/src/app/(service)/home/home-content.tsx new file mode 100644 index 00000000..a57707ed --- /dev/null +++ b/src/app/(service)/home/home-content.tsx @@ -0,0 +1,96 @@ +'use client'; + +import { Suspense } from 'react'; +import { useSearchParams } from 'next/navigation'; +import dynamic from 'next/dynamic'; +import Banner from '@/widgets/home/banner'; +import StudyCard from '@/features/study/schedule/ui/study-card'; +import TabNavigation from '@/components/home/tab-navigation'; + +// 탭 컴포넌트들을 동적으로 로드 (성능 최적화) +const StudyTab = dynamic(() => import('@/components/home/tabs/study-tab'), { + loading: () => ( +
+ + +
+ ), + ssr: false +}); + +const HallOfFameTab = dynamic(() => import('@/components/home/tabs/hall-of-fame-tab'), { + loading: () =>
로딩 중...
, + ssr: false +}); + +const ArchiveTab = dynamic(() => import('@/components/home/tabs/archive-tab'), { + loading: () =>
로딩 중...
, + ssr: false +}); + +const CommunityTab = dynamic(() => import('@/components/home/tabs/community-tab'), { + loading: () =>
로딩 중...
, + ssr: false +}); + +const StudyHistoryTab = dynamic(() => import('@/components/home/tabs/study-history-tab'), { + loading: () =>
로딩 중...
, + ssr: false +}); + +function HomeContentInner() { + const searchParams = useSearchParams(); + const activeTab = searchParams?.get('tab') || 'study'; // 기본값을 'study'로 설정 + + // 이제 항상 탭 시스템을 사용 + const renderTabContent = () => { + switch (activeTab) { + case 'study': + return ; + case 'history': + return ; + case 'ranking': + return ; + case 'archive': + return ; + case 'community': + return ; + default: + // 알 수 없는 탭이면 기본값으로 폴백 + return ; + } + }; + + return ( + <> + {/* 탭 네비게이션 */} + + + {/* 탭 콘텐츠 */} + + 로딩 중... + + } + > + {renderTabContent()} + + + ); +} + +export default function HomeContent() { + return ( + + + + + } + > + + + ); +} diff --git a/src/app/(service)/home/page.tsx b/src/app/(service)/home/page.tsx index 0e2b8815..956b1618 100644 --- a/src/app/(service)/home/page.tsx +++ b/src/app/(service)/home/page.tsx @@ -1,8 +1,10 @@ import { Metadata } from 'next'; +import { Suspense } from 'react'; 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 HomeContent from './home-content'; export const metadata: Metadata = generateSEOMetadata({ title: '홈 - ZERO-ONE', @@ -17,11 +19,20 @@ export default async function Home() { return (
- - + {/* 기존 기능을 100% 보존하면서 새로운 탭 시스템 추가 */} + + + + + } + > + +
-
diff --git a/src/app/(service)/insights/weekly/[id]/page.tsx b/src/app/(service)/insights/weekly/[id]/page.tsx index 845c78e7..ec9f2f84 100644 --- a/src/app/(service)/insights/weekly/[id]/page.tsx +++ b/src/app/(service)/insights/weekly/[id]/page.tsx @@ -1,389 +1,18 @@ 'use client'; -import React, { useState, useEffect } from 'react'; +import React from 'react'; import { useParams, useRouter } from 'next/navigation'; -import { Loader2, ArrowLeft, TrendingUp, Info, MessageCircle, Check, Users } from 'lucide-react'; -import { Voting, VotingComment } from '@/types/voting'; -import { mockFetchVotingDetail } from '@/mocks/voting-mock-data'; -import VoteResultsChart from '@/components/voting/vote-results-chart'; -import VoteTimer from '@/components/voting/vote-timer'; -import DailyStatsChart from '@/components/voting/daily-stats-chart'; -import CommentList from '@/components/discussion/comment-list'; -import CommentForm from '@/components/discussion/comment-form'; -import { CommentFormData } from '@/types/schemas/zod-schema'; -import { cn } from '@/components/ui/(shadcn)/lib/utils'; +import VotingDetailView from '@/components/voting/voting-detail-view'; export default function VotingDetailPage() { const params = useParams(); const router = useRouter(); const votingId = Number(params.id); - const [voting, setVoting] = useState(null); - const [selectedOption, setSelectedOption] = useState(); - const [isLoading, setIsLoading] = useState(true); - const [isSubmitting, setIsSubmitting] = useState(false); - const [showDescription, setShowDescription] = useState(false); - const [error, setError] = useState(null); - - // 데이터 로드 - useEffect(() => { - const loadVoting = async () => { - try { - setIsLoading(true); - const data = await mockFetchVotingDetail(votingId); - if (data) { - setVoting(data); - setSelectedOption(data.myVote); - } else { - setError('투표를 찾을 수 없습니다.'); - } - } catch (err) { - setError('데이터를 불러오는데 실패했습니다.'); - console.error(err); - } finally { - setIsLoading(false); - } - }; - - loadVoting(); - }, [votingId]); - - // 투표 핸들러 - const handleVote = async () => { - if (!selectedOption || !voting?.isActive) return; - - setIsSubmitting(true); - try { - // Mock API 호출 (실제로는 서버에 요청) - await new Promise((resolve) => setTimeout(resolve, 500)); - - // 로컬 상태 업데이트 - setVoting((prev) => { - if (!prev) return prev; - - const oldVote = prev.myVote; - const updatedOptions = prev.options.map((opt) => { - let newVoteCount = opt.voteCount; - - if (oldVote === opt.id) { - newVoteCount--; - } - if (opt.id === selectedOption) { - newVoteCount++; - } - - return { ...opt, voteCount: newVoteCount }; - }); - - const newTotalVotes = oldVote ? prev.totalVotes : prev.totalVotes + 1; - const optionsWithPercentage = updatedOptions.map((opt) => ({ - ...opt, - percentage: (opt.voteCount / newTotalVotes) * 100, - })); - - return { - ...prev, - myVote: selectedOption, - options: optionsWithPercentage, - totalVotes: newTotalVotes, - }; - }); - } finally { - setIsSubmitting(false); - } - }; - - // 댓글 추가 핸들러 - const handleAddComment = async (data: CommentFormData) => { - if (!voting) return; - - const newComment: VotingComment = { - id: Date.now(), - author: { id: 999, nickname: '나' }, - content: data.content, - createdAt: new Date().toISOString(), - isAuthor: true, - votedOption: voting.options.find((opt) => opt.id === voting.myVote)?.label, - }; - - setVoting((prev) => { - if (!prev) return prev; - return { - ...prev, - comments: [...prev.comments, newComment], - commentCount: prev.commentCount + 1, - }; - }); - }; - - // 댓글 삭제 핸들러 - const handleDeleteComment = (commentId: number) => { - setVoting((prev) => { - if (!prev) return prev; - return { - ...prev, - comments: prev.comments.filter((c) => c.id !== commentId), - commentCount: prev.commentCount - 1, - }; - }); - }; - - // 로딩 상태 - if (isLoading) { - return ( -
-
- -

투표를 불러오는 중...

-
-
- ); - } - - // 에러 상태 - if (error || !voting) { - return ( -
-
-

{error || '투표를 찾을 수 없습니다.'}

- -
-
- ); - } - - const hasVoted = voting.myVote !== undefined; - return ( -
-
- {/* 뒤로가기 버튼 */} - - - {/* 헤더 */} -
- {/* 라운드 & 상태 */} -
-
- - {voting.round} 라운드 -
- -
- - {/* 제목 */} -

{voting.title}

- - {/* 설명 토글 */} - {voting.description && ( - <> - - - {showDescription && ( -

- {voting.description} -

- )} - - )} - - {/* 태그 */} - {voting.tags.length > 0 && ( -
- {voting.tags.map((tag) => ( - - #{tag} - - ))} -
- )} -
- - {/* 투표 섹션 */} -
- {!hasVoted && voting.isActive ? ( - <> - {/* 헤더 */} -
-

투표해주세요

- - {/* 현재 투표 참여 인원 */} -
-
- -
-
- 현재 참여 - - {voting.totalVotes.toLocaleString()}명 - -
-
-
- - {/* 선택지 */} -
- {voting.options.map((option, index) => { - const isSelected = selectedOption === option.id; - const colors = [ - { border: 'border-blue-500', bg: 'bg-blue-50', text: 'text-blue-600', ring: 'ring-blue-500' }, - { border: 'border-green-500', bg: 'bg-green-50', text: 'text-green-600', ring: 'ring-green-500' }, - { border: 'border-purple-500', bg: 'bg-purple-50', text: 'text-purple-600', ring: 'ring-purple-500' }, - { border: 'border-orange-500', bg: 'bg-orange-50', text: 'text-orange-600', ring: 'ring-orange-500' }, - { border: 'border-pink-500', bg: 'bg-pink-50', text: 'text-pink-600', ring: 'ring-pink-500' }, - ]; - const color = colors[index % colors.length]; - - return ( - - ); - })} -
- - {/* 투표하기 버튼 */} - - - ) : ( - <> -

투표 결과

- - - )} -
- - {/* 일별 통계 (투표 후에만 표시) */} - {hasVoted && voting.dailyStats && voting.dailyStats.length > 0 && ( -
- -
- )} - - {/* 댓글 섹션 */} -
-
- - 댓글 {voting.commentCount} -
- - {/* 댓글 목록 (항상 표시) */} -
- -
- - {/* 댓글 작성 폼 */} - {voting.isActive && ( - <> - {!hasVoted ? ( - /* 투표 안 했으면 댓글 작성 불가 안내 */ -
-

- 투표 후 댓글을 작성할 수 있습니다 -

-
- ) : ( - /* 투표 했으면 댓글 작성 폼 */ -
- -
- )} - - )} -
-
-
+ router.back()} + /> ); } diff --git a/src/app/(service)/one-on-one/page.tsx b/src/app/(service)/one-on-one/page.tsx index 30c20564..43ba88d5 100644 --- a/src/app/(service)/one-on-one/page.tsx +++ b/src/app/(service)/one-on-one/page.tsx @@ -24,9 +24,7 @@ import { LayoutGrid, List, ArrowUpDown, - MessageSquareText, - Bookmark, - Sparkles + MessageSquareText } from 'lucide-react'; // ---------------------------------------------------------------------- @@ -120,7 +118,7 @@ const TAB_CONFIG: Record< }, STUDY_LOG: { label: '열정 기록왕', - desc: '제로원의 아카이브를 채운, 자료 공유의 신', + desc: '제로원의 도서관을 채운, 자료 공유의 신', icon: , colorClass: 'text-text-information', }, @@ -136,215 +134,80 @@ const TAB_CONFIG: Record< // Types & Mock Data (Library) // ---------------------------------------------------------------------- -type CurationType = 'ARTICLE' | 'VIDEO' | 'PROBLEM' | 'REFERENCE'; -type CurationLevel = 'BEGINNER' | 'INTERMEDIATE' | 'ADVANCED'; -type CurationBenefit = 'CONCEPT' | 'PRACTICE' | 'REVIEW'; - interface LibraryItem { id: number; title: string; - description: string; author: string; date: string; views: number; likes: number; link: string; isLiked: boolean; - level: CurationLevel; - tags: string[]; - isBookmarked: boolean; - isRecommended?: boolean; } -interface CurationItem { - id: number; - title: string; - description: string; - type: CurationType; - level: CurationLevel; - tags: string[]; - benefit: CurationBenefit; - link: string; - isBookmarked: boolean; - isRecommended?: boolean; - author: string; - views: number; - likes: number; - isLiked: boolean; -} - -const CURATION_LABELS = { - type: { - ARTICLE: '글', - VIDEO: '강의', - PROBLEM: '문제', - REFERENCE: '레퍼런스', - }, - level: { - BEGINNER: '초급', - INTERMEDIATE: '중급', - ADVANCED: '고급', - }, - benefit: { - CONCEPT: '개념 정리', - PRACTICE: '실전 적용', - REVIEW: '복습용', - }, -} as const; - const MOCK_LIBRARY_DATA: LibraryItem[] = [ { id: 1, title: '2025년 상반기 백엔드 개발자 면접 질문 모음 (네카라쿠배)', - description: '네이버, 카카오, 라인, 쿠팡, 배민 등 주요 기업의 실제 면접 질문을 분석했습니다.', author: '제로원 운영진', date: '2025.01.10', views: 1250, likes: 342, link: 'https://velog.io', isLiked: true, - level: 'INTERMEDIATE', - tags: ['면접', '백엔드', '취업'], - isBookmarked: false, - isRecommended: true, }, { id: 2, title: '프론트엔드 성능 최적화: React 19 도입 가이드', - description: '최신 React 19의 성능 개선 포인트와 마이그레이션 전략을 소개합니다.', author: 'TechLead_Kim', date: '2025.01.12', views: 890, likes: 120, link: 'https://medium.com', isLiked: false, - level: 'ADVANCED', - tags: ['React', '성능', '최적화'], - isBookmarked: true, }, { id: 3, title: '비전공자가 6개월 만에 개발자로 취업한 현실적인 공부법', - description: '비전공 출신이 실제로 경험한 학습 로드맵과 포트폴리오 전략을 공유합니다.', author: 'NewDeveloper', date: '2025.01.05', views: 2100, likes: 560, link: 'https://brunch.co.kr', isLiked: true, - level: 'BEGINNER', - tags: ['비전공', '취업', '학습법'], - isBookmarked: false, - isRecommended: true, }, { id: 4, title: 'CS 기초: 운영체제와 네트워크 핵심 요약 (PDF 다운로드)', - description: '면접 대비를 위한 운영체제와 네트워크 핵심 개념 정리 자료입니다.', author: 'CS_Master', date: '2024.12.28', views: 1500, likes: 410, link: 'https://tistory.com', isLiked: false, - level: 'INTERMEDIATE', - tags: ['CS', '운영체제', '네트워크'], - isBookmarked: false, }, { id: 5, title: '주니어 개발자를 위한 이력서 첨삭 가이드 101', - description: '합격하는 개발자 이력서 작성법과 실제 첨삭 사례를 소개합니다.', author: 'HR_Manager', date: '2025.01.15', views: 750, likes: 230, link: 'https://linkedin.com', isLiked: false, - level: 'BEGINNER', - tags: ['이력서', '취업', '포트폴리오'], - isBookmarked: true, }, - ...Array.from({ length: 10 }, (_, i): LibraryItem => ({ + ...Array.from({ length: 10 }, (_, i) => ({ id: 10 + i, title: `개발자 면접 대비 - 자료구조 핵심 질문 ${i + 1}탄`, - description: '자주 출제되는 자료구조 면접 질문과 모범 답변을 정리했습니다.', author: 'Admin', date: `2024.12.${20 - i}`, views: 100 + i * 10, likes: 10 + i, link: 'https://google.com', isLiked: Math.random() > 0.5, - level: i % 3 === 0 ? 'BEGINNER' : i % 3 === 1 ? 'INTERMEDIATE' : 'ADVANCED', - tags: ['면접', '자료구조', 'CS'], - isBookmarked: Math.random() > 0.7, })), ]; -const MOCK_CURATION_DATA: CurationItem[] = [ - { - id: 101, - title: 'Spring JPA 기본기, 실무에서 바로 쓰는 패턴', - description: '기본 엔티티 설계부터 N+1 대응까지 핵심 패턴을 빠르게 정리합니다.', - type: 'ARTICLE', - level: 'INTERMEDIATE', - tags: ['Spring', 'JPA', 'DB 설계'], - benefit: 'PRACTICE', - link: 'https://velog.io', - isBookmarked: true, - isRecommended: true, - author: '제로원 운영진', - views: 2340, - likes: 580, - isLiked: true, - }, - { - id: 102, - title: 'React 성능 최적화 30분 요약', - description: '렌더링 병목, 메모이제이션, 번들 최적화까지 압축 정리.', - type: 'VIDEO', - level: 'BEGINNER', - tags: ['React', '성능', '최적화'], - benefit: 'CONCEPT', - link: 'https://youtube.com', - isBookmarked: false, - author: 'FrontendMaster', - views: 1890, - likes: 340, - isLiked: false, - }, - { - id: 103, - title: 'CS 면접 핵심 30제', - description: '운영체제·네트워크·자료구조를 한 번에 복습하세요.', - type: 'PROBLEM', - level: 'INTERMEDIATE', - tags: ['면접', 'CS', '자료구조'], - benefit: 'REVIEW', - link: 'https://notion.so', - isBookmarked: false, - author: 'CS_Master', - views: 3200, - likes: 720, - isLiked: true, - }, - { - id: 104, - title: '서비스 설계 레퍼런스 모음', - description: '요구사항 정의부터 아키텍처 설계까지 참고할 수 있는 실무 모음.', - type: 'REFERENCE', - level: 'ADVANCED', - tags: ['설계', '아키텍처', '문서화'], - benefit: 'PRACTICE', - link: 'https://medium.com', - isBookmarked: false, - author: 'TechLead_Kim', - views: 1560, - likes: 290, - isLiked: false, - }, -]; - // ---------------------------------------------------------------------- // Components @@ -526,64 +389,25 @@ const TopRankerCard = ({ ranker }: { ranker: Ranker }) => { const LibraryCard = ({ item, onLike, - onView, - onBookmark, - onSimilar, + onView }: { item: LibraryItem; onLike: (e: React.MouseEvent, id: number) => void; onView: (link: string) => void; - onBookmark: (e: React.MouseEvent, id: number) => void; - onSimilar: (e: React.MouseEvent, tag: string) => void; }) => { return ( -
-
-
- - {CURATION_LABELS.level[item.level]} - - {item.isRecommended && ( - - - 추천 - - )} -
- +
onView(item.link)} + className="group flex cursor-pointer flex-col gap-200 rounded-200 border border-border-subtle bg-background-default p-400 transition-all hover:-translate-y-50 hover:shadow-2 hover:border-border-default" + > +
+ {item.date} +
-
-

- {item.title} -

-

- {item.description} -

-
- -
- {item.tags.map((tag) => ( - - ))} -
+

+ {item.title} +

@@ -617,13 +441,11 @@ const LibraryCard = ({ const LibraryRow = ({ item, onLike, - onView, - onBookmark, + onView }: { item: LibraryItem; onLike: (e: React.MouseEvent, id: number) => void; onView: (link: string) => void; - onBookmark: (e: React.MouseEvent, id: number) => void; }) => { return (
{/* Title Area */} -
-
-

- {item.title} -

- - {CURATION_LABELS.level[item.level]} - - {item.isRecommended && ( - - - 추천 - - )} -
+
+

+ {item.title} +

{item.author} @@ -653,21 +464,8 @@ const LibraryRow = ({
- {/* Stats Area */} -
- - + {/* Stats Area - Hidden on very small screens if needed, but flex-wrap handles it */} +
{item.views.toLocaleString()} @@ -692,96 +490,6 @@ const LibraryRow = ({ ); }; -const CurationCard = ({ - item, - onBookmark, - onView, - onSimilar, - onLike, -}: { - item: CurationItem; - onBookmark: (e: React.MouseEvent, id: number) => void; - onView: (e: React.MouseEvent, link: string) => void; - onSimilar: (e: React.MouseEvent, tag: string) => void; - onLike: (e: React.MouseEvent, id: number) => void; -}) => { - return ( -
-
-
- - {CURATION_LABELS.level[item.level]} - - {item.isRecommended && ( - - - 추천 - - )} -
- -
- -
-

- {item.title} -

-

- {item.description} -

-
- -
- {item.tags.map((tag) => ( - - ))} -
- -
- - by {item.author} - -
-
- - {item.views.toLocaleString()} -
- -
-
-
- ); -}; // ---------------------------------------------------------------------- // Main Page @@ -792,7 +500,6 @@ export default function OneOnOnePage() { const [rankingType, setRankingType] = useState('ATTENDANCE'); const [rankers, setRankers] = useState([]); const [libraryItems, setLibraryItems] = useState([]); - const [curationItems, setCurationItems] = useState([]); const [isLoading, setIsLoading] = useState(true); // Library Specific States @@ -823,7 +530,6 @@ export default function OneOnOnePage() { setRankers(data); } else if (activeTab === 'LIBRARY') { setLibraryItems(MOCK_LIBRARY_DATA); - setCurationItems(MOCK_CURATION_DATA); } setIsLoading(false); }, 400); @@ -846,56 +552,11 @@ export default function OneOnOnePage() { })); }; - const handleBookmark = (e: React.MouseEvent, id: number) => { - e.stopPropagation(); - setCurationItems(prev => - prev.map(item => - item.id === id ? { ...item, isBookmarked: !item.isBookmarked } : item, - ), - ); - }; - // Handler for Viewing (Clicking Card) const handleView = (link: string) => { window.open(link, '_blank'); }; - const handleCurationView = (e: React.MouseEvent, link: string) => { - e.stopPropagation(); - handleView(link); - }; - - const handleSimilar = (e: React.MouseEvent, tag: string) => { - e.stopPropagation(); - setSearchTerm(tag); - setCurrentPage(1); - }; - - const handleCurationLike = (e: React.MouseEvent, id: number) => { - e.stopPropagation(); - setCurationItems(prev => - prev.map(item => { - if (item.id === id) { - return { - ...item, - isLiked: !item.isLiked, - likes: item.isLiked ? item.likes - 1 : item.likes + 1, - }; - } - return item; - }), - ); - }; - - const handleLibraryBookmark = (e: React.MouseEvent, id: number) => { - e.stopPropagation(); - setLibraryItems(prev => - prev.map(item => - item.id === id ? { ...item, isBookmarked: !item.isBookmarked } : item, - ), - ); - }; - // Filtering Logic const filteredRankers = rankers.filter((r) => { @@ -974,7 +635,7 @@ export default function OneOnOnePage() { )} > - 제로원 아카이브 + 제로원 도서관 - 위클리 소통 공간 + 밸런스게임 NEW @@ -998,7 +659,7 @@ export default function OneOnOnePage() {

{activeTab === 'RANKING' && '명예의 전당'} - {activeTab === 'LIBRARY' && '제로원 아카이브'} + {activeTab === 'LIBRARY' && '제로원 도서관'} {activeTab === 'RANKING' && } {activeTab === 'LIBRARY' && } @@ -1058,7 +719,6 @@ export default function OneOnOnePage() { placeholder={ activeTab === 'RANKING' ? '닉네임 검색' : '제목, 내용으로 검색' } - value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="h-600 w-full rounded-100 border border-border-subtle bg-background-default pl-200 pr-500 font-designer-14m outline-none focus:border-border-default focus:ring-2 focus:ring-fill-neutral-default-default transition-all" /> @@ -1165,10 +825,6 @@ export default function OneOnOnePage() { {ranker.rank} -

@@ -1208,30 +864,6 @@ export default function OneOnOnePage() { ) : ( /* Library Content */ <> -
-
-

- 자료 큐레이션 -

- - - 취향 맞춤 추천 - -
-
- {curationItems.map((item) => ( - - ))} -
-
- {viewMode === 'GRID' ? ( /* Grid View */
@@ -1242,8 +874,6 @@ export default function OneOnOnePage() { item={item} onLike={handleLike} onView={handleView} - onBookmark={handleLibraryBookmark} - onSimilar={handleSimilar} /> )) ) : ( @@ -1264,7 +894,6 @@ export default function OneOnOnePage() { item={item} onLike={handleLike} onView={handleView} - onBookmark={handleLibraryBookmark} /> ))}
diff --git a/src/components/cards/discussion-card.tsx b/src/components/cards/discussion-card.tsx index 768e76dd..8f8d6aba 100644 --- a/src/components/cards/discussion-card.tsx +++ b/src/components/cards/discussion-card.tsx @@ -1,5 +1,7 @@ import React from 'react'; import { MessageCircle, ThumbsUp, ThumbsDown, Eye, Clock } from 'lucide-react'; +import UserAvatar from '@/components/ui/avatar'; +import UserProfileModal from '@/entities/user/ui/user-profile-modal'; import { Discussion } from '@/types/discussion'; import { TOPIC_LABELS } from '@/mocks/discussion-mock-data'; import { formatDistanceToNow } from 'date-fns'; @@ -21,21 +23,36 @@ export default function DiscussionCard({ discussion, onClick }: DiscussionCardPr
{/* 헤더: 작성자 정보 & 주제 */}
- {/* 아바타 */} -
- {discussion.author.nickname.charAt(0)} + {/* 아바타 & 닉네임 */} +
e.stopPropagation()}> + +
+ +
+ + {discussion.author.nickname} + +
+ } + />
- {/* 작성자 & 시간 */} + {/* 시간 */}
- {discussion.author.nickname}
diff --git a/src/components/cards/voting-card.tsx b/src/components/cards/voting-card.tsx index f510ca5d..81c9c993 100644 --- a/src/components/cards/voting-card.tsx +++ b/src/components/cards/voting-card.tsx @@ -1,47 +1,57 @@ import React from 'react'; import Link from 'next/link'; import { Voting } from '@/types/voting'; -import { MessageCircle, Users, TrendingUp, CheckCircle2 } from 'lucide-react'; +import { MessageCircle, Users } from 'lucide-react'; +import UserAvatar from '@/components/ui/avatar'; +import UserProfileModal from '@/entities/user/ui/user-profile-modal'; import { cn } from '@/components/ui/(shadcn)/lib/utils'; import VoteTimer from '../voting/vote-timer'; interface VotingCardProps { voting: Voting; + onClick?: () => void; } -export default function VotingCard({ voting }: VotingCardProps) { +export default function VotingCard({ voting, onClick }: VotingCardProps) { const topOption = voting.options.reduce((prev, current) => prev.percentage > current.percentage ? prev : current, ); const hasVoted = voting.myVote !== undefined; - return ( - { e.preventDefault(); onClick(); } : undefined} > - {/* 헤더: 라운드 번호 & 상태 */} + {/* 헤더: 작성자 & 상태 */}
-
-
- - {voting.round} 라운드 -
- - {hasVoted && ( -
- - 투표 완료 -
- )} + {/* 작성자 정보 */} +
e.stopPropagation()}> + +
+ +
+ + {voting.author.nickname} + +
+ } + />
- {/* 투표 안 한 항목에만 타이머 표시 */} - {!hasVoted && } + {/* 타이머 표시 */} +
{/* 제목 */} @@ -104,6 +114,17 @@ export default function VotingCard({ voting }: VotingCardProps) { )}
+
+ ); + + // onClick이 있으면 Link 없이 렌더링, 없으면 Link로 감싸기 + if (onClick) { + return cardContent; + } + + return ( + + {cardContent} ); } diff --git a/src/components/discussion/comment-list.tsx b/src/components/discussion/comment-list.tsx index 81c1c90a..6810d522 100644 --- a/src/components/discussion/comment-list.tsx +++ b/src/components/discussion/comment-list.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { DiscussionComment } from '@/types/discussion'; import { VotingComment, VotingOption } from '@/types/voting'; import { MoreVertical, Trash2, Edit, CheckCircle2 } from 'lucide-react'; +import UserAvatar from '@/components/ui/avatar'; +import UserProfileModal from '@/entities/user/ui/user-profile-modal'; import { formatDistanceToNow } from 'date-fns'; import { ko } from 'date-fns/locale'; import { cn } from '@/components/ui/(shadcn)/lib/utils'; @@ -62,8 +64,11 @@ export default function CommentList({ comments, onDelete, onEdit, votingOptions
{/* 작성자 정보 */}
-
- {comment.author.nickname.charAt(0)} +
e.stopPropagation()}> + } + />
diff --git a/src/components/discussion/discussion-detail-modal.tsx b/src/components/discussion/discussion-detail-modal.tsx index 66a84835..dcf48e13 100644 --- a/src/components/discussion/discussion-detail-modal.tsx +++ b/src/components/discussion/discussion-detail-modal.tsx @@ -2,6 +2,8 @@ import React, { useState } from 'react'; import { Discussion, VoteType } from '@/types/discussion'; import { TOPIC_LABELS } from '@/mocks/discussion-mock-data'; import { X, ThumbsUp, ThumbsDown, Eye, Clock, MessageCircle } from 'lucide-react'; +import UserAvatar from '@/components/ui/avatar'; +import UserProfileModal from '@/entities/user/ui/user-profile-modal'; import { formatDistanceToNow } from 'date-fns'; import { ko } from 'date-fns/locale'; import { cn } from '@/components/ui/(shadcn)/lib/utils'; @@ -73,13 +75,24 @@ export default function DiscussionDetailModal({ {/* 메타 정보 */}
{/* 작성자 */} -
-
- {discussion.author.nickname.charAt(0)} -
- - {discussion.author.nickname} - +
e.stopPropagation()}> + +
+ +
+ + {discussion.author.nickname} + +
+ } + />
{/* 시간 */} diff --git a/src/components/home/tab-navigation.tsx b/src/components/home/tab-navigation.tsx new file mode 100644 index 00000000..36b36064 --- /dev/null +++ b/src/components/home/tab-navigation.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import { cn } from '@/components/ui/(shadcn)/lib/utils'; +import { Trophy, BookOpen, MessageSquareText, Calendar, History } from 'lucide-react'; + +interface TabNavigationProps { + activeTab: string; +} + +const TABS = [ + { + id: 'study', + label: '스터디', + icon: Calendar, + description: '나의 스터디 일정' + }, + { + id: 'ranking', + label: '명예의 전당', + icon: Trophy, + description: '랭킹 시스템' + }, + { + id: 'archive', + label: '제로원 아카이브', + icon: BookOpen, + description: '학습 자료' + }, + { + id: 'history', + label: '나의 스터디 기록', + icon: History, + description: '1:1 스터디 히스토리' + }, + { + id: 'community', + label: '밸런스게임', + icon: MessageSquareText, + description: '커뮤니티' + } +]; + +export default function TabNavigation({ activeTab }: TabNavigationProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + + const handleTabChange = (tabId: string) => { + const params = new URLSearchParams(searchParams.toString()); + params.set('tab', tabId); + router.push(`/home?${params.toString()}`); + }; + + return ( +
+
+

제로원 홈

+
+ + +
+ ); +} diff --git a/src/components/home/tabs/archive-tab.tsx b/src/components/home/tabs/archive-tab.tsx new file mode 100644 index 00000000..ae32571c --- /dev/null +++ b/src/components/home/tabs/archive-tab.tsx @@ -0,0 +1,597 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { cn } from '@/components/ui/(shadcn)/lib/utils'; +import { + LibraryBig, + LayoutGrid, + List, + ArrowUpDown, + Bookmark, + Search, + Eye, + Heart, + Loader2, + ChevronLeft, + ChevronRight, +} from 'lucide-react'; + +// ---------------------------------------------------------------------- +// Types & Mock Data (Library) +// ---------------------------------------------------------------------- + +type CurationLevel = 'BEGINNER' | 'INTERMEDIATE' | 'ADVANCED'; + +interface LibraryItem { + id: number; + title: string; + description: string; + author: string; + date: string; + views: number; + likes: number; + link: string; + isLiked: boolean; + level: CurationLevel; + tags: string[]; + isBookmarked: boolean; + isRecommended?: boolean; + isHidden?: boolean; +} + +const MOCK_LIBRARY_DATA: LibraryItem[] = [ + { + id: 1, + title: '2025년 상반기 백엔드 개발자 면접 질문 모음 (네카라쿠배)', + description: '네이버, 카카오, 라인, 쿠팡, 배민 등 주요 기업의 실제 면접 질문을 분석했습니다.', + author: '제로원 운영진', + date: '2025.01.10', + views: 1250, + likes: 342, + link: 'https://velog.io', + isLiked: true, + level: 'INTERMEDIATE', + tags: ['면접', '백엔드', '취업'], + isBookmarked: false, + isRecommended: true, + }, + { + id: 2, + title: '프론트엔드 성능 최적화: React 19 도입 가이드', + description: '최신 React 19의 성능 개선 포인트와 마이그레이션 전략을 소개합니다.', + author: 'TechLead_Kim', + date: '2025.01.12', + views: 890, + likes: 120, + link: 'https://medium.com', + isLiked: false, + level: 'ADVANCED', + tags: ['React', '성능', '최적화'], + isBookmarked: true, + }, + { + id: 3, + title: '비전공자가 6개월 만에 개발자로 취업한 현실적인 공부법', + description: '비전공 출신이 실제로 경험한 학습 로드맵과 포트폴리오 전략을 공유합니다.', + author: 'NewDeveloper', + date: '2025.01.05', + views: 2100, + likes: 560, + link: 'https://brunch.co.kr', + isLiked: true, + level: 'BEGINNER', + tags: ['비전공', '취업', '학습법'], + isBookmarked: false, + isRecommended: true, + }, + { + id: 4, + title: 'CS 기초: 운영체제와 네트워크 핵심 요약 (PDF 다운로드)', + description: '면접 대비를 위한 운영체제와 네트워크 핵심 개념 정리 자료입니다.', + author: 'CS_Master', + date: '2024.12.28', + views: 1500, + likes: 410, + link: 'https://tistory.com', + isLiked: false, + level: 'INTERMEDIATE', + tags: ['CS', '운영체제', '네트워크'], + isBookmarked: false, + }, + { + id: 5, + title: '주니어 개발자를 위한 이력서 첨삭 가이드 101', + description: '합격하는 개발자 이력서 작성법과 실제 첨삭 사례를 소개합니다.', + author: 'HR_Manager', + date: '2025.01.15', + views: 750, + likes: 230, + link: 'https://linkedin.com', + isLiked: false, + level: 'BEGINNER', + tags: ['이력서', '취업', '포트폴리오'], + isBookmarked: true, + }, + ...Array.from({ length: 10 }, (_, i): LibraryItem => ({ + id: 10 + i, + title: `개발자 면접 대비 - 자료구조 핵심 질문 ${i + 1}탄`, + description: '자주 출제되는 자료구조 면접 질문과 모범 답변을 정리했습니다.', + author: 'Admin', + date: `2024.12.${20 - i}`, + views: 100 + i * 10, + likes: 10 + i, + link: 'https://google.com', + isLiked: Math.random() > 0.5, + level: i % 3 === 0 ? 'BEGINNER' : i % 3 === 1 ? 'INTERMEDIATE' : 'ADVANCED', + tags: ['면접', '자료구조', 'CS'], + isBookmarked: Math.random() > 0.7, + })), +]; + +// ---------------------------------------------------------------------- +// Components +// ---------------------------------------------------------------------- + +const LibraryCard = ({ + item, + onLike, + onView, + onBookmark, + onHide, + isAdmin, +}: { + item: LibraryItem; + onLike: (e: React.MouseEvent, id: number) => void; + onView: (link: string) => void; + onBookmark: (e: React.MouseEvent, id: number) => void; + onHide?: (e: React.MouseEvent, id: number) => void; + isAdmin?: boolean; +}) => { + return ( +
+
+
+ {item.isHidden && ( + + 숨김됨 + + )} +
+
+ {isAdmin && onHide && ( + + )} + +
+
+ +
+

+ {item.title} +

+

+ {item.description} +

+
+ +
+ + by {item.author} + +
+
+ + {item.views.toLocaleString()} +
+ +
+
+
+ ); +}; + +const LibraryRow = ({ + item, + onLike, + onView, + onBookmark, + onHide, + isAdmin, +}: { + item: LibraryItem; + onLike: (e: React.MouseEvent, id: number) => void; + onView: (link: string) => void; + onBookmark: (e: React.MouseEvent, id: number) => void; + onHide?: (e: React.MouseEvent, id: number) => void; + isAdmin?: boolean; +}) => { + return ( +
onView(item.link)} + className={cn( + "group flex items-center gap-300 px-300 py-200 border-b border-border-subtlest hover:bg-fill-neutral-subtle-hover transition-colors cursor-pointer last:border-0", + item.isHidden && "opacity-50" + )} + > + {/* Title Area */} +
+
+

+ {item.title} +

+ {item.isHidden && ( + + 숨김됨 + + )} +
+
+ {item.author} + + {item.date} +
+
+ + {/* Stats Area */} +
+ {isAdmin && onHide && ( + + )} + + +
+ + {item.views.toLocaleString()} +
+ + +
+
+ ); +}; + +// ---------------------------------------------------------------------- +// Main Component +// ---------------------------------------------------------------------- + +export default function ArchiveTab() { + const [libraryItems, setLibraryItems] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [librarySort, setLibrarySort] = useState<'LATEST' | 'VIEWS' | 'LIKES'>('LATEST'); + const [viewMode, setViewMode] = useState<'GRID' | 'LIST'>('GRID'); + const [currentPage, setCurrentPage] = useState(1); + const [searchTerm, setSearchTerm] = useState(''); + + // New States + const [showBookmarkedOnly, setShowBookmarkedOnly] = useState(false); + const [isAdmin, setIsAdmin] = useState(false); // Mock Admin Mode + + const ITEMS_PER_PAGE = viewMode === 'LIST' ? 15 : 10; + + // Data Loading Simulation + useEffect(() => { + setIsLoading(true); + setCurrentPage(1); + + const timer = setTimeout(() => { + setLibraryItems(MOCK_LIBRARY_DATA); + setIsLoading(false); + }, 400); + + return () => clearTimeout(timer); + }, []); + + // Handler for Likes + const handleLike = (e: React.MouseEvent, id: number) => { + e.stopPropagation(); + setLibraryItems(prev => prev.map(item => { + if (item.id === id) { + return { + ...item, + isLiked: !item.isLiked, + likes: item.isLiked ? item.likes - 1 : item.likes + 1 + }; + } + return item; + })); + }; + + const handleView = (link: string) => { + window.open(link, '_blank'); + }; + + const handleLibraryBookmark = (e: React.MouseEvent, id: number) => { + e.stopPropagation(); + setLibraryItems(prev => + prev.map(item => + item.id === id ? { ...item, isBookmarked: !item.isBookmarked } : item, + ), + ); + }; + + const handleHide = (e: React.MouseEvent, id: number) => { + e.stopPropagation(); + setLibraryItems(prev => + prev.map(item => + item.id === id ? { ...item, isHidden: !item.isHidden } : item, + ), + ); + }; + + // Filtering Logic + const filteredLibrary = libraryItems.filter((item) => { + const matchesSearch = item.title.toLowerCase().includes(searchTerm.toLowerCase()); + const matchesBookmark = showBookmarkedOnly ? item.isBookmarked : true; + const matchesHidden = isAdmin ? true : !item.isHidden; + + return matchesSearch && matchesBookmark && matchesHidden; + }); + + const sortedLibrary = [...filteredLibrary].sort((a, b) => { + if (librarySort === 'VIEWS') return b.views - a.views; + if (librarySort === 'LIKES') return b.likes - a.likes; + return new Date(b.date).getTime() - new Date(a.date).getTime(); + }); + + // Pagination Logic + const totalItems = sortedLibrary.length; + const totalPages = Math.ceil(totalItems / ITEMS_PER_PAGE); + + const currentLibrary = sortedLibrary.slice( + (currentPage - 1) * ITEMS_PER_PAGE, + currentPage * ITEMS_PER_PAGE, + ); + + return ( +
+ {/* Header Area */} +
+

+ 제로원 아카이브 + +

+ + {/* Admin Toggle (Hidden/Dev feature) */} + +
+ + {/* Filters & Search */} +
+
+ {/* Left Side Filters */} +
+ {/* Bookmark Filter */} + +
+ + {/* Right Side Controls */} +
+ {/* Search Bar */} +
+ setSearchTerm(e.target.value)} + className="h-600 w-full rounded-100 border border-border-subtle bg-background-default pl-200 pr-500 font-designer-14m outline-none focus:border-border-default focus:ring-2 focus:ring-fill-neutral-default-default transition-all" + /> + + + +
+ + {/* Sort & View Toggle */} +
+ {/* Sort Dropdown */} +
+ + + {/* Dropdown */} +
+
+ + + +
+
+
+ +
+ + {/* View Mode Toggle */} +
+ + +
+
+
+
+
+ + {isLoading ? ( +
+ +
데이터를 불러오는 중입니다...
+
+ ) : ( + <> + {/* Library Content */} + {viewMode === 'GRID' ? ( + /* Grid View */ +
+ {currentLibrary.length > 0 ? ( + currentLibrary.map((item) => ( + + )) + ) : ( +
+ +

검색 결과가 없습니다.

+
+ )} +
+ ) : ( + /* List View */ +
+ {currentLibrary.length > 0 ? ( +
+ {currentLibrary.map((item) => ( + + ))} +
+ ) : ( +
+ +

검색 결과가 없습니다.

+
+ )} +
+ )} + + )} + + {/* Pagination */} + {totalPages > 1 && ( +
+ + + {currentPage} / {totalPages} + + +
+ )} +
+ ); +} diff --git a/src/components/home/tabs/community-tab.tsx b/src/components/home/tabs/community-tab.tsx new file mode 100644 index 00000000..51908306 --- /dev/null +++ b/src/components/home/tabs/community-tab.tsx @@ -0,0 +1,303 @@ +'use client'; + +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { Loader2, Vote, SearchX, Plus, MessageSquareText } from 'lucide-react'; +import { Voting } from '@/types/voting'; +import { mockFetchVotings } from '@/mocks/voting-mock-data'; +import VotingCard from '@/components/cards/voting-card'; +import VotingCreateModal from '@/components/voting/voting-create-modal'; +import VotingDetailView from '@/components/voting/voting-detail-view'; +import { VotingCreateFormData } from '@/types/schemas/zod-schema'; +import { cn } from '@/components/ui/(shadcn)/lib/utils'; + +export default function CommunityTab() { + // 상태 관리 + const [votings, setVotings] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(true); + const [page, setPage] = useState(1); + const [error, setError] = useState(null); + const [filterMode, setFilterMode] = useState<'active' | 'all' | 'popular'>('active'); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [selectedVotingId, setSelectedVotingId] = useState(null); + + // 무한 스크롤용 ref + const observerTarget = useRef(null); + + // 데이터 로딩 함수 + const loadVotings = useCallback( + async (pageNum: number, reset: boolean = false) => { + try { + if (reset) { + setIsLoading(true); + setError(null); + } else { + setIsLoadingMore(true); + } + + const activeOnly = filterMode === 'active'; + const sortBy = filterMode === 'popular' ? 'popular' : 'latest'; + + const result = await mockFetchVotings({ + page: pageNum, + limit: 10, + activeOnly: activeOnly, + sortBy: sortBy, + }); + + setVotings((prev) => (reset ? result.items : [...prev, ...result.items])); + setHasMore(result.hasMore); + } catch (err) { + setError('데이터를 불러오는데 실패했습니다.'); + console.error(err); + } finally { + setIsLoading(false); + setIsLoadingMore(false); + } + }, + [filterMode], + ); + + // 투표 생성 핸들러 + const handleCreateVoting = async (data: VotingCreateFormData) => { + try { + // Mock API 호출 (실제로는 서버에 요청) + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // 새 투표 객체 생성 + const newVoting: Voting = { + id: Date.now(), // 임시 ID + round: votings.length > 0 ? Math.max(...votings.map(v => v.round)) + 1 : 1, + title: data.title, + description: data.description || undefined, + options: data.options.map((opt, index) => ({ + id: Date.now() + index, + label: opt.label, + voteCount: 0, + percentage: 0, + })), + totalVotes: 0, + myVote: undefined, + commentCount: 0, + comments: [], + createdAt: new Date().toISOString(), + endsAt: data.endsAt || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), // 7일 후 + isActive: true, + tags: data.tags || [], + author: { id: 999, nickname: '나' }, // TODO: 실제 사용자 정보로 교체 + }; + + // localStorage에 저장 (상세 페이지에서 조회 가능하도록) + const customVotings = localStorage.getItem('customVotings'); + const existingVotings = customVotings ? JSON.parse(customVotings) : []; + localStorage.setItem('customVotings', JSON.stringify([newVoting, ...existingVotings])); + + // 목록 맨 앞에 추가 + setVotings((prev) => [newVoting, ...prev]); + + console.log('새 투표 생성 완료:', newVoting); + } catch (error) { + console.error('투표 생성 실패:', error); + throw error; + } + }; + + // 필터 변경 시 첫 페이지 로드 + useEffect(() => { + setPage(1); + loadVotings(1, true); + }, [loadVotings]); + + // 무한 스크롤 Intersection Observer + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasMore && !isLoadingMore && !isLoading) { + const nextPage = page + 1; + setPage(nextPage); + loadVotings(nextPage, false); + } + }, + { threshold: 0.1 }, + ); + + const currentTarget = observerTarget.current; + if (currentTarget) { + observer.observe(currentTarget); + } + + return () => { + if (currentTarget) { + observer.unobserve(currentTarget); + } + }; + }, [hasMore, isLoadingMore, isLoading, page, loadVotings]); + + // 상세 화면으로 전환 + const handleVotingClick = (votingId: number) => { + setSelectedVotingId(votingId); + }; + + // 목록으로 돌아가기 + const handleBackToList = () => { + setSelectedVotingId(null); + }; + + // 상세 화면이 열려있으면 상세 화면 표시 + if (selectedVotingId) { + return ( +
+ +
+ ); + } + + // 로딩 상태 + if (isLoading) { + return ( +
+
+ +

투표를 불러오는 중...

+
+
+ ); + } + + // 에러 상태 + if (error) { + return ( +
+
+ +

{error}

+ +
+
+ ); + } + + return ( + <> +
+ {/* Header */} +
+

+ 밸런스게임 + +

+
+ + {/* 헤더 설명 */} +
+

+ 다양한 주제에 투표하고 댓글로 자유롭게 토론할 수 있습니다. +

+
+ + {/* 필터 + 주제 생성 버튼 */} +
+ {/* 필터 버튼 */} +
+ + + +
+ + {/* 주제 생성 버튼 */} + +
+ + {/* 투표 목록 */} + {votings.length === 0 ? ( +
+ +
+

투표가 없습니다

+

+ 곧 새로운 투표가 등록될 예정입니다 +

+
+
+ ) : ( + <> +
+ {votings.map((voting) => ( + handleVotingClick(voting.id)} + /> + ))} +
+ + {/* 무한 스크롤 트리거 */} +
+ {isLoadingMore && ( +
+ + 불러오는 중... +
+ )} + {!hasMore && votings.length > 0 && ( +

모든 투표를 불러왔습니다

+ )} +
+ + )} +
+ + {/* 주제 생성 모달 */} + setIsCreateModalOpen(false)} + onSubmit={handleCreateVoting} + /> + + ); +} \ No newline at end of file diff --git a/src/components/home/tabs/hall-of-fame-tab.tsx b/src/components/home/tabs/hall-of-fame-tab.tsx new file mode 100644 index 00000000..fc91d13e --- /dev/null +++ b/src/components/home/tabs/hall-of-fame-tab.tsx @@ -0,0 +1,497 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import Image from 'next/image'; +import { cn } from '@/components/ui/(shadcn)/lib/utils'; +import UserProfileModal from '@/entities/user/ui/user-profile-modal'; +import { + Trophy, + Flame, + FileText, + Thermometer, + Search, + Loader2, + ChevronLeft, + ChevronRight, +} from 'lucide-react'; + +// ---------------------------------------------------------------------- +// Types & Mock Data (Hall of Fame) +// ---------------------------------------------------------------------- + +type RankingType = 'ATTENDANCE' | 'STUDY_LOG' | 'SINCERITY'; + +interface Ranker { + rank: number; + userId: number; + nickname: string; + profileImage: string | null; + score: number; + scoreLabel: string; + change?: 'up' | 'down' | 'same'; + lastActive: string; + studyTime: string; + major?: string; + streak?: number; + changeValue?: number; +} + +const JOBS = [ + 'IT 노베이스 - 비지니스/창업', + 'IT 노베이스 - 업무 자동화', + 'IT 노베이스 - 내 서비스 개발', + 'IT 실무자 - PM/PO/기획', + 'IT 실무자 - 프론트엔드', + 'IT 실무자 - 백엔드', + 'IT 실무자 - AI/머신러닝', + 'IT 실무자 - iOS', + 'IT 실무자 - 안드로이드', + 'IT 실무자 - DevOps', + 'IT 실무자 - 데이터 분석', + 'IT 실무자 - QA', + 'IT 실무자 - 게임 개발', + 'IT 실무자 - 디자인', + 'IT 실무자 - 마케팅', + 'IT 실무자 - 기타', +]; + +const generateMockRankers = (type: RankingType): Ranker[] => { + return Array.from({ length: 20 }, (_, i) => { + const rank = i + 1; + let score = 0; + let scoreLabel = ''; + + if (type === 'ATTENDANCE') { + score = 150 - i * 3; + scoreLabel = `${score}회`; + } else if (type === 'STUDY_LOG') { + score = 80 - i * 2; + scoreLabel = `${score}건`; + } else { + score = parseFloat((99.9 - i * 1.5).toFixed(1)); + scoreLabel = `${score}℃`; + } + + const lastActive = i < 3 ? '방금 전' : `${i * 10 + 5}분 전`; + const studyTime = `${Math.floor(Math.random() * 40 + 10)}시간`; + const streak = Math.floor(Math.random() * 50) + 1; + const major = JOBS[Math.floor(Math.random() * JOBS.length)]; + + return { + rank, + userId: 100 + i, + nickname: `User_${100 + i}`, + profileImage: null as string | null, + score, + scoreLabel, + change: Math.random() > 0.7 ? 'up' : Math.random() > 0.8 ? 'down' : 'same', + lastActive, + studyTime, + major, + streak, + changeValue: Math.floor(Math.random() * 5), + }; + }); +}; + +const TAB_CONFIG: Record< + RankingType, + { label: string; desc: string; icon: React.ReactNode; colorClass: string } +> = { + ATTENDANCE: { + label: '불꽃 출석왕', + desc: '비가 오나 눈이 오나 자리를 지킨, 제로원의 개근상', + icon: , + colorClass: 'text-text-brand', + }, + STUDY_LOG: { + label: '열정 기록왕', + desc: '제로원의 아카이브를 채운, 자료 공유의 신', + icon: , + colorClass: 'text-text-information', + }, + SINCERITY: { + label: '성실 온도왕', + desc: '가장 뜨거운 심장을 가진, 제로원의 신뢰 아이콘', + icon: , + colorClass: 'text-text-warning', + }, +}; + +// ---------------------------------------------------------------------- +// Components +// ---------------------------------------------------------------------- + +const RankBadge = ({ rank }: { rank: number }) => { + const iconPath = + rank === 1 + ? '/icons/gold-rank.svg' + : rank === 2 + ? '/icons/silver-rank.svg' + : '/icons/bronze-rank.svg'; + + return ( +
+ {`${rank}위`} +
+ ); +}; + +const ProfileAvatar = ({ + src, + alt, + size = 'md', + className = '', +}: { + src: string | null; + alt: string; + size?: 'sm' | 'md' | 'lg' | 'xl'; + className?: string; +}) => { + const sizeClass = { + sm: 'w-[40px] h-[40px]', + md: 'w-[48px] h-[48px]', + lg: 'w-[80px] h-[80px]', + xl: 'w-[120px] h-[120px]', + }[size]; + + return ( +
+ {src ? ( + {alt} + ) : ( + default profile + )} +
+ ); +}; + +const TopRankerCard = ({ ranker }: { ranker: Ranker }) => { + const isFirst = ranker.rank === 1; + + return ( + + {isFirst && ( +
+ +
+ )} + + + +
+ {!isFirst && ( +
+ +
+ )} +
+ {ranker.nickname} +
+ +
+ + {parseInt(ranker.scoreLabel.replace(/[^0-9.]/g, ''))} + + + {ranker.scoreLabel.replace(/[0-9.]/g, '')} + +
+ +
+
+ 최근 활동 + {ranker.lastActive} +
+
+
+ 누적 학습 + {ranker.studyTime} +
+
+
+
+ } + /> + ); +}; + +// ---------------------------------------------------------------------- +// Main Component +// ---------------------------------------------------------------------- + +export default function HallOfFameTab() { + const [rankingType, setRankingType] = useState('ATTENDANCE'); + const [rankers, setRankers] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [currentPage, setCurrentPage] = useState(1); + const [searchTerm, setSearchTerm] = useState(''); + const [jobFilter, setJobFilter] = useState(''); + + const ITEMS_PER_PAGE = 15; + + // Data Loading Simulation + useEffect(() => { + setIsLoading(true); + setCurrentPage(1); + + const timer = setTimeout(() => { + const data = Array.from({ length: 100 }, (_, i) => { + return generateMockRankers(rankingType).map((r) => ({ + ...r, + rank: r.rank + i * 20, + })); + }) + .flat() + .map((item, index) => ({ ...item, rank: index + 1 })); + setRankers(data); + setIsLoading(false); + }, 400); + + return () => clearTimeout(timer); + }, [rankingType]); + + // Filtering Logic + const filteredRankers = rankers.filter((r) => { + const matchesSearch = r.nickname + .toLowerCase() + .includes(searchTerm.toLowerCase()); + const matchesJob = jobFilter ? r.major === jobFilter : true; + return matchesSearch && matchesJob; + }); + + // Pagination Logic + const totalItems = filteredRankers.length; + const totalPages = Math.ceil(Math.max(0, totalItems - 3) / ITEMS_PER_PAGE); + + const currentRankers = filteredRankers + .slice(3) + .slice( + (currentPage - 1) * ITEMS_PER_PAGE, + currentPage * ITEMS_PER_PAGE, + ); + + return ( +
+ {/* Header Area */} +
+

+ 명예의 전당 + +

+
+ + {/* Filters & Search */} +
+
+ {(Object.keys(TAB_CONFIG) as RankingType[]).map((type) => ( + + ))} +
+ +
+
+ + +
+ setSearchTerm(e.target.value)} + className="h-600 w-full rounded-100 border border-border-subtle bg-background-default pl-200 pr-500 font-designer-14m outline-none focus:border-border-default focus:ring-2 focus:ring-fill-neutral-default-default transition-all" + /> + + + +
+
+
+
+ + {isLoading ? ( +
+ +
데이터를 불러오는 중입니다...
+
+ ) : ( + <> + {/* Top 3 Section */} + {filteredRankers.length > 0 && ( +
+ {filteredRankers[1] && ( +
+ +
+ )} + {filteredRankers[0] && ( +
+ +
+ )} + {filteredRankers[2] && ( +
+ +
+ )} +
+ )} + + {/* Ranking Table */} +
+
+
Rank
+
Member
+
Score
+
Activity
+
+ +
+ {currentRankers.map((ranker) => ( + +
+ + {ranker.rank} + +
+ +
+ +
+
+ {ranker.nickname} +
+
+ {ranker.major} +
+
+
+ +
+
+ {ranker.scoreLabel} +
+
+ 상위 1% +
+
+ +
+
{ranker.studyTime} 누적
+
+ 최근: {ranker.lastActive} +
+
+
+ } + /> + ))} +
+
+ + )} + + {/* Pagination */} + {totalPages > 1 && ( +
+ + + {currentPage} / {totalPages} + + +
+ )} +
+ ); +} diff --git a/src/components/home/tabs/history-tab.tsx b/src/components/home/tabs/history-tab.tsx new file mode 100644 index 00000000..da11e2b2 --- /dev/null +++ b/src/components/home/tabs/history-tab.tsx @@ -0,0 +1,127 @@ +'use client'; + +import { MessageSquareText, History, Clock, CheckCircle2 } from 'lucide-react'; +import { cn } from '@/components/ui/(shadcn)/lib/utils'; + +export default function HistoryTab() { + // 임시 데이터: 나의 1:1 스터디 히스토리 (최근순) + const historyItems = [ + { + id: 'h1', + title: '알고리즘 1:1 스터디', + partner: '박지민', + status: '완료', + date: '2026-01-18', + time: 'AM 07:00', + note: 'DP/그리디 복습 및 코드 리뷰', + }, + { + id: 'h2', + title: 'CS 네트워크 심화', + partner: '김서준', + status: '완료', + date: '2026-01-15', + time: 'AM 06:30', + note: 'HTTP/HTTPS, TLS 핸드셰이크 정리', + }, + { + id: 'h3', + title: '자료구조 리마인드', + partner: '이지은', + status: '완료', + date: '2026-01-10', + time: 'AM 07:30', + note: '트리/그래프 순회 패턴 리뷰', + }, + { + id: 'h4', + title: '시스템 디자인 라이트', + partner: '홍민수', + status: '진행중', + date: '2026-01-22', + time: 'AM 06:50', + note: '캐시 전략과 확장성 개념 잡기', + }, + ]; + + return ( +
+
+

+ 제로원 히스토리 + +

+
+ + {/* 임시 데이터 기반 1:1 스터디 히스토리 목록 */} +
+
+
+ +
+
+

나의 1:1 스터디 기록

+

+ 최근 진행한 1:1 스터디 히스토리를 임시 데이터로 보여줍니다. +

+
+
+ +
+ {historyItems.map((item) => ( +
+
+
+ +
+ {item.title} +
+
+
+ + {item.date} · {item.time} +
+
+ +
+ + 파트너: {item.partner} + + + {item.status} + +
+ +

+ {item.note} +

+
+ ))} +
+ +
+ * 실제 데이터 연동 전 임시 목록입니다. +
+
+
+ ); +} + + diff --git a/src/components/home/tabs/study-history-tab.tsx b/src/components/home/tabs/study-history-tab.tsx new file mode 100644 index 00000000..a45e2208 --- /dev/null +++ b/src/components/home/tabs/study-history-tab.tsx @@ -0,0 +1,248 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { History, List, Calendar as CalendarIcon, Loader2 } from 'lucide-react'; +import { cn } from '@/components/ui/(shadcn)/lib/utils'; +import { StudyHistoryRow } from '@/components/study-history/study-history-row'; +import { StudyCalendar } from '@/components/study-history/study-calendar'; +import { StudyHistoryItem } from '@/types/study-history'; + +const MOCK_HISTORY_DATA: StudyHistoryItem[] = [ + { + id: 1, + date: '2025.01.20 (월)', + subject: 'JPA 영속성 컨텍스트 학습', + role: 'INTERVIEWER', + attendance: 'ATTENDED', + link: 'https://notion.so', + status: 'COMPLETED', + }, + { + id: 2, + date: '2025.01.19 (일)', + subject: 'Spring Security 인증 필터 체인', + role: 'INTERVIEWEE', + attendance: 'ATTENDED', + link: 'https://github.com', + status: 'COMPLETED', + }, + { + id: 3, + date: '2025.01.18 (토)', + subject: 'OS - 프로세스와 스레드 차이', + role: 'INTERVIEWER', + attendance: 'NOT_STARTED', + link: null, + status: 'COMPLETED', + }, + { + id: 4, + date: '2025.01.17 (금)', + subject: 'Java GC 알고리즘 종류', + role: 'INTERVIEWEE', + attendance: 'NOT_STARTED', + link: null, + status: 'IN_PROGRESS', + }, + { + id: 5, + date: '2025.01.16 (목)', + subject: '네트워크 - TCP 3-way Handshake', + role: 'INTERVIEWER', + attendance: 'ATTENDED', + link: 'https://velog.io', + status: 'COMPLETED', + }, + { + id: 6, + date: '2025.01.15 (수)', + subject: 'Spring Boot Auto Configuration', + role: 'INTERVIEWEE', + attendance: 'ATTENDED', + link: 'https://notion.so', + status: 'COMPLETED', + }, + { + id: 7, + date: '2025.01.14 (화)', + subject: 'Database Index 설계 원칙', + role: 'INTERVIEWER', + attendance: 'NOT_STARTED', + link: null, + status: 'COMPLETED', + }, + { + id: 8, + date: '2025.01.13 (월)', + subject: 'Redis 캐싱 전략', + role: 'INTERVIEWEE', + attendance: 'ATTENDED', + link: 'https://github.com', + status: 'COMPLETED', + }, + { + id: 9, + date: '2025.01.12 (일)', + subject: 'Docker 컨테이너 최적화', + role: 'INTERVIEWER', + attendance: 'NOT_STARTED', + link: null, + status: 'COMPLETED', + }, + { + id: 10, + date: '2025.01.11 (토)', + subject: 'Kubernetes Pod 라이프사이클', + role: 'INTERVIEWEE', + attendance: 'ATTENDED', + link: 'https://velog.io', + status: 'COMPLETED', + }, + { + id: 11, + date: '2024.12.30 (월)', + subject: 'React Hook 최적화 패턴', + role: 'INTERVIEWER', + attendance: 'ATTENDED', + link: 'https://notion.so', + status: 'COMPLETED', + }, + { + id: 12, + date: '2024.12.29 (일)', + subject: 'TypeScript 고급 타입 시스템', + role: 'INTERVIEWEE', + attendance: 'NOT_STARTED', + link: null, + status: 'COMPLETED', + }, + ...Array.from({ length: 10 }, (_, i) => ({ + id: 20 + i, + date: `2024.12.${20 - i} (${['월', '화', '수', '목', '금', '토', '일'][i % 7]})`, + subject: `백엔드 면접 스터디 ${i + 1}주차 - 심화 질문`, + role: i % 2 === 0 ? 'INTERVIEWEE' : 'INTERVIEWER' as const, + attendance: Math.random() > 0.3 ? 'ATTENDED' : 'NOT_STARTED' as const, + link: Math.random() > 0.5 ? 'https://google.com' : null, + status: 'COMPLETED' as const, + })), +]; + +export default function StudyHistoryTab() { + const [historyItems, setHistoryItems] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [viewMode, setViewMode] = useState<'LIST' | 'CALENDAR'>('LIST'); + const [currentPage, setCurrentPage] = useState(1); + + const ITEMS_PER_PAGE = 15; + + useEffect(() => { + setIsLoading(true); + const timer = setTimeout(() => { + setHistoryItems(MOCK_HISTORY_DATA); + setIsLoading(false); + }, 300); + return () => clearTimeout(timer); + }, []); + + const totalPages = Math.ceil(historyItems.length / ITEMS_PER_PAGE) || 1; + const currentHistory = historyItems.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE); + + if (isLoading) { + return ( +
+ +
1:1 스터디 기록을 불러오는 중입니다...
+
+ ); + } + + return ( +
+
+

+ 나의 1:1 스터디 기록 + +

+ +
+ + +
+
+ +
+
+ 총 {historyItems.length}개의 1:1 스터디 기록이 있습니다. +
+ + {viewMode === 'LIST' ? ( +
+
+
날짜
+
오늘의 주제
+
내 역할
+
출석
+
링크
+
진행 상태
+
+ +
+ {currentHistory.length > 0 ? ( + currentHistory.map((item) => ) + ) : ( +
+ +

아직 1:1 스터디 기록이 없습니다.

+
+ )} +
+
+ ) : ( + + )} + + {viewMode === 'LIST' && totalPages > 1 && ( +
+ + + {currentPage} / {totalPages} + + +
+ )} +
+
+ ); +} + + diff --git a/src/components/home/tabs/study-tab.tsx b/src/components/home/tabs/study-tab.tsx new file mode 100644 index 00000000..204af9ca --- /dev/null +++ b/src/components/home/tabs/study-tab.tsx @@ -0,0 +1,12 @@ +import Banner from '@/widgets/home/banner'; +import StudyCard from '@/features/study/schedule/ui/study-card'; + +export default function StudyTab() { + return ( +
+ {/* 기존 컴포넌트들을 그대로 사용 - 100% 안전 */} + + +
+ ); +} diff --git a/src/components/study-history/study-calendar.tsx b/src/components/study-history/study-calendar.tsx new file mode 100644 index 00000000..85607b67 --- /dev/null +++ b/src/components/study-history/study-calendar.tsx @@ -0,0 +1,104 @@ +'use client'; + +import { useState } from 'react'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { cn } from '@/components/ui/(shadcn)/lib/utils'; +import { StudyHistoryItem } from '@/types/study-history'; + +export const StudyCalendar = ({ items }: { items: StudyHistoryItem[] }) => { + const [currentDate, setCurrentDate] = useState(new Date(2025, 0, 1)); + + const getDaysInMonth = (date: Date) => new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate(); + const getFirstDayOfMonth = (date: Date) => new Date(date.getFullYear(), date.getMonth(), 1).getDay(); + const formatDateKey = (year: number, month: number, day: number) => + `${year}.${String(month + 1).padStart(2, '0')}.${String(day).padStart(2, '0')}`; + + const itemsByDate = items.reduce((acc, item) => { + const match = item.date.match(/(\d{4})\.(\d{2})\.(\d{2})/); + if (match) { + const key = `${match[1]}.${match[2]}.${match[3]}`; + acc[key] = acc[key] ? [...acc[key], item] : [item]; + } + return acc; + }, {} as Record); + + const year = currentDate.getFullYear(); + const month = currentDate.getMonth(); + const daysInMonth = getDaysInMonth(currentDate); + const firstDay = getFirstDayOfMonth(currentDate); + + const monthNames = ['1월','2월','3월','4월','5월','6월','7월','8월','9월','10월','11월','12월']; + const dayNames = ['일','월','화','수','목','금','토']; + + const renderCalendarDay = (day: number) => { + const dateKey = formatDateKey(year, month, day); + const dayItems = itemsByDate[dateKey] || []; + const hasItems = dayItems.length > 0; + + return ( +
+
{day}
+ {dayItems.map((item) => ( +
+ {item.role === 'INTERVIEWER' ? '🎤' : '🙋'} {item.subject.slice(0, 8)}... +
+ ))} + {dayItems.length > 2 && ( +
+{dayItems.length - 2}개 더
+ )} +
+ ); + }; + + return ( +
+
+ +

{year}년 {monthNames[month]}

+ +
+ +
+ {dayNames.map((name, idx) => ( +
+ {name} +
+ ))} +
+ +
+ {Array.from({ length: firstDay }, (_, i) => ( +
+ ))} + {Array.from({ length: daysInMonth }, (_, i) => renderCalendarDay(i + 1))} +
+
+ ); +}; + + diff --git a/src/components/study-history/study-history-row.tsx b/src/components/study-history/study-history-row.tsx new file mode 100644 index 00000000..0bf94e9a --- /dev/null +++ b/src/components/study-history/study-history-row.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { Calendar, Mic, User, CheckCircle, Clock, ExternalLink } from 'lucide-react'; +import { StudyHistoryItem } from '@/types/study-history'; + +export const StudyHistoryRow = ({ item }: { item: StudyHistoryItem }) => { + return ( +
+ {/* 날짜 */} +
+ + {item.date} +
+ + {/* 주제 */} +
+ {item.subject} +
+ + {/* 역할 */} +
+ {item.role === 'INTERVIEWER' ? ( + + + 면접자 + + ) : ( + + + 답변자 + + )} +
+ + {/* 출석 */} +
+ {item.attendance === 'ATTENDED' ? ( + + + 출석 + + ) : ( + + + 미진행 + + )} +
+ + {/* 링크 */} +
+ {item.link ? ( + + + + ) : ( + - + )} +
+ + {/* 상태 */} +
+ {item.status === 'COMPLETED' ? ( + 완료됨 + ) : ( + 진행중 + )} +
+
+ ); +}; + + diff --git a/src/components/voting/vote-timer.tsx b/src/components/voting/vote-timer.tsx index de6f0d3c..8614567d 100644 --- a/src/components/voting/vote-timer.tsx +++ b/src/components/voting/vote-timer.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { Clock } from 'lucide-react'; +import { Clock, Timer } from 'lucide-react'; interface VoteTimerProps { endsAt?: string; @@ -50,49 +50,49 @@ export default function VoteTimer({ endsAt, isActive }: VoteTimerProps) { if (!isActive) { return ( -
- - 종료된 투표 +
+ + 종료된 투표
); } if (!endsAt) { return ( -
- - 진행 중 +
+ + 진행 중
); } if (!timeLeft) { return ( -
- - 종료 +
+ + 종료
); } return ( -
-
- - 남은 시간 -
-
- {timeLeft.days > 0 && ( - <> - {timeLeft.days}일 - : - - )} - {String(timeLeft.hours).padStart(2, '0')} - : - {String(timeLeft.minutes).padStart(2, '0')} - : - {String(timeLeft.seconds).padStart(2, '0')} +
+ +
+ 남은 시간 + + {timeLeft.days > 0 && ( + <> + {timeLeft.days}일 + : + + )} + {String(timeLeft.hours).padStart(2, '0')} + : + {String(timeLeft.minutes).padStart(2, '0')} + : + {String(timeLeft.seconds).padStart(2, '0')} +
); diff --git a/src/components/voting/voting-detail-modal.tsx b/src/components/voting/voting-detail-modal.tsx index e3100636..91e39810 100644 --- a/src/components/voting/voting-detail-modal.tsx +++ b/src/components/voting/voting-detail-modal.tsx @@ -2,6 +2,8 @@ import React, { useState } from 'react'; import { Voting } from '@/types/voting'; import { X, TrendingUp, Info, MessageCircle } from 'lucide-react'; import { cn } from '@/components/ui/(shadcn)/lib/utils'; +import UserAvatar from '@/components/ui/avatar'; +import UserProfileModal from '@/entities/user/ui/user-profile-modal'; import VoteResultsChart from './vote-results-chart'; import VoteTimer from './vote-timer'; import CommentList from '../discussion/comment-list'; @@ -62,6 +64,27 @@ export default function VotingDetailModal({ {/* 제목 */}

{voting.title}

+ {/* 작성자 정보 */} +
e.stopPropagation()}> + +
+ +
+ + {voting.author.nickname} + +
+ } + /> +
+ {/* 설명 토글 */} {voting.description && ( +
+
+ ); + } + + const hasVoted = voting.myVote !== undefined; + + return ( +
+ {/* 뒤로가기 버튼 */} + + + {/* 헤더 */} +
+ {/* 작성자 & 상태 */} +
+ {/* 작성자 정보 */} +
e.stopPropagation()}> + +
+ +
+ + {voting.author.nickname} + +
+ } + /> +
+ + {/* 타이머 표시 */} + +
+ + {/* 제목 */} +

{voting.title}

+ + {/* 설명 */} + {voting.description && ( +

+ {voting.description} +

+ )} + + {/* 태그 */} + {voting.tags.length > 0 && ( +
+ {voting.tags.map((tag) => ( + + #{tag} + + ))} +
+ )} +
+ + {/* 투표 섹션 */} +
+ {!hasVoted && voting.isActive ? ( + <> + {/* 헤더 */} +
+

투표해주세요

+ + {/* 현재 투표 참여 인원 */} +
+
+ +
+
+ 현재 참여 + + {voting.totalVotes.toLocaleString()}명 + +
+
+
+ + {/* 선택지 */} +
+ {voting.options.map((option, index) => { + const isSelected = selectedOption === option.id; + const colors = [ + { border: 'border-blue-500', bg: 'bg-blue-50', text: 'text-blue-600', ring: 'ring-blue-500', primary: 'bg-blue-500' }, + { border: 'border-green-500', bg: 'bg-green-50', text: 'text-green-600', ring: 'ring-green-500', primary: 'bg-green-500' }, + { border: 'border-purple-500', bg: 'bg-purple-50', text: 'text-purple-600', ring: 'ring-purple-500', primary: 'bg-purple-500' }, + { border: 'border-orange-500', bg: 'bg-orange-50', text: 'text-orange-600', ring: 'ring-orange-500', primary: 'bg-orange-500' }, + { border: 'border-pink-500', bg: 'bg-pink-50', text: 'text-pink-600', ring: 'ring-pink-500', primary: 'bg-pink-500' }, + ]; + const color = colors[index % colors.length]; + + return ( + + ); + })} +
+ + {/* 투표하기 버튼 */} + + + ) : ( + <> +

투표 결과

+ + + )} +
+ + {/* 일별 통계 (투표 후에만 표시) */} + {hasVoted && voting.dailyStats && voting.dailyStats.length > 0 && ( +
+ +
+ )} + + {/* 댓글 섹션 */} +
+
+ + 댓글 {voting.commentCount} +
+ + {/* 댓글 목록 (항상 표시) */} +
+ +
+ + {/* 댓글 작성 폼 */} + {voting.isActive && ( + <> + {!hasVoted ? ( +
+

+ 투표 후 댓글을 작성할 수 있습니다 +

+
+ ) : ( +
+ +
+ )} + + )} +
+
+ ); +} + diff --git a/src/mocks/voting-mock-data.ts b/src/mocks/voting-mock-data.ts index 0d265734..9542b75d 100644 --- a/src/mocks/voting-mock-data.ts +++ b/src/mocks/voting-mock-data.ts @@ -20,6 +20,7 @@ export const MOCK_VOTINGS: Voting[] = [ endsAt: '2026-01-25T23:59:59Z', isActive: true, tags: ['AI', '개발도구'], + author: { id: 1, nickname: 'AI개발자' }, }, { id: 2, @@ -39,6 +40,7 @@ export const MOCK_VOTINGS: Voting[] = [ endsAt: '2026-01-22T23:59:59Z', isActive: true, tags: ['라이프스타일', '워라밸'], + author: { id: 2, nickname: '워라밸중시' }, }, { id: 3, @@ -59,6 +61,7 @@ export const MOCK_VOTINGS: Voting[] = [ endsAt: '2026-01-21T23:59:59Z', isActive: true, tags: ['코드리뷰', '개발문화'], + author: { id: 3, nickname: '시니어개발자' }, }, { id: 4, @@ -78,6 +81,7 @@ export const MOCK_VOTINGS: Voting[] = [ endsAt: '2026-01-27T23:59:59Z', isActive: true, tags: ['프로그래밍', '언어'], + author: { id: 4, nickname: '주니어개발자' }, }, { id: 5, @@ -96,6 +100,7 @@ export const MOCK_VOTINGS: Voting[] = [ endsAt: '2026-01-26T23:59:59Z', isActive: true, tags: ['IDE', '취향'], + author: { id: 5, nickname: '프론트개발자' }, }, { id: 6, @@ -116,6 +121,7 @@ export const MOCK_VOTINGS: Voting[] = [ endsAt: '2026-01-28T23:59:59Z', isActive: true, tags: ['백엔드', 'Node.js'], + author: { id: 6, nickname: '백엔드개발자' }, }, ]; diff --git a/src/types/study-history.ts b/src/types/study-history.ts new file mode 100644 index 00000000..40ff3149 --- /dev/null +++ b/src/types/study-history.ts @@ -0,0 +1,13 @@ +'use client'; + +export interface StudyHistoryItem { + id: number; + date: string; + subject: string; + role: 'INTERVIEWER' | 'INTERVIEWEE'; + attendance: 'ATTENDED' | 'NOT_STARTED'; + link: string | null; + status: 'COMPLETED' | 'IN_PROGRESS'; +} + + diff --git a/src/types/voting.ts b/src/types/voting.ts index c7a1d487..800bbcc2 100644 --- a/src/types/voting.ts +++ b/src/types/voting.ts @@ -42,6 +42,7 @@ export interface Voting { isActive: boolean; // 진행 중인지 종료되었는지 tags: string[]; dailyStats?: DailyStatistic[]; // 일별 통계 + author: VotingAuthor; // 작성자 정보 } export interface VotingListResponse { diff --git a/src/widgets/home/home-dashboard.tsx b/src/widgets/home/home-dashboard.tsx new file mode 100644 index 00000000..727579bc --- /dev/null +++ b/src/widgets/home/home-dashboard.tsx @@ -0,0 +1,193 @@ +import Image from 'next/image'; +import Link from 'next/link'; +import { getUserProfileInServer } from '@/entities/user/api/get-user-profile.server'; +import UserAvatar from '@/components/ui/avatar'; +import StartStudyModal from '@/features/study/participation/ui/start-study-modal'; +import { getServerCookie } from '@/utils/server-cookie'; +import FeedbackLink from '@/widgets/home/feedback-link'; +import { getSincerityPresetByLevelName } from '@/config/sincerity-temp-presets'; +import { cn } from '@/components/ui/(shadcn)/lib/utils'; +import SettingIcon from 'public/icons/setting.svg'; +import AssignmentIcon from 'public/icons/assignment.svg'; +import AccessTimeIcon from 'public/icons/access_time.svg'; +import CodeIcon from 'public/icons/code.svg'; + +export default async function HomeDashboard() { + const memberIdStr = await getServerCookie('memberId'); + const memberId = Number(memberIdStr); + + const userProfile = await getUserProfileInServer(memberId); + const temperPreset = getSincerityPresetByLevelName(userProfile.sincerityTemp.levelName); + + // 데이터 가공 + const subject = userProfile?.memberInfo.preferredStudySubject?.name || '미설정'; + const timeSlots = userProfile?.memberInfo.availableStudyTimes?.map(t => t.label).join(', ') || '시간 협의 가능'; + const techStacks = userProfile?.memberProfile.techStacks?.slice(0, 3).map(t => t.techStackName).join(' · ') || '미설정'; + const extraTechCount = Math.max(0, (userProfile?.memberProfile.techStacks?.length || 0) - 3); + + return ( +
+ {/* 상단 프로필 바 */} +
+
+
+ +
+ {userProfile.sincerityTemp.temperature.toFixed(1)}℃ +
+
+ +
+
+

+ {userProfile?.memberProfile.nickname || '비회원'}님 +

+ + {userProfile.studyApplied ? '스터디 참여중' : '스터디 대기중'} + +
+

+ {userProfile?.memberProfile.simpleIntroduction || '오늘도 힘차게 공부해봐요! 🔥'} +

+
+
+ + + + 설정 + +
+ + {/* 메인 대시보드 그리드 */} +
+ {/* 1. 스터디 정보 카드 */} +
+
+
+ +
+

스터디 정보

+
+ +
+
+ 선호 주제 + {subject} +
+
+ 가능 시간 + {timeSlots} +
+
+ 기술 스택 +
+ {techStacks} + {extraTechCount > 0 && ( + +{extraTechCount} + )} +
+
+
+
+ + {/* 2. 액션 센터 */} +
+ {userProfile.studyApplied ? ( + <> +
+
+
+ +
+

오늘의 할 일

+
+ + 0/3 완료 + +
+ +
+
+
+ 참고 자료 첨부하기 +
+
+
+ 스터디 진행 상태 체크하기 +
+
+
+ 코멘트 확인하기 +
+
+ + ) : ( + <> +
+
+ icon +
+

스터디 시작

+
+ + +
+
+

CS 스터디 시작하기

+

+ 매일 아침 1:1 매칭으로 루틴 만들기 +

+
+
+ icon +
+
+ + } + /> + + )} +
+ + {/* 3. 피드백 & 퀵 액션 */} +
+
+
+ 피드백 +
+

의견 보내기

+
+ + + + {/* 추가 퀵 스탯 */} +
+
+

이번 달

+

0회

+
+
+

총 완료

+

0회

+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/widgets/home/sidebar.tsx b/src/widgets/home/sidebar.tsx index 7f4653e8..15d882a5 100644 --- a/src/widgets/home/sidebar.tsx +++ b/src/widgets/home/sidebar.tsx @@ -36,15 +36,7 @@ export default async function Sidebar() { sincerityTemp={userProfile.sincerityTemp} /> - {/* 랭킹 & 도서관 바로가기 (통합 페이지로 연결) */} -
- - - -
+ {/* 1:1 인사이트 버튼 제거됨 - 이제 홈 페이지 탭에서 접근 가능 */} {userProfile.studyApplied ? (