+ {comments.map((comment) => {
+ const isEditing = editingCommentId === comment.id;
+ const timeAgo = formatDistanceToNow(new Date(comment.createdAt), {
+ addSuffix: true,
+ locale: ko,
+ });
+
+ // VotingComment 타입인지 확인
+ const votedOption =
+ 'votedOption' in comment ? comment.votedOption : undefined;
+
+ // 투표 옵션의 색상 찾기
+ let optionColor = {
+ bg: 'bg-fill-brand-subtle-default',
+ text: 'text-text-brand',
+ icon: 'text-text-brand',
+ };
+ if (votedOption && votingOptions) {
+ const optionIndex = votingOptions.findIndex(
+ (opt) => opt.label === votedOption,
+ );
+ if (optionIndex !== -1) {
+ optionColor =
+ OPTION_BADGE_COLORS[optionIndex % OPTION_BADGE_COLORS.length];
+ }
+ }
+
+ // Author image handling
+ const authorImage =
+ 'avatar' in comment.author
+ ? comment.author.avatar
+ : 'profileImage' in comment.author
+ ? (comment.author as any).profileImage
+ : undefined;
+
+ return (
+
+
+ {/* 작성자 정보 */}
+
+
e.stopPropagation()}>
+ }
+ />
+
+
+
+
+ {comment.author.nickname}
+
+ {votedOption && (
+
+
+
+ {votedOption}
+
+
+ )}
+
+
+ {timeAgo}
+
+
+
+
+ {/* 더보기 메뉴 (본인 댓글만) */}
+ {comment.isAuthor && (
+
+
+
+ {openMenuId === comment.id && (
+ <>
+ {/* 백드롭 */}
+
setOpenMenuId(null)}
+ />
+
+ {/* 메뉴 */}
+
+
+
+
+ >
+ )}
+
+ )}
+
+
+ {/* 댓글 내용 또는 수정 폼 */}
+ {isEditing ? (
+
+
+
+ ) : (
+
+ {comment.content}
+
+ )}
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/discussion/discussion-detail-modal.tsx b/src/components/discussion/discussion-detail-modal.tsx
new file mode 100644
index 00000000..3765f1be
--- /dev/null
+++ b/src/components/discussion/discussion-detail-modal.tsx
@@ -0,0 +1,255 @@
+import { formatDistanceToNow } from 'date-fns';
+import { ko } from 'date-fns/locale';
+import {
+ X,
+ ThumbsUp,
+ ThumbsDown,
+ Eye,
+ Clock,
+ MessageCircle,
+} from 'lucide-react';
+import React, { useState } from '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 { TOPIC_LABELS } from '@/mocks/discussion-mock-data';
+import { Discussion, VoteType } from '@/types/discussion';
+import { CommentFormData } from '@/types/schemas/zod-schema';
+import CommentForm from './comment-form';
+import CommentList from './comment-list';
+
+interface DiscussionDetailModalProps {
+ discussion: Discussion;
+ onClose: () => void;
+ onVote?: (discussionId: number, voteType: VoteType) => void;
+ onAddComment?: (discussionId: number, content: string) => void;
+ onDeleteComment?: (discussionId: number, commentId: number) => void;
+ onEditComment?: (
+ discussionId: number,
+ commentId: number,
+ content: string,
+ ) => void;
+}
+
+export default function DiscussionDetailModal({
+ discussion,
+ onClose,
+ onVote,
+ onAddComment,
+ onDeleteComment,
+ onEditComment,
+}: DiscussionDetailModalProps) {
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const timeAgo = formatDistanceToNow(new Date(discussion.createdAt), {
+ addSuffix: true,
+ locale: ko,
+ });
+
+ const handleVote = (voteType: VoteType) => {
+ if (onVote) {
+ onVote(discussion.id, voteType);
+ }
+ };
+
+ const handleCommentSubmit = async (data: CommentFormData) => {
+ setIsSubmitting(true);
+ try {
+ await onAddComment?.(discussion.id, data.content);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+
+
+ {/* 헤더 */}
+
+
+ {/* 주제 배지 */}
+
+ {TOPIC_LABELS[discussion.topic]}
+
+
+ {/* 제목 */}
+
+ {discussion.title}
+
+
+ {/* 메타 정보 */}
+
+ {/* 작성자 */}
+
e.stopPropagation()}>
+
+
+
+
+
+ {discussion.author.nickname}
+
+
+ }
+ />
+
+
+ {/* 시간 */}
+
+
+ {timeAgo}
+
+
+ {/* 조회수 */}
+
+
+ {discussion.viewCount.toLocaleString()}
+
+
+
+ {/* 태그 */}
+ {discussion.tags.length > 0 && (
+
+ {discussion.tags.map((tag) => (
+
+ #{tag}
+
+ ))}
+
+ )}
+
+
+ {/* 닫기 버튼 */}
+
+
+
+ {/* 본문 + 투표 */}
+
+ {/* 본문 */}
+
+
+ {discussion.content}
+
+
+
+ {/* 투표 섹션 */}
+
+
+ 이 토론에 대한 의견은?
+
+
+ {/* 찬성 버튼 */}
+
+
+ {/* 반대 버튼 */}
+
+
+
+
+ {/* 댓글 섹션 */}
+
+
+
+ 댓글 {discussion.commentCount}
+
+
+ {/* 댓글 목록 */}
+
+
+ onDeleteComment?.(discussion.id, commentId)
+ }
+ onEdit={(commentId, content) =>
+ onEditComment?.(discussion.id, commentId, content)
+ }
+ />
+
+
+ {/* 댓글 작성 폼 */}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/discussion/filter-bar.tsx b/src/components/discussion/filter-bar.tsx
new file mode 100644
index 00000000..780d7281
--- /dev/null
+++ b/src/components/discussion/filter-bar.tsx
@@ -0,0 +1,81 @@
+import { ArrowUpDown, Filter } from 'lucide-react';
+import React from 'react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import { TOPIC_LABELS } from '@/mocks/discussion-mock-data';
+import { SortOption, DiscussionTopic } from '@/types/discussion';
+
+interface FilterBarProps {
+ sort: SortOption;
+ topic: DiscussionTopic;
+ onSortChange: (sort: SortOption) => void;
+ onTopicChange: (topic: DiscussionTopic) => void;
+}
+
+const TOPICS: DiscussionTopic[] = [
+ 'all',
+ 'development',
+ 'study',
+ 'free',
+ 'question',
+];
+
+export default function FilterBar({
+ sort,
+ topic,
+ onSortChange,
+ onTopicChange,
+}: FilterBarProps) {
+ return (
+
+ {/* 주제 필터 */}
+
+
+
+ {TOPICS.map((t) => (
+
+ ))}
+
+
+
+ {/* 정렬 옵션 */}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/discussion/search-bar.tsx b/src/components/discussion/search-bar.tsx
new file mode 100644
index 00000000..784853de
--- /dev/null
+++ b/src/components/discussion/search-bar.tsx
@@ -0,0 +1,62 @@
+import { Search, X } from 'lucide-react';
+import React, { useState, useEffect } from 'react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+
+interface SearchBarProps {
+ value: string;
+ onChange: (value: string) => void;
+ placeholder?: string;
+ className?: string;
+}
+
+export default function SearchBar({
+ value,
+ onChange,
+ placeholder = '토론 검색...',
+ className,
+}: SearchBarProps) {
+ const [localValue, setLocalValue] = useState(value);
+
+ // 디바운스를 위한 useEffect
+ useEffect(() => {
+ setLocalValue(value);
+ }, [value]);
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ onChange(localValue);
+ }, 300);
+
+ return () => clearTimeout(timer);
+ }, [localValue, onChange]);
+
+ const handleClear = () => {
+ setLocalValue('');
+ onChange('');
+ };
+
+ return (
+ ,
+ );
+
+ 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, itemIndex) => (
+
item.link && window.open(item.link, '_blank')}
+ onMouseEnter={(e) => {
+ const rect = e.currentTarget.getBoundingClientRect();
+ setActiveTooltip({ id: item.id, text: item.subject, rect });
+ }}
+ onMouseLeave={() => {
+ setActiveTooltip(null);
+ }}
+ className={cn(
+ 'rounded-50 relative mb-50 flex cursor-pointer items-center gap-50 truncate border px-100 py-50 text-[11px] font-medium transition-all hover:z-20',
+ item.attendance === 'ATTENDED'
+ ? 'bg-fill-success-subtle-default text-text-success hover:bg-fill-success-default border-transparent'
+ : 'bg-fill-warning-subtle-default text-text-warning hover:bg-fill-warning-default border-transparent',
+ )}
+ >
+ {item.attendance === 'ATTENDED' ? (
+
+ ) : (
+
+ )}
+ {item.subject}
+
+ ))}
+ {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),
+ )}
+
+
+ {/* Global Tooltip Portal (Fixed Position) */}
+ {activeTooltip && (
+
+ {/* Arrow (Bottom) */}
+
+
+ {activeTooltip.text}
+
+ )}
+
+ );
+};
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..851ccef4
--- /dev/null
+++ b/src/components/study-history/study-history-row.tsx
@@ -0,0 +1,123 @@
+'use client';
+
+import {
+ Calendar,
+ Mic,
+ User,
+ CheckCircle,
+ Clock,
+ ExternalLink,
+ CheckCircle2,
+} from 'lucide-react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import { ProfileAvatar } from '@/components/ui/profile-avatar';
+import UserProfileModal from '@/entities/user/ui/user-profile-modal';
+import { StudyHistoryItem } from '@/types/study-history';
+
+export const StudyHistoryRow = ({ item }: { item: StudyHistoryItem }) => {
+ return (
+
+ {/* 날짜 */}
+
+
+ {item.date}
+
+
+ {/* 주제 */}
+
+ {item.subject}
+
+
+ {/* 상대방 */}
+
+
e.stopPropagation()} // 행 클릭 이벤트(링크 이동) 방지
+ >
+
+
+ {item.partner.name}
+
+
+ }
+ />
+
+
+ {/* 역할 */}
+
+ {item.role === 'INTERVIEWER' ? (
+
+
+ 면접자
+
+ ) : (
+
+
+ 답변자
+
+ )}
+
+
+ {/* 역할수행여부 */}
+
+ {item.attendance === 'ATTENDED' ? (
+
+
+ 역할수행
+
+ ) : (
+
+
+ 미진행
+
+ )}
+
+
+ {/* 진행상태 */}
+
+ {item.status === 'COMPLETED' ? (
+
+
+ 완료
+
+ ) : (
+
+
+ 진행중
+
+ )}
+
+
+ {/* 링크 */}
+
+
+ );
+};
diff --git a/src/components/ui/avatar/index.tsx b/src/components/ui/avatar/index.tsx
index 49239515..f1192c46 100644
--- a/src/components/ui/avatar/index.tsx
+++ b/src/components/ui/avatar/index.tsx
@@ -25,7 +25,17 @@ export default function Avatar({
}: UserAvatarProps) {
const [isError, setIsError] = useState(false);
- const showImage = !!image && !isError;
+ // 유효하지 않은 이미지 URL 필터링 (LOCAL, 빈 문자열, 상대 경로만 있는 경우 등)
+ const isValidImage =
+ image &&
+ typeof image === 'string' &&
+ image.trim() !== '' &&
+ image.toUpperCase() !== 'LOCAL' &&
+ (image.startsWith('http://') ||
+ image.startsWith('https://') ||
+ image.startsWith('/'));
+
+ const showImage = isValidImage && !isError;
return (
{
+ const px = { sm: 32, md: 48, lg: 80, xl: 120 }[size]; // 기존 명예의 전당 사이즈와 최대한 비슷하게 매핑 (sm:32px는 기존 row에 맞춤)
+ const effectiveAlt = alt || name || 'profile';
+
+ // src 정리(공백/이상한 상대경로 방지)
+ const normalizedSrc = useMemo(() => {
+ if (!src || typeof src !== 'string') return null;
+ const s = src.trim();
+ if (!s) return null;
+ // 유효하지 않은 값 필터링 (LOCAL, null 등)
+ if (s.toUpperCase() === 'LOCAL' || s === 'null' || s === 'undefined')
+ return null;
+ // LOCAL/로 시작하는 경우 처리 (예: LOCAL/https:/picsum.photos/202)
+ if (s.toUpperCase().startsWith('LOCAL/')) {
+ const afterLocal = s.substring(6); // 'LOCAL/'.length = 6
+ // LOCAL/ 뒤에 실제 URL이 있는 경우
+ if (
+ afterLocal.startsWith('http://') ||
+ afterLocal.startsWith('https://')
+ ) {
+ return afterLocal;
+ }
+
+ // LOCAL/ 뒤에 유효하지 않은 값인 경우
+ return null;
+ }
+ if (
+ s.startsWith('http://') ||
+ s.startsWith('https://') ||
+ s.startsWith('/')
+ )
+ return s;
+
+ return `/${s}`;
+ }, [src]);
+
+ const [broken, setBroken] = useState(false);
+ const finalSrc =
+ !broken && normalizedSrc ? normalizedSrc : '/profile-default.svg';
+
+ return (
+ setBroken(true)}
+ />
+ );
+};
diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx
new file mode 100644
index 00000000..3c231840
--- /dev/null
+++ b/src/components/ui/toast.tsx
@@ -0,0 +1,52 @@
+'use client';
+
+import { CheckCircle2 } from 'lucide-react';
+import React, { useEffect } from 'react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+
+interface ToastProps {
+ message: string;
+ isVisible: boolean;
+ onClose: () => void;
+ duration?: number;
+}
+
+export default function Toast({
+ message,
+ isVisible,
+ onClose,
+ duration = 3000,
+}: ToastProps) {
+ useEffect(() => {
+ if (isVisible) {
+ const timer = setTimeout(() => {
+ onClose();
+ }, duration);
+
+ return () => clearTimeout(timer);
+ }
+ }, [isVisible, duration, onClose]);
+
+ if (!isVisible) return null;
+
+ return (
+
+
+ {message}
+
+ );
+}
diff --git a/src/components/voting/daily-stats-chart.tsx b/src/components/voting/daily-stats-chart.tsx
new file mode 100644
index 00000000..1b421946
--- /dev/null
+++ b/src/components/voting/daily-stats-chart.tsx
@@ -0,0 +1,211 @@
+import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
+import React from 'react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import { DailyStatistic, VotingOption } from '@/types/voting';
+
+interface DailyStatsChartProps {
+ dailyStats: DailyStatistic[];
+ options: VotingOption[];
+ myVote?: number;
+}
+
+const OPTION_COLORS = [
+ {
+ bg: 'bg-blue-500',
+ light: 'bg-blue-100',
+ text: 'text-blue-600',
+ hex: '#3b82f6',
+ },
+ {
+ bg: 'bg-green-500',
+ light: 'bg-green-100',
+ text: 'text-green-600',
+ hex: '#22c55e',
+ },
+ {
+ bg: 'bg-purple-500',
+ light: 'bg-purple-100',
+ text: 'text-purple-600',
+ hex: '#a855f7',
+ },
+ {
+ bg: 'bg-orange-500',
+ light: 'bg-orange-100',
+ text: 'text-orange-600',
+ hex: '#f97316',
+ },
+ {
+ bg: 'bg-pink-500',
+ light: 'bg-pink-100',
+ text: 'text-pink-600',
+ hex: '#ec4899',
+ },
+];
+
+export default function DailyStatsChart({
+ dailyStats,
+ options,
+ myVote,
+}: DailyStatsChartProps) {
+ if (!dailyStats || dailyStats.length === 0) return null;
+
+ // 내가 선택한 옵션의 추이 계산
+ const myOptionTrend =
+ myVote && dailyStats.length > 1
+ ? (dailyStats[dailyStats.length - 1].percentages[myVote] || 0) -
+ (dailyStats[0].percentages[myVote] || 0)
+ : null;
+
+ // 최대값 찾기 (차트 높이 계산용)
+ const maxPercentage = Math.max(
+ ...dailyStats.flatMap((stat) => Object.values(stat.percentages)),
+ );
+
+ return (
+
+
+ 일별 투표 추이
+
+
+ {/* 내 선택 추세 */}
+ {myVote && myOptionTrend !== null && (
+
0 && 'bg-green-50',
+ myOptionTrend < 0 && 'bg-red-50',
+ myOptionTrend === 0 && 'bg-gray-50',
+ )}
+ >
+
+ {myOptionTrend > 0 && (
+
+ )}
+ {myOptionTrend < 0 && (
+
+ )}
+ {myOptionTrend === 0 && }
+
+
+
+ 내가 선택한 의견이 어제보다{' '}
+ {myOptionTrend > 0 && (
+
+ +{myOptionTrend.toFixed(1)}%p 증가
+
+ )}
+ {myOptionTrend < 0 && (
+
+ {myOptionTrend.toFixed(1)}%p 감소
+
+ )}
+ {myOptionTrend === 0 && (
+ 변화 없음
+ )}
+
+
+ {options.find((opt) => opt.id === myVote)?.label}
+
+
+
+ )}
+
+ {/* 차트 */}
+
+
+ {/* Y축 레이블 */}
+
+ {maxPercentage.toFixed(0)}%
+ {(maxPercentage * 0.75).toFixed(0)}%
+ {(maxPercentage * 0.5).toFixed(0)}%
+ {(maxPercentage * 0.25).toFixed(0)}%
+ 0%
+
+
+ {/* 그리드 라인 */}
+
+ {[0, 25, 50, 75, 100].map((_, index) => (
+
+ ))}
+
+
+ {/* 라인 차트 */}
+
+
+
+ {/* X축 레이블 */}
+
+ {dailyStats.map((stat, index) => (
+
+ {stat.date}
+
+ ))}
+
+
+
+ {/* 범례 */}
+
+ {options.map((option, index) => {
+ const color = OPTION_COLORS[index % OPTION_COLORS.length];
+ const isMyVote = myVote === option.id;
+
+ return (
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/components/voting/vote-results-chart.tsx b/src/components/voting/vote-results-chart.tsx
new file mode 100644
index 00000000..36da2767
--- /dev/null
+++ b/src/components/voting/vote-results-chart.tsx
@@ -0,0 +1,205 @@
+import { Check, Crown, TrendingUp } from 'lucide-react';
+import React from 'react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import { VotingOption } from '@/types/voting';
+
+interface VoteResultsChartProps {
+ options: VotingOption[];
+ myVote?: number;
+ totalVotes: number;
+ showPercentage?: boolean;
+}
+
+const OPTION_COLORS = [
+ {
+ primary: 'bg-blue-500',
+ gradient: 'from-blue-500 to-blue-400',
+ light: 'bg-blue-50',
+ text: 'text-blue-600',
+ ring: 'ring-blue-500',
+ },
+ {
+ primary: 'bg-green-500',
+ gradient: 'from-green-500 to-green-400',
+ light: 'bg-green-50',
+ text: 'text-green-600',
+ ring: 'ring-green-500',
+ },
+ {
+ primary: 'bg-purple-500',
+ gradient: 'from-purple-500 to-purple-400',
+ light: 'bg-purple-50',
+ text: 'text-purple-600',
+ ring: 'ring-purple-500',
+ },
+ {
+ primary: 'bg-orange-500',
+ gradient: 'from-orange-500 to-orange-400',
+ light: 'bg-orange-50',
+ text: 'text-orange-600',
+ ring: 'ring-orange-500',
+ },
+ {
+ primary: 'bg-pink-500',
+ gradient: 'from-pink-500 to-pink-400',
+ light: 'bg-pink-50',
+ text: 'text-pink-600',
+ ring: 'ring-pink-500',
+ },
+];
+
+export default function VoteResultsChart({
+ options,
+ myVote,
+ totalVotes,
+ showPercentage = true,
+}: VoteResultsChartProps) {
+ // 퍼센트 기준으로 정렬 (높은 순)
+ const sortedOptions = [...options].sort(
+ (a, b) => b.percentage - a.percentage,
+ );
+ const topOption = sortedOptions[0];
+
+ return (
+
+ {sortedOptions.map((option, index) => {
+ const isMyVote = myVote === option.id;
+ const isTopVote = option.id === topOption.id;
+ const colorIndex = options.findIndex((opt) => opt.id === option.id);
+ const colors = OPTION_COLORS[colorIndex % OPTION_COLORS.length];
+ const rank = index + 1;
+
+ return (
+
+ {/* 배경 그라데이션 프로그레스 바 */}
+
+
+ {/* 컨텐츠 */}
+
+ {/* 왼쪽: 순위 + 옵션명 + 내 투표 표시 */}
+
+ {/* 순위 배지 */}
+
+ {isTopVote ? : rank}
+
+
+
+ {/* 옵션 라벨 */}
+
+
+ {option.label}
+
+
+ {/* 내가 투표한 항목 표시 */}
+ {isMyVote && (
+
+
+
+ 내 선택
+
+
+ )}
+
+ {/* 1위 표시 */}
+ {isTopVote && (
+
+
+
+ 1위
+
+
+ )}
+
+
+ {/* 투표 수 */}
+
+ {option.voteCount.toLocaleString()}명이 선택
+
+
+
+
+ {/* 오른쪽: 퍼센트 */}
+ {showPercentage && (
+
+
+ {option.percentage.toFixed(1)}%
+
+
+
+ )}
+
+
+ {/* 호버 효과용 빛나는 테두리 */}
+ {!isMyVote && (
+
+ )}
+
+ );
+ })}
+
+ {/* 총 투표 수 - 더 강조된 디자인 */}
+
+
+
+
+
+
+ 총 투표 참여
+
+
+ {totalVotes.toLocaleString()}명
+
+
+
+
+ );
+}
diff --git a/src/components/voting/vote-timer.tsx b/src/components/voting/vote-timer.tsx
new file mode 100644
index 00000000..1cefa2db
--- /dev/null
+++ b/src/components/voting/vote-timer.tsx
@@ -0,0 +1,99 @@
+import { Clock, Timer } from 'lucide-react';
+import React, { useEffect, useState } from 'react';
+
+interface VoteTimerProps {
+ endsAt?: string;
+ isActive: boolean;
+}
+
+interface TimeLeft {
+ days: number;
+ hours: number;
+ minutes: number;
+ seconds: number;
+}
+
+function calculateTimeLeft(endsAt: string): TimeLeft | null {
+ const difference = new Date(endsAt).getTime() - new Date().getTime();
+
+ if (difference <= 0) {
+ return null;
+ }
+
+ return {
+ days: Math.floor(difference / (1000 * 60 * 60 * 24)),
+ hours: Math.floor((difference / (1000 * 60 * 60)) % 24),
+ minutes: Math.floor((difference / 1000 / 60) % 60),
+ seconds: Math.floor((difference / 1000) % 60),
+ };
+}
+
+export default function VoteTimer({ endsAt, isActive }: VoteTimerProps) {
+ const [timeLeft, setTimeLeft] = useState(
+ endsAt ? calculateTimeLeft(endsAt) : null,
+ );
+
+ useEffect(() => {
+ if (!endsAt || !isActive) return;
+
+ const timer = setInterval(() => {
+ const newTimeLeft = calculateTimeLeft(endsAt);
+ setTimeLeft(newTimeLeft);
+
+ if (!newTimeLeft) {
+ clearInterval(timer);
+ }
+ }, 1000);
+
+ return () => clearInterval(timer);
+ }, [endsAt, isActive]);
+
+ 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')}
+
+
+
+ );
+}
diff --git a/src/components/voting/voting-create-modal.tsx b/src/components/voting/voting-create-modal.tsx
new file mode 100644
index 00000000..250e9e50
--- /dev/null
+++ b/src/components/voting/voting-create-modal.tsx
@@ -0,0 +1,392 @@
+'use client';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { X, Plus, Trash2, Loader2 } from 'lucide-react';
+import React, { useState } from 'react';
+import { useForm, useFieldArray } from 'react-hook-form';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import { Modal } from '@/components/ui/modal';
+import {
+ VotingCreateFormSchema,
+ VotingCreateFormData,
+} from '@/types/schemas/zod-schema';
+
+interface VotingCreateModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSubmit: (data: VotingCreateFormData) => Promise;
+}
+
+export default function VotingCreateModal({
+ isOpen,
+ onClose,
+ onSubmit,
+}: VotingCreateModalProps) {
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [tagInput, setTagInput] = useState('');
+
+ const {
+ register,
+ handleSubmit,
+ control,
+ watch,
+ setValue,
+ formState: { errors },
+ reset,
+ } = useForm({
+ resolver: zodResolver(VotingCreateFormSchema),
+ defaultValues: {
+ title: '',
+ description: '',
+ options: [{ label: '' }, { label: '' }],
+ tags: [],
+ endsAt: '',
+ },
+ });
+
+ const { fields, append, remove } = useFieldArray({
+ control,
+ name: 'options',
+ });
+
+ const watchedTags = watch('tags') || [];
+ const watchedTitle = watch('title') || '';
+ const watchedDescription = watch('description') || '';
+ const watchedEndsAt = watch('endsAt') || '';
+
+ // 날짜만 선택하고 시간은 23:59로 고정하는 핸들러
+ const handleDateChange = (e: React.ChangeEvent) => {
+ const selectedDate = e.target.value;
+ if (selectedDate) {
+ // 선택한 날짜의 23:59로 설정
+ const dateTimeString = `${selectedDate}T23:59`;
+ setValue('endsAt', dateTimeString);
+ } else {
+ setValue('endsAt', '');
+ }
+ };
+
+ // 날짜만 추출 (표시용)
+ const selectedDateOnly = watchedEndsAt ? watchedEndsAt.split('T')[0] : '';
+
+ // 오늘 날짜를 YYYY-MM-DD 형식으로 가져오기
+ const getTodayDateString = () => {
+ const today = new Date();
+ const year = today.getFullYear();
+ const month = String(today.getMonth() + 1).padStart(2, '0');
+ const day = String(today.getDate()).padStart(2, '0');
+
+ return `${year}-${month}-${day}`;
+ };
+
+ // 태그 추가
+ const handleAddTag = () => {
+ const trimmedTag = tagInput.trim();
+ if (
+ trimmedTag &&
+ watchedTags.length < 3 &&
+ !watchedTags.includes(trimmedTag)
+ ) {
+ setValue('tags', [...watchedTags, trimmedTag]);
+ setTagInput('');
+ }
+ };
+
+ // 태그 삭제
+ const handleRemoveTag = (tagToRemove: string) => {
+ setValue(
+ 'tags',
+ watchedTags.filter((tag) => tag !== tagToRemove),
+ );
+ };
+
+ // 폼 제출
+ const handleFormSubmit = async (data: VotingCreateFormData) => {
+ setIsSubmitting(true);
+ try {
+ // endsAt이 빈 문자열이면 undefined로 변환
+ const submitData = {
+ ...data,
+ endsAt:
+ data.endsAt && data.endsAt.trim() !== '' ? data.endsAt : undefined,
+ };
+ await onSubmit(submitData);
+ reset();
+ onClose();
+ } catch (error) {
+ console.error('투표 생성 실패:', error);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ // 모달 닫기
+ const handleClose = () => {
+ if (!isSubmitting) {
+ reset();
+ setTagInput('');
+ onClose();
+ }
+ };
+
+ return (
+ {
+ if (!nextOpen) handleClose();
+ }}
+ >
+
+
+
+
+
+ 새 투표 주제 만들기
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/voting/voting-detail-modal.tsx b/src/components/voting/voting-detail-modal.tsx
new file mode 100644
index 00000000..0c417658
--- /dev/null
+++ b/src/components/voting/voting-detail-modal.tsx
@@ -0,0 +1,228 @@
+import { X, TrendingUp, Info, MessageCircle } from 'lucide-react';
+import React, { useState } from '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 { CommentFormData } from '@/types/schemas/zod-schema';
+import { Voting } from '@/types/voting';
+import VoteResultsChart from './vote-results-chart';
+import VoteTimer from './vote-timer';
+import CommentForm from '../discussion/comment-form';
+import CommentList from '../discussion/comment-list';
+
+interface VotingDetailModalProps {
+ voting: Voting;
+ onClose: () => void;
+ onVote?: (votingId: number, optionId: number) => void;
+ onAddComment?: (votingId: number, content: string) => void;
+ onDeleteComment?: (votingId: number, commentId: number) => void;
+}
+
+export default function VotingDetailModal({
+ voting,
+ onClose,
+ onVote,
+ onAddComment,
+ onDeleteComment,
+}: VotingDetailModalProps) {
+ const [selectedOption, setSelectedOption] = useState(
+ voting.myVote,
+ );
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [showDescription, setShowDescription] = useState(false);
+
+ const hasVoted = voting.myVote !== undefined;
+
+ const handleVote = async () => {
+ if (!selectedOption || !voting.isActive) return;
+
+ setIsSubmitting(true);
+ try {
+ await onVote?.(voting.id, selectedOption);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const handleCommentSubmit = async (data: CommentFormData) => {
+ await onAddComment?.(voting.id, data.content);
+ };
+
+ return (
+
+
+ {/* 헤더 */}
+
+
+ {/* 라운드 & 상태 */}
+
+
+
+
+ {voting.round} 라운드
+
+
+
+
+
+ {/* 제목 */}
+
+ {voting.title}
+
+
+ {/* 작성자 정보 */}
+
e.stopPropagation()}>
+
+
+
+
+
+ {voting.author.nickname}
+
+
+ }
+ />
+
+
+ {/* 설명 토글 */}
+ {voting.description && (
+
+ )}
+
+ {showDescription && voting.description && (
+
+ {voting.description}
+
+ )}
+
+ {/* 태그 */}
+ {voting.tags &&
+ Array.isArray(voting.tags) &&
+ voting.tags.length > 0 && (
+
+ {voting.tags.map((tag, index) => (
+
+ #{tag}
+
+ ))}
+
+ )}
+
+
+ {/* 닫기 버튼 */}
+
+
+
+ {/* 본문 */}
+
+ {/* 투표 섹션 */}
+ {!hasVoted && voting.isActive ? (
+
+
+ 투표해주세요
+
+
+ {voting.options.map((option) => (
+
+ ))}
+
+
+
+
+ ) : (
+
+
+ 투표 결과
+
+
+
+ )}
+
+ {/* 댓글 섹션 */}
+
+
+
+ 댓글 {voting.commentCount}
+
+
+ {/* 투표 안 했으면 댓글 작성 불가 안내 */}
+ {!hasVoted && voting.isActive ? (
+
+
+ 투표 후 댓글을 작성할 수 있습니다
+
+
+ ) : (
+ <>
+ {/* 댓글 목록 */}
+
+
+ onDeleteComment?.(voting.id, commentId)
+ }
+ />
+
+
+ {/* 댓글 작성 폼 */}
+ {voting.isActive && (
+
+
+
+ )}
+ >
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/voting/voting-detail-view.tsx b/src/components/voting/voting-detail-view.tsx
new file mode 100644
index 00000000..6b19ba55
--- /dev/null
+++ b/src/components/voting/voting-detail-view.tsx
@@ -0,0 +1,632 @@
+'use client';
+
+import {
+ Loader2,
+ ArrowLeft,
+ TrendingUp,
+ MessageCircle,
+ MoreVertical,
+ Edit,
+ Trash2,
+} from 'lucide-react';
+import React, { useState, useEffect } from 'react';
+import CommentForm from '@/components/discussion/comment-form';
+import CommentList from '@/components/discussion/comment-list';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import UserAvatar from '@/components/ui/avatar';
+import Button from '@/components/ui/button';
+import { Modal } from '@/components/ui/modal';
+import DailyStatsChart from '@/components/voting/daily-stats-chart';
+import VoteResultsChart from '@/components/voting/vote-results-chart';
+import VoteTimer from '@/components/voting/vote-timer';
+import UserProfileModal from '@/entities/user/ui/user-profile-modal';
+import {
+ useVoteBalanceGameMutation,
+ useCancelVoteBalanceGameMutation,
+ useCreateBalanceGameCommentMutation,
+ useDeleteBalanceGameCommentMutation,
+ useDeleteBalanceGameMutation,
+ useUpdateBalanceGameCommentMutation,
+ useUpdateBalanceGameMutation,
+} from '@/features/study/one-to-one/balance-game/model/use-balance-game-mutation';
+import {
+ useBalanceGameDetailQuery,
+ useBalanceGameCommentsQuery,
+} from '@/features/study/one-to-one/balance-game/model/use-balance-game-query';
+import { useUserStore } from '@/stores/useUserStore';
+import { BalanceGameComment } from '@/types/balance-game';
+import {
+ CommentFormData,
+ VotingCreateFormData,
+} from '@/types/schemas/zod-schema';
+import { VotingOption } from '@/types/voting';
+import VotingEditModal from './voting-edit-modal';
+
+interface VotingDetailViewProps {
+ votingId: number;
+ onBack: () => void;
+}
+
+export default function VotingDetailView({
+ votingId,
+ onBack,
+}: VotingDetailViewProps) {
+ const [selectedOption, setSelectedOption] = useState();
+ const [editingCommentId, setEditingCommentId] = useState(null);
+ const [editingCommentContent, setEditingCommentContent] =
+ useState('');
+ const [isEditModalOpen, setIsEditModalOpen] = useState(false);
+ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+
+ // User Info
+ const memberId = useUserStore((state) => state.memberId);
+
+ // Queries
+ const {
+ data: voting,
+ isLoading,
+ error,
+ } = useBalanceGameDetailQuery(votingId);
+
+ // 투표 여부 확인 (투표를 한 경우에만 댓글 목록 가져오기)
+ const hasVoted = voting?.myVote !== undefined && voting?.myVote !== null;
+
+ const {
+ data: commentsData,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ } = useBalanceGameCommentsQuery(votingId, { enabled: !!hasVoted });
+
+ // Mutations
+ const voteMutation = useVoteBalanceGameMutation(votingId);
+ const cancelVoteMutation = useCancelVoteBalanceGameMutation(votingId);
+ const createCommentMutation = useCreateBalanceGameCommentMutation(votingId);
+ const updateCommentMutation = useUpdateBalanceGameCommentMutation(votingId);
+ const deleteCommentMutation = useDeleteBalanceGameCommentMutation(votingId);
+ const updateGameMutation = useUpdateBalanceGameMutation(votingId);
+ const deleteGameMutation = useDeleteBalanceGameMutation(votingId);
+
+ // Set selected option when voting loads
+ useEffect(() => {
+ if (voting?.myVote) {
+ setSelectedOption(voting.myVote);
+ }
+ }, [voting?.myVote]);
+
+ const comments = React.useMemo(() => {
+ const allComments =
+ commentsData?.pages.flatMap((page) => page.content) || [];
+ // 중복 제거 (key prop warning 방지)
+ const seen = new Set();
+
+ return allComments.filter((comment) => {
+ if (seen.has(comment.id)) return false;
+ seen.add(comment.id);
+
+ return true;
+ });
+ }, [commentsData]);
+
+ // isActive는 백엔드가 내려줄 수도 있고(권장), 없으면 endsAt 기준으로 프론트에서 계산
+ // VoteTimer도 endsAt으로 "종료"를 판단하므로, 두 로직이 어긋나지 않게 맞춘다.
+ const isActiveByEndsAt = React.useMemo(() => {
+ if (!voting?.endsAt) return true;
+
+ return new Date(voting.endsAt).getTime() > Date.now();
+ }, [voting?.endsAt]);
+
+ const isActive = voting?.isActive ?? isActiveByEndsAt;
+
+ const handleVote = async () => {
+ if (!selectedOption || !isActive) return;
+ try {
+ await voteMutation.mutateAsync(selectedOption);
+ } catch (error) {
+ console.error('Vote failed:', error);
+ }
+ };
+
+ const handleRevote = async () => {
+ try {
+ if (voting?.myVote) {
+ await cancelVoteMutation.mutateAsync();
+ setSelectedOption(undefined);
+ }
+ } catch (error) {
+ console.error('Cancel vote failed:', error);
+ }
+ };
+
+ const handleAddComment = async (data: CommentFormData) => {
+ if (!voting) return;
+ try {
+ await createCommentMutation.mutateAsync(data.content);
+ } catch (error) {
+ console.error('Add comment failed:', error);
+ }
+ };
+
+ const handleDeleteComment = async (commentId: number) => {
+ try {
+ await deleteCommentMutation.mutateAsync(commentId);
+ } catch (error) {
+ console.error('Delete comment failed:', error);
+ }
+ };
+
+ const handleStartEditComment = (commentId: number, content: string) => {
+ setEditingCommentId(commentId);
+ setEditingCommentContent(content);
+ };
+
+ const handleCancelEditComment = () => {
+ setEditingCommentId(null);
+ setEditingCommentContent('');
+ };
+
+ const handleUpdateComment = async (data: CommentFormData) => {
+ if (!editingCommentId) return;
+ try {
+ await updateCommentMutation.mutateAsync({
+ commentId: editingCommentId,
+ content: data.content,
+ });
+ handleCancelEditComment();
+ } catch (error) {
+ console.error('Update comment failed:', error);
+ }
+ };
+
+ const handleUpdateGame = async (data: Partial) => {
+ try {
+ await updateGameMutation.mutateAsync({
+ title: data.title,
+ description: data.description,
+ tags: data.tags || [], // tags가 undefined일 경우 빈 배열로 전달
+ });
+ setIsEditModalOpen(false);
+ } catch (error) {
+ console.error('Update game failed:', error);
+ }
+ };
+
+ const handleDeleteGame = async () => {
+ try {
+ await deleteGameMutation.mutateAsync();
+ setIsDeleteModalOpen(false);
+ onBack(); // 삭제 후 목록으로 돌아가기
+ } catch (error) {
+ console.error('Delete game failed:', error);
+ alert('투표 삭제에 실패했습니다. 다시 시도해주세요.');
+ }
+ };
+
+ // Check if current user is author
+ const isAuthor = voting?.author.id === memberId;
+
+ // 로딩 상태
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ // 에러 상태
+ if (error || !voting) {
+ return (
+
+
+
+ 데이터를 불러오는데 실패했습니다.
+
+
+
+
+ );
+ }
+
+ // Adapt BalanceGame options to VotingOption for compatibility
+ const votingOptions: VotingOption[] = voting.options.map((opt) => ({
+ ...opt,
+ }));
+
+ // 디버깅용 로그 추가
+ console.log('Voting Data:', {
+ myVote: voting.myVote,
+ hasVoted,
+ endsAt: voting.endsAt,
+ rawIsActive: voting.isActive,
+ isActiveByEndsAt,
+ isActive,
+ });
+
+ const showVoteOptions = !hasVoted && isActive;
+
+ return (
+
+ {/* 뒤로가기 버튼 */}
+
+
+ {/* 헤더 */}
+
+ {/* 작성자 & 상태 */}
+
+ {/* 작성자 정보 */}
+
e.stopPropagation()}>
+
+
+
+
+
+ {voting.author.nickname}
+
+
+ }
+ />
+
+
+ {/* 우측 컨트롤: 타이머 + 작성자 메뉴 (겹침 방지) */}
+
+
+
+ {isAuthor && (
+
+
+
+ {isMenuOpen && (
+ <>
+
setIsMenuOpen(false)}
+ />
+
+
+
+
+ >
+ )}
+
+ )}
+
+
+
+ {/* 제목 */}
+
{voting.title}
+
+ {/* 태그 - 제목 바로 아래에 표시 */}
+ {voting.tags &&
+ Array.isArray(voting.tags) &&
+ voting.tags.length > 0 && (
+
+ {voting.tags.map((tag, index) => (
+
+ #{tag}
+
+ ))}
+
+ )}
+
+ {/* 설명 */}
+ {voting.description && (
+
+ {voting.description}
+
+ )}
+
+
+ {/* 투표 섹션 */}
+
+ {showVoteOptions ? (
+ <>
+ {/* 헤더 */}
+
+
+ 투표해주세요
+
+
+ {/* 현재 투표 참여 인원 */}
+
+
+
+
+
+
+ 현재 참여
+
+
+ {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 (
+
+ );
+ })}
+
+
+ {/* 투표하기 버튼 */}
+
+ >
+ ) : (
+ <>
+
+
투표 결과
+ {isActive && hasVoted && (
+
+ )}
+
+
+ >
+ )}
+
+
+ {/* 일별 통계 (투표 후에만 표시) */}
+ {hasVoted && voting.dailyStats && voting.dailyStats.length > 0 && (
+
+
+
+ )}
+
+ {/* 댓글 섹션 */}
+
+
+
+ 댓글 {voting.commentCount || 0}
+
+
+ {/* 댓글 목록 (항상 표시) */}
+
+
+
+ {hasNextPage && (
+
+ )}
+
+
+ {/* 댓글 작성 폼 */}
+ {isActive && (
+ <>
+ {!hasVoted ? (
+
+
+ 투표 후 댓글을 작성할 수 있습니다
+
+
+ ) : editingCommentId === null ? (
+
+
+
+ ) : null}
+ >
+ )}
+
+
+ {/* 수정 모달 */}
+
setIsEditModalOpen(false)}
+ onSubmit={handleUpdateGame}
+ initialData={voting}
+ />
+
+ {/* 삭제 확인 모달 */}
+
+
+
+
+
+ 투표 주제를 삭제하시겠습니까?
+
+
+
+ 작성하신 투표 주제가 영구적으로 삭제됩니다.
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/voting/voting-edit-modal.tsx b/src/components/voting/voting-edit-modal.tsx
new file mode 100644
index 00000000..f667df5d
--- /dev/null
+++ b/src/components/voting/voting-edit-modal.tsx
@@ -0,0 +1,276 @@
+'use client';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { X, Loader2 } from 'lucide-react';
+import React, { useState, useEffect } from 'react';
+import { useForm } from 'react-hook-form';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import { BalanceGame } from '@/types/balance-game';
+import {
+ VotingEditFormSchema,
+ VotingEditFormData,
+ VotingCreateFormData,
+} from '@/types/schemas/zod-schema';
+
+interface VotingEditModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSubmit: (data: Partial) => Promise;
+ initialData: BalanceGame;
+}
+
+export default function VotingEditModal({
+ isOpen,
+ onClose,
+ onSubmit,
+ initialData,
+}: VotingEditModalProps) {
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [tagInput, setTagInput] = useState('');
+
+ // 모달이 열릴 때 배경 스크롤 방지 (스크롤바 2개 문제 해결)
+ useEffect(() => {
+ if (!isOpen) return;
+
+ const prevOverflow = document.body.style.overflow;
+ document.body.style.overflow = 'hidden';
+
+ return () => {
+ document.body.style.overflow = prevOverflow;
+ };
+ }, [isOpen]);
+
+ const {
+ register,
+ handleSubmit,
+ watch,
+ setValue,
+ formState: { errors },
+ } = useForm({
+ resolver: zodResolver(VotingEditFormSchema), // 옵션과 마감일은 수정 불가
+ defaultValues: {
+ title: initialData.title,
+ description: initialData.description || '',
+ tags: initialData.tags || [],
+ },
+ });
+
+ // Initialize form when opening
+ useEffect(() => {
+ if (isOpen) {
+ setValue('title', initialData.title);
+ setValue('description', initialData.description || '');
+ setValue('tags', initialData.tags || []);
+ }
+ }, [isOpen, initialData, setValue]);
+
+ const watchedTags = watch('tags') || [];
+ const watchedTitle = watch('title') || '';
+ const watchedDescription = watch('description') || '';
+
+ // 태그 추가
+ const handleAddTag = () => {
+ const trimmedTag = tagInput.trim();
+ if (
+ trimmedTag &&
+ watchedTags.length < 3 &&
+ !watchedTags.includes(trimmedTag)
+ ) {
+ setValue('tags', [...watchedTags, trimmedTag]);
+ setTagInput('');
+ }
+ };
+
+ // 태그 삭제
+ const handleRemoveTag = (tagToRemove: string) => {
+ setValue(
+ 'tags',
+ watchedTags.filter((tag) => tag !== tagToRemove),
+ );
+ };
+
+ // 폼 제출
+ const handleFormSubmit = async (data: VotingEditFormData) => {
+ setIsSubmitting(true);
+ try {
+ // Only pass editable fields
+ await onSubmit({
+ title: data.title,
+ description: data.description,
+ tags: data.tags || [], // tags가 undefined일 경우 빈 배열로 전달
+ });
+ onClose();
+ } catch (error) {
+ console.error('투표 수정 실패:', error);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ if (!isOpen) return null;
+
+ return (
+
+
+ {/* 헤더 */}
+
+
투표 주제 수정하기
+
+
+
+ {/* 폼 */}
+
+
+
+ );
+}
diff --git a/src/features/auth/api/test-login.ts b/src/features/auth/api/test-login.ts
new file mode 100644
index 00000000..b16ec59d
--- /dev/null
+++ b/src/features/auth/api/test-login.ts
@@ -0,0 +1,18 @@
+import { axiosInstance } from '@/api/client/axios';
+
+export interface TestLoginResponse {
+ accessToken: string;
+ memberId: string;
+ profileImageUrl?: string;
+}
+
+export const testLogin = async (
+ memberId: number,
+): Promise => {
+ const { data } = await axiosInstance.post<{ content: TestLoginResponse }>(
+ '/growth/test/login',
+ { memberId },
+ );
+
+ return data.content;
+};
diff --git a/src/features/auth/ui/login-modal.tsx b/src/features/auth/ui/login-modal.tsx
index 0c4f9722..15143752 100644
--- a/src/features/auth/ui/login-modal.tsx
+++ b/src/features/auth/ui/login-modal.tsx
@@ -3,8 +3,11 @@
import { sendGTMEvent } from '@next/third-parties/google';
import { XIcon } from 'lucide-react';
import Image from 'next/image';
+import { useRouter } from 'next/navigation';
import { ReactNode, useEffect, useState } from 'react';
+import { setCookie } from '@/api/client/cookie';
import { Modal } from '@/components/ui/modal';
+import { testLogin } from '@/features/auth/api/test-login';
import { getAttributionParams } from '@/utils/attribution-tracker';
export default function LoginModal({
@@ -14,6 +17,42 @@ export default function LoginModal({
}) {
const [state, setState] = useState(null);
const [isOpen, setIsOpen] = useState(false);
+ const router = useRouter();
+
+ // Test Login State
+ const [testMemberId, setTestMemberId] = useState('1');
+ const [isTestLoading, setIsTestLoading] = useState(false);
+ const isDev =
+ process.env.NODE_ENV === 'development' ||
+ process.env.NEXT_PUBLIC_ENABLE_TEST_LOGIN === 'true';
+
+ const handleTestLogin = async () => {
+ if (!testMemberId) return;
+
+ setIsTestLoading(true);
+ try {
+ const { accessToken, memberId, profileImageUrl } = await testLogin(
+ Number(testMemberId),
+ );
+
+ setCookie('accessToken', accessToken);
+ setCookie('memberId', memberId);
+ if (profileImageUrl) {
+ setCookie('socialImageURL', profileImageUrl);
+ }
+
+ setIsOpen(false);
+ router.push('/home');
+ router.refresh();
+
+ // alert(`테스트 로그인 성공! (ID: ${memberId})`);
+ } catch (error) {
+ console.error('Test login failed:', error);
+ alert('테스트 로그인 실패 (백엔드 실행 여부를 확인하세요)');
+ } finally {
+ setIsTestLoading(false);
+ }
+ };
useEffect(() => {
const origin = window.location.origin;
@@ -124,6 +163,37 @@ export default function LoginModal({
+
+ {/* Test Login Section (Dev Only) */}
+ {isDev && (
+
+
+
+ 🧪 개발자 테스트 로그인
+
+
+
+ setTestMemberId(e.target.value)}
+ placeholder="Member ID"
+ className="border-border-default focus:ring-fill-brand-default-default w-full max-w-[120px] rounded-md border px-3 py-1 text-sm focus:ring-2 focus:outline-none"
+ min="1"
+ />
+
+
+
+ * 로컬/개발 환경에서만 보입니다 (Member ID 입력)
+
+
+ )}
diff --git a/src/features/auth/ui/sign-up-modal.tsx b/src/features/auth/ui/sign-up-modal.tsx
index 9a9f70e7..7081e1ce 100644
--- a/src/features/auth/ui/sign-up-modal.tsx
+++ b/src/features/auth/ui/sign-up-modal.tsx
@@ -115,7 +115,6 @@ export default function SignupModal({
const memberId = content?.generatedMemberId;
const accessToken = content?.accessToken;
const refreshToken = content?.refreshToken;
-
if (memberId && accessToken && refreshToken) {
setCookie('memberId', memberId);
setCookie('accessToken', accessToken);
diff --git a/src/features/study/one-to-one/archive/api/get-archive.server.ts b/src/features/study/one-to-one/archive/api/get-archive.server.ts
new file mode 100644
index 00000000..b7fdf112
--- /dev/null
+++ b/src/features/study/one-to-one/archive/api/get-archive.server.ts
@@ -0,0 +1,13 @@
+import { axiosServerInstance } from '@/api/client/axios.server';
+import { ArchiveResponse, GetArchiveParams } from '@/types/archive';
+
+export const getArchiveServer = async (params: GetArchiveParams) => {
+ const { data } = await axiosServerInstance.get<{ content: ArchiveResponse }>(
+ '/archive',
+ {
+ params,
+ },
+ );
+
+ return data.content;
+};
diff --git a/src/features/study/one-to-one/archive/api/get-archive.ts b/src/features/study/one-to-one/archive/api/get-archive.ts
new file mode 100644
index 00000000..0c33f199
--- /dev/null
+++ b/src/features/study/one-to-one/archive/api/get-archive.ts
@@ -0,0 +1,13 @@
+import { axiosInstance } from '@/api/client/axios';
+import { ArchiveResponse, GetArchiveParams } from '@/types/archive';
+
+export const getArchive = async (params: GetArchiveParams) => {
+ const { data } = await axiosInstance.get<{ content: ArchiveResponse }>(
+ '/archive',
+ {
+ params,
+ },
+ );
+
+ return data.content;
+};
diff --git a/src/features/study/one-to-one/archive/api/record-view.ts b/src/features/study/one-to-one/archive/api/record-view.ts
new file mode 100644
index 00000000..d947cdd2
--- /dev/null
+++ b/src/features/study/one-to-one/archive/api/record-view.ts
@@ -0,0 +1,5 @@
+import { axiosInstance } from '@/api/client/axios';
+
+export const recordArchiveView = async (id: number) => {
+ await axiosInstance.post(`/archive/${id}/view`);
+};
diff --git a/src/features/study/one-to-one/archive/api/toggle-bookmark.ts b/src/features/study/one-to-one/archive/api/toggle-bookmark.ts
new file mode 100644
index 00000000..160dfbf9
--- /dev/null
+++ b/src/features/study/one-to-one/archive/api/toggle-bookmark.ts
@@ -0,0 +1,9 @@
+import { axiosInstance } from '@/api/client/axios';
+
+export const toggleArchiveBookmark = async (id: number) => {
+ const { data } = await axiosInstance.post<{
+ content: { isBookmarked: boolean };
+ }>(`/archive/${id}/bookmark`);
+
+ return data.content;
+};
diff --git a/src/features/study/one-to-one/archive/api/toggle-like.ts b/src/features/study/one-to-one/archive/api/toggle-like.ts
new file mode 100644
index 00000000..ac806d83
--- /dev/null
+++ b/src/features/study/one-to-one/archive/api/toggle-like.ts
@@ -0,0 +1,9 @@
+import { axiosInstance } from '@/api/client/axios';
+
+export const toggleArchiveLike = async (id: number) => {
+ const { data } = await axiosInstance.post<{ content: { isLiked: boolean } }>(
+ `/archive/${id}/like`,
+ );
+
+ return data.content;
+};
diff --git a/src/features/study/one-to-one/archive/const/archive.ts b/src/features/study/one-to-one/archive/const/archive.ts
new file mode 100644
index 00000000..92bae807
--- /dev/null
+++ b/src/features/study/one-to-one/archive/const/archive.ts
@@ -0,0 +1,15 @@
+export const ARCHIVE_SORT_OPTIONS = [
+ { value: 'LATEST', label: '최신순' },
+ { value: 'VIEWS', label: '조회순' },
+ { value: 'LIKES', label: '좋아요순' },
+] as const;
+
+export const ARCHIVE_VIEW_MODES = {
+ GRID: 'GRID',
+ LIST: 'LIST',
+} as const;
+
+export const ARCHIVE_PAGE_SIZE = {
+ GRID: 10,
+ LIST: 15,
+} as const;
diff --git a/src/features/study/one-to-one/archive/model/use-archive-actions.ts b/src/features/study/one-to-one/archive/model/use-archive-actions.ts
new file mode 100644
index 00000000..badc8382
--- /dev/null
+++ b/src/features/study/one-to-one/archive/model/use-archive-actions.ts
@@ -0,0 +1,42 @@
+import { useCallback } from 'react';
+import { useToggleArchiveBookmarkMutation } from '@/features/study/one-to-one/archive/model/use-bookmark-mutation';
+import { useToggleArchiveLikeMutation } from '@/features/study/one-to-one/archive/model/use-like-mutation';
+import { useRecordArchiveViewMutation } from '@/features/study/one-to-one/archive/model/use-view-mutation';
+interface ArchiveViewTarget {
+ id: number;
+ link: string;
+}
+
+export const useArchiveActions = () => {
+ const { mutate: toggleBookmark } = useToggleArchiveBookmarkMutation();
+ const { mutate: toggleLike } = useToggleArchiveLikeMutation();
+ const { mutate: recordView } = useRecordArchiveViewMutation();
+
+ const handleToggleBookmark = useCallback(
+ (id: number) => {
+ toggleBookmark(id);
+ },
+ [toggleBookmark],
+ );
+
+ const handleToggleLike = useCallback(
+ (id: number) => {
+ toggleLike(id);
+ },
+ [toggleLike],
+ );
+
+ const openAndRecordView = useCallback(
+ (target: ArchiveViewTarget) => {
+ window.open(target.link, '_blank');
+ recordView(target.id);
+ },
+ [recordView],
+ );
+
+ return {
+ toggleBookmark: handleToggleBookmark,
+ toggleLike: handleToggleLike,
+ openAndRecordView,
+ };
+};
diff --git a/src/features/study/one-to-one/archive/model/use-archive-query.ts b/src/features/study/one-to-one/archive/model/use-archive-query.ts
new file mode 100644
index 00000000..0b1d2cf9
--- /dev/null
+++ b/src/features/study/one-to-one/archive/model/use-archive-query.ts
@@ -0,0 +1,21 @@
+import { useQuery, keepPreviousData } from '@tanstack/react-query';
+import { getArchive } from '@/features/study/one-to-one/archive/api/get-archive';
+import { GetArchiveParams } from '@/types/archive';
+
+export const ARCHIVE_QUERY_KEY = {
+ all: ['archive'] as const,
+ list: (params: GetArchiveParams) =>
+ [...ARCHIVE_QUERY_KEY.all, params] as const,
+};
+
+export const useArchiveQuery = (
+ params: GetArchiveParams,
+ options?: { initialData?: Awaited> },
+) => {
+ return useQuery({
+ queryKey: ARCHIVE_QUERY_KEY.list(params),
+ queryFn: () => getArchive(params),
+ placeholderData: keepPreviousData,
+ initialData: options?.initialData,
+ });
+};
diff --git a/src/features/study/one-to-one/archive/model/use-bookmark-mutation.ts b/src/features/study/one-to-one/archive/model/use-bookmark-mutation.ts
new file mode 100644
index 00000000..32e85880
--- /dev/null
+++ b/src/features/study/one-to-one/archive/model/use-bookmark-mutation.ts
@@ -0,0 +1,56 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { toggleArchiveBookmark } from '@/features/study/one-to-one/archive/api/toggle-bookmark';
+import { ArchiveResponse } from '@/types/archive';
+import { ARCHIVE_QUERY_KEY } from './use-archive-query';
+
+export const useToggleArchiveBookmarkMutation = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: toggleArchiveBookmark,
+ onMutate: async (id) => {
+ // 진행 중인 쿼리 취소
+ await queryClient.cancelQueries({ queryKey: ARCHIVE_QUERY_KEY.all });
+
+ // 이전 데이터 스냅샷
+ const previousData = queryClient.getQueriesData({
+ queryKey: ARCHIVE_QUERY_KEY.all,
+ });
+
+ // 낙관적 업데이트
+ queryClient.setQueriesData(
+ { queryKey: ARCHIVE_QUERY_KEY.all },
+ (oldData) => {
+ if (!oldData) return oldData;
+
+ return {
+ ...oldData,
+ content: oldData.content.map((item) =>
+ item.id === id
+ ? { ...item, isBookmarked: !item.isBookmarked }
+ : item,
+ ),
+ };
+ },
+ );
+
+ return { previousData };
+ },
+ onError: (err, id, context) => {
+ // 에러 시 롤백
+ if (context?.previousData) {
+ context.previousData.forEach(([queryKey, data]) => {
+ queryClient.setQueryData(queryKey, data);
+ });
+ }
+ },
+ onSettled: () => {
+ // 완료 후 리프레시
+ queryClient
+ .invalidateQueries({ queryKey: ARCHIVE_QUERY_KEY.all })
+ .catch(() => {
+ // 쿼리 무효화 실패 시 무시
+ });
+ },
+ });
+};
diff --git a/src/features/study/one-to-one/archive/model/use-like-mutation.ts b/src/features/study/one-to-one/archive/model/use-like-mutation.ts
new file mode 100644
index 00000000..1735f0b5
--- /dev/null
+++ b/src/features/study/one-to-one/archive/model/use-like-mutation.ts
@@ -0,0 +1,60 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { toggleArchiveLike } from '@/features/study/one-to-one/archive/api/toggle-like';
+import { ArchiveResponse } from '@/types/archive';
+import { ARCHIVE_QUERY_KEY } from './use-archive-query';
+
+export const useToggleArchiveLikeMutation = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: toggleArchiveLike,
+ onMutate: async (id) => {
+ // 진행 중인 쿼리 취소
+ await queryClient.cancelQueries({ queryKey: ARCHIVE_QUERY_KEY.all });
+
+ // 이전 데이터 스냅샷
+ const previousData = queryClient.getQueriesData({
+ queryKey: ARCHIVE_QUERY_KEY.all,
+ });
+
+ // 낙관적 업데이트
+ queryClient.setQueriesData(
+ { queryKey: ARCHIVE_QUERY_KEY.all },
+ (oldData) => {
+ if (!oldData) return oldData;
+
+ return {
+ ...oldData,
+ content: oldData.content.map((item) =>
+ item.id === id
+ ? {
+ ...item,
+ isLiked: !item.isLiked,
+ likes: item.isLiked ? item.likes - 1 : item.likes + 1,
+ }
+ : item,
+ ),
+ };
+ },
+ );
+
+ return { previousData };
+ },
+ onError: (err, id, context) => {
+ // 에러 시 롤백
+ if (context?.previousData) {
+ context.previousData.forEach(([queryKey, data]) => {
+ queryClient.setQueryData(queryKey, data);
+ });
+ }
+ },
+ onSettled: () => {
+ // 완료 후 리프레시
+ queryClient
+ .invalidateQueries({ queryKey: ARCHIVE_QUERY_KEY.all })
+ .catch(() => {
+ // 쿼리 무효화 실패 시 무시
+ });
+ },
+ });
+};
diff --git a/src/features/study/one-to-one/archive/model/use-view-mutation.ts b/src/features/study/one-to-one/archive/model/use-view-mutation.ts
new file mode 100644
index 00000000..e27d5178
--- /dev/null
+++ b/src/features/study/one-to-one/archive/model/use-view-mutation.ts
@@ -0,0 +1,81 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { recordArchiveView } from '@/features/study/one-to-one/archive/api/record-view';
+import { ArchiveResponse } from '@/types/archive';
+import { ARCHIVE_QUERY_KEY } from './use-archive-query';
+
+const VIEWED_ARCHIVES_KEY = 'viewed_archives';
+
+// localStorage에서 조회한 아카이브 목록 가져오기
+const getViewedArchives = (): Record => {
+ try {
+ const stored = localStorage.getItem(VIEWED_ARCHIVES_KEY);
+
+ return stored ? JSON.parse(stored) : {};
+ } catch {
+ return {};
+ }
+};
+
+// localStorage에 조회 기록 저장
+const setViewedArchive = (id: number) => {
+ try {
+ const viewed = getViewedArchives();
+ viewed[id] = Date.now();
+ localStorage.setItem(VIEWED_ARCHIVES_KEY, JSON.stringify(viewed));
+ } catch (error) {
+ console.error('Failed to save view record:', error);
+ }
+};
+
+// 이미 조회했는지 확인
+const hasViewed = (id: number): boolean => {
+ const viewed = getViewedArchives();
+
+ return !!viewed[id];
+};
+
+export const useRecordArchiveViewMutation = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (id: number) => {
+ // 이미 조회한 아카이브인지 확인
+ if (hasViewed(id)) {
+ console.log(`Archive ${id} already viewed, skipping API call`);
+
+ return; // API 호출하지 않음
+ }
+
+ // API 호출
+ await recordArchiveView(id);
+
+ // localStorage에 기록
+ setViewedArchive(id);
+ },
+ onMutate: async (id) => {
+ // 이미 조회한 경우 낙관적 업데이트도 스킵
+ if (hasViewed(id)) {
+ return;
+ }
+
+ // 낙관적 업데이트: 조회수 즉시 +1
+ queryClient.setQueriesData(
+ { queryKey: ARCHIVE_QUERY_KEY.all },
+ (oldData) => {
+ if (!oldData) return oldData;
+
+ return {
+ ...oldData,
+ content: oldData.content.map((item) =>
+ item.id === id ? { ...item, views: item.views + 1 } : item,
+ ),
+ };
+ },
+ );
+ },
+ // 에러 발생해도 무시 (Fire-and-forget)
+ onError: () => {
+ // 조용히 실패 처리
+ },
+ });
+};
diff --git a/src/features/study/one-to-one/archive/ui/archive-filters.tsx b/src/features/study/one-to-one/archive/ui/archive-filters.tsx
new file mode 100644
index 00000000..bd82ddb4
--- /dev/null
+++ b/src/features/study/one-to-one/archive/ui/archive-filters.tsx
@@ -0,0 +1,122 @@
+import { ArrowUpDown, Bookmark, LayoutGrid, List, Search } from 'lucide-react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import {
+ ARCHIVE_SORT_OPTIONS,
+ ARCHIVE_VIEW_MODES,
+} from '@/features/study/one-to-one/archive/const/archive';
+
+interface ArchiveFiltersProps {
+ librarySort: 'LATEST' | 'VIEWS' | 'LIKES';
+ onSortChange: (sort: 'LATEST' | 'VIEWS' | 'LIKES') => void;
+ viewMode: 'GRID' | 'LIST';
+ onViewModeChange: (mode: 'GRID' | 'LIST') => void;
+ searchTerm: string;
+ onSearchChange: (value: string) => void;
+ showBookmarkedOnly: boolean;
+ onToggleBookmarkedOnly: () => void;
+}
+
+export default function ArchiveFilters({
+ librarySort,
+ onSortChange,
+ viewMode,
+ onViewModeChange,
+ searchTerm,
+ onSearchChange,
+ showBookmarkedOnly,
+ onToggleBookmarkedOnly,
+}: ArchiveFiltersProps) {
+ const sortLabel =
+ ARCHIVE_SORT_OPTIONS.find((option) => option.value === librarySort)
+ ?.label ?? '최신순';
+
+ return (
+
+
+
+
+
+
+
+
+ onSearchChange(e.target.value)}
+ className="rounded-100 border-border-subtle bg-background-default font-designer-14m focus:border-border-default focus:ring-fill-neutral-default-default h-600 w-full border pr-500 pl-200 transition-all outline-none focus:ring-2"
+ />
+
+
+
+
+
+
+
+
+
+
+
+ {ARCHIVE_SORT_OPTIONS.map((option) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/features/study/one-to-one/archive/ui/archive-grid.tsx b/src/features/study/one-to-one/archive/ui/archive-grid.tsx
new file mode 100644
index 00000000..fea248bd
--- /dev/null
+++ b/src/features/study/one-to-one/archive/ui/archive-grid.tsx
@@ -0,0 +1,141 @@
+import { Bookmark, Eye, Heart, Search } from 'lucide-react';
+import React from 'react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import { ArchiveItem } from '@/types/archive';
+
+interface ArchiveGridProps {
+ items: ArchiveItem[];
+ isAdmin: boolean;
+ onLike: (e: React.MouseEvent, id: number) => void;
+ onView: (item: ArchiveItem) => void;
+ onBookmark: (e: React.MouseEvent, id: number) => void;
+ onHide?: (e: React.MouseEvent, id: number) => void;
+}
+
+const LibraryCard = ({
+ item,
+ onLike,
+ onView,
+ onBookmark,
+ onHide,
+ isAdmin,
+}: {
+ item: ArchiveItem;
+ onLike: (e: React.MouseEvent, id: number) => void;
+ onView: (item: ArchiveItem) => void;
+ onBookmark: (e: React.MouseEvent, id: number) => void;
+ onHide?: (e: React.MouseEvent, id: number) => void;
+ isAdmin?: boolean;
+}) => {
+ const isHidden = (item as any).isHidden;
+
+ return (
+ onView(item)}
+ className={cn(
+ 'rounded-200 border-border-subtle bg-background-default shadow-1 hover:shadow-2 flex h-full cursor-pointer flex-col gap-250 border p-400 transition-all hover:-translate-y-50',
+ isHidden && 'opacity-50',
+ )}
+ >
+
+
+ {isHidden && (
+
+ 숨김됨
+
+ )}
+
+
+ {isAdmin && onHide && (
+
+ )}
+
+
+
+
+
+
+ {item.title}
+
+
+
+
+
+ by{' '}
+ {item.author}
+
+
+
+
+ {item.views.toLocaleString()}
+
+
+
+
+
+ );
+};
+
+export default function ArchiveGrid({
+ items,
+ isAdmin,
+ onLike,
+ onView,
+ onBookmark,
+ onHide,
+}: ArchiveGridProps) {
+ if (items.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {items.map((item) => (
+
+ ))}
+
+ );
+}
diff --git a/src/features/study/one-to-one/archive/ui/archive-header.tsx b/src/features/study/one-to-one/archive/ui/archive-header.tsx
new file mode 100644
index 00000000..aa5a0dfe
--- /dev/null
+++ b/src/features/study/one-to-one/archive/ui/archive-header.tsx
@@ -0,0 +1,33 @@
+import { LibraryBig } from 'lucide-react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+
+interface ArchiveHeaderProps {
+ isAdmin: boolean;
+ onToggleAdmin: () => void;
+}
+
+export default function ArchiveHeader({
+ isAdmin,
+ onToggleAdmin,
+}: ArchiveHeaderProps) {
+ return (
+
+
+ 제로원 아카이브
+
+
+
+
+
+ );
+}
diff --git a/src/features/study/one-to-one/archive/ui/archive-list.tsx b/src/features/study/one-to-one/archive/ui/archive-list.tsx
new file mode 100644
index 00000000..8d452997
--- /dev/null
+++ b/src/features/study/one-to-one/archive/ui/archive-list.tsx
@@ -0,0 +1,146 @@
+import { Bookmark, Eye, Heart, Search } from 'lucide-react';
+import React from 'react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import { ArchiveItem } from '@/types/archive';
+
+interface ArchiveListProps {
+ items: ArchiveItem[];
+ isAdmin: boolean;
+ onLike: (e: React.MouseEvent, id: number) => void;
+ onView: (item: ArchiveItem) => void;
+ onBookmark: (e: React.MouseEvent, id: number) => void;
+ onHide?: (e: React.MouseEvent, id: number) => void;
+}
+
+const LibraryRow = ({
+ item,
+ onLike,
+ onView,
+ onBookmark,
+ onHide,
+ isAdmin,
+}: {
+ item: ArchiveItem;
+ onLike: (e: React.MouseEvent, id: number) => void;
+ onView: (item: ArchiveItem) => void;
+ onBookmark: (e: React.MouseEvent, id: number) => void;
+ onHide?: (e: React.MouseEvent, id: number) => void;
+ isAdmin?: boolean;
+}) => {
+ const isHidden = (item as any).isHidden;
+
+ return (
+ onView(item)}
+ className={cn(
+ 'group border-border-subtlest hover:bg-fill-neutral-subtle-hover flex cursor-pointer items-center gap-300 border-b px-300 py-200 transition-colors last:border-0',
+ isHidden && 'opacity-50',
+ )}
+ >
+
+
+
+ {item.title}
+
+ {isHidden && (
+
+ 숨김됨
+
+ )}
+
+
+ {item.author}
+
+ {item.date}
+
+
+
+
+ {isAdmin && onHide && (
+
+ )}
+
+
+
+
+ {item.views.toLocaleString()}
+
+
+
+
+
+ );
+};
+
+export default function ArchiveList({
+ items,
+ isAdmin,
+ onLike,
+ onView,
+ onBookmark,
+ onHide,
+}: ArchiveListProps) {
+ if (items.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {items.map((item) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/features/study/one-to-one/archive/ui/archive-tab-client.tsx b/src/features/study/one-to-one/archive/ui/archive-tab-client.tsx
new file mode 100644
index 00000000..275d5841
--- /dev/null
+++ b/src/features/study/one-to-one/archive/ui/archive-tab-client.tsx
@@ -0,0 +1,175 @@
+'use client';
+
+import { Loader2, ChevronLeft, ChevronRight } from 'lucide-react';
+import React, { useState } from 'react';
+import {
+ ARCHIVE_PAGE_SIZE,
+ ARCHIVE_VIEW_MODES,
+} from '@/features/study/one-to-one/archive/const/archive';
+import { useArchiveActions } from '@/features/study/one-to-one/archive/model/use-archive-actions';
+import { useArchiveQuery } from '@/features/study/one-to-one/archive/model/use-archive-query';
+import PaginationCircleButton from '@/features/study/one-to-one/ui/pagination-circle-button';
+import { useDebounce } from '@/hooks/use-debounce'; // Assuming this hook exists, or I will create it/use raw
+import {
+ ArchiveItem,
+ ArchiveResponse,
+ GetArchiveParams,
+} from '@/types/archive';
+import ArchiveFilters from './archive-filters';
+import ArchiveGrid from './archive-grid';
+import ArchiveHeader from './archive-header';
+import ArchiveList from './archive-list';
+
+// ----------------------------------------------------------------------
+// Main Component
+// ----------------------------------------------------------------------
+
+interface ArchiveTabClientProps {
+ initialData?: ArchiveResponse;
+ initialParams: GetArchiveParams;
+}
+
+export default function ArchiveTabClient({
+ initialData,
+ initialParams,
+}: ArchiveTabClientProps) {
+ const [librarySort, setLibrarySort] = useState<'LATEST' | 'VIEWS' | 'LIKES'>(
+ 'LATEST',
+ );
+ const [viewMode, setViewMode] = useState<'GRID' | 'LIST'>(
+ ARCHIVE_VIEW_MODES.GRID,
+ );
+ const [currentPage, setCurrentPage] = useState(1);
+ const [searchTerm, setSearchTerm] = useState('');
+ const debouncedSearchTerm = useDebounce(searchTerm, 500);
+
+ // New States
+ const [showBookmarkedOnly, setShowBookmarkedOnly] = useState(false);
+ const [isAdmin, setIsAdmin] = useState(false); // Mock Admin Mode
+
+ const ITEMS_PER_PAGE =
+ viewMode === ARCHIVE_VIEW_MODES.LIST
+ ? ARCHIVE_PAGE_SIZE.LIST
+ : ARCHIVE_PAGE_SIZE.GRID;
+
+ // React Query Hook
+ const archiveParams = {
+ page: currentPage - 1,
+ size: ITEMS_PER_PAGE,
+ sort: librarySort,
+ search: debouncedSearchTerm || undefined,
+ bookmarkedOnly: showBookmarkedOnly || undefined,
+ };
+
+ const shouldUseInitialData =
+ archiveParams.page === initialParams.page &&
+ archiveParams.size === initialParams.size &&
+ archiveParams.sort === initialParams.sort &&
+ archiveParams.search === initialParams.search &&
+ archiveParams.bookmarkedOnly === initialParams.bookmarkedOnly;
+
+ const { data: archiveData, isLoading } = useArchiveQuery(archiveParams, {
+ initialData: shouldUseInitialData ? initialData : undefined,
+ });
+
+ const { toggleBookmark, toggleLike, openAndRecordView } = useArchiveActions();
+
+ const libraryItems = archiveData?.content || [];
+ const totalPages = archiveData?.totalPages || 1;
+
+ // Handler for Likes
+ const handleLike = (e: React.MouseEvent, id: number) => {
+ e.stopPropagation();
+ toggleLike(id);
+ };
+
+ const handleView = (item: ArchiveItem) => {
+ openAndRecordView(item);
+ };
+
+ const handleLibraryBookmark = (e: React.MouseEvent, id: number) => {
+ e.stopPropagation();
+ toggleBookmark(id);
+ };
+
+ const handleHide = (e: React.MouseEvent, id: number) => {
+ e.stopPropagation();
+ // TODO: Implement Hide Mutation (Admin Only)
+ console.log('Hide', id);
+ };
+
+ return (
+
+
setIsAdmin(!isAdmin)}
+ />
+
+
+ setShowBookmarkedOnly(!showBookmarkedOnly)
+ }
+ />
+
+ {isLoading ? (
+
+ ) : (
+ <>
+ {/* Library Content */}
+ {viewMode === ARCHIVE_VIEW_MODES.GRID ? (
+
+ ) : (
+
+ )}
+ >
+ )}
+
+ {/* Pagination */}
+ {totalPages > 1 && (
+
+
setCurrentPage(Math.max(1, currentPage - 1))}
+ disabled={currentPage === 1}
+ >
+
+
+
+ {currentPage} / {totalPages}
+
+
+ setCurrentPage(Math.min(totalPages, currentPage + 1))
+ }
+ disabled={currentPage === totalPages}
+ >
+
+
+
+ )}
+
+ );
+}
diff --git a/src/features/study/one-to-one/archive/ui/archive-tab.tsx b/src/features/study/one-to-one/archive/ui/archive-tab.tsx
new file mode 100644
index 00000000..ef75951d
--- /dev/null
+++ b/src/features/study/one-to-one/archive/ui/archive-tab.tsx
@@ -0,0 +1,18 @@
+import { getArchiveServer } from '@/features/study/one-to-one/archive/api/get-archive.server';
+import { ARCHIVE_PAGE_SIZE } from '@/features/study/one-to-one/archive/const/archive';
+import { GetArchiveParams } from '@/types/archive';
+import ArchiveTabClient from './archive-tab-client';
+
+export default async function ArchiveTab() {
+ const initialParams: GetArchiveParams = {
+ page: 0,
+ size: ARCHIVE_PAGE_SIZE.GRID,
+ sort: 'LATEST',
+ };
+
+ const initialData = await getArchiveServer(initialParams);
+
+ return (
+
+ );
+}
diff --git a/src/features/study/one-to-one/balance-game/api/balance-game-api.server.ts b/src/features/study/one-to-one/balance-game/api/balance-game-api.server.ts
new file mode 100644
index 00000000..99aa8793
--- /dev/null
+++ b/src/features/study/one-to-one/balance-game/api/balance-game-api.server.ts
@@ -0,0 +1,65 @@
+import { axiosServerInstance } from '@/api/client/axios.server';
+import type {
+ ApiResponse,
+ BalanceGame,
+ BalanceGameCommentListResponse,
+ BalanceGameListResponse,
+} from '@/types/balance-game';
+
+export const getBalanceGameListServer = async (params: {
+ page?: number;
+ size?: number;
+ sort?: 'latest' | 'popular';
+ status?: 'active' | 'closed';
+}): Promise => {
+ const { page = 1, size = 10, sort = 'latest', status } = params;
+
+ const response = await axiosServerInstance.get<
+ ApiResponse
+ >('/balance-games', {
+ params: {
+ page,
+ limit: size,
+ sort,
+ status,
+ },
+ });
+
+ if (response.data && 'content' in response.data) {
+ return response.data.content;
+ }
+
+ return response.data as unknown as BalanceGameListResponse;
+};
+
+export const getBalanceGameDetailServer = async (
+ gameId: number,
+): Promise => {
+ const response = await axiosServerInstance.get>(
+ `/balance-games/${gameId}`,
+ );
+
+ if (response.data && 'content' in response.data) {
+ return response.data.content;
+ }
+
+ return response.data as unknown as BalanceGame;
+};
+
+export const getBalanceGameCommentsServer = async (
+ gameId: number,
+ params: { page?: number; size?: number },
+): Promise => {
+ const { page = 0, size = 10 } = params;
+ const response = await axiosServerInstance.get<
+ ApiResponse
+ >(`/balance-games/${gameId}/comments`, {
+ params: { page, size },
+ });
+
+ if (response.data && 'content' in response.data) {
+ return response.data.content;
+ }
+
+ return response.data as unknown as BalanceGameCommentListResponse;
+};
diff --git a/src/features/study/one-to-one/balance-game/api/balance-game-api.ts b/src/features/study/one-to-one/balance-game/api/balance-game-api.ts
new file mode 100644
index 00000000..0fa9a16a
--- /dev/null
+++ b/src/features/study/one-to-one/balance-game/api/balance-game-api.ts
@@ -0,0 +1,155 @@
+import { axiosInstance } from '@/api/client/axios';
+import {
+ ApiResponse,
+ BalanceGame,
+ BalanceGameCommentListResponse,
+ BalanceGameListResponse,
+ CreateBalanceGameRequest,
+ CreateCommentRequest,
+ UpdateBalanceGameRequest,
+ UpdateCommentRequest,
+ VoteRequest,
+} from '@/types/balance-game';
+
+// 1. 밸런스 게임 목록 조회
+export const getBalanceGameList = async (params: {
+ page?: number;
+ size?: number;
+ sort?: 'latest' | 'popular';
+ status?: 'active' | 'closed';
+}): Promise => {
+ // 백엔드는 page를 1부터 시작하고 limit을 사용함
+ const { page = 1, size = 10, sort = 'latest', status } = params;
+
+ const response = await axiosInstance.get<
+ ApiResponse
+ >('/balance-games', {
+ params: {
+ page,
+ limit: size, // 백엔드는 limit 파라미터를 사용
+ sort,
+ status,
+ },
+ });
+
+ // content 필드 사용 (실제 백엔드 응답 구조 반영)
+ if (response.data && 'content' in response.data) {
+ return response.data.content;
+ }
+
+ // Fallback: 혹시 content 없이 바로 데이터가 오는 경우
+ return response.data as unknown as BalanceGameListResponse;
+};
+
+// 2. 밸런스 게임 상세 조회
+export const getBalanceGameDetail = async (
+ gameId: number,
+): Promise => {
+ const response = await axiosInstance.get>(
+ `/balance-games/${gameId}`,
+ );
+
+ if (response.data && 'content' in response.data) {
+ return response.data.content;
+ }
+
+ return response.data as unknown as BalanceGame;
+};
+
+// 3. 밸런스 게임 생성
+export const createBalanceGame = async (
+ body: CreateBalanceGameRequest,
+): Promise => {
+ const response = await axiosInstance.post>(
+ '/balance-games',
+ body,
+ );
+
+ return response.data.content;
+};
+
+// 4. 투표하기
+export const voteBalanceGame = async (
+ gameId: number,
+ body: VoteRequest,
+): Promise => {
+ await axiosInstance.post>(
+ `/balance-games/${gameId}/votes`,
+ body,
+ );
+};
+
+// 5. 투표 취소
+export const cancelVoteBalanceGame = async (gameId: number): Promise => {
+ await axiosInstance.delete>(
+ `/balance-games/${gameId}/votes`,
+ );
+};
+
+// 6. 댓글 목록 조회
+export const getBalanceGameComments = async (
+ gameId: number,
+ params: { page?: number; size?: number },
+): Promise => {
+ const { page = 0, size = 10 } = params;
+
+ const response = await axiosInstance.get<
+ ApiResponse
+ >(`/balance-games/${gameId}/comments`, {
+ params: { page, size },
+ });
+
+ if (response.data && 'content' in response.data) {
+ return response.data.content;
+ }
+
+ return response.data as unknown as BalanceGameCommentListResponse;
+};
+
+// 7. 댓글 작성
+export const createBalanceGameComment = async (
+ gameId: number,
+ body: CreateCommentRequest,
+): Promise => {
+ const { data } = await axiosInstance.post>(
+ `/balance-games/${gameId}/comments`,
+ body,
+ );
+
+ return data.content;
+};
+
+// 8. 댓글 수정
+export const updateBalanceGameComment = async (
+ gameId: number,
+ commentId: number,
+ body: UpdateCommentRequest,
+): Promise => {
+ await axiosInstance.put>(
+ `/balance-games/${gameId}/comments/${commentId}`,
+ body,
+ );
+};
+
+// 9. 댓글 삭제
+export const deleteBalanceGameComment = async (
+ gameId: number,
+ commentId: number,
+): Promise => {
+ await axiosInstance.delete>(
+ `/balance-games/${gameId}/comments/${commentId}`,
+ );
+};
+
+// 10. 밸런스 게임 수정
+export const updateBalanceGame = async (
+ gameId: number,
+ body: UpdateBalanceGameRequest,
+): Promise => {
+ await axiosInstance.put>(`/balance-games/${gameId}`, body);
+};
+
+// 11. 밸런스 게임 삭제
+export const deleteBalanceGame = async (gameId: number): Promise => {
+ await axiosInstance.delete>(`/balance-games/${gameId}`);
+};
diff --git a/src/features/study/one-to-one/balance-game/model/use-balance-game-mutation.ts b/src/features/study/one-to-one/balance-game/model/use-balance-game-mutation.ts
new file mode 100644
index 00000000..ac9a0ac6
--- /dev/null
+++ b/src/features/study/one-to-one/balance-game/model/use-balance-game-mutation.ts
@@ -0,0 +1,184 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { UpdateBalanceGameRequest } from '@/types/balance-game';
+import { BALANCE_GAME_KEYS } from './use-balance-game-query';
+import {
+ cancelVoteBalanceGame,
+ createBalanceGame,
+ createBalanceGameComment,
+ deleteBalanceGame,
+ deleteBalanceGameComment,
+ updateBalanceGame,
+ updateBalanceGameComment,
+ voteBalanceGame,
+} from '../api/balance-game-api';
+
+export const useCreateBalanceGameMutation = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: createBalanceGame,
+ onSuccess: () => {
+ queryClient
+ .invalidateQueries({ queryKey: BALANCE_GAME_KEYS.lists() })
+ .catch(() => {
+ // 쿼리 무효화 실패 시 무시
+ });
+ },
+ });
+};
+
+export const useVoteBalanceGameMutation = (gameId: number) => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (optionId: number) => voteBalanceGame(gameId, { optionId }),
+ onSuccess: () => {
+ queryClient
+ .invalidateQueries({
+ queryKey: BALANCE_GAME_KEYS.detail(gameId),
+ })
+ .catch(() => {
+ // 쿼리 무효화 실패 시 무시
+ });
+ queryClient
+ .invalidateQueries({ queryKey: BALANCE_GAME_KEYS.lists() })
+ .catch(() => {
+ // 쿼리 무효화 실패 시 무시
+ });
+ },
+ });
+};
+
+export const useCancelVoteBalanceGameMutation = (gameId: number) => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: () => cancelVoteBalanceGame(gameId),
+ onSuccess: () => {
+ queryClient
+ .invalidateQueries({
+ queryKey: BALANCE_GAME_KEYS.detail(gameId),
+ })
+ .catch(() => {
+ // 쿼리 무효화 실패 시 무시
+ });
+ queryClient
+ .invalidateQueries({ queryKey: BALANCE_GAME_KEYS.lists() })
+ .catch(() => {
+ // 쿼리 무효화 실패 시 무시
+ });
+ },
+ });
+};
+
+export const useCreateBalanceGameCommentMutation = (gameId: number) => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (content: string) =>
+ createBalanceGameComment(gameId, { content }),
+ onSuccess: () => {
+ queryClient
+ .invalidateQueries({
+ queryKey: BALANCE_GAME_KEYS.comments(gameId),
+ })
+ .catch(() => {
+ // 쿼리 무효화 실패 시 무시
+ });
+ queryClient
+ .invalidateQueries({
+ queryKey: BALANCE_GAME_KEYS.detail(gameId),
+ })
+ .catch(() => {
+ // 쿼리 무효화 실패 시 무시
+ });
+ },
+ });
+};
+
+export const useUpdateBalanceGameCommentMutation = (gameId: number) => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({
+ commentId,
+ content,
+ }: {
+ commentId: number;
+ content: string;
+ }) => updateBalanceGameComment(gameId, commentId, { content }),
+ onSuccess: () => {
+ queryClient
+ .invalidateQueries({
+ queryKey: BALANCE_GAME_KEYS.comments(gameId),
+ })
+ .catch(() => {
+ // 쿼리 무효화 실패 시 무시
+ });
+ },
+ });
+};
+
+export const useDeleteBalanceGameCommentMutation = (gameId: number) => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (commentId: number) =>
+ deleteBalanceGameComment(gameId, commentId),
+ onSuccess: () => {
+ queryClient
+ .invalidateQueries({
+ queryKey: BALANCE_GAME_KEYS.comments(gameId),
+ })
+ .catch(() => {
+ // 쿼리 무효화 실패 시 무시
+ });
+ queryClient
+ .invalidateQueries({
+ queryKey: BALANCE_GAME_KEYS.detail(gameId),
+ })
+ .catch(() => {
+ // 쿼리 무효화 실패 시 무시
+ });
+ },
+ });
+};
+
+export const useUpdateBalanceGameMutation = (gameId: number) => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (body: UpdateBalanceGameRequest) =>
+ updateBalanceGame(gameId, body),
+ onSuccess: () => {
+ queryClient
+ .invalidateQueries({
+ queryKey: BALANCE_GAME_KEYS.detail(gameId),
+ })
+ .catch(() => {
+ // 쿼리 무효화 실패 시 무시
+ });
+ queryClient
+ .invalidateQueries({ queryKey: BALANCE_GAME_KEYS.lists() })
+ .catch(() => {
+ // 쿼리 무효화 실패 시 무시
+ });
+ },
+ });
+};
+
+export const useDeleteBalanceGameMutation = (gameId: number) => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: () => deleteBalanceGame(gameId),
+ onSuccess: () => {
+ queryClient
+ .invalidateQueries({ queryKey: BALANCE_GAME_KEYS.lists() })
+ .catch(() => {
+ // 쿼리 무효화 실패 시 무시
+ });
+ queryClient.removeQueries({ queryKey: BALANCE_GAME_KEYS.detail(gameId) });
+ },
+ });
+};
diff --git a/src/features/study/one-to-one/balance-game/model/use-balance-game-query.ts b/src/features/study/one-to-one/balance-game/model/use-balance-game-query.ts
new file mode 100644
index 00000000..bf1eb080
--- /dev/null
+++ b/src/features/study/one-to-one/balance-game/model/use-balance-game-query.ts
@@ -0,0 +1,102 @@
+import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
+import {
+ getBalanceGameComments,
+ getBalanceGameDetail,
+ getBalanceGameList,
+} from '../api/balance-game-api';
+
+export const BALANCE_GAME_KEYS = {
+ all: ['balanceGames'] as const,
+ lists: () => [...BALANCE_GAME_KEYS.all, 'list'] as const,
+ list: (filters: Record) =>
+ [...BALANCE_GAME_KEYS.lists(), filters] as const,
+ details: () => [...BALANCE_GAME_KEYS.all, 'detail'] as const,
+ detail: (id: number) => [...BALANCE_GAME_KEYS.details(), id] as const,
+ comments: (id: number) =>
+ [...BALANCE_GAME_KEYS.detail(id), 'comments'] as const,
+};
+
+export const useBalanceGameListQuery = (
+ sort: 'latest' | 'popular' = 'latest',
+ status?: 'active' | 'closed',
+ options?: {
+ initialPage?: Awaited>;
+ },
+) => {
+ return useInfiniteQuery({
+ queryKey: BALANCE_GAME_KEYS.list({ sort, status }),
+ queryFn: ({ pageParam = 1 }) =>
+ getBalanceGameList({ page: pageParam, size: 10, sort, status }),
+ getNextPageParam: (lastPage) => {
+ // lastPage가 유효하고 pageable 정보가 있는지 확인
+ if (
+ !lastPage ||
+ !lastPage.pageable ||
+ typeof lastPage.totalPages !== 'number'
+ ) {
+ return undefined;
+ }
+
+ // Spring Data Page의 pageable.pageNumber는 0-based
+ // 백엔드 API는 page 파라미터를 1-based로 받지만, 내부적으로 0-based로 변환
+ // 예: 요청 page=1 → 내부 page=0 → 응답 pageable.pageNumber=0
+ // 요청 page=2 → 내부 page=1 → 응답 pageable.pageNumber=1
+ const currentPageNumber = lastPage.pageable.pageNumber; // 0-based
+ const totalPages = lastPage.totalPages;
+
+ // 다음 페이지가 존재하는지 확인 (0-based 기준)
+ // currentPageNumber는 0-based이므로, totalPages와 비교할 때는 +1 해서 비교
+ if (currentPageNumber + 1 < totalPages) {
+ // 다음 페이지를 1-based로 요청해야 하므로, 0-based + 1 + 1 = +2
+ return currentPageNumber + 2;
+ }
+
+ return undefined;
+ },
+ initialPageParam: 1, // 백엔드는 1부터 시작
+ initialData: options?.initialPage
+ ? { pages: [options.initialPage], pageParams: [1] }
+ : undefined,
+ });
+};
+
+export const useBalanceGameDetailQuery = (gameId: number) => {
+ return useQuery({
+ queryKey: BALANCE_GAME_KEYS.detail(gameId),
+ queryFn: () => getBalanceGameDetail(gameId),
+ enabled: !!gameId,
+ });
+};
+
+export const useBalanceGameCommentsQuery = (
+ gameId: number,
+ options?: { enabled?: boolean },
+) => {
+ return useInfiniteQuery({
+ queryKey: BALANCE_GAME_KEYS.comments(gameId),
+ queryFn: ({ pageParam = 0 }) =>
+ getBalanceGameComments(gameId, { page: pageParam, size: 10 }),
+ getNextPageParam: (lastPage) => {
+ // lastPage가 유효하고 pageable 정보가 있는지 확인
+ if (
+ !lastPage ||
+ !lastPage.pageable ||
+ typeof lastPage.totalPages !== 'number'
+ ) {
+ return undefined;
+ }
+
+ const currentPage = lastPage.pageable.pageNumber;
+ const totalPages = lastPage.totalPages;
+
+ // 다음 페이지가 존재하는지 확인 (현재 페이지 + 1 < 전체 페이지 수)
+ if (currentPage + 1 < totalPages) {
+ return currentPage + 1;
+ }
+
+ return undefined;
+ },
+ initialPageParam: 0,
+ enabled: !!gameId && options?.enabled !== false,
+ });
+};
diff --git a/src/features/study/one-to-one/balance-game/ui/balance-game-page.tsx b/src/features/study/one-to-one/balance-game/ui/balance-game-page.tsx
new file mode 100644
index 00000000..26b3ce1d
--- /dev/null
+++ b/src/features/study/one-to-one/balance-game/ui/balance-game-page.tsx
@@ -0,0 +1,248 @@
+'use client';
+
+import { Loader2, Vote, SearchX, Plus, ArrowUpDown } from 'lucide-react';
+import React, { useState, useEffect, useRef } from 'react';
+import VotingCard from '@/components/card/voting-card';
+import Toast from '@/components/ui/toast';
+import VotingCreateModal from '@/components/voting/voting-create-modal';
+import { useCreateBalanceGameMutation } from '@/features/study/one-to-one/balance-game/model/use-balance-game-mutation';
+import { useBalanceGameListQuery } from '@/features/study/one-to-one/balance-game/model/use-balance-game-query';
+import { CreateBalanceGameRequest } from '@/types/balance-game';
+import { VotingCreateFormData } from '@/types/schemas/zod-schema';
+import FilterPillButton from './filter-pill-button';
+
+export default function BalanceGamePage() {
+ // 상태 관리
+ const [statusFilter, setStatusFilter] = useState<'active' | 'closed' | 'all'>(
+ 'active',
+ );
+ const [sortMode, setSortMode] = useState<'latest' | 'popular'>('latest');
+ const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
+ const [showToast, setShowToast] = useState(false);
+
+ // React Query Hooks
+ const {
+ data,
+ fetchNextPage,
+ hasNextPage,
+ isFetching,
+ isFetchingNextPage,
+ status,
+ isPending,
+ error,
+ } = useBalanceGameListQuery(
+ sortMode,
+ statusFilter === 'all' ? undefined : statusFilter,
+ );
+
+ const createMutation = useCreateBalanceGameMutation();
+
+ // 무한 스크롤용 ref
+ const observerTarget = useRef(null);
+
+ // 투표 생성 핸들러
+ const handleCreateVoting = async (data: VotingCreateFormData) => {
+ try {
+ const requestBody: CreateBalanceGameRequest = {
+ title: data.title,
+ description: data.description || '',
+ options: data.options.map((opt) => opt.label),
+ endsAt:
+ data.endsAt && data.endsAt.trim() !== '' ? data.endsAt : undefined,
+ tags: data.tags || [],
+ };
+
+ await createMutation.mutateAsync(requestBody);
+ setIsCreateModalOpen(false);
+ setShowToast(true);
+ } catch (error) {
+ console.error('투표 생성 실패:', error);
+ throw error;
+ }
+ };
+
+ // 무한 스크롤 Intersection Observer
+ useEffect(() => {
+ const currentTarget = observerTarget.current;
+ if (!currentTarget) return;
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (
+ entries[0].isIntersecting &&
+ hasNextPage &&
+ !isFetchingNextPage &&
+ !isFetching
+ ) {
+ fetchNextPage().catch(() => {
+ // 무한 스크롤 실패 시 무시
+ });
+ }
+ },
+ { threshold: 0.1 },
+ );
+
+ observer.observe(currentTarget);
+
+ return () => {
+ observer.disconnect();
+ };
+ }, [hasNextPage, isFetchingNextPage, isFetching, fetchNextPage]);
+
+ // 로딩 상태 (첫 로드만)
+ if (isPending) {
+ return (
+
+ );
+ }
+
+ // 에러 상태
+ if (status === 'error') {
+ return (
+
+
+
+
+ 데이터를 불러오는데 실패했습니다.
+
+
+
+
+ );
+ }
+
+ const votings = data?.pages.flatMap((page) => page.content) || [];
+
+ return (
+ <>
+
+
+ {/* 사이드바 + 메인 컨텐츠 */}
+
+ {/* Left Sidebar */}
+
+
+ {/* Main Content */}
+
+ {/* 헤더 */}
+
+
+ 선택하고, 의견을 나눠보세요
+
+
+ 다양한 주제에 투표하고 댓글로 자유롭게 토론할 수 있습니다.
+
+
+
+ {/* 필터 + 주제 생성 버튼 */}
+
+ {/* 필터(상태) + 정렬 */}
+
+
setStatusFilter('active')}
+ >
+ 진행 중
+
+
setStatusFilter('closed')}
+ >
+ 종료됨
+
+
setStatusFilter('all')}
+ >
+ 전체
+
+
+ {/* divider */}
+
+
+
setSortMode('latest')}
+ >
+ 최신순
+
+
setSortMode('popular')}
+ >
+ 인기순
+
+
+
+ {/* 주제 생성 버튼 */}
+
+
+
+ {/* 카드 리스트 */}
+
+ {votings.map((voting) => (
+
+ ))}
+
+
+ {/* 무한 스크롤 로딩 */}
+
+ {isFetchingNextPage && (
+
+ )}
+ {!hasNextPage && votings.length > 0 && (
+
+ 더 이상 불러올 투표가 없습니다.
+
+ )}
+
+
+
+
+
+
+ {/* Toast */}
+ setShowToast(false)}
+ />
+
+ {/* 모달 */}
+ setIsCreateModalOpen(false)}
+ onSubmit={handleCreateVoting}
+ />
+ >
+ );
+}
diff --git a/src/features/study/one-to-one/balance-game/ui/community-tab-client.tsx b/src/features/study/one-to-one/balance-game/ui/community-tab-client.tsx
new file mode 100644
index 00000000..0d4b723a
--- /dev/null
+++ b/src/features/study/one-to-one/balance-game/ui/community-tab-client.tsx
@@ -0,0 +1,322 @@
+'use client';
+
+import {
+ Loader2,
+ Vote,
+ SearchX,
+ Plus,
+ MessageSquareText,
+ ArrowUpDown,
+} from 'lucide-react';
+import React, { useState, useEffect, useRef } from 'react';
+import VotingCard from '@/components/card/voting-card';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import Toast from '@/components/ui/toast';
+import VotingCreateModal from '@/components/voting/voting-create-modal';
+import VotingDetailView from '@/components/voting/voting-detail-view';
+import { useCreateBalanceGameMutation } from '@/features/study/one-to-one/balance-game/model/use-balance-game-mutation';
+import { useBalanceGameListQuery } from '@/features/study/one-to-one/balance-game/model/use-balance-game-query';
+import type {
+ BalanceGameListResponse,
+ CreateBalanceGameRequest,
+} from '@/types/balance-game';
+import { VotingCreateFormData } from '@/types/schemas/zod-schema';
+import FilterPillButton from './filter-pill-button';
+
+interface CommunityTabClientProps {
+ initialList?: BalanceGameListResponse;
+}
+
+export default function CommunityTabClient({
+ initialList,
+}: CommunityTabClientProps) {
+ // 상태 관리
+ const [statusFilter, setStatusFilter] = useState<'active' | 'closed' | 'all'>(
+ 'active',
+ );
+ const [sortMode, setSortMode] = useState<'latest' | 'popular'>('latest');
+ const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
+ const [selectedVotingId, setSelectedVotingId] = useState(null);
+ const [showToast, setShowToast] = useState(false);
+
+ // React Query Hooks
+ const shouldUseInitialList =
+ sortMode === 'latest' && statusFilter === 'active';
+ const {
+ data,
+ fetchNextPage,
+ hasNextPage,
+ isFetching,
+ isFetchingNextPage,
+ status,
+ isPending,
+ error,
+ } = useBalanceGameListQuery(
+ sortMode,
+ statusFilter === 'all' ? undefined : statusFilter,
+ {
+ initialPage: shouldUseInitialList ? initialList : undefined,
+ },
+ );
+
+ const createMutation = useCreateBalanceGameMutation();
+
+ // 무한 스크롤용 ref
+ const observerTarget = useRef(null);
+
+ // 투표 생성 핸들러
+ const handleCreateVoting = async (data: VotingCreateFormData) => {
+ try {
+ const requestBody: CreateBalanceGameRequest = {
+ title: data.title,
+ description: data.description || '',
+ options: data.options.map((opt) => opt.label),
+ endsAt:
+ data.endsAt && data.endsAt.trim() !== '' ? data.endsAt : undefined,
+ tags: data.tags || [],
+ };
+
+ await createMutation.mutateAsync(requestBody);
+ setIsCreateModalOpen(false);
+ setShowToast(true);
+ } catch (error) {
+ console.error('투표 생성 실패:', error);
+ throw error;
+ }
+ };
+
+ // 무한 스크롤 Intersection Observer
+ useEffect(() => {
+ const currentTarget = observerTarget.current;
+ if (!currentTarget) return;
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (
+ entries[0].isIntersecting &&
+ hasNextPage &&
+ !isFetchingNextPage &&
+ !isFetching
+ ) {
+ fetchNextPage().catch(() => {
+ // 무한 스크롤 실패 시 무시
+ });
+ }
+ },
+ { threshold: 0.1 },
+ );
+
+ observer.observe(currentTarget);
+
+ return () => {
+ observer.disconnect();
+ };
+ }, [hasNextPage, isFetchingNextPage, isFetching, fetchNextPage]);
+
+ // 상세 화면으로 전환
+ const handleVotingClick = (votingId: number) => {
+ setSelectedVotingId(votingId);
+ };
+
+ // 목록으로 돌아가기
+ const handleBackToList = () => {
+ setSelectedVotingId(null);
+ };
+
+ // 상세 화면이 열려있으면 상세 화면 표시
+ if (selectedVotingId) {
+ return (
+
+
+
+ );
+ }
+
+ // 로딩 상태 (첫 로드만)
+ if (isPending) {
+ return (
+
+ );
+ }
+
+ // 에러 상태
+ if (status === 'error') {
+ return (
+
+
+
+
+ 데이터를 불러오는데 실패했습니다.
+
+
+
+
+ );
+ }
+
+ const votings = data?.pages.flatMap((page) => page.content) || [];
+
+ return (
+ <>
+
+ {/* Header */}
+
+
+ 밸런스게임
+
+
+
+
+ {/* 헤더 설명 */}
+
+
+ 다양한 주제에 투표하고 댓글로 자유롭게 토론할 수 있습니다.
+
+
+
+ {/* 필터 + 주제 생성 버튼 */}
+
+ {/* 필터(상태) + 정렬 */}
+
+
setStatusFilter('active')}
+ >
+ 진행 중
+
+
setStatusFilter('closed')}
+ >
+ 종료됨
+
+
setStatusFilter('all')}
+ >
+ 전체
+
+
+ {/* divider */}
+
+
+ {/* Sort Dropdown */}
+
+
+
+ {/* Dropdown */}
+
+
+
+
+
+
+
+
+
+ {/* 주제 생성 버튼 */}
+
+
+
+ {/* 투표 목록 */}
+ {votings.length === 0 ? (
+
+
+
+
+ 투표가 없습니다
+
+
+ 곧 새로운 투표가 등록될 예정입니다
+
+
+
+ ) : (
+ <>
+
+ {votings.map((voting) => (
+ handleVotingClick(voting.id)}
+ />
+ ))}
+
+
+ {/* 무한 스크롤 트리거 */}
+
+ {isFetchingNextPage && (
+
+
+
+ 불러오는 중...
+
+
+ )}
+ {!hasNextPage && votings.length > 0 && (
+
+ 모든 투표를 불러왔습니다
+
+ )}
+
+ >
+ )}
+
+
+ {/* 주제 생성 모달 */}
+ setIsCreateModalOpen(false)}
+ onSubmit={handleCreateVoting}
+ />
+
+ {/* 토스트 */}
+ setShowToast(false)}
+ />
+ >
+ );
+}
diff --git a/src/features/study/one-to-one/balance-game/ui/community-tab.tsx b/src/features/study/one-to-one/balance-game/ui/community-tab.tsx
new file mode 100644
index 00000000..6988f14b
--- /dev/null
+++ b/src/features/study/one-to-one/balance-game/ui/community-tab.tsx
@@ -0,0 +1,13 @@
+import { getBalanceGameListServer } from '@/features/study/one-to-one/balance-game/api/balance-game-api.server';
+import CommunityTabClient from './community-tab-client';
+
+export default async function CommunityTab() {
+ const initialList = await getBalanceGameListServer({
+ page: 1,
+ size: 10,
+ sort: 'latest',
+ status: 'active',
+ });
+
+ return ;
+}
diff --git a/src/features/study/one-to-one/balance-game/ui/filter-pill-button.tsx b/src/features/study/one-to-one/balance-game/ui/filter-pill-button.tsx
new file mode 100644
index 00000000..49662159
--- /dev/null
+++ b/src/features/study/one-to-one/balance-game/ui/filter-pill-button.tsx
@@ -0,0 +1,28 @@
+import React from 'react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+
+interface FilterPillButtonProps {
+ isActive: boolean;
+ onClick: () => void;
+ children: React.ReactNode;
+}
+
+export default function FilterPillButton({
+ isActive,
+ onClick,
+ children,
+}: FilterPillButtonProps) {
+ return (
+
+ );
+}
diff --git a/src/features/study/one-to-one/balance-game/ui/voting-detail-page-client.tsx b/src/features/study/one-to-one/balance-game/ui/voting-detail-page-client.tsx
new file mode 100644
index 00000000..81469e17
--- /dev/null
+++ b/src/features/study/one-to-one/balance-game/ui/voting-detail-page-client.tsx
@@ -0,0 +1,17 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import React from 'react';
+import VotingDetailView from '@/components/voting/voting-detail-view';
+
+interface VotingDetailPageClientProps {
+ votingId: number;
+}
+
+export default function VotingDetailPageClient({
+ votingId,
+}: VotingDetailPageClientProps) {
+ const router = useRouter();
+
+ return router.back()} />;
+}
diff --git a/src/features/study/one-to-one/hall-of-fame/api/hall-of-fame-api.server.ts b/src/features/study/one-to-one/hall-of-fame/api/hall-of-fame-api.server.ts
new file mode 100644
index 00000000..bc0abed3
--- /dev/null
+++ b/src/features/study/one-to-one/hall-of-fame/api/hall-of-fame-api.server.ts
@@ -0,0 +1,13 @@
+import { axiosServerInstance } from '@/api/client/axios.server';
+import type { HallOfFameResponse, HallOfFameData } from '@/types/hall-of-fame';
+
+export const getHallOfFameServer = async (): Promise => {
+ const response =
+ await axiosServerInstance.get('/hall-of-fame');
+
+ if (response.data && response.data.content) {
+ return response.data.content;
+ }
+
+ throw new Error('Invalid API response structure');
+};
diff --git a/src/features/study/one-to-one/hall-of-fame/api/hall-of-fame-api.ts b/src/features/study/one-to-one/hall-of-fame/api/hall-of-fame-api.ts
new file mode 100644
index 00000000..6e3c2f9f
--- /dev/null
+++ b/src/features/study/one-to-one/hall-of-fame/api/hall-of-fame-api.ts
@@ -0,0 +1,22 @@
+import { axiosInstance } from '@/api/client/axios';
+import type { HallOfFameResponse, HallOfFameData } from '@/types/hall-of-fame';
+
+/**
+ * 명예의 전당 정보 조회
+ * GET /api/v1/hall-of-fame
+ */
+export const getHallOfFame = async (): Promise => {
+ try {
+ const response =
+ await axiosInstance.get('/hall-of-fame');
+
+ if (response.data && response.data.content) {
+ return response.data.content;
+ }
+
+ throw new Error('Invalid API response structure');
+ } catch (error) {
+ console.error('Failed to fetch hall of fame data:', error);
+ throw error;
+ }
+};
diff --git a/src/features/study/one-to-one/hall-of-fame/model/use-hall-of-fame-query.ts b/src/features/study/one-to-one/hall-of-fame/model/use-hall-of-fame-query.ts
new file mode 100644
index 00000000..25bb410c
--- /dev/null
+++ b/src/features/study/one-to-one/hall-of-fame/model/use-hall-of-fame-query.ts
@@ -0,0 +1,22 @@
+import { useQuery } from '@tanstack/react-query';
+import type { HallOfFameData } from '@/types/hall-of-fame';
+import { getHallOfFame } from '../api/hall-of-fame-api';
+
+export const HALL_OF_FAME_KEYS = {
+ all: ['hallOfFame'] as const,
+ detail: () => [...HALL_OF_FAME_KEYS.all, 'detail'] as const,
+} as const;
+
+/**
+ * 명예의 전당 정보 조회 훅
+ */
+export const useHallOfFameQuery = (options?: {
+ initialData?: HallOfFameData;
+}) => {
+ return useQuery({
+ queryKey: HALL_OF_FAME_KEYS.detail(),
+ queryFn: getHallOfFame,
+ staleTime: 1000 * 60 * 5, // 5분간 캐시 유지
+ initialData: options?.initialData,
+ });
+};
diff --git a/src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-tab-client.tsx b/src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-tab-client.tsx
new file mode 100644
index 00000000..63354486
--- /dev/null
+++ b/src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-tab-client.tsx
@@ -0,0 +1,393 @@
+'use client';
+
+import {
+ Trophy,
+ Flame,
+ Crown,
+ Users,
+ FileText,
+ Thermometer,
+} from 'lucide-react';
+import Image from 'next/image';
+import React, { useState, useMemo } from 'react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import { ProfileAvatar } from '@/components/ui/profile-avatar';
+import UserProfileModal from '@/entities/user/ui/user-profile-modal';
+import { useHallOfFameQuery } from '@/features/study/one-to-one/hall-of-fame/model/use-hall-of-fame-query';
+import type { HallOfFameData, Ranker, MVPTeam } from '@/types/hall-of-fame';
+import RankingTabButton from './ranking-tab-button';
+
+// ----------------------------------------------------------------------
+// Types & Constants
+// ----------------------------------------------------------------------
+
+type RankingType = 'ATTENDANCE' | 'STUDY_LOG' | 'SINCERITY';
+
+interface RankerWithLabel extends Ranker {
+ scoreLabel: string;
+}
+
+const TAB_CONFIG: Record<
+ RankingType,
+ { label: string; icon: React.ReactNode; unit: string; colorClass: string }
+> = {
+ ATTENDANCE: {
+ label: '불꽃 출석왕',
+ icon: ,
+ unit: '회',
+ colorClass: 'text-text-brand',
+ },
+ STUDY_LOG: {
+ label: '열정 기록왕',
+ icon: ,
+ unit: '건',
+ colorClass: 'text-text-information',
+ },
+ SINCERITY: {
+ label: '성실 온도왕',
+ icon: ,
+ unit: '℃',
+ colorClass: 'text-text-warning',
+ },
+};
+
+/**
+ * 랭커 데이터에 scoreLabel 추가
+ */
+const addScoreLabel = (ranker: Ranker, type: RankingType): RankerWithLabel => {
+ let scoreLabel = '';
+
+ if (type === 'ATTENDANCE') {
+ scoreLabel = `${ranker.score}회`;
+ } else if (type === 'STUDY_LOG') {
+ scoreLabel = `${ranker.score}건`;
+ } else {
+ // SINCERITY
+ scoreLabel = `${ranker.score}℃`;
+ }
+
+ return {
+ ...ranker,
+ scoreLabel,
+ };
+};
+
+// ----------------------------------------------------------------------
+// Components
+// ----------------------------------------------------------------------
+
+const RankBadge = ({ rank }: { rank: number }) => {
+ const iconPath =
+ rank === 1
+ ? '/icons/gold-rank.svg'
+ : rank === 2
+ ? '/icons/silver-rank.svg'
+ : '/icons/bronze-rank.svg';
+
+ if (rank > 3)
+ return (
+
+ {rank}
+
+ );
+
+ return (
+
+
+
+ );
+};
+
+const MVPTeamCard = ({
+ team,
+ className,
+}: {
+ team: MVPTeam;
+ className?: string;
+}) => {
+ return (
+
+
+
+
+
+
+
+
+ {team.weekDate} MVP 팀
+
+
+ 최고의 스터디 메이트
+
+
+
+
+ {team.members.map((member, index) => (
+
+
+
+
+
+
+ {member.nickname}
+
+
+
+ }
+ />
+
+ {index === 0 && (
+
+ &
+
+ )}
+
+ ))}
+
+
+
+
+
+
+ 이번 주 공유한 자료
+
+
+
+
+
+
+ );
+};
+
+const RankerListItem = ({ ranker }: { ranker: RankerWithLabel }) => {
+ return (
+
+
+
+
+
+
+
+
+
+
+ {ranker.nickname}
+
+ {ranker.rank === 1 && (
+
+ )}
+
+
+ {ranker.jobs && ranker.jobs.length > 0
+ ? ranker.jobs
+ .map((job) => job.description || job.job || '')
+ .filter(Boolean)
+ .join(', ')
+ : ranker.major}
+
+
+
+
+
+ {ranker.scoreLabel}
+
+
+
+ }
+ />
+ );
+};
+
+// ----------------------------------------------------------------------
+// Main Component
+// ----------------------------------------------------------------------
+
+interface HallOfFameTabClientProps {
+ initialData?: HallOfFameData;
+}
+
+export default function HallOfFameTabClient({
+ initialData,
+}: HallOfFameTabClientProps) {
+ const [rankingType, setRankingType] = useState('ATTENDANCE');
+ const { data, isLoading, error } = useHallOfFameQuery({ initialData });
+
+ // 랭킹 데이터 변환 및 scoreLabel 추가
+ const allRankers = useMemo(() => {
+ if (!data) {
+ return {
+ ATTENDANCE: [] as RankerWithLabel[],
+ STUDY_LOG: [] as RankerWithLabel[],
+ SINCERITY: [] as RankerWithLabel[],
+ };
+ }
+
+ return {
+ ATTENDANCE: data.rankings.attendanceRankings.map((r) =>
+ addScoreLabel(r, 'ATTENDANCE'),
+ ),
+ STUDY_LOG: data.rankings.studyLogRankings.map((r) =>
+ addScoreLabel(r, 'STUDY_LOG'),
+ ),
+ SINCERITY: data.rankings.sincerityRankings.map((r) =>
+ addScoreLabel(r, 'SINCERITY'),
+ ),
+ };
+ }, [data]);
+
+ const currentRankers = allRankers[rankingType];
+ const baseDate = data?.rankings.baseDate
+ ? new Date(data.rankings.baseDate).toLocaleDateString('ko-KR')
+ : new Date().toLocaleDateString('ko-KR');
+
+ if (isLoading) {
+ return (
+
+
+
+
+ 명예의 전당을 불러오고 있습니다...
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+ 명예의 전당 정보를 불러오는 중 오류가 발생했습니다.
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+ 명예의 전당
+
+
+
+
제로원을 빛낸 열정적인 멤버들과 최고의 유저들을 소개합니다.
+
+ 꾸준한 1:1 스터디를 통해 제로원 명예의 전당에 이름을 올려보세요!
+
+
+
+
+
+ {/* Section 1: Top 5 Rankers */}
+
+
+
+
+
+ {TAB_CONFIG[rankingType].icon}
+
+ {TAB_CONFIG[rankingType].label} TOP 5
+
+
+ {baseDate} 기준
+
+
+
+
+ {(Object.keys(TAB_CONFIG) as RankingType[]).map((type) => (
+ setRankingType(type)}
+ >
+
+ {TAB_CONFIG[type].icon}
+
+ {TAB_CONFIG[type].label}
+
+ ))}
+
+
+
+
+ {currentRankers.length > 0 ? (
+ currentRankers.map((ranker) => (
+
+ ))
+ ) : (
+
+ )}
+
+
+
+ {/* Section 2: MVP Team */}
+
+
+
+ 저번 주 스터디 MVP 팀
+
+
+ {data?.mvpTeam ? (
+
+ ) : (
+
+
+ 이번 주 MVP 팀이 없습니다.
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-tab.tsx b/src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-tab.tsx
new file mode 100644
index 00000000..792724ae
--- /dev/null
+++ b/src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-tab.tsx
@@ -0,0 +1,8 @@
+import { getHallOfFameServer } from '@/features/study/one-to-one/hall-of-fame/api/hall-of-fame-api.server';
+import HallOfFameTabClient from './hall-of-fame-tab-client';
+
+export default async function HallOfFameTab() {
+ const initialData = await getHallOfFameServer();
+
+ return ;
+}
diff --git a/src/features/study/one-to-one/hall-of-fame/ui/ranking-tab-button.tsx b/src/features/study/one-to-one/hall-of-fame/ui/ranking-tab-button.tsx
new file mode 100644
index 00000000..f382d7cd
--- /dev/null
+++ b/src/features/study/one-to-one/hall-of-fame/ui/ranking-tab-button.tsx
@@ -0,0 +1,28 @@
+import React from 'react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+
+interface RankingTabButtonProps {
+ isActive: boolean;
+ onClick: () => void;
+ children: React.ReactNode;
+}
+
+export default function RankingTabButton({
+ isActive,
+ onClick,
+ children,
+}: RankingTabButtonProps) {
+ return (
+
+ );
+}
diff --git a/src/features/study/one-to-one/history/api/get-my-study-history.server.ts b/src/features/study/one-to-one/history/api/get-my-study-history.server.ts
new file mode 100644
index 00000000..c145a734
--- /dev/null
+++ b/src/features/study/one-to-one/history/api/get-my-study-history.server.ts
@@ -0,0 +1,16 @@
+import { axiosServerInstance } from '@/api/client/axios.server';
+import type {
+ PageableResponse,
+ StudyHistoryContent,
+} from '@/types/study-history';
+import type { GetMyStudyHistoryParams } from './get-my-study-history';
+
+export const getMyStudyHistoryServer = async (
+ params: GetMyStudyHistoryParams,
+) => {
+ const { data } = await axiosServerInstance.get<
+ PageableResponse
+ >('study/daily/history', { params });
+
+ return data;
+};
diff --git a/src/features/study/one-to-one/history/api/get-my-study-history.ts b/src/features/study/one-to-one/history/api/get-my-study-history.ts
new file mode 100644
index 00000000..97ae5539
--- /dev/null
+++ b/src/features/study/one-to-one/history/api/get-my-study-history.ts
@@ -0,0 +1,18 @@
+import { axiosInstance } from '@/api/client/axios';
+import { PageableResponse, StudyHistoryContent } from '@/types/study-history';
+
+export interface GetMyStudyHistoryParams {
+ page?: number;
+ size?: number;
+ startDate?: string;
+ endDate?: string;
+ sort?: string;
+}
+
+export const getMyStudyHistory = async (params: GetMyStudyHistoryParams) => {
+ const { data } = await axiosInstance.get<
+ PageableResponse
+ >('study/daily/history', { params });
+
+ return data;
+};
diff --git a/src/features/study/one-to-one/history/model/use-my-study-history-query.ts b/src/features/study/one-to-one/history/model/use-my-study-history-query.ts
new file mode 100644
index 00000000..1190080c
--- /dev/null
+++ b/src/features/study/one-to-one/history/model/use-my-study-history-query.ts
@@ -0,0 +1,23 @@
+import { useQuery } from '@tanstack/react-query';
+import {
+ getMyStudyHistory,
+ GetMyStudyHistoryParams,
+} from '@/features/study/one-to-one/history/api/get-my-study-history';
+
+export const STUDY_HISTORY_QUERY_KEY = {
+ all: ['myStudyHistory'] as const,
+ list: (params: GetMyStudyHistoryParams) =>
+ [...STUDY_HISTORY_QUERY_KEY.all, params] as const,
+};
+
+export const useMyStudyHistoryQuery = (
+ params: GetMyStudyHistoryParams,
+ options?: { initialData?: Awaited> },
+) => {
+ return useQuery({
+ queryKey: STUDY_HISTORY_QUERY_KEY.list(params),
+ queryFn: () => getMyStudyHistory(params),
+ select: (data) => data.content, // API 응답에서 content 부분만 추출해서 사용하기 편하게 함
+ initialData: options?.initialData,
+ });
+};
diff --git a/src/features/study/one-to-one/history/ui/study-history-tab-client.tsx b/src/features/study/one-to-one/history/ui/study-history-tab-client.tsx
new file mode 100644
index 00000000..77fbf653
--- /dev/null
+++ b/src/features/study/one-to-one/history/ui/study-history-tab-client.tsx
@@ -0,0 +1,206 @@
+'use client';
+
+import { History, List, Calendar as CalendarIcon, Loader2 } from 'lucide-react';
+import { useState } from 'react';
+import { StudyCalendar } from '@/components/study-history/study-calendar';
+import { StudyHistoryRow } from '@/components/study-history/study-history-row';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import { GetMyStudyHistoryParams } from '@/features/study/one-to-one/history/api/get-my-study-history';
+import { useMyStudyHistoryQuery } from '@/features/study/one-to-one/history/model/use-my-study-history-query';
+import PaginationCircleButton from '@/features/study/one-to-one/ui/pagination-circle-button';
+import {
+ PageableResponse,
+ StudyHistoryItem,
+ StudyHistoryContent,
+} from '@/types/study-history';
+
+// 데이터 매핑 함수 (API Response -> UI Model)
+const mapHistoryItem = (data: StudyHistoryContent): StudyHistoryItem => {
+ const dateObj = new Date(data.scheduledAt);
+ const dateStr = `${dateObj.getFullYear()}.${String(dateObj.getMonth() + 1).padStart(2, '0')}.${String(dateObj.getDate()).padStart(2, '0')}`;
+ const dayName = ['일', '월', '화', '수', '목', '금', '토'][dateObj.getDay()];
+
+ return {
+ id: data.studyId,
+ date: `${dateStr} (${dayName})`,
+ subject: data.title,
+ role: data.participation.role,
+ attendance:
+ data.participation.attendance === 'PRESENT' ? 'ATTENDED' : 'NOT_STARTED',
+ link: data.studyLink,
+ status: data.status === 'COMPLETE' ? 'COMPLETED' : 'IN_PROGRESS',
+ partner: {
+ id: data.partner.memberId,
+ name: data.partner.nickname,
+ profileImage: data.partner.profileImageUrl,
+ },
+ };
+};
+
+interface StudyHistoryTabClientProps {
+ initialData?: PageableResponse;
+ initialParams: GetMyStudyHistoryParams;
+}
+
+export default function StudyHistoryTabClient({
+ initialData,
+ initialParams,
+}: StudyHistoryTabClientProps) {
+ const [viewMode, setViewMode] = useState<'LIST' | 'CALENDAR'>('LIST');
+ const [currentPage, setCurrentPage] = useState(1);
+ const [currentCalendarDate, setCurrentCalendarDate] = useState(new Date());
+ const ITEMS_PER_PAGE = 15;
+
+ // API 파라미터 동적 생성
+ const getQueryParams = () => {
+ if (viewMode === 'CALENDAR') {
+ const year = currentCalendarDate.getFullYear();
+ const month = currentCalendarDate.getMonth() + 1;
+ const lastDay = new Date(year, month, 0).getDate(); // 해당 월의 마지막 날짜
+
+ return {
+ page: 0,
+ size: 100, // 한 달치 데이터를 충분히 가져오기 위해 크게 설정
+ startDate: `${year}-${String(month).padStart(2, '0')}-01`,
+ endDate: `${year}-${String(month).padStart(2, '0')}-${lastDay}`,
+ sort: 'createdAt,desc', // 요청대로 createdAt 사용 (단, 캘린더 뷰라면 scheduledAt이 더 적절할 수 있음)
+ };
+ }
+
+ return {
+ page: currentPage - 1,
+ size: ITEMS_PER_PAGE,
+ sort: 'createdAt,desc',
+ };
+ };
+
+ const queryParams = getQueryParams();
+ const shouldUseInitialData =
+ queryParams.page === initialParams.page &&
+ queryParams.size === initialParams.size &&
+ queryParams.startDate === initialParams.startDate &&
+ queryParams.endDate === initialParams.endDate &&
+ queryParams.sort === initialParams.sort;
+
+ const { data: historyData, isLoading } = useMyStudyHistoryQuery(queryParams, {
+ initialData: shouldUseInitialData ? initialData : undefined,
+ });
+
+ // 데이터 변환
+ const historyItems = historyData?.content?.map(mapHistoryItem) || [];
+ const totalPages = historyData?.totalPages || 1;
+ const totalElements = historyData?.totalElements || 0;
+
+ if (isLoading) {
+ return (
+
+
+
+ 1:1 스터디 기록을 불러오는 중입니다...
+
+
+ );
+ }
+
+ return (
+
+
+
+ 나의 1:1 스터디 기록
+
+
+
+
+
+
+
+
+
+
+
+ 총 {totalElements}
+ 개의 1:1 스터디 기록이 있습니다.
+
+
+ {viewMode === 'LIST' ? (
+
+
+
날짜
+
오늘의 주제
+
상대방
+
내 역할
+
역할수행여부
+
진행상태
+
링크
+
+
+
+ {historyItems.length > 0 ? (
+ historyItems.map((item) => (
+
+ ))
+ ) : (
+
+
+
+ 아직 1:1 스터디 기록이 없습니다.
+
+
+ )}
+
+
+ ) : (
+
+ )}
+
+ {viewMode === 'LIST' && totalPages > 1 && (
+
+
setCurrentPage(Math.max(1, currentPage - 1))}
+ disabled={currentPage === 1}
+ >
+ ←
+
+
+ {currentPage} / {totalPages}
+
+
+ setCurrentPage(Math.min(totalPages, currentPage + 1))
+ }
+ disabled={currentPage === totalPages}
+ >
+ →
+
+
+ )}
+
+
+ );
+}
diff --git a/src/features/study/one-to-one/history/ui/study-history-tab.tsx b/src/features/study/one-to-one/history/ui/study-history-tab.tsx
new file mode 100644
index 00000000..9f0fd64d
--- /dev/null
+++ b/src/features/study/one-to-one/history/ui/study-history-tab.tsx
@@ -0,0 +1,20 @@
+import { GetMyStudyHistoryParams } from '@/features/study/one-to-one/history/api/get-my-study-history';
+import { getMyStudyHistoryServer } from '@/features/study/one-to-one/history/api/get-my-study-history.server';
+import StudyHistoryTabClient from './study-history-tab-client';
+
+export default async function StudyHistoryTab() {
+ const initialParams: GetMyStudyHistoryParams = {
+ page: 0,
+ size: 15,
+ sort: 'createdAt,desc',
+ };
+
+ const initialData = await getMyStudyHistoryServer(initialParams);
+
+ return (
+
+ );
+}
diff --git a/src/features/study/schedule/api/get-study-schedule.tsx b/src/features/study/one-to-one/schedule/api/get-study-schedule.tsx
similarity index 95%
rename from src/features/study/schedule/api/get-study-schedule.tsx
rename to src/features/study/one-to-one/schedule/api/get-study-schedule.tsx
index a5357909..096ac484 100644
--- a/src/features/study/schedule/api/get-study-schedule.tsx
+++ b/src/features/study/one-to-one/schedule/api/get-study-schedule.tsx
@@ -7,7 +7,7 @@ import {
MonthlyCalendarResponse,
StudyStatus,
WeeklyParticipationResponse,
-} from '@/features/study/schedule/api/schedule-types';
+} from '@/features/study/one-to-one/schedule/api/schedule-types';
// 스터디 전체 조회
export const getDailyStudies = async (
diff --git a/src/features/study/schedule/api/schedule-types.ts b/src/features/study/one-to-one/schedule/api/schedule-types.ts
similarity index 100%
rename from src/features/study/schedule/api/schedule-types.ts
rename to src/features/study/one-to-one/schedule/api/schedule-types.ts
diff --git a/src/features/study/schedule/model/use-schedule-query.ts b/src/features/study/one-to-one/schedule/model/use-schedule-query.ts
similarity index 85%
rename from src/features/study/schedule/model/use-schedule-query.ts
rename to src/features/study/one-to-one/schedule/model/use-schedule-query.ts
index aa8bfc4f..87a91f3e 100644
--- a/src/features/study/schedule/model/use-schedule-query.ts
+++ b/src/features/study/one-to-one/schedule/model/use-schedule-query.ts
@@ -4,16 +4,16 @@ import {
getMonthlyStudyCalendar,
getStudyStatus,
getWeeklyParticipation,
-} from '@/features/study/schedule/api/get-study-schedule';
+} from '@/features/study/one-to-one/schedule/api/get-study-schedule';
import {
GetDailyStudiesParams,
GetMonthlyCalendarParams,
MonthlyCalendarResponse,
StudyStatus,
-} from '@/features/study/schedule/api/schedule-types';
+} from '@/features/study/one-to-one/schedule/api/schedule-types';
// 스터디 주간 참여 유무 확인 query
-export const useWeeklyParticipation = (params: string, enabled: boolean) => {
+export const useWeeklyParticipationQuery = (params: string, enabled = true) => {
return useQuery({
queryKey: ['weeklyParticipation', params],
queryFn: () => getWeeklyParticipation(params),
diff --git a/src/features/study/schedule/ui/data-selector.tsx b/src/features/study/one-to-one/schedule/ui/data-selector.tsx
similarity index 100%
rename from src/features/study/schedule/ui/data-selector.tsx
rename to src/features/study/one-to-one/schedule/ui/data-selector.tsx
diff --git a/src/features/study/one-to-one/schedule/ui/home-study-tab.tsx b/src/features/study/one-to-one/schedule/ui/home-study-tab.tsx
new file mode 100644
index 00000000..892b5888
--- /dev/null
+++ b/src/features/study/one-to-one/schedule/ui/home-study-tab.tsx
@@ -0,0 +1,10 @@
+import StudyCard from '@/features/study/one-to-one/schedule/ui/study-card';
+
+export default function StudyTab() {
+ return (
+
+ {/* 기존 컴포넌트들을 그대로 사용 - 100% 안전 */}
+
+
+ );
+}
diff --git a/src/features/study/schedule/ui/study-card.tsx b/src/features/study/one-to-one/schedule/ui/study-card.tsx
similarity index 88%
rename from src/features/study/schedule/ui/study-card.tsx
rename to src/features/study/one-to-one/schedule/ui/study-card.tsx
index f0afcf94..065407a0 100644
--- a/src/features/study/schedule/ui/study-card.tsx
+++ b/src/features/study/one-to-one/schedule/ui/study-card.tsx
@@ -2,20 +2,20 @@
import { getMonth, getDay, startOfWeek, getDate } from 'date-fns';
import { useMemo, useState } from 'react';
-import ReservationList from '@/features/study/participation/ui/reservation-list';
import {
useStudyStatusQuery,
- useWeeklyParticipation,
-} from '@/features/study/schedule/model/use-schedule-query';
-import DateSelector from '@/features/study/schedule/ui/data-selector';
-import TodayStudyCard from '@/features/study/schedule/ui/today-study-card';
+ useWeeklyParticipationQuery,
+} from '@/features/study/one-to-one/schedule/model/use-schedule-query';
+import DateSelector from '@/features/study/one-to-one/schedule/ui/data-selector';
+import TodayStudyCard from '@/features/study/one-to-one/schedule/ui/today-study-card';
+import ReservationList from '@/features/study/participation/ui/reservation-list';
import { useAuth } from '@/hooks/common/use-auth';
import {
formatKoreaYMD,
getKoreaDate,
getKoreaDisplayMonday,
} from '@/utils/time';
-import StudyListSection from '../../../../widgets/home/study-list-table';
+import StudyListSection from '@/widgets/home/study-list-table';
// 스터디 주차 구하는 함수
function getWeekly(date: Date): { month: number; week: number } {
@@ -74,11 +74,10 @@ export default function StudyCard() {
const { data: status } = useStudyStatusQuery();
// 인증 API (로그인 한 사용자만 호출)
- const { data: participationData } = useWeeklyParticipation(
+ const { data: participationData } = useWeeklyParticipationQuery(
studyDate,
isLoggedIn,
);
-
const isParticipate = participationData?.isParticipate ?? false;
const displayMonday = useMemo(
diff --git a/src/features/study/schedule/ui/today-study-card.tsx b/src/features/study/one-to-one/schedule/ui/today-study-card.tsx
similarity index 95%
rename from src/features/study/schedule/ui/today-study-card.tsx
rename to src/features/study/one-to-one/schedule/ui/today-study-card.tsx
index e33db149..fd5bf978 100644
--- a/src/features/study/schedule/ui/today-study-card.tsx
+++ b/src/features/study/one-to-one/schedule/ui/today-study-card.tsx
@@ -6,12 +6,12 @@ import UserAvatar from '@/components/ui/avatar';
import Badge from '@/components/ui/badge';
import UserPhoneNumberCopyModal from '@/entities/user/ui/user-phone-number-copy-modal';
import UserProfileModal from '@/entities/user/ui/user-profile-modal';
+import { DailyStudyDetail } from '@/features/study/interview/api/interview-types';
+import { useDailyStudyDetailQuery } from '@/features/study/interview/model/use-interview-query';
+import { getStatusBadge } from '@/features/study/interview/ui/status-badge-map';
+import StudyDoneModal from '@/features/study/interview/ui/study-done-modal';
+import StudyReadyModal from '@/features/study/interview/ui/study-ready-modal';
import { useAuth } from '@/hooks/common/use-auth';
-import { DailyStudyDetail } from '../../interview/api/interview-types';
-import { useDailyStudyDetailQuery } from '../../interview/model/use-interview-query';
-import { getStatusBadge } from '../../interview/ui/status-badge-map';
-import StudyDoneModal from '../../interview/ui/study-done-modal';
-import StudyReadyModal from '../../interview/ui/study-ready-modal';
export default function TodayStudyCard({ studyDate }: { studyDate: string }) {
const { data: authData } = useAuth();
diff --git a/src/features/study/one-to-one/ui/one-on-one-page.tsx b/src/features/study/one-to-one/ui/one-on-one-page.tsx
new file mode 100644
index 00000000..83f6cf78
--- /dev/null
+++ b/src/features/study/one-to-one/ui/one-on-one-page.tsx
@@ -0,0 +1,988 @@
+'use client';
+
+import {
+ Trophy,
+ BookOpen,
+ Flame,
+ FileText,
+ Thermometer,
+ Search,
+ Eye,
+ Heart,
+ Loader2,
+ ChevronUp,
+ ChevronDown,
+ Minus,
+ ChevronLeft,
+ ChevronRight,
+ ExternalLink,
+ LibraryBig,
+ LayoutGrid,
+ List,
+ ArrowUpDown,
+ MessageSquareText,
+} from 'lucide-react';
+import Image from 'next/image';
+import Link from 'next/link';
+import React, { useState, useEffect } from 'react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import { ProfileAvatar } from '@/components/ui/profile-avatar';
+import UserProfileModal from '@/entities/user/ui/user-profile-modal';
+
+// ----------------------------------------------------------------------
+// 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',
+ },
+};
+
+// ----------------------------------------------------------------------
+// Types & Mock Data (Library)
+// ----------------------------------------------------------------------
+
+interface LibraryItem {
+ id: number;
+ title: string;
+ author: string;
+ date: string;
+ views: number;
+ likes: number;
+ link: string;
+ isLiked: boolean;
+}
+
+const MOCK_LIBRARY_DATA: LibraryItem[] = [
+ {
+ id: 1,
+ title: '2025년 상반기 백엔드 개발자 면접 질문 모음 (네카라쿠배)',
+ author: '제로원 운영진',
+ date: '2025.01.10',
+ views: 1250,
+ likes: 342,
+ link: 'https://velog.io',
+ isLiked: true,
+ },
+ {
+ id: 2,
+ title: '프론트엔드 성능 최적화: React 19 도입 가이드',
+ author: 'TechLead_Kim',
+ date: '2025.01.12',
+ views: 890,
+ likes: 120,
+ link: 'https://medium.com',
+ isLiked: false,
+ },
+ {
+ id: 3,
+ title: '비전공자가 6개월 만에 개발자로 취업한 현실적인 공부법',
+ author: 'NewDeveloper',
+ date: '2025.01.05',
+ views: 2100,
+ likes: 560,
+ link: 'https://brunch.co.kr',
+ isLiked: true,
+ },
+ {
+ id: 4,
+ title: 'CS 기초: 운영체제와 네트워크 핵심 요약 (PDF 다운로드)',
+ author: 'CS_Master',
+ date: '2024.12.28',
+ views: 1500,
+ likes: 410,
+ link: 'https://tistory.com',
+ isLiked: false,
+ },
+ {
+ id: 5,
+ title: '주니어 개발자를 위한 이력서 첨삭 가이드 101',
+ author: 'HR_Manager',
+ date: '2025.01.15',
+ views: 750,
+ likes: 230,
+ link: 'https://linkedin.com',
+ isLiked: false,
+ },
+ ...Array.from({ length: 10 }, (_, i) => ({
+ id: 10 + i,
+ title: `개발자 면접 대비 - 자료구조 핵심 질문 ${i + 1}탄`,
+ author: 'Admin',
+ date: `2024.12.${20 - i}`,
+ views: 100 + i * 10,
+ likes: 10 + i,
+ link: 'https://google.com',
+ isLiked: Math.random() > 0.5,
+ })),
+];
+
+// ----------------------------------------------------------------------
+// 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 (
+
+
+
+ );
+};
+
+const RankChangeIndicator = ({
+ change,
+ value,
+}: {
+ change?: 'up' | 'down' | 'same';
+ value?: number;
+}) => {
+ if (change === 'same' || !value) {
+ return ;
+ }
+ if (change === 'up') {
+ return (
+
+
+ {value}
+
+ );
+ }
+
+ return (
+
+
+ {value}
+
+ );
+};
+
+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}
+
+
+
+
+ }
+ />
+ );
+};
+
+const LibraryCard = ({
+ item,
+ onLike,
+ onView,
+}: {
+ item: LibraryItem;
+ onLike: (e: React.MouseEvent, id: number) => void;
+ onView: (link: string) => void;
+}) => {
+ return (
+ onView(item.link)}
+ className="group rounded-200 border-border-subtle bg-background-default hover:shadow-2 hover:border-border-default flex cursor-pointer flex-col gap-200 border p-400 transition-all hover:-translate-y-50"
+ >
+
+
+ {item.date}
+
+
+
+
+
+ {item.title}
+
+
+
+
+ by{' '}
+ {item.author}
+
+
+
+
+ {item.views.toLocaleString()}
+
+
+
+
+
+ );
+};
+
+const LibraryRow = ({
+ item,
+ onLike,
+ onView,
+}: {
+ item: LibraryItem;
+ onLike: (e: React.MouseEvent, id: number) => void;
+ onView: (link: string) => void;
+}) => {
+ return (
+ onView(item.link)}
+ className="group border-border-subtlest hover:bg-fill-neutral-subtle-hover flex cursor-pointer items-center gap-300 border-b px-300 py-200 transition-colors last:border-0"
+ >
+ {/* Title Area */}
+
+
+ {item.title}
+
+
+ {item.author}
+
+ {item.date}
+
+
+
+ {/* Stats Area - Hidden on very small screens if needed, but flex-wrap handles it */}
+
+
+
+ {item.views.toLocaleString()}
+
+
+
+
+
+ );
+};
+
+// ----------------------------------------------------------------------
+// Main Page
+// ----------------------------------------------------------------------
+
+export default function OneOnOnePage() {
+ const [activeTab, setActiveTab] = useState<'RANKING' | 'LIBRARY'>('RANKING');
+ const [rankingType, setRankingType] = useState('ATTENDANCE');
+ const [rankers, setRankers] = useState([]);
+ const [libraryItems, setLibraryItems] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+
+ // Library Specific States
+ const [librarySort, setLibrarySort] = useState<'LATEST' | 'VIEWS' | 'LIKES'>(
+ 'LATEST',
+ );
+ const [viewMode, setViewMode] = useState<'GRID' | 'LIST'>('GRID');
+
+ // Pagination & Filter States
+ const [currentPage, setCurrentPage] = useState(1);
+ const ITEMS_PER_PAGE = viewMode === 'LIST' ? 15 : 10;
+ const [searchTerm, setSearchTerm] = useState('');
+ const [jobFilter, setJobFilter] = useState('');
+
+ // Data Loading Simulation
+ useEffect(() => {
+ setIsLoading(true);
+ setCurrentPage(1); // 탭 변경 시 페이지 리셋
+
+ const timer = setTimeout(() => {
+ if (activeTab === 'RANKING') {
+ 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);
+ } else if (activeTab === 'LIBRARY') {
+ setLibraryItems(MOCK_LIBRARY_DATA);
+ }
+ setIsLoading(false);
+ }, 400);
+
+ return () => clearTimeout(timer);
+ }, [activeTab, rankingType]);
+
+ // Handler for Likes
+ const handleLike = (e: React.MouseEvent, id: number) => {
+ e.stopPropagation(); // Prevent card click
+ 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;
+ }),
+ );
+ };
+
+ // Handler for Viewing (Clicking Card)
+ const handleView = (link: string) => {
+ window.open(link, '_blank');
+ };
+
+ // 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;
+ });
+
+ const filteredLibrary = libraryItems.filter((item) =>
+ item.title.toLowerCase().includes(searchTerm.toLowerCase()),
+ );
+
+ 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 =
+ activeTab === 'RANKING' ? filteredRankers.length : sortedLibrary.length;
+
+ const totalPages = Math.ceil(
+ (activeTab === 'RANKING' ? Math.max(0, totalItems - 3) : totalItems) /
+ ITEMS_PER_PAGE,
+ );
+
+ const currentRankers = filteredRankers
+ .slice(3)
+ .slice(
+ (currentPage - 1) * ITEMS_PER_PAGE,
+ currentPage * ITEMS_PER_PAGE + (currentPage === 1 ? 0 : 0),
+ );
+
+ const currentLibrary = sortedLibrary.slice(
+ (currentPage - 1) * ITEMS_PER_PAGE,
+ currentPage * ITEMS_PER_PAGE,
+ );
+
+ return (
+
+
+ {/* Left Sidebar (Tabs) */}
+
+
+ {/* Main Content Area */}
+
+
+ {/* Header Area */}
+
+
+ {activeTab === 'RANKING' && '명예의 전당'}
+ {activeTab === 'LIBRARY' && '제로원 도서관'}
+
+ {activeTab === 'RANKING' && (
+
+ )}
+ {activeTab === 'LIBRARY' && (
+
+ )}
+
+
+
+ {/* Filters & Search */}
+
+ {activeTab === 'RANKING' && (
+
+ {(Object.keys(TAB_CONFIG) as RankingType[]).map((type) => (
+
+ ))}
+
+ )}
+
+
+ {/* 1. Total Count (Left) */}
+ {activeTab === 'LIBRARY' && (
+
+ 총{' '}
+
+ {totalItems}
+
+ 개의 자료가 있습니다.
+
+ )}
+
+ {/* Right Side Controls */}
+
+ {activeTab === 'RANKING' && (
+
+ )}
+
+ {/* 2. Search Bar (Center/Right) */}
+
+ setSearchTerm(e.target.value)}
+ className="rounded-100 border-border-subtle bg-background-default font-designer-14m focus:border-border-default focus:ring-fill-neutral-default-default h-600 w-full border pr-500 pl-200 transition-all outline-none focus:ring-2"
+ />
+
+
+
+
+
+ {/* 3. Sort & View Toggle (Right End - Library Only) */}
+ {activeTab === 'LIBRARY' && (
+
+ {/* Sort Dropdown */}
+
+
+
+ {/* Dropdown Wrapper - Invisible Bridge */}
+
+ {/* Visual Dropdown */}
+
+
+
+
+
+
+
+
+
+
+ {/* View Mode Toggle */}
+
+
+
+
+
+ )}
+
+
+
+
+ {isLoading ? (
+
+
+
+ 데이터를 불러오는 중입니다...
+
+
+ ) : activeTab === 'RANKING' ? (
+ <>
+ {/* 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}
+
+
+
+ }
+ />
+ ))}
+
+
+ >
+ ) : (
+ /* Library Content */
+ <>
+ {viewMode === 'GRID' ? (
+ /* Grid View */
+
+ {currentLibrary.length > 0 ? (
+ currentLibrary.map((item) => (
+
+ ))
+ ) : (
+
+ )}
+
+ ) : (
+ /* List View (Compact Rows) */
+
+ {currentLibrary.length > 0 ? (
+
+ {currentLibrary.map((item) => (
+
+ ))}
+
+ ) : (
+
+ )}
+
+ )}
+ >
+ )}
+
+ {/* Pagination */}
+ {totalPages > 1 && (
+
+
+
+ {currentPage} / {totalPages}
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/features/study/one-to-one/ui/pagination-circle-button.tsx b/src/features/study/one-to-one/ui/pagination-circle-button.tsx
new file mode 100644
index 00000000..ca06d547
--- /dev/null
+++ b/src/features/study/one-to-one/ui/pagination-circle-button.tsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+
+interface PaginationCircleButtonProps {
+ onClick: () => void;
+ disabled?: boolean;
+ children: React.ReactNode;
+ className?: string;
+}
+
+export default function PaginationCircleButton({
+ onClick,
+ disabled,
+ children,
+ className,
+}: PaginationCircleButtonProps) {
+ return (
+
+ );
+}
diff --git a/src/hooks/common/use-auth.ts b/src/hooks/common/use-auth.ts
index 1f56f3d1..9af3fd96 100644
--- a/src/hooks/common/use-auth.ts
+++ b/src/hooks/common/use-auth.ts
@@ -1,15 +1,34 @@
-import { useMemo } from 'react';
+// 데이터 조회(Query) 를 담당하는 커스텀 훅
+
+// cf. useQuery 는 클라이언트 컴포넌트에서 사용하는 React Hook 으로 실시간 데이터 구독, 캐싱, 리렌더링 처리를 담당함
+// 한편, prefetchQuery 는 서버컴포넌트에서 사용하는 함수로 데이터를 미리 가져와서 캐시에 저장함
+import { useQuery } from '@tanstack/react-query';
+import { useMemo } from 'react';
import { getCookie } from '@/api/client/cookie';
+import { getMemberId } from '@/features/auth/api/auth';
import { decodeJwt } from '@/utils/jwt';
+// 회원 Id 조회
+export const useMemberId = () => {
+ return useQuery<{ memberId: string }>({
+ queryKey: ['member'],
+ queryFn: getMemberId,
+ enabled: !!getCookie('accessToken'), // 토큰이 있을 때만 실행
+ });
+};
+
type RoleId = 'ROLE_MEMBER' | 'ROLE_ADMIN' | 'ROLE_MENTOR' | 'ROLE_GUEST';
-type AuthVendor = 'GOOGLE' | 'KAKAO';
+type AuthVendor = 'GOOGLE' | 'KAKAO' | 'TEST'; // NATIVE -> TEST 변경
-interface DecodedToken {
+export interface DecodedToken {
roleIds: RoleId[];
authVendor: AuthVendor;
- memberId: number | null;
+
+ memberId: number;
+ sub: string;
+ iat: number;
+ exp: number;
}
interface UseAuthReturn {
@@ -25,16 +44,12 @@ 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',
- 'ROLE_GUEST',
- ];
- if (!obj.roleIds.every((role) => validRoles.includes(role))) return false;
+ // TODO: 서버 역할과 프론트 역할 싱크 필요. 일단 validation 완화
+ // 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'];
+ const validVendors: AuthVendor[] = ['GOOGLE', 'KAKAO', 'TEST']; // NATIVE -> TEST 변경
if (!validVendors.includes(obj.authVendor as AuthVendor)) return false;
// memberId가 숫자이거나 null인지 확인
@@ -49,7 +64,9 @@ function isDecodedToken(value: unknown): value is DecodedToken {
}
export function useAuth(): UseAuthReturn {
- const accessToken = getCookie('accessToken');
+ // getCookie는 클라이언트 사이드에서만 실행되어야 함
+ const accessToken =
+ typeof window !== 'undefined' ? getCookie('accessToken') : undefined;
const decodedToken: DecodedToken | undefined = useMemo(() => {
if (!accessToken) return undefined;
@@ -57,8 +74,9 @@ export function useAuth(): UseAuthReturn {
try {
const decoded = decodeJwt(accessToken);
- if (!isDecodedToken(decoded)) {
- console.error('Invalid token structure');
+ // decoded가 null이거나 형식이 안 맞으면 에러 출력
+ if (!decoded || !isDecodedToken(decoded)) {
+ console.error('Invalid token structure', decoded);
return undefined;
}
diff --git a/src/hooks/use-debounce.ts b/src/hooks/use-debounce.ts
new file mode 100644
index 00000000..4574fc6a
--- /dev/null
+++ b/src/hooks/use-debounce.ts
@@ -0,0 +1,15 @@
+import { useEffect, useState } from 'react';
+
+export function useDebounce(value: T, delay?: number): T {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
+
+ return () => {
+ clearTimeout(timer);
+ };
+ }, [value, delay]);
+
+ return debouncedValue;
+}
diff --git a/src/hooks/use-discussion-params.ts b/src/hooks/use-discussion-params.ts
new file mode 100644
index 00000000..d54943d2
--- /dev/null
+++ b/src/hooks/use-discussion-params.ts
@@ -0,0 +1,82 @@
+import { useSearchParams, useRouter } from 'next/navigation';
+import { useCallback, useMemo } from 'react';
+import { DiscussionTopic, SortOption } from '@/types/discussion';
+
+export interface DiscussionParams {
+ q: string;
+ sort: SortOption;
+ topic: DiscussionTopic;
+}
+
+/**
+ * Discussion 페이지의 URL 쿼리스트링을 관리하는 훅
+ * ?q=검색어&sort=latest&topic=all 형태로 동기화
+ */
+export function useDiscussionParams() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+
+ // 현재 파라미터 값 파싱
+ const params = useMemo(() => {
+ return {
+ q: searchParams.get('q') || '',
+ sort: (searchParams.get('sort') as SortOption) || 'latest',
+ topic: (searchParams.get('topic') as DiscussionTopic) || 'all',
+ };
+ }, [searchParams]);
+
+ // URL 업데이트 함수
+ const updateParams = useCallback(
+ (updates: Partial) => {
+ const current = new URLSearchParams(searchParams.toString());
+
+ Object.entries(updates).forEach(([key, value]) => {
+ if (value === '' || value === 'all' || value === 'latest') {
+ current.delete(key);
+ } else {
+ current.set(key, value);
+ }
+ });
+
+ const queryString = current.toString();
+ const newUrl = queryString ? `?${queryString}` : '';
+ router.push(newUrl, { scroll: false });
+ },
+ [searchParams, router],
+ );
+
+ // 개별 업데이트 헬퍼 함수들
+ const setSearch = useCallback(
+ (q: string) => {
+ updateParams({ q });
+ },
+ [updateParams],
+ );
+
+ const setSort = useCallback(
+ (sort: SortOption) => {
+ updateParams({ sort });
+ },
+ [updateParams],
+ );
+
+ const setTopic = useCallback(
+ (topic: DiscussionTopic) => {
+ updateParams({ topic });
+ },
+ [updateParams],
+ );
+
+ const resetParams = useCallback(() => {
+ router.push('', { scroll: false });
+ }, [router]);
+
+ return {
+ params,
+ updateParams,
+ setSearch,
+ setSort,
+ setTopic,
+ resetParams,
+ };
+}
diff --git a/src/mocks/discussion-mock-data.ts b/src/mocks/discussion-mock-data.ts
new file mode 100644
index 00000000..be6faf11
--- /dev/null
+++ b/src/mocks/discussion-mock-data.ts
@@ -0,0 +1,325 @@
+import { Discussion, DiscussionTopic } from '@/types/discussion';
+
+// Mock Discussion 데이터
+export const MOCK_DISCUSSIONS: Discussion[] = [
+ {
+ id: 1,
+ title: 'Next.js 15 App Router에서 서버 컴포넌트 활용법',
+ content: `Next.js 15의 App Router를 사용하면서 서버 컴포넌트와 클라이언트 컴포넌트를 어떻게 나눠야 할지 고민이 많습니다.
+
+특히 다음과 같은 경우에 어떤 선택을 하시나요?
+
+1. 데이터 페칭이 필요한 컴포넌트
+2. 사용자 인터랙션이 필요한 컴포넌트
+3. 상태 관리가 필요한 컴포넌트
+
+여러분의 경험과 노하우를 공유해주세요!`,
+ summary:
+ 'Next.js 15의 App Router를 사용하면서 서버 컴포넌트와 클라이언트 컴포넌트를 어떻게 나눠야 할지 고민이 많습니다.',
+ author: {
+ id: 1,
+ nickname: '프론트엔드개발자',
+ avatar: undefined,
+ },
+ topic: 'development',
+ tags: ['Next.js', 'React', 'Server Components'],
+ vote: {
+ agreeCount: 42,
+ disagreeCount: 3,
+ myVote: null,
+ },
+ commentCount: 15,
+ comments: [
+ {
+ id: 101,
+ author: { id: 2, nickname: '시니어개발자' },
+ content:
+ '저는 데이터 페칭은 무조건 서버 컴포넌트에서 하고, useState나 useEffect가 필요한 부분만 클라이언트 컴포넌트로 분리합니다.',
+ createdAt: '2026-01-19T10:30:00Z',
+ isAuthor: false,
+ },
+ {
+ id: 102,
+ author: { id: 3, nickname: '주니어개발자' },
+ content:
+ '저도 같은 고민을 했었는데, 결국 성능 측정해보고 결정하는게 제일 좋더라구요!',
+ createdAt: '2026-01-19T11:15:00Z',
+ isAuthor: false,
+ },
+ ],
+ viewCount: 1234,
+ createdAt: '2026-01-18T14:30:00Z',
+ updatedAt: '2026-01-18T14:30:00Z',
+ lastActivityAt: '2026-01-19T11:15:00Z',
+ },
+ {
+ id: 2,
+ title: '효과적인 스터디 방법 공유합니다',
+ content: `저는 3개월간 매일 아침 6시에 일어나서 1시간씩 알고리즘 문제를 풀었습니다.
+
+처음에는 정말 힘들었지만, 2주가 지나니 습관이 되더라구요.
+
+제가 사용한 방법:
+- 전날 밤 11시 전에 잠들기
+- 알람을 침대에서 먼 곳에 설정
+- 스터디 그룹에서 서로 인증하기
+- 작은 목표부터 시작하기
+
+여러분은 어떤 방법을 사용하시나요?`,
+ summary:
+ '저는 3개월간 매일 아침 6시에 일어나서 1시간씩 알고리즘 문제를 풀었습니다. 처음에는 정말 힘들었지만, 2주가 지나니 습관이 되더라구요.',
+ author: {
+ id: 4,
+ nickname: '아침형인간',
+ },
+ topic: 'study',
+ tags: ['습관', '알고리즘', '아침 루틴'],
+ vote: {
+ agreeCount: 89,
+ disagreeCount: 2,
+ myVote: 'agree',
+ },
+ commentCount: 23,
+ comments: [],
+ viewCount: 2567,
+ createdAt: '2026-01-17T09:00:00Z',
+ updatedAt: '2026-01-17T09:00:00Z',
+ lastActivityAt: '2026-01-19T08:45:00Z',
+ },
+ {
+ id: 3,
+ title: 'TypeScript의 제네릭은 언제 사용해야 할까요?',
+ content: `TypeScript를 배우고 있는데 제네릭 개념이 너무 어렵습니다.
+
+어떤 상황에서 제네릭을 사용하는 것이 좋을까요?
+그리고 제네릭을 잘 사용하는 팁이 있다면 공유해주세요!`,
+ summary:
+ 'TypeScript를 배우고 있는데 제네릭 개념이 너무 어렵습니다. 어떤 상황에서 제네릭을 사용하는 것이 좋을까요?',
+ author: {
+ id: 5,
+ nickname: 'TS초보',
+ },
+ topic: 'question',
+ tags: ['TypeScript', '제네릭', '질문'],
+ vote: {
+ agreeCount: 15,
+ disagreeCount: 1,
+ myVote: null,
+ },
+ commentCount: 8,
+ comments: [],
+ viewCount: 456,
+ createdAt: '2026-01-19T08:00:00Z',
+ updatedAt: '2026-01-19T08:00:00Z',
+ lastActivityAt: '2026-01-19T10:20:00Z',
+ },
+ {
+ id: 4,
+ title: '개발자 취업 준비, 이렇게 했습니다',
+ content: `6개월간 개발자 취업 준비를 하고 드디어 합격했습니다!
+
+제가 한 것들:
+1. 백준 골드 5 달성
+2. 개인 프로젝트 3개 (Next.js, Spring Boot)
+3. 기술 블로그 운영 (주 1회 포스팅)
+4. 오픈소스 기여 경험
+5. 네트워킹 (개발자 밋업 참석)
+
+가장 도움이 된 것은 역시 실전 프로젝트 경험이었습니다.`,
+ summary:
+ '6개월간 개발자 취업 준비를 하고 드디어 합격했습니다! 제가 한 것들과 가장 도움이 된 것을 공유합니다.',
+ author: {
+ id: 6,
+ nickname: '신입개발자',
+ },
+ topic: 'free',
+ tags: ['취업', '경험담', '신입'],
+ vote: {
+ agreeCount: 156,
+ disagreeCount: 5,
+ myVote: null,
+ },
+ commentCount: 45,
+ comments: [],
+ viewCount: 3421,
+ createdAt: '2026-01-16T15:30:00Z',
+ updatedAt: '2026-01-16T15:30:00Z',
+ lastActivityAt: '2026-01-19T09:30:00Z',
+ },
+ {
+ id: 5,
+ title: 'React Query vs SWR, 어떤 것을 선택해야 할까요?',
+ content: `새 프로젝트를 시작하는데 데이터 페칭 라이브러리를 고민 중입니다.
+
+React Query (TanStack Query)와 SWR 중 어떤 것이 더 좋을까요?
+
+각각의 장단점과 실제 사용 경험을 공유해주시면 감사하겠습니다.`,
+ summary:
+ '새 프로젝트를 시작하는데 데이터 페칭 라이브러리를 고민 중입니다. React Query와 SWR 중 어떤 것이 더 좋을까요?',
+ author: {
+ id: 7,
+ nickname: '기술선택고민중',
+ },
+ topic: 'development',
+ tags: ['React Query', 'SWR', '비교'],
+ vote: {
+ agreeCount: 28,
+ disagreeCount: 7,
+ myVote: 'disagree',
+ },
+ commentCount: 19,
+ comments: [],
+ viewCount: 892,
+ createdAt: '2026-01-19T07:00:00Z',
+ updatedAt: '2026-01-19T07:00:00Z',
+ lastActivityAt: '2026-01-19T12:00:00Z',
+ },
+ {
+ id: 6,
+ title: '코드 리뷰 문화, 어떻게 만들어가나요?',
+ content: `팀에 코드 리뷰 문화가 없어서 도입하려고 합니다.
+
+어떻게 시작하면 좋을까요?
+그리고 효과적인 코드 리뷰 방법이 있다면 공유해주세요!`,
+ summary:
+ '팀에 코드 리뷰 문화가 없어서 도입하려고 합니다. 어떻게 시작하면 좋을까요?',
+ author: {
+ id: 8,
+ nickname: '팀리더',
+ },
+ topic: 'development',
+ tags: ['코드리뷰', '문화', '협업'],
+ vote: {
+ agreeCount: 67,
+ disagreeCount: 3,
+ myVote: null,
+ },
+ commentCount: 31,
+ comments: [],
+ viewCount: 1567,
+ createdAt: '2026-01-18T10:00:00Z',
+ updatedAt: '2026-01-18T10:00:00Z',
+ lastActivityAt: '2026-01-19T11:30:00Z',
+ },
+ {
+ id: 7,
+ title: '스터디 모임, 온라인 vs 오프라인?',
+ content: `코로나 이후로 계속 온라인 스터디만 했는데, 요즘 오프라인도 고려 중입니다.
+
+여러분은 어떤 방식을 선호하시나요?
+각각의 장단점을 경험해보신 분들의 의견이 궁금합니다!`,
+ summary:
+ '코로나 이후로 계속 온라인 스터디만 했는데, 요즘 오프라인도 고려 중입니다. 여러분은 어떤 방식을 선호하시나요?',
+ author: {
+ id: 9,
+ nickname: '스터디장',
+ },
+ topic: 'study',
+ tags: ['스터디', '온라인', '오프라인'],
+ vote: {
+ agreeCount: 34,
+ disagreeCount: 28,
+ myVote: null,
+ },
+ commentCount: 42,
+ comments: [],
+ viewCount: 1123,
+ createdAt: '2026-01-17T16:00:00Z',
+ updatedAt: '2026-01-17T16:00:00Z',
+ lastActivityAt: '2026-01-19T13:15:00Z',
+ },
+ {
+ id: 8,
+ title: 'Git 커밋 메시지 컨벤션 추천해주세요',
+ content: `팀 프로젝트를 시작하는데 Git 커밋 메시지 규칙을 정하려고 합니다.
+
+Conventional Commits를 사용하시나요?
+아니면 다른 컨벤션을 사용하시나요?
+
+실제로 사용하시는 커밋 메시지 예시와 함께 공유해주시면 감사하겠습니다!`,
+ summary:
+ '팀 프로젝트를 시작하는데 Git 커밋 메시지 규칙을 정하려고 합니다. 어떤 컨벤션을 추천하시나요?',
+ author: {
+ id: 10,
+ nickname: 'Git초보',
+ },
+ topic: 'question',
+ tags: ['Git', '커밋', '컨벤션'],
+ vote: {
+ agreeCount: 23,
+ disagreeCount: 2,
+ myVote: null,
+ },
+ commentCount: 14,
+ comments: [],
+ viewCount: 678,
+ createdAt: '2026-01-19T09:30:00Z',
+ updatedAt: '2026-01-19T09:30:00Z',
+ lastActivityAt: '2026-01-19T12:45:00Z',
+ },
+];
+
+// 토픽 한글 라벨
+export const TOPIC_LABELS: Record = {
+ all: '전체',
+ development: '개발',
+ study: '스터디',
+ free: '자유',
+ question: '질문',
+};
+
+// Mock API 함수들
+export const mockFetchDiscussions = async (params: {
+ q?: string;
+ sort?: 'latest' | 'popular';
+ topic?: DiscussionTopic;
+ page?: number;
+ limit?: number;
+}): Promise<{ items: Discussion[]; hasMore: boolean; total: number }> => {
+ // 시뮬레이션 딜레이
+ await new Promise((resolve) => setTimeout(resolve, 500));
+
+ let filtered = [...MOCK_DISCUSSIONS];
+
+ // 검색 필터
+ if (params.q) {
+ const query = params.q.toLowerCase();
+ filtered = filtered.filter(
+ (d) =>
+ d.title.toLowerCase().includes(query) ||
+ d.content.toLowerCase().includes(query) ||
+ d.tags.some((tag) => tag.toLowerCase().includes(query)),
+ );
+ }
+
+ // 토픽 필터
+ if (params.topic && params.topic !== 'all') {
+ filtered = filtered.filter((d) => d.topic === params.topic);
+ }
+
+ // 정렬
+ if (params.sort === 'popular') {
+ filtered.sort((a, b) => b.vote.agreeCount - a.vote.agreeCount);
+ } else {
+ // latest (기본값)
+ filtered.sort(
+ (a, b) =>
+ new Date(b.lastActivityAt).getTime() -
+ new Date(a.lastActivityAt).getTime(),
+ );
+ }
+
+ const page = params.page || 1;
+ const limit = params.limit || 10;
+ const start = (page - 1) * limit;
+ const end = start + limit;
+
+ const items = filtered.slice(start, end);
+ const hasMore = end < filtered.length;
+
+ return {
+ items,
+ hasMore,
+ total: filtered.length,
+ };
+};
diff --git a/src/types/archive.ts b/src/types/archive.ts
new file mode 100644
index 00000000..9e2605a9
--- /dev/null
+++ b/src/types/archive.ts
@@ -0,0 +1,29 @@
+export interface ArchiveItem {
+ id: number;
+ title: string;
+ description: string;
+ author: string;
+ date: string;
+ views: number;
+ likes: number;
+ link: string;
+ isLiked: boolean;
+ isBookmarked: boolean;
+ tags: string[];
+}
+
+export interface GetArchiveParams {
+ page: number;
+ size: number;
+ sort?: 'LATEST' | 'VIEWS' | 'LIKES';
+ search?: string;
+ bookmarkedOnly?: boolean;
+}
+
+export interface ArchiveResponse {
+ content: ArchiveItem[];
+ totalPages: number;
+ totalElements: number;
+ first: boolean;
+ last: boolean;
+}
diff --git a/src/types/balance-game.ts b/src/types/balance-game.ts
new file mode 100644
index 00000000..b4a9c006
--- /dev/null
+++ b/src/types/balance-game.ts
@@ -0,0 +1,95 @@
+export interface BalanceGameOption {
+ id: number;
+ label: string;
+ voteCount: number;
+ percentage: number;
+}
+
+export interface BalanceGameAuthor {
+ id: number;
+ nickname: string;
+ profileImage: string | null;
+}
+
+export interface DailyStatistic {
+ date: string;
+ percentages: { [key: string]: number };
+}
+
+export interface BalanceGame {
+ id: number;
+ title: string;
+ description: string;
+ options: BalanceGameOption[];
+ totalVotes: number;
+ commentCount?: number;
+ myVote?: number | null;
+ createdAt: string;
+ endsAt: string;
+ isActive?: boolean;
+ tags?: string[];
+ author: BalanceGameAuthor;
+ dailyStats?: DailyStatistic[];
+}
+
+export interface Pageable {
+ pageNumber: number;
+ pageSize: number;
+}
+
+export interface BalanceGameListResponse {
+ content: BalanceGame[];
+ pageable: Pageable;
+ totalElements: number;
+ totalPages: number;
+}
+
+export interface BalanceGameComment {
+ id: number;
+ content: string;
+ createdAt: string;
+ votedOption: string;
+ author: BalanceGameAuthor;
+ isAuthor: boolean;
+}
+
+export interface BalanceGameCommentListResponse {
+ content: BalanceGameComment[];
+ pageable: Pageable;
+ totalElements: number;
+ totalPages: number;
+}
+
+export interface CreateBalanceGameRequest {
+ title: string;
+ description: string;
+ options: string[];
+ endsAt?: string;
+ tags: string[];
+}
+
+export interface UpdateBalanceGameRequest {
+ title?: string;
+ description?: string;
+ tags?: string[];
+}
+
+export interface CreateCommentRequest {
+ content: string;
+}
+
+export interface UpdateCommentRequest {
+ content: string;
+}
+
+export interface VoteRequest {
+ optionId: number;
+}
+
+// Common Response Wrapper (Actual Backend Structure)
+export interface ApiResponse {
+ content: T; // Changed from data to content
+ statusCode: number; // Changed from status to statusCode
+ message: string;
+ timestamp?: string; // Added timestamp
+}
diff --git a/src/types/discussion.ts b/src/types/discussion.ts
new file mode 100644
index 00000000..f9f53ea2
--- /dev/null
+++ b/src/types/discussion.ts
@@ -0,0 +1,62 @@
+// Discussion 관련 타입 정의
+
+export type DiscussionTopic =
+ | 'all'
+ | 'development'
+ | 'study'
+ | 'free'
+ | 'question';
+export type SortOption = 'latest' | 'popular';
+export type VoteType = 'agree' | 'disagree';
+
+export interface DiscussionAuthor {
+ id: number;
+ nickname: string;
+ avatar?: string;
+}
+
+export interface DiscussionVote {
+ agreeCount: number;
+ disagreeCount: number;
+ myVote?: VoteType | null;
+}
+
+export interface DiscussionComment {
+ id: number;
+ author: DiscussionAuthor;
+ content: string;
+ createdAt: string;
+ isAuthor: boolean; // 현재 로그인 유저가 작성자인지
+}
+
+export interface Discussion {
+ id: number;
+ title: string;
+ content: string;
+ summary: string; // 목록용 요약 (2줄)
+ author: DiscussionAuthor;
+ topic: DiscussionTopic;
+ tags: string[];
+ vote: DiscussionVote;
+ commentCount: number;
+ comments: DiscussionComment[];
+ viewCount: number;
+ createdAt: string;
+ updatedAt: string;
+ lastActivityAt: string; // 최신 활동 시간 (댓글 등)
+}
+
+export interface DiscussionListParams {
+ q?: string; // 검색 키워드
+ sort?: SortOption;
+ topic?: DiscussionTopic;
+ page?: number;
+ limit?: number;
+}
+
+export interface DiscussionListResponse {
+ items: Discussion[];
+ total: number;
+ page: number;
+ hasMore: boolean;
+}
diff --git a/src/types/hall-of-fame.ts b/src/types/hall-of-fame.ts
new file mode 100644
index 00000000..207ee707
--- /dev/null
+++ b/src/types/hall-of-fame.ts
@@ -0,0 +1,64 @@
+// 명예의 전당 API 타입 정의
+
+export interface ApiResponse {
+ statusCode: number;
+ timestamp: string;
+ content: T;
+ message: string;
+}
+
+export interface Job {
+ job?: string; // Enum 값 (예: "IT_PRACTITIONER_GAME_DEV")
+ description?: string; // 설명 (예: "게임 개발자")
+}
+
+export interface Ranker {
+ rank: number;
+ userId: number;
+ nickname: string;
+ profileImage: string | null;
+ score: number;
+ sincerity: number | null;
+ major: string;
+ jobs?: Job[] | null; // 직무 정보
+ lastActive: string;
+}
+
+export interface Rankings {
+ attendanceRankings: Ranker[];
+ studyLogRankings: Ranker[];
+ sincerityRankings: Ranker[];
+ baseDate: string;
+}
+
+export interface MVPTeamMember {
+ userId: number;
+ nickname: string;
+ profileImage: string | null;
+}
+
+export interface SharedLink {
+ id: number;
+ title: string;
+ url: string;
+ sharedAt: string;
+}
+
+export interface MVPTeam {
+ id: number;
+ studyId: number;
+ studyTitle: string;
+ members: [MVPTeamMember, MVPTeamMember];
+ sharedLinks: SharedLink[];
+ weekDate: string;
+ weekStartDate: string;
+ weekEndDate: string;
+ totalSharedLinks: number;
+}
+
+export interface HallOfFameData {
+ rankings: Rankings;
+ mvpTeam: MVPTeam | null;
+}
+
+export type HallOfFameResponse = ApiResponse;
diff --git a/src/types/schemas/zod-schema.ts b/src/types/schemas/zod-schema.ts
index 72126071..541cf2bc 100644
--- a/src/types/schemas/zod-schema.ts
+++ b/src/types/schemas/zod-schema.ts
@@ -16,3 +16,79 @@ export const UrlSchema = z
.refine((v) => !v || isValidUrl(v), {
message: '올바른 URL 형식이 아닙니다.',
});
+
+// Discussion 댓글 폼 스키마
+export const CommentFormSchema = z.object({
+ content: z
+ .string()
+ .trim()
+ .min(1, '댓글 내용을 입력해주세요.')
+ .max(1000, '댓글은 1000자 이하로 입력해주세요.'),
+});
+
+export type CommentFormData = z.infer;
+
+// Discussion 작성 폼 스키마
+export const DiscussionFormSchema = z.object({
+ title: z
+ .string()
+ .trim()
+ .min(5, '제목은 5자 이상 입력해주세요.')
+ .max(100, '제목은 100자 이하로 입력해주세요.'),
+ content: z
+ .string()
+ .trim()
+ .min(10, '내용은 10자 이상 입력해주세요.')
+ .max(5000, '내용은 5000자 이하로 입력해주세요.'),
+ topic: z.enum(['development', 'study', 'free', 'question'], {
+ message: '주제를 선택해주세요.',
+ }),
+ tags: z
+ .array(z.string())
+ .min(1, '태그를 1개 이상 입력해주세요.')
+ .max(5, '태그는 5개까지만 입력 가능합니다.')
+ .optional(),
+});
+
+export type DiscussionFormData = z.infer;
+
+// Voting 생성 폼 스키마
+export const VotingCreateFormSchema = z.object({
+ title: z
+ .string()
+ .trim()
+ .min(5, '제목은 5자 이상 입력해주세요.')
+ .max(200, '제목은 200자 이하로 입력해주세요.'),
+ description: z
+ .string()
+ .trim()
+ .max(500, '설명은 500자 이하로 입력해주세요.')
+ .optional(),
+ options: z
+ .array(
+ z.object({
+ label: z
+ .string()
+ .trim()
+ .min(1, '선택지를 입력해주세요.')
+ .max(100, '선택지는 100자 이하로 입력해주세요.'),
+ }),
+ )
+ .min(2, '선택지는 최소 2개 이상 입력해주세요.')
+ .max(5, '선택지는 최대 5개까지 입력 가능합니다.'),
+ tags: z
+ .array(z.string().trim().min(1))
+ .max(3, '태그는 최대 3개까지 입력 가능합니다.')
+ .optional(),
+ endsAt: z.string().optional(), // ISO date string
+});
+
+export type VotingCreateFormData = z.infer;
+
+// Voting 수정 폼 스키마 (옵션/마감일 수정 불가)
+export const VotingEditFormSchema = VotingCreateFormSchema.omit({
+ options: true,
+ endsAt: true,
+});
+
+export type VotingEditFormData = z.infer;
diff --git a/src/types/study-history.ts b/src/types/study-history.ts
new file mode 100644
index 00000000..fcae31f5
--- /dev/null
+++ b/src/types/study-history.ts
@@ -0,0 +1,74 @@
+'use client';
+
+// 백엔드 API 응답 구조에 맞춘 타입 정의
+
+export type StudyRole = 'INTERVIEWER' | 'INTERVIEWEE';
+export type AttendanceStatus = 'PRESENT' | 'PENDING' | 'ABSENT'; // 백엔드 값에 맞춤 (PRESENT, PENDING 등)
+export type StudyStatus = 'COMPLETE' | 'IN_PROGRESS' | 'PENDING';
+
+export interface StudyHistoryContent {
+ studyId: number;
+ title: string;
+ scheduledAt: string; // ISO Date String
+ status: StudyStatus;
+ studyLink: string | null;
+ participation: {
+ role: StudyRole;
+ attendance: AttendanceStatus;
+ };
+ partner: {
+ memberId: number;
+ nickname: string;
+ profileImageUrl: string | null;
+ };
+}
+
+export interface PageableResponse {
+ statusCode: number;
+ timestamp: string;
+ content: {
+ content: T[];
+ pageable: {
+ pageNumber: number;
+ pageSize: number;
+ sort: {
+ sorted: boolean;
+ unsorted: boolean;
+ empty: boolean;
+ };
+ offset: number;
+ paged: boolean;
+ unpaged: boolean;
+ };
+ totalElements: number;
+ totalPages: number;
+ last: boolean;
+ first: boolean;
+ size: number;
+ number: number;
+ sort: {
+ sorted: boolean;
+ unsorted: boolean;
+ empty: boolean;
+ };
+ numberOfElements: number;
+ empty: boolean;
+ };
+ message: string;
+}
+
+// 프론트엔드 컴포넌트에서 사용하기 편하게 변환한 타입 (UI용)
+export interface StudyHistoryItem {
+ id: number;
+ date: string;
+ subject: string;
+ role: StudyRole;
+ attendance: 'ATTENDED' | 'NOT_STARTED'; // UI 표현용 (PRESENT -> ATTENDED, PENDING -> NOT_STARTED)
+ link: string | null;
+ status: 'COMPLETED' | 'IN_PROGRESS'; // UI 표현용
+ partner: {
+ id: number;
+ name: string;
+ profileImage: string | null;
+ };
+}
diff --git a/src/types/voting.ts b/src/types/voting.ts
new file mode 100644
index 00000000..800bbcc2
--- /dev/null
+++ b/src/types/voting.ts
@@ -0,0 +1,52 @@
+// Voting (투표형 토론) 관련 타입 정의
+
+export interface VotingOption {
+ id: number;
+ label: string;
+ voteCount: number;
+ percentage: number;
+}
+
+export interface VotingAuthor {
+ id: number;
+ nickname: string;
+ avatar?: string;
+}
+
+export interface VotingComment {
+ id: number;
+ author: VotingAuthor;
+ content: string;
+ createdAt: string;
+ isAuthor: boolean;
+ votedOption?: string; // 작성자가 어떤 선택지에 투표했는지
+}
+
+export interface DailyStatistic {
+ date: string; // 날짜 (예: "1일", "2일")
+ percentages: { [optionId: number]: number }; // 각 선택지의 퍼센트
+}
+
+export interface Voting {
+ id: number;
+ round: number; // 라운드 번호
+ title: string; // 투표 주제
+ description?: string; // 주제 설명 (선택)
+ options: VotingOption[]; // 선택지 목록 (2~5개)
+ totalVotes: number; // 총 투표 수
+ myVote?: number; // 내가 투표한 옵션 ID (null이면 아직 투표 안함)
+ commentCount: number;
+ comments: VotingComment[];
+ createdAt: string;
+ endsAt?: string; // 투표 마감 시간 (선택)
+ isActive: boolean; // 진행 중인지 종료되었는지
+ tags: string[];
+ dailyStats?: DailyStatistic[]; // 일별 통계
+ author: VotingAuthor; // 작성자 정보
+}
+
+export interface VotingListResponse {
+ items: Voting[];
+ total: number;
+ hasMore: boolean;
+}
diff --git a/src/utils/jwt.ts b/src/utils/jwt.ts
index 7b3c3b4e..573dc75e 100644
--- a/src/utils/jwt.ts
+++ b/src/utils/jwt.ts
@@ -1,16 +1,26 @@
export const decodeJwt = (token: string) => {
if (!token) return null;
- const base64Url = token.split('.')[1];
- const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
- const jsonPayload = decodeURIComponent(
- atob(base64)
- .split('')
- .map(function (c) {
- return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
- })
- .join(''),
- );
+ // JWT 형식 검사 (x.y.z 형태여야 함)
+ const parts = token.split('.');
+ if (parts.length !== 3) {
+ return null; // 형식이 맞지 않으면 조용히 null 반환
+ }
- return JSON.parse(jsonPayload);
+ try {
+ const base64Url = parts[1];
+ const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
+ const jsonPayload = decodeURIComponent(
+ atob(base64)
+ .split('')
+ .map(function (c) {
+ return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
+ })
+ .join(''),
+ );
+
+ return JSON.parse(jsonPayload);
+ } catch (e) {
+ return null; // 디코딩 실패 시 null 반환
+ }
};
diff --git a/src/widgets/home/calendar.tsx b/src/widgets/home/calendar.tsx
index fa5706b6..c9c68e4d 100644
--- a/src/widgets/home/calendar.tsx
+++ b/src/widgets/home/calendar.tsx
@@ -9,7 +9,7 @@ import {
} from 'react-day-picker';
import { cn } from '@/components/ui/(shadcn)/lib/utils';
import { Calendar as ShadcnCalendar } from '@/components/ui/(shadcn)/ui/calendar';
-import { useMonthlyStudyCalendarQuery } from '@/features/study/schedule/model/use-schedule-query';
+import { useMonthlyStudyCalendarQuery } from '@/features/study/one-to-one/schedule/model/use-schedule-query';
interface CalendarDayProps extends HTMLAttributes {
day: DayPickerDay;
diff --git a/src/widgets/home/home-dashboard.tsx b/src/widgets/home/home-dashboard.tsx
new file mode 100644
index 00000000..ab06ae7d
--- /dev/null
+++ b/src/widgets/home/home-dashboard.tsx
@@ -0,0 +1,246 @@
+import Image from 'next/image';
+import Link from 'next/link';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import UserAvatar from '@/components/ui/avatar';
+import { getSincerityPresetByLevelName } from '@/config/sincerity-temp-presets';
+import { getUserProfileInServer } from '@/entities/user/api/get-user-profile.server';
+import StartStudyModal from '@/features/study/participation/ui/start-study-modal';
+import { getServerCookie } from '@/utils/server-cookie';
+import FeedbackLink from '@/widgets/home/feedback-link';
+import AccessTimeIcon from 'public/icons/access_time.svg';
+import AssignmentIcon from 'public/icons/assignment.svg';
+import CodeIcon from 'public/icons/code.svg';
+import SettingIcon from 'public/icons/setting.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 ? (
+ <>
+
+
+
+ >
+ ) : (
+ <>
+
+
+
+
+
+
+ CS 스터디 시작하기
+
+
+ 매일 아침 1:1 매칭으로 루틴 만들기
+
+
+
+
+
+
+
+ }
+ />
+ >
+ )}
+
+
+ {/* 3. 피드백 & 퀵 액션 */}
+
+
+
+
+
+ {/* 추가 퀵 스탯 */}
+
+
+
+
+ );
+}
diff --git a/src/widgets/home/sidebar.tsx b/src/widgets/home/sidebar.tsx
index 4577a83e..349c41a8 100644
--- a/src/widgets/home/sidebar.tsx
+++ b/src/widgets/home/sidebar.tsx
@@ -1,4 +1,5 @@
import Image from 'next/image';
+import Link from 'next/link';
import { getUserProfileInServer } from '@/entities/user/api/get-user-profile.server';
import MyProfileCard from '@/entities/user/ui/my-profile-card';
import StartStudyModal from '@/features/study/participation/ui/start-study-modal';
@@ -45,6 +46,9 @@ export default async function Sidebar() {
studyApplied={userProfile?.studyApplied ?? false}
sincerityTemp={userProfile.sincerityTemp}
/>
+
+ {/* 1:1 인사이트 버튼 제거됨 - 이제 홈 페이지 탭에서 접근 가능 */}
+
{userProfile.studyApplied ? (
) : (
diff --git a/src/widgets/home/study-list-table.tsx b/src/widgets/home/study-list-table.tsx
index e18510f6..f3db456d 100644
--- a/src/widgets/home/study-list-table.tsx
+++ b/src/widgets/home/study-list-table.tsx
@@ -4,8 +4,8 @@ import { sendGTMEvent } from '@next/third-parties/google';
import UserAvatar from '@/components/ui/avatar';
import TableList from '@/components/ui/table';
import { getStatusBadge } from '@/features/study/interview/ui/status-badge-map';
-import { DailyStudy } from '@/features/study/schedule/api/schedule-types';
-import { useDailyStudiesQuery } from '@/features/study/schedule/model/use-schedule-query';
+import { DailyStudy } from '@/features/study/one-to-one/schedule/api/schedule-types';
+import { useDailyStudiesQuery } from '@/features/study/one-to-one/schedule/model/use-schedule-query';
import LinkIcon from 'public/icons/Link.svg';
const headers = [
diff --git a/yarn.lock b/yarn.lock
index 99c36a6a..83e3c7ad 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5258,6 +5258,15 @@ forwarded@0.2.0:
resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811"
integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==
+framer-motion@^12.27.1:
+ version "12.27.1"
+ resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-12.27.1.tgz#bdd8e41f43df493977a0fc9d80ae3acd2731a741"
+ integrity sha512-cEAqO69kcZt3gL0TGua8WTgRQfv4J57nqt1zxHtLKwYhAwA0x9kDS/JbMa1hJbwkGY74AGJKvZ9pX/IqWZtZWQ==
+ dependencies:
+ motion-dom "^12.27.1"
+ motion-utils "^12.24.10"
+ tslib "^2.4.0"
+
fresh@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/fresh/-/fresh-2.0.0.tgz#8dd7df6a1b3a1b3a5cf186c05a5dd267622635a4"
@@ -6933,6 +6942,18 @@ module-alias@^2.2.3:
resolved "https://registry.yarnpkg.com/module-alias/-/module-alias-2.2.3.tgz#ec2e85c68973bda6ab71ce7c93b763ec96053221"
integrity sha512-23g5BFj4zdQL/b6tor7Ji+QY4pEfNH784BMslY9Qb0UnJWRAt+lQGLYmRaM0KDBwIG23ffEBELhZDP2rhi9f/Q==
+motion-dom@^12.27.1:
+ version "12.27.1"
+ resolved "https://registry.yarnpkg.com/motion-dom/-/motion-dom-12.27.1.tgz#874bb0f49196fb7932173f333bfdc37676a1dbad"
+ integrity sha512-V/53DA2nBqKl9O2PMJleSUb/G0dsMMeZplZwgIQf5+X0bxIu7Q1cTv6DrjvTTGYRm3+7Y5wMlRZ1wT61boU/bQ==
+ dependencies:
+ motion-utils "^12.24.10"
+
+motion-utils@^12.24.10:
+ version "12.24.10"
+ resolved "https://registry.yarnpkg.com/motion-utils/-/motion-utils-12.24.10.tgz#73d0bead3c08c4ba2965a5f7ee39dd53f661ae7e"
+ integrity sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww==
+
mrmime@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-2.0.1.tgz#bc3e87f7987853a54c9850eeb1f1078cd44adddc"
@@ -8214,7 +8235,16 @@ strict-event-emitter@^0.5.1:
resolved "https://registry.yarnpkg.com/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz#1602ece81c51574ca39c6815e09f1a3e8550bd93"
integrity sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==
-"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
+"string-width-cjs@npm:string-width@^4.2.0":
+ version "4.2.3"
+ resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
+ integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
+ dependencies:
+ emoji-regex "^8.0.0"
+ is-fullwidth-code-point "^3.0.0"
+ strip-ansi "^6.0.1"
+
+string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -8324,7 +8354,14 @@ stringify-object@^5.0.0:
is-obj "^3.0.0"
is-regexp "^3.1.0"
-"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@6, strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1, strip-ansi@^7.0.1, strip-ansi@^7.1.0:
+"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
+ version "6.0.1"
+ resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
+ integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
+ dependencies:
+ ansi-regex "^5.0.1"
+
+strip-ansi@6, strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1, strip-ansi@^7.0.1, strip-ansi@^7.1.0:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -9098,7 +9135,7 @@ word-wrap@^1.2.5:
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
-"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
+"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@@ -9116,6 +9153,15 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"
+wrap-ansi@^7.0.0:
+ version "7.0.0"
+ resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
+ integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
+ dependencies:
+ ansi-styles "^4.0.0"
+ string-width "^4.1.0"
+ strip-ansi "^6.0.0"
+
wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"