From fefd0d0dab69f4381ded9ff025c5c43364f3940f Mon Sep 17 00:00:00 2001 From: Hyeonjun0527 Date: Sat, 31 Jan 2026 16:46:43 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=95=88=EC=A0=84=ED=95=98=EA=B2=8C=20?= =?UTF-8?q?=EA=B7=B8=EB=A1=9C=EC=8A=A4=ED=8C=80=20=EC=8B=A0=EA=B7=9C=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor: 프리티어 수정 --- package.json | 4 +- src/app/(service)/(my)/notification/page.tsx | 2 +- src/app/(service)/home/home-content.tsx | 45 + src/app/(service)/home/page.tsx | 23 +- src/app/(service)/insights/page.tsx | 11 + .../(service)/insights/weekly/[id]/page.tsx | 12 + src/app/(service)/insights/weekly/page.tsx | 5 + src/app/(service)/layout.tsx | 4 +- src/app/(service)/one-on-one/page.tsx | 5 + src/components/card/discussion-card.tsx | 135 +++ src/components/card/voting-card.tsx | 133 +++ src/components/discussion/comment-form.tsx | 104 ++ src/components/discussion/comment-list.tsx | 211 ++++ .../discussion/discussion-detail-modal.tsx | 255 +++++ src/components/discussion/filter-bar.tsx | 81 ++ src/components/discussion/search-bar.tsx | 62 ++ src/components/home/tab-navigation.tsx | 90 ++ .../study-history/study-calendar.tsx | 178 ++++ .../study-history/study-history-row.tsx | 123 +++ src/components/ui/avatar/index.tsx | 12 +- src/components/ui/profile-avatar.tsx | 76 ++ src/components/ui/toast.tsx | 52 + src/components/voting/daily-stats-chart.tsx | 211 ++++ src/components/voting/vote-results-chart.tsx | 205 ++++ src/components/voting/vote-timer.tsx | 99 ++ src/components/voting/voting-create-modal.tsx | 392 +++++++ src/components/voting/voting-detail-modal.tsx | 228 ++++ src/components/voting/voting-detail-view.tsx | 632 +++++++++++ src/components/voting/voting-edit-modal.tsx | 276 +++++ src/features/auth/api/test-login.ts | 18 + src/features/auth/ui/login-modal.tsx | 70 ++ src/features/auth/ui/sign-up-modal.tsx | 1 - .../archive/api/get-archive.server.ts | 13 + .../one-to-one/archive/api/get-archive.ts | 13 + .../one-to-one/archive/api/record-view.ts | 5 + .../one-to-one/archive/api/toggle-bookmark.ts | 9 + .../one-to-one/archive/api/toggle-like.ts | 9 + .../study/one-to-one/archive/const/archive.ts | 15 + .../archive/model/use-archive-actions.ts | 42 + .../archive/model/use-archive-query.ts | 21 + .../archive/model/use-bookmark-mutation.ts | 56 + .../archive/model/use-like-mutation.ts | 60 ++ .../archive/model/use-view-mutation.ts | 81 ++ .../one-to-one/archive/ui/archive-filters.tsx | 122 +++ .../one-to-one/archive/ui/archive-grid.tsx | 141 +++ .../one-to-one/archive/ui/archive-header.tsx | 33 + .../one-to-one/archive/ui/archive-list.tsx | 146 +++ .../archive/ui/archive-tab-client.tsx | 175 ++++ .../one-to-one/archive/ui/archive-tab.tsx | 18 + .../api/balance-game-api.server.ts | 65 ++ .../balance-game/api/balance-game-api.ts | 155 +++ .../model/use-balance-game-mutation.ts | 184 ++++ .../model/use-balance-game-query.ts | 102 ++ .../balance-game/ui/balance-game-page.tsx | 248 +++++ .../balance-game/ui/community-tab-client.tsx | 322 ++++++ .../balance-game/ui/community-tab.tsx | 13 + .../balance-game/ui/filter-pill-button.tsx | 28 + .../ui/voting-detail-page-client.tsx | 17 + .../api/hall-of-fame-api.server.ts | 13 + .../hall-of-fame/api/hall-of-fame-api.ts | 22 + .../model/use-hall-of-fame-query.ts | 22 + .../ui/hall-of-fame-tab-client.tsx | 393 +++++++ .../hall-of-fame/ui/hall-of-fame-tab.tsx | 8 + .../hall-of-fame/ui/ranking-tab-button.tsx | 28 + .../api/get-my-study-history.server.ts | 16 + .../history/api/get-my-study-history.ts | 18 + .../model/use-my-study-history-query.ts | 23 + .../history/ui/study-history-tab-client.tsx | 206 ++++ .../history/ui/study-history-tab.tsx | 20 + .../schedule/api/get-study-schedule.tsx | 2 +- .../schedule/api/schedule-types.ts | 0 .../schedule/model/use-schedule-query.ts | 6 +- .../schedule/ui/data-selector.tsx | 0 .../one-to-one/schedule/ui/home-study-tab.tsx | 10 + .../schedule/ui/study-card.tsx | 15 +- .../schedule/ui/today-study-card.tsx | 10 +- .../study/one-to-one/ui/one-on-one-page.tsx | 988 ++++++++++++++++++ .../ui/pagination-circle-button.tsx | 29 + src/hooks/common/use-auth.ts | 48 +- src/hooks/use-debounce.ts | 15 + src/hooks/use-discussion-params.ts | 82 ++ src/mocks/discussion-mock-data.ts | 325 ++++++ src/types/archive.ts | 29 + src/types/balance-game.ts | 95 ++ src/types/discussion.ts | 62 ++ src/types/hall-of-fame.ts | 64 ++ src/types/schemas/zod-schema.ts | 76 ++ src/types/study-history.ts | 74 ++ src/types/voting.ts | 52 + src/utils/jwt.ts | 32 +- src/widgets/home/calendar.tsx | 2 +- src/widgets/home/home-dashboard.tsx | 246 +++++ src/widgets/home/sidebar.tsx | 4 + src/widgets/home/study-list-table.tsx | 4 +- yarn.lock | 52 +- 95 files changed, 8874 insertions(+), 65 deletions(-) create mode 100644 src/app/(service)/home/home-content.tsx create mode 100644 src/app/(service)/insights/weekly/[id]/page.tsx create mode 100644 src/app/(service)/insights/weekly/page.tsx create mode 100644 src/app/(service)/one-on-one/page.tsx create mode 100644 src/components/card/discussion-card.tsx create mode 100644 src/components/card/voting-card.tsx create mode 100644 src/components/discussion/comment-form.tsx create mode 100644 src/components/discussion/comment-list.tsx create mode 100644 src/components/discussion/discussion-detail-modal.tsx create mode 100644 src/components/discussion/filter-bar.tsx create mode 100644 src/components/discussion/search-bar.tsx create mode 100644 src/components/home/tab-navigation.tsx create mode 100644 src/components/study-history/study-calendar.tsx create mode 100644 src/components/study-history/study-history-row.tsx create mode 100644 src/components/ui/profile-avatar.tsx create mode 100644 src/components/ui/toast.tsx create mode 100644 src/components/voting/daily-stats-chart.tsx create mode 100644 src/components/voting/vote-results-chart.tsx create mode 100644 src/components/voting/vote-timer.tsx create mode 100644 src/components/voting/voting-create-modal.tsx create mode 100644 src/components/voting/voting-detail-modal.tsx create mode 100644 src/components/voting/voting-detail-view.tsx create mode 100644 src/components/voting/voting-edit-modal.tsx create mode 100644 src/features/auth/api/test-login.ts create mode 100644 src/features/study/one-to-one/archive/api/get-archive.server.ts create mode 100644 src/features/study/one-to-one/archive/api/get-archive.ts create mode 100644 src/features/study/one-to-one/archive/api/record-view.ts create mode 100644 src/features/study/one-to-one/archive/api/toggle-bookmark.ts create mode 100644 src/features/study/one-to-one/archive/api/toggle-like.ts create mode 100644 src/features/study/one-to-one/archive/const/archive.ts create mode 100644 src/features/study/one-to-one/archive/model/use-archive-actions.ts create mode 100644 src/features/study/one-to-one/archive/model/use-archive-query.ts create mode 100644 src/features/study/one-to-one/archive/model/use-bookmark-mutation.ts create mode 100644 src/features/study/one-to-one/archive/model/use-like-mutation.ts create mode 100644 src/features/study/one-to-one/archive/model/use-view-mutation.ts create mode 100644 src/features/study/one-to-one/archive/ui/archive-filters.tsx create mode 100644 src/features/study/one-to-one/archive/ui/archive-grid.tsx create mode 100644 src/features/study/one-to-one/archive/ui/archive-header.tsx create mode 100644 src/features/study/one-to-one/archive/ui/archive-list.tsx create mode 100644 src/features/study/one-to-one/archive/ui/archive-tab-client.tsx create mode 100644 src/features/study/one-to-one/archive/ui/archive-tab.tsx create mode 100644 src/features/study/one-to-one/balance-game/api/balance-game-api.server.ts create mode 100644 src/features/study/one-to-one/balance-game/api/balance-game-api.ts create mode 100644 src/features/study/one-to-one/balance-game/model/use-balance-game-mutation.ts create mode 100644 src/features/study/one-to-one/balance-game/model/use-balance-game-query.ts create mode 100644 src/features/study/one-to-one/balance-game/ui/balance-game-page.tsx create mode 100644 src/features/study/one-to-one/balance-game/ui/community-tab-client.tsx create mode 100644 src/features/study/one-to-one/balance-game/ui/community-tab.tsx create mode 100644 src/features/study/one-to-one/balance-game/ui/filter-pill-button.tsx create mode 100644 src/features/study/one-to-one/balance-game/ui/voting-detail-page-client.tsx create mode 100644 src/features/study/one-to-one/hall-of-fame/api/hall-of-fame-api.server.ts create mode 100644 src/features/study/one-to-one/hall-of-fame/api/hall-of-fame-api.ts create mode 100644 src/features/study/one-to-one/hall-of-fame/model/use-hall-of-fame-query.ts create mode 100644 src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-tab-client.tsx create mode 100644 src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-tab.tsx create mode 100644 src/features/study/one-to-one/hall-of-fame/ui/ranking-tab-button.tsx create mode 100644 src/features/study/one-to-one/history/api/get-my-study-history.server.ts create mode 100644 src/features/study/one-to-one/history/api/get-my-study-history.ts create mode 100644 src/features/study/one-to-one/history/model/use-my-study-history-query.ts create mode 100644 src/features/study/one-to-one/history/ui/study-history-tab-client.tsx create mode 100644 src/features/study/one-to-one/history/ui/study-history-tab.tsx rename src/features/study/{ => one-to-one}/schedule/api/get-study-schedule.tsx (95%) rename src/features/study/{ => one-to-one}/schedule/api/schedule-types.ts (100%) rename src/features/study/{ => one-to-one}/schedule/model/use-schedule-query.ts (85%) rename src/features/study/{ => one-to-one}/schedule/ui/data-selector.tsx (100%) create mode 100644 src/features/study/one-to-one/schedule/ui/home-study-tab.tsx rename src/features/study/{ => one-to-one}/schedule/ui/study-card.tsx (88%) rename src/features/study/{ => one-to-one}/schedule/ui/today-study-card.tsx (95%) create mode 100644 src/features/study/one-to-one/ui/one-on-one-page.tsx create mode 100644 src/features/study/one-to-one/ui/pagination-circle-button.tsx create mode 100644 src/hooks/use-debounce.ts create mode 100644 src/hooks/use-discussion-params.ts create mode 100644 src/mocks/discussion-mock-data.ts create mode 100644 src/types/archive.ts create mode 100644 src/types/balance-game.ts create mode 100644 src/types/discussion.ts create mode 100644 src/types/hall-of-fame.ts create mode 100644 src/types/study-history.ts create mode 100644 src/types/voting.ts create mode 100644 src/widgets/home/home-dashboard.tsx diff --git a/package.json b/package.json index c6c9358f..234db76c 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "date-fns": "^4.1.0", "dayjs": "^1.11.18", "embla-carousel-react": "^8.6.0", + "framer-motion": "^12.27.1", "googleapis": "^164.1.0", "lucide-react": "^0.475.0", "next": "15.2.8", @@ -106,8 +107,7 @@ "typescript-eslint": "^8.24.0", "vitest": "^3.1.1" }, - "resolutions": { + "resolutions": { "strip-ansi": "6.0.1" } - } diff --git a/src/app/(service)/(my)/notification/page.tsx b/src/app/(service)/(my)/notification/page.tsx index 7711187b..aa396981 100644 --- a/src/app/(service)/(my)/notification/page.tsx +++ b/src/app/(service)/(my)/notification/page.tsx @@ -1,6 +1,5 @@ 'use client'; -import NotificationIcon from 'public/images/notification.svg'; import { useState } from 'react'; import type { GetMemberNotificationsTopicTypeEnum } from '@/api/openapi/api/notification-api'; @@ -13,6 +12,7 @@ import { useGetNotificationCategories, useReadNotifications, } from '@/hooks/queries/notification-api'; +import NotificationIcon from 'public/images/notification.svg'; const READ_STATUS_OPTIONS = [ { value: 'all', label: '상태 전체' }, diff --git a/src/app/(service)/home/home-content.tsx b/src/app/(service)/home/home-content.tsx new file mode 100644 index 00000000..b20f2849 --- /dev/null +++ b/src/app/(service)/home/home-content.tsx @@ -0,0 +1,45 @@ +import { Suspense } from 'react'; +import TabNavigation from '@/components/home/tab-navigation'; +import ArchiveTab from '@/features/study/one-to-one/archive/ui/archive-tab'; +import CommunityTab from '@/features/study/one-to-one/balance-game/ui/community-tab'; +import HallOfFameTab from '@/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-tab'; +import StudyHistoryTab from '@/features/study/one-to-one/history/ui/study-history-tab'; +import StudyTab from '@/features/study/one-to-one/schedule/ui/home-study-tab'; + +interface HomeContentProps { + activeTab: string; +} + +export default function HomeContent({ activeTab }: HomeContentProps) { + const renderTabContent = () => { + switch (activeTab) { + case 'study': + return ; + case 'history': + return ; + case 'ranking': + return ; + case 'archive': + return ; + case 'community': + return ; + default: + return ; + } + }; + + return ( + <> + + + 로딩 중... + + } + > + {renderTabContent()} + + + ); +} diff --git a/src/app/(service)/home/page.tsx b/src/app/(service)/home/page.tsx index 4a653e5a..b54d5980 100644 --- a/src/app/(service)/home/page.tsx +++ b/src/app/(service)/home/page.tsx @@ -1,9 +1,9 @@ import { Metadata } from 'next'; import StartStudyButton from '@/components/home/start-study-button'; -import StudyCard from '@/features/study/schedule/ui/study-card'; import { generateMetadata as generateSEOMetadata } from '@/utils/seo'; import Banner from '@/widgets/home/banner'; import FeedbackLink from '@/widgets/home/feedback-link'; +import HomeContent from './home-content'; export const metadata: Metadata = generateSEOMetadata({ title: '홈 - ZERO-ONE', @@ -14,15 +14,20 @@ export const metadata: Metadata = generateSEOMetadata({ canonicalUrl: 'https://www.zeroone.it.kr/home', }); -export default async function Home() { +export default async function Home({ + searchParams, +}: { + searchParams?: Promise<{ tab?: string }>; +}) { + const resolvedSearchParams = await searchParams; + const activeTab = resolvedSearchParams?.tab || 'study'; + return ( -
-
- - - - -
+
+ + + +
); } diff --git a/src/app/(service)/insights/page.tsx b/src/app/(service)/insights/page.tsx index c996bc7c..30799732 100644 --- a/src/app/(service)/insights/page.tsx +++ b/src/app/(service)/insights/page.tsx @@ -92,6 +92,17 @@ export default async function BlogPage({ searchParams }: BlogPageProps) { {category.name} ))} + + + 위클리 + + NEW + + +
{/* 아티클 목록 */} diff --git a/src/app/(service)/insights/weekly/[id]/page.tsx b/src/app/(service)/insights/weekly/[id]/page.tsx new file mode 100644 index 00000000..24c45459 --- /dev/null +++ b/src/app/(service)/insights/weekly/[id]/page.tsx @@ -0,0 +1,12 @@ +import VotingDetailPageClient from '@/features/study/one-to-one/balance-game/ui/voting-detail-page-client'; + +export default async function VotingDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const votingId = Number(id); + + return ; +} diff --git a/src/app/(service)/insights/weekly/page.tsx b/src/app/(service)/insights/weekly/page.tsx new file mode 100644 index 00000000..0b380169 --- /dev/null +++ b/src/app/(service)/insights/weekly/page.tsx @@ -0,0 +1,5 @@ +import BalanceGamePage from '@/features/study/one-to-one/balance-game/ui/balance-game-page'; + +export default function VotingPage() { + return ; +} diff --git a/src/app/(service)/layout.tsx b/src/app/(service)/layout.tsx index 8954c45e..41b1a551 100644 --- a/src/app/(service)/layout.tsx +++ b/src/app/(service)/layout.tsx @@ -42,9 +42,9 @@ export default function ServiceLayout({ -
+
-
{children}
+
{children}
diff --git a/src/app/(service)/one-on-one/page.tsx b/src/app/(service)/one-on-one/page.tsx new file mode 100644 index 00000000..8facbe95 --- /dev/null +++ b/src/app/(service)/one-on-one/page.tsx @@ -0,0 +1,5 @@ +import OneOnOnePage from '@/features/study/one-to-one/ui/one-on-one-page'; + +export default function OneOnOnePageRoute() { + return ; +} diff --git a/src/components/card/discussion-card.tsx b/src/components/card/discussion-card.tsx new file mode 100644 index 00000000..b16283b1 --- /dev/null +++ b/src/components/card/discussion-card.tsx @@ -0,0 +1,135 @@ +import { formatDistanceToNow } from 'date-fns'; +import { ko } from 'date-fns/locale'; +import { MessageCircle, ThumbsUp, ThumbsDown, Eye, Clock } from 'lucide-react'; +import React 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 } from '@/types/discussion'; + +interface DiscussionCardProps { + discussion: Discussion; + onClick?: () => void; +} + +export default function DiscussionCard({ + discussion, + onClick, +}: DiscussionCardProps) { + const timeAgo = formatDistanceToNow(new Date(discussion.lastActivityAt), { + addSuffix: true, + locale: ko, + }); + + return ( +
+ {/* 헤더: 작성자 정보 & 주제 */} +
+
+ {/* 아바타 & 닉네임 */} +
e.stopPropagation()}> + +
+ +
+ + {discussion.author.nickname} + +
+ } + /> +
+ + {/* 시간 */} +
+ +
+ + {timeAgo} +
+
+
+ + {/* 주제 배지 */} +
+ {TOPIC_LABELS[discussion.topic]} +
+
+ + {/* 제목 */} +

+ {discussion.title} +

+ + {/* 요약 */} +

+ {discussion.summary} +

+ + {/* 태그 */} + {discussion.tags.length > 0 && ( +
+ {discussion.tags.map((tag) => ( + + #{tag} + + ))} +
+ )} + + {/* 하단 메타 정보 */} +
+
+ {/* 찬성 */} +
+ + {discussion.vote.agreeCount} +
+ + {/* 반대 */} +
+ + {discussion.vote.disagreeCount} +
+ + {/* 댓글 */} +
+ + {discussion.commentCount} +
+ + {/* 조회수 */} +
+ + {discussion.viewCount.toLocaleString()} +
+
+
+
+ ); +} diff --git a/src/components/card/voting-card.tsx b/src/components/card/voting-card.tsx new file mode 100644 index 00000000..81b2b836 --- /dev/null +++ b/src/components/card/voting-card.tsx @@ -0,0 +1,133 @@ +import { MessageCircle, Users } from 'lucide-react'; +import Link from 'next/link'; +import React 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 { BalanceGame } from '@/types/balance-game'; +import VoteTimer from '../voting/vote-timer'; + +interface VotingCardProps { + voting: BalanceGame; + onClick?: () => void; +} + +export default function VotingCard({ voting, onClick }: VotingCardProps) { + const topOption = voting.options.reduce((prev, current) => + prev.percentage > current.percentage ? prev : current, + ); + + // myVote can be null or number (optionId) + const hasVoted = voting.myVote !== undefined && voting.myVote !== null; + + const cardContent = ( +
{ + e.preventDefault(); + onClick(); + } + : undefined + } + > + {/* 헤더: 작성자 & 상태 */} +
+ {/* 작성자 정보 */} +
e.stopPropagation()}> + +
+ +
+ + {voting.author.nickname} + +
+ } + /> +
+ + {/* 타이머 표시 */} + +
+ + {/* 제목 */} +

+ {voting.title} +

+ + {/* 태그 - 제목 바로 아래에 표시 */} + {voting.tags && Array.isArray(voting.tags) && voting.tags.length > 0 && ( +
+ {voting.tags.map((tag, index) => ( + + #{tag} + + ))} +
+ )} + + {/* 설명 (있으면) */} + {voting.description && ( +

+ {voting.description} +

+ )} + + {/* 간단한 투표 결과 미리보기 (투표했을 때만) */} + {hasVoted && ( +
+
+ 현재 1위 +
+
+ + {topOption.label} + + + {topOption.percentage.toFixed(1)}% + +
+
+ )} + + {/* 하단 메타 정보 */} +
+ {/* 총 투표 수 */} +
+ + {voting.totalVotes.toLocaleString()} +
+ + {/* 댓글 수 */} +
+ + {voting.commentCount || 0} +
+
+ + ); + + // onClick이 있으면 Link 없이 렌더링, 없으면 Link로 감싸기 + if (onClick) { + return cardContent; + } + + return {cardContent}; +} diff --git a/src/components/discussion/comment-form.tsx b/src/components/discussion/comment-form.tsx new file mode 100644 index 00000000..df2f9271 --- /dev/null +++ b/src/components/discussion/comment-form.tsx @@ -0,0 +1,104 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { Send, Loader2 } from 'lucide-react'; +import React from 'react'; +import { useForm } from 'react-hook-form'; +import { cn } from '@/components/ui/(shadcn)/lib/utils'; +import { CommentFormSchema, CommentFormData } from '@/types/schemas/zod-schema'; + +interface CommentFormProps { + onSubmit: (data: CommentFormData) => void | Promise; + isSubmitting?: boolean; + placeholder?: string; + autoFocus?: boolean; + initialValue?: string; + onCancel?: () => void; +} + +export default function CommentForm({ + onSubmit, + isSubmitting = false, + placeholder = '댓글을 입력하세요...', + autoFocus = false, + initialValue = '', + onCancel, +}: CommentFormProps) { + const { + register, + handleSubmit, + formState: { errors }, + reset, + } = useForm({ + resolver: zodResolver(CommentFormSchema), + defaultValues: { + content: initialValue, + }, + }); + + const handleFormSubmit = async (data: CommentFormData) => { + await onSubmit(data); + reset(); + }; + + return ( +
+
+
+