-
+
+
+ 내 아카이브
+
-
+
onSearchChange(e.target.value)}
+ onFocus={() => openIfEligible(searchTerm)}
+ onChange={(e) => {
+ const nextValue = e.target.value;
+ onSearchChange(nextValue);
+ openIfEligible(nextValue);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'ArrowDown' && flatSuggestions.length) {
+ e.preventDefault();
+ setIsOpen(true);
+ setActiveIndex((prev) =>
+ prev + 1 >= flatSuggestions.length ? 0 : prev + 1,
+ );
+
+ return;
+ }
+ if (e.key === 'ArrowUp' && flatSuggestions.length) {
+ e.preventDefault();
+ setIsOpen(true);
+ setActiveIndex((prev) =>
+ prev <= 0 ? flatSuggestions.length - 1 : prev - 1,
+ );
+
+ return;
+ }
+ if (e.key === 'Enter') {
+ if (activeIndex >= 0 && flatSuggestions[activeIndex]) {
+ e.preventDefault();
+ handleSelectSuggestion(flatSuggestions[activeIndex].value);
+ }
+
+ return;
+ }
+ if (e.key === 'Escape') {
+ setIsOpen(false);
+ setActiveIndex(-1);
+ }
+ }}
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"
/>
+ {isOpen &&
+ (flatSuggestions.length > 0 || isFetching) &&
+ searchTerm.trim().length >= minQueryLength && (
+
+
+ {isFetching ? (
+
+ 검색 중...
+
+ ) : (
+ <>
+ {titleSuggestions.length > 0 && (
+
+
+ 제목
+
+ {titleSuggestions.map((title, index) => (
+
+ ))}
+
+ )}
+ {authorSuggestions.length > 0 && (
+
0 &&
+ 'border-border-subtle border-t pt-100',
+ )}
+ >
+
+ 작성자
+
+ {authorSuggestions.map((author, index) => {
+ const authorIndex =
+ titleSuggestions.length + index;
+
+ return (
+
+ );
+ })}
+
+ )}
+ >
+ )}
+
+
+ )}
+ {isOpen &&
+ !isFetching &&
+ flatSuggestions.length === 0 &&
+ searchTerm.trim().length >= minQueryLength && (
+
+ )}
-
-
-
-
-
- {ARCHIVE_SORT_OPTIONS.map((option) => (
-
- ))}
-
-
-
+
}
+ />
-
-
-
-
+
,
+ title: '2열 보기',
+ },
+ {
+ value: ARCHIVE_VIEW_MODES.LIST,
+ icon:
,
+ title: '1열 보기 (촘촘하게)',
+ },
+ ]}
+ />
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
index fea248bd..2689de81 100644
--- a/src/features/study/one-to-one/archive/ui/archive-grid.tsx
+++ b/src/features/study/one-to-one/archive/ui/archive-grid.tsx
@@ -1,15 +1,23 @@
-import { Bookmark, Eye, Heart, Search } from 'lucide-react';
+import { Bookmark, Check, Eye, Heart, Pencil, Search, X } from 'lucide-react';
import React from 'react';
import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import ActionPillButton from '@/components/ui/action-pill-button';
+import UserAvatar from '@/components/ui/avatar';
+import Badge from '@/components/ui/badge';
+import Checkbox from '@/components/ui/checkbox';
+import { BaseInput, TextAreaInput } from '@/components/ui/input';
+import StatItem from '@/components/ui/stat-item';
+import UserProfileModal from '@/entities/user/ui/user-profile-modal';
+import type { UpdateArchiveRequest } from '@/features/study/one-to-one/archive/api/update-archive';
import { ArchiveItem } from '@/types/archive';
interface ArchiveGridProps {
items: ArchiveItem[];
- isAdmin: boolean;
+ canEdit: 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;
+ onUpdate: (id: number, request: UpdateArchiveRequest) => void;
}
const LibraryCard = ({
@@ -17,89 +25,242 @@ const LibraryCard = ({
onLike,
onView,
onBookmark,
- onHide,
- isAdmin,
+ onUpdate,
+ canEdit,
}: {
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;
+ onUpdate: (id: number, request: UpdateArchiveRequest) => void;
+ canEdit?: boolean;
}) => {
- const isHidden = (item as any).isHidden;
+ const isPrivate = item.isPrivate;
+ const bookmarkCount = item.bookmarks ?? 0;
+ const cardRef = React.useRef
(null);
+ const [isEditing, setIsEditing] = React.useState(false);
+ const [title, setTitle] = React.useState(item.title);
+ const [description, setDescription] = React.useState(item.description ?? '');
+ const [nextPrivate, setNextPrivate] = React.useState(!!item.isPrivate);
+
+ React.useEffect(() => {
+ if (!isEditing) {
+ setTitle(item.title);
+ setDescription(item.description ?? '');
+ setNextPrivate(!!item.isPrivate);
+ }
+ }, [isEditing, item.title, item.description, item.isPrivate]);
+
+ const handleSave = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ const request: UpdateArchiveRequest = {};
+ const trimmedTitle = title.trim();
+ const trimmedDesc = description.trim();
+ const currentDesc = item.description ?? '';
+
+ if (trimmedTitle !== item.title) request.title = trimmedTitle;
+ if (trimmedDesc !== currentDesc) request.description = trimmedDesc;
+ if (nextPrivate !== !!item.isPrivate) request.isPrivate = nextPrivate;
+
+ if (Object.keys(request).length === 0) {
+ setIsEditing(false);
+
+ return;
+ }
+
+ onUpdate(item.id, request);
+ setIsEditing(false);
+ };
+
+ const handleCancel = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ setIsEditing(false);
+ setTitle(item.title);
+ setDescription(item.description);
+ setNextPrivate(!!item.isPrivate);
+ };
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',
- )}
+ ref={cardRef}
+ onClick={() => {
+ if (isEditing) return;
+ onView(item);
+ }}
+ className="rounded-200 border-border-subtle bg-background-default shadow-1 hover:shadow-2 flex h-full cursor-pointer flex-col gap-200 border p-300 transition-all hover:-translate-y-25"
>
-
-
- {isHidden && (
-
- 숨김됨
-
- )}
-
-
- {isAdmin && onHide && (
-
- )}
-
-
-
-
-
-
- {item.title}
-
+ >
+ ) : (
+ <>
+
+
+ {item.title}
+
+ {canEdit && (
+
+ {isPrivate ? '비공개' : '공개'}
+
+ )}
+
+
+ {item.description ?? ''}
+
+ >
+ )}
-
-
- by{' '}
- {item.author}
-
-
-
-
- {item.views.toLocaleString()}
-
-
+
+
e.stopPropagation()}
+ className="font-designer-13m text-text-subtle hover:text-text-brand flex items-center gap-100"
+ >
+
+
+ {item.author}
+
+
+ }
+ />
+
+ {!isEditing && (
+
+ }
+ value={item.views.toLocaleString()}
+ className="font-designer-12r text-text-subtle min-w-[64px]"
+ />
+ onLike(e, item.id)}
+ icon={
+
+ }
+ value={item.likes.toLocaleString()}
+ className="font-designer-12r min-w-[64px]"
+ valueClassName={cn(
+ item.isLiked ? 'font-bold text-red-500' : 'text-text-subtle',
+ )}
+ hoverClassName="hover:scale-110 hover:bg-red-50"
+ />
+ onBookmark(e, item.id)}
+ icon={
+
+ }
+ value={bookmarkCount.toLocaleString()}
+ className="font-designer-12r min-w-[64px]"
+ valueClassName={cn(
+ 'text-[11px]',
+ item.isBookmarked
+ ? 'text-text-strong font-bold'
+ : 'text-text-subtle',
+ )}
+ hoverClassName="hover:scale-110 hover:bg-fill-neutral-subtle-hover"
+ />
+
+ )}
+ {isEditing ? (
+
+
}
+ >
+ 완료
+
+
}
+ >
+ 취소
+
+
+ ) : (
+ canEdit && (
+
{
+ e.stopPropagation();
+ setIsEditing(true);
+ requestAnimationFrame(() => {
+ cardRef.current?.scrollIntoView({
+ behavior: 'smooth',
+ block: 'center',
+ });
+ });
+ }}
+ variant="ghost"
+ size="sm"
+ className="min-w-[64px] justify-center gap-25"
+ icon={}
+ >
+ 수정
+
+ )
+ )}
@@ -108,11 +269,11 @@ const LibraryCard = ({
export default function ArchiveGrid({
items,
- isAdmin,
onLike,
onView,
onBookmark,
- onHide,
+ onUpdate,
+ canEdit,
}: ArchiveGridProps) {
if (items.length === 0) {
return (
@@ -132,8 +293,8 @@ export default function ArchiveGrid({
onLike={onLike}
onView={onView}
onBookmark={onBookmark}
- onHide={onHide}
- isAdmin={isAdmin}
+ onUpdate={onUpdate}
+ canEdit={canEdit}
/>
))}
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
index aa5a0dfe..82b7c692 100644
--- a/src/features/study/one-to-one/archive/ui/archive-header.tsx
+++ b/src/features/study/one-to-one/archive/ui/archive-header.tsx
@@ -1,33 +1,12 @@
import { LibraryBig } from 'lucide-react';
-import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import SectionHeader from '@/components/ui/section-header';
-interface ArchiveHeaderProps {
- isAdmin: boolean;
- onToggleAdmin: () => void;
-}
-
-export default function ArchiveHeader({
- isAdmin,
- onToggleAdmin,
-}: ArchiveHeaderProps) {
+export default function ArchiveHeader() {
return (
-
-
- 제로원 아카이브
-
-
-
-
-
+
}
+ description="제로원 아카이브는 스터디 멤버들이 공유한 자료를 모아볼 수 있는 공간입니다."
+ />
);
}
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
index 8d452997..63652dd8 100644
--- a/src/features/study/one-to-one/archive/ui/archive-list.tsx
+++ b/src/features/study/one-to-one/archive/ui/archive-list.tsx
@@ -1,15 +1,23 @@
-import { Bookmark, Eye, Heart, Search } from 'lucide-react';
+import { Bookmark, Check, Eye, Heart, Pencil, Search, X } from 'lucide-react';
import React from 'react';
import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import ActionPillButton from '@/components/ui/action-pill-button';
+import UserAvatar from '@/components/ui/avatar';
+import Badge from '@/components/ui/badge';
+import Checkbox from '@/components/ui/checkbox';
+import { BaseInput, TextAreaInput } from '@/components/ui/input';
+import StatItem from '@/components/ui/stat-item';
+import UserProfileModal from '@/entities/user/ui/user-profile-modal';
+import type { UpdateArchiveRequest } from '@/features/study/one-to-one/archive/api/update-archive';
import { ArchiveItem } from '@/types/archive';
interface ArchiveListProps {
items: ArchiveItem[];
- isAdmin: boolean;
+ canEdit: 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;
+ onUpdate: (id: number, request: UpdateArchiveRequest) => void;
}
const LibraryRow = ({
@@ -17,93 +25,266 @@ const LibraryRow = ({
onLike,
onView,
onBookmark,
- onHide,
- isAdmin,
+ onUpdate,
+ canEdit,
}: {
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;
+ onUpdate: (id: number, request: UpdateArchiveRequest) => void;
+ canEdit?: boolean;
}) => {
- const isHidden = (item as any).isHidden;
+ const isPrivate = item.isPrivate;
+ const bookmarkCount = item.bookmarks ?? 0;
+ const rowRef = React.useRef
(null);
+ const [isEditing, setIsEditing] = React.useState(false);
+ const [title, setTitle] = React.useState(item.title);
+ const [description, setDescription] = React.useState(item.description ?? '');
+ const [nextPrivate, setNextPrivate] = React.useState(!!item.isPrivate);
+
+ React.useEffect(() => {
+ if (!isEditing) {
+ setTitle(item.title);
+ setDescription(item.description ?? '');
+ setNextPrivate(!!item.isPrivate);
+ }
+ }, [isEditing, item.title, item.description, item.isPrivate]);
+
+ const handleSave = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ const request: UpdateArchiveRequest = {};
+ const trimmedTitle = title.trim();
+ const trimmedDesc = description.trim();
+ const currentDesc = item.description ?? '';
+
+ if (trimmedTitle !== item.title) request.title = trimmedTitle;
+ if (trimmedDesc !== currentDesc) request.description = trimmedDesc;
+ if (nextPrivate !== !!item.isPrivate) request.isPrivate = nextPrivate;
+
+ if (Object.keys(request).length === 0) {
+ setIsEditing(false);
+
+ return;
+ }
+
+ onUpdate(item.id, request);
+ setIsEditing(false);
+ };
+
+ const handleCancel = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ setIsEditing(false);
+ setTitle(item.title);
+ setDescription(item.description ?? '');
+ setNextPrivate(!!item.isPrivate);
+ };
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',
- )}
+ ref={rowRef}
+ onClick={() => {
+ if (isEditing) return;
+ onView(item);
+ }}
+ 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"
>
-
-
- {item.title}
-
- {isHidden && (
-
- 숨김됨
-
- )}
-
-
- {item.author}
-
- {item.date}
-
+ {isEditing ? (
+
+
+
+ setTitle(e.target.value)}
+ maxLength={100}
+ className="w-full"
+ />
+
+ {canEdit && (
+
+ )}
+
+
setDescription(e.target.value)}
+ className="h-[96px]"
+ maxLength={100}
+ />
+
+
+ e.stopPropagation()}
+ className="text-text-default hover:text-text-brand flex items-center gap-100"
+ >
+
+ {item.author}
+
+ }
+ />
+
+ {item.date}
+
+
+
}
+ >
+ 완료
+
+
}
+ >
+ 취소
+
+
+
+
+ ) : (
+ <>
+
+
+ {item.title}
+
+ {canEdit && (
+
+ {isPrivate ? '비공개' : '공개'}
+
+ )}
+
+
+ {item.description ?? ''}
+
+
+ e.stopPropagation()}
+ className="text-text-default hover:text-text-brand flex items-center gap-100"
+ >
+
+ {item.author}
+
+ }
+ />
+
+ {item.date}
+
+ >
+ )}
- {isAdmin && onHide && (
-
+ )}
+ {!isEditing && canEdit && (
+
{
e.stopPropagation();
- onHide(e, item.id);
+ setIsEditing(true);
+ requestAnimationFrame(() => {
+ rowRef.current?.scrollIntoView({
+ behavior: 'smooth',
+ block: 'center',
+ });
+ });
}}
- className="rounded-100 font-designer-11m bg-background-alternative text-text-subtle hover:bg-fill-neutral-subtle-hover flex items-center gap-25 px-100 py-50 transition-colors"
+ variant="ghost"
+ size="xs"
+ className="min-w-[64px] justify-center gap-25"
+ icon={}
>
- {isHidden ? '보이기' : '숨기기'}
-
+ 수정
+
)}
-
-
-
-
- {item.views.toLocaleString()}
-
-
-
);
@@ -111,11 +292,11 @@ const LibraryRow = ({
export default function ArchiveList({
items,
- isAdmin,
onLike,
onView,
onBookmark,
- onHide,
+ onUpdate,
+ canEdit,
}: ArchiveListProps) {
if (items.length === 0) {
return (
@@ -136,8 +317,8 @@ export default function ArchiveList({
onLike={onLike}
onView={onView}
onBookmark={onBookmark}
- onHide={onHide}
- isAdmin={isAdmin}
+ onUpdate={onUpdate}
+ canEdit={canEdit}
/>
))}
diff --git a/src/features/study/one-to-one/archive/ui/archive-pagination.tsx b/src/features/study/one-to-one/archive/ui/archive-pagination.tsx
new file mode 100644
index 00000000..7678d478
--- /dev/null
+++ b/src/features/study/one-to-one/archive/ui/archive-pagination.tsx
@@ -0,0 +1,39 @@
+'use client';
+
+import { ChevronLeft, ChevronRight } from 'lucide-react';
+import React from 'react';
+import PaginationCircleButton from '@/features/study/one-to-one/ui/pagination-circle-button';
+
+interface ArchivePaginationProps {
+ currentPage: number;
+ totalPages: number;
+ onPageChange: (page: number) => void;
+}
+
+export default function ArchivePagination({
+ currentPage,
+ totalPages,
+ onPageChange,
+}: ArchivePaginationProps) {
+ if (totalPages <= 1) return null;
+
+ return (
+
+
onPageChange(Math.max(1, currentPage - 1))}
+ disabled={currentPage === 1}
+ >
+
+
+
+ {currentPage} / {totalPages}
+
+
onPageChange(Math.min(totalPages, currentPage + 1))}
+ disabled={currentPage === totalPages}
+ >
+
+
+
+ );
+}
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
index ea8c5758..11f3731d 100644
--- 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
@@ -1,15 +1,13 @@
'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 { Loader2 } from 'lucide-react';
+import React from 'react';
+import SectionShell from '@/components/ui/section-shell';
+import { 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 { useScrollToHomeContent } from '@/hooks/use-scroll-to-home-content';
import {
ArchiveItem,
ArchiveResponse,
@@ -19,6 +17,8 @@ import ArchiveFilters from './archive-filters';
import ArchiveGrid from './archive-grid';
import ArchiveHeader from './archive-header';
import ArchiveList from './archive-list';
+import ArchivePagination from './archive-pagination';
+import { useArchiveFilters } from './use-archive-filters';
// ----------------------------------------------------------------------
// Main Component
@@ -33,32 +33,37 @@ 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 [isClientReady, setIsClientReady] = React.useState(false);
+ const scrollToHomeContent = useScrollToHomeContent();
+ const {
+ librarySort,
+ viewMode,
+ currentPage,
+ searchTerm,
+ showBookmarkedOnly,
+ showMyOnly,
+ itemsPerPage,
+ setLibrarySort,
+ setViewMode,
+ setCurrentPage,
+ setSearchTerm,
+ toggleBookmarkedOnly,
+ toggleMyOnly,
+ } = useArchiveFilters({
+ onToggleScroll: () => requestAnimationFrame(scrollToHomeContent),
+ });
- const ITEMS_PER_PAGE =
- viewMode === ARCHIVE_VIEW_MODES.LIST
- ? ARCHIVE_PAGE_SIZE.LIST
- : ARCHIVE_PAGE_SIZE.GRID;
+ const debouncedSearchTerm = useDebounce(searchTerm, 500);
// React Query Hook
- const archiveParams = {
+ const archiveParams: GetArchiveParams = {
page: currentPage - 1,
- size: ITEMS_PER_PAGE,
+ size: itemsPerPage,
sort: librarySort,
search: debouncedSearchTerm || undefined,
bookmarkedOnly: showBookmarkedOnly || undefined,
+ authorOnly: showMyOnly || undefined,
+ authorId: undefined,
};
const shouldUseInitialData =
@@ -66,14 +71,25 @@ export default function ArchiveTabClient({
archiveParams.size === initialParams.size &&
archiveParams.sort === initialParams.sort &&
archiveParams.search === initialParams.search &&
- archiveParams.bookmarkedOnly === initialParams.bookmarkedOnly;
+ archiveParams.bookmarkedOnly === initialParams.bookmarkedOnly &&
+ archiveParams.authorOnly === initialParams.authorOnly &&
+ archiveParams.authorId === initialParams.authorId;
const { data: archiveData, isLoading } = useArchiveQuery(archiveParams, {
initialData: shouldUseInitialData ? initialData : undefined,
});
- const { toggleBookmark, toggleLike, openAndRecordView, isAuthenticated } =
- useArchiveActions();
+ const {
+ toggleBookmark,
+ toggleLike,
+ updateArchive,
+ openAndRecordView,
+ isAuthenticated,
+ } = useArchiveActions();
+
+ React.useEffect(() => {
+ setIsClientReady(true);
+ }, []);
const libraryItems = archiveData?.content || [];
const totalPages = archiveData?.totalPages || 1;
@@ -81,7 +97,7 @@ export default function ArchiveTabClient({
// Handler for Likes
const handleLike = (e: React.MouseEvent, id: number) => {
e.stopPropagation();
- if (!isAuthenticated) return;
+ if (!isClientReady || !isAuthenticated) return;
toggleLike(id);
};
@@ -91,22 +107,13 @@ export default function ArchiveTabClient({
const handleLibraryBookmark = (e: React.MouseEvent, id: number) => {
e.stopPropagation();
- if (!isAuthenticated) return;
+ if (!isClientReady || !isAuthenticated) return;
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)
- }
- isAuthenticated={isAuthenticated}
+ onToggleBookmarkedOnly={toggleBookmarkedOnly}
+ showMyOnly={showMyOnly}
+ onToggleMyOnly={toggleMyOnly}
+ isAuthenticated={isClientReady ? isAuthenticated : false}
/>
{isLoading ? (
@@ -133,47 +140,31 @@ export default function ArchiveTabClient({
{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/use-archive-filters.ts b/src/features/study/one-to-one/archive/ui/use-archive-filters.ts
new file mode 100644
index 00000000..d3beda47
--- /dev/null
+++ b/src/features/study/one-to-one/archive/ui/use-archive-filters.ts
@@ -0,0 +1,70 @@
+'use client';
+
+import { useState } from 'react';
+import {
+ ARCHIVE_PAGE_SIZE,
+ ARCHIVE_VIEW_MODES,
+} from '@/features/study/one-to-one/archive/const/archive';
+
+type LibrarySort = 'LATEST' | 'VIEWS' | 'LIKES';
+type ViewMode = 'GRID' | 'LIST';
+
+interface UseArchiveFiltersOptions {
+ onToggleScroll?: () => void;
+}
+
+export const useArchiveFilters = (options?: UseArchiveFiltersOptions) => {
+ const [librarySort, setLibrarySort] = useState
('LATEST');
+ const [viewMode, setViewMode] = useState(ARCHIVE_VIEW_MODES.LIST);
+ const [currentPage, setCurrentPage] = useState(1);
+ const [searchTerm, setSearchTerm] = useState('');
+ const [showBookmarkedOnly, setShowBookmarkedOnly] = useState(false);
+ const [showMyOnly, setShowMyOnly] = useState(false);
+
+ const triggerScroll = () => {
+ options?.onToggleScroll?.();
+ };
+
+ const toggleBookmarkedOnly = () => {
+ setShowBookmarkedOnly((prev) => {
+ if (!prev) {
+ setShowMyOnly(false);
+ }
+
+ return !prev;
+ });
+ triggerScroll();
+ };
+
+ const toggleMyOnly = () => {
+ setShowMyOnly((prev) => {
+ if (!prev) {
+ setShowBookmarkedOnly(false);
+ }
+
+ return !prev;
+ });
+ triggerScroll();
+ };
+
+ const itemsPerPage =
+ viewMode === ARCHIVE_VIEW_MODES.LIST
+ ? ARCHIVE_PAGE_SIZE.LIST
+ : ARCHIVE_PAGE_SIZE.GRID;
+
+ return {
+ librarySort,
+ viewMode,
+ currentPage,
+ searchTerm,
+ showBookmarkedOnly,
+ showMyOnly,
+ itemsPerPage,
+ setLibrarySort,
+ setViewMode,
+ setCurrentPage,
+ setSearchTerm,
+ toggleBookmarkedOnly,
+ toggleMyOnly,
+ };
+};
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
index 99aa8793..879b9521 100644
--- 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
@@ -11,17 +11,19 @@ export const getBalanceGameListServer = async (params: {
size?: number;
sort?: 'latest' | 'popular';
status?: 'active' | 'closed';
+ q?: string;
}): Promise => {
- const { page = 1, size = 10, sort = 'latest', status } = params;
+ const { page = 1, size = 10, sort = 'latest', status, q } = params;
const response = await axiosServerInstance.get<
ApiResponse
>('/balance-games', {
params: {
page,
- limit: size,
+ size,
sort,
status,
+ q,
},
});
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
index 0fa9a16a..9112b85d 100644
--- 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
@@ -9,6 +9,7 @@ import {
UpdateBalanceGameRequest,
UpdateCommentRequest,
VoteRequest,
+ BalanceGameTagSuggestion,
} from '@/types/balance-game';
// 1. 밸런스 게임 목록 조회
@@ -17,18 +18,24 @@ export const getBalanceGameList = async (params: {
size?: number;
sort?: 'latest' | 'popular';
status?: 'active' | 'closed';
+ tags?: string[];
+ q?: string;
}): Promise => {
- // 백엔드는 page를 1부터 시작하고 limit을 사용함
- const { page = 1, size = 10, sort = 'latest', status } = params;
+ // 백엔드는 page를 1부터 시작하고 size를 사용함
+ const { page = 1, size = 10, sort = 'latest', status, tags, q } = params;
+ const tagParam =
+ tags && tags.length > 0 ? tags.filter(Boolean).join(',') : undefined;
const response = await axiosInstance.get<
ApiResponse
>('/balance-games', {
params: {
page,
- limit: size, // 백엔드는 limit 파라미터를 사용
+ size,
sort,
status,
+ tags: tagParam,
+ q,
},
});
@@ -153,3 +160,33 @@ export const updateBalanceGame = async (
export const deleteBalanceGame = async (gameId: number): Promise => {
await axiosInstance.delete>(`/balance-games/${gameId}`);
};
+
+// 12. 밸런스 게임 태그 검색 (prefix)
+export const getBalanceGameTagSuggestions = async (params: {
+ q?: string;
+ minLength?: number;
+ limit?: number;
+ sort?: 'popular' | 'alphabetical';
+}): Promise => {
+ const { q, minLength = 1, limit = 10, sort = 'popular' } = params;
+ const response = await axiosInstance.get<
+ ApiResponse<{
+ suggestions?: BalanceGameTagSuggestion[] | string[];
+ tags?: BalanceGameTagSuggestion[] | string[];
+ }>
+ >('/balance-games/tags', {
+ params: { q, minLength, limit, sort },
+ });
+
+ const payload =
+ response.data && 'content' in response.data
+ ? response.data.content
+ : (response.data as unknown as {
+ suggestions?: BalanceGameTagSuggestion[] | string[];
+ tags?: BalanceGameTagSuggestion[] | string[];
+ });
+
+ const rawTags = payload?.suggestions ?? payload?.tags ?? [];
+
+ return rawTags.map((tag) => (typeof tag === 'string' ? { name: tag } : tag));
+};
diff --git a/src/features/study/one-to-one/balance-game/api/get-balance-game-search-suggestions.ts b/src/features/study/one-to-one/balance-game/api/get-balance-game-search-suggestions.ts
new file mode 100644
index 00000000..c32754d9
--- /dev/null
+++ b/src/features/study/one-to-one/balance-game/api/get-balance-game-search-suggestions.ts
@@ -0,0 +1,42 @@
+import { axiosInstance } from '@/api/client/axios';
+import type {
+ BalanceGameSearchSuggestionResponse,
+ GetBalanceGameSearchSuggestionsParams,
+} from '@/types/balance-game';
+
+export const getBalanceGameSearchSuggestions = async ({
+ q,
+ minLength = 1,
+ size = 10,
+ scope = 'all',
+}: GetBalanceGameSearchSuggestionsParams): Promise => {
+ const response = await axiosInstance.get<
+ | BalanceGameSearchSuggestionResponse
+ | { content?: BalanceGameSearchSuggestionResponse }
+ | {
+ statusCode?: number;
+ timestamp?: string;
+ content?: BalanceGameSearchSuggestionResponse;
+ message?: string;
+ }
+ >('/balance-games/suggestions', {
+ params: { q, minLength, size, scope },
+ });
+
+ const isSuggestionResponse = (
+ value: unknown,
+ ): value is BalanceGameSearchSuggestionResponse =>
+ !!value &&
+ typeof value === 'object' &&
+ Array.isArray((value as BalanceGameSearchSuggestionResponse).titles) &&
+ Array.isArray((value as BalanceGameSearchSuggestionResponse).authors);
+
+ const payload =
+ 'content' in response.data ? response.data.content : response.data;
+ const safePayload = isSuggestionResponse(payload) ? payload : undefined;
+
+ return {
+ titles: safePayload?.titles ?? [],
+ authors: safePayload?.authors ?? [],
+ };
+};
diff --git a/src/features/study/one-to-one/balance-game/const/tags.ts b/src/features/study/one-to-one/balance-game/const/tags.ts
new file mode 100644
index 00000000..23f2a9f4
--- /dev/null
+++ b/src/features/study/one-to-one/balance-game/const/tags.ts
@@ -0,0 +1,3 @@
+export const BALANCE_GAME_TAG_MAX_LEN = 40;
+export const BALANCE_GAME_TAG_MAX_COUNT = 3;
+export const BALANCE_GAME_TAG_MIN_QUERY_LEN = 1;
diff --git a/src/features/study/one-to-one/balance-game/model/balance-game-keys.ts b/src/features/study/one-to-one/balance-game/model/balance-game-keys.ts
new file mode 100644
index 00000000..e20c2896
--- /dev/null
+++ b/src/features/study/one-to-one/balance-game/model/balance-game-keys.ts
@@ -0,0 +1,31 @@
+export const BALANCE_GAME_QUERY_KEYS = {
+ all: ['balanceGames'] as const,
+ lists: () => [...BALANCE_GAME_QUERY_KEYS.all, 'list'] as const,
+ list: (filters: {
+ sort: 'latest' | 'popular';
+ status?: 'active' | 'closed';
+ tags?: string[];
+ q?: string;
+ }) => [...BALANCE_GAME_QUERY_KEYS.lists(), filters] as const,
+ details: () => [...BALANCE_GAME_QUERY_KEYS.all, 'detail'] as const,
+ detail: (id: number) => [...BALANCE_GAME_QUERY_KEYS.details(), id] as const,
+ comments: (id: number) =>
+ [...BALANCE_GAME_QUERY_KEYS.detail(id), 'comments'] as const,
+ tags: (query: string, limit: number, minLength: number, sort: string) =>
+ [
+ ...BALANCE_GAME_QUERY_KEYS.all,
+ 'tags',
+ query,
+ limit,
+ minLength,
+ sort,
+ ] as const,
+ searchSuggestions: () =>
+ [...BALANCE_GAME_QUERY_KEYS.all, 'search-suggestions'] as const,
+ searchSuggestionList: (params: {
+ q: string;
+ size: number;
+ minLength: number;
+ scope: 'title' | 'author' | 'all';
+ }) => [...BALANCE_GAME_QUERY_KEYS.searchSuggestions(), params] as const,
+};
diff --git a/src/features/study/one-to-one/balance-game/model/index.ts b/src/features/study/one-to-one/balance-game/model/index.ts
new file mode 100644
index 00000000..a02c14dd
--- /dev/null
+++ b/src/features/study/one-to-one/balance-game/model/index.ts
@@ -0,0 +1,18 @@
+export { BALANCE_GAME_QUERY_KEYS } from './balance-game-keys';
+export {
+ useBalanceGameCommentsQuery,
+ useBalanceGameDetailQuery,
+ useBalanceGameListQuery,
+ useBalanceGameTagSuggestionsQuery,
+} from './use-balance-game-query';
+export { useBalanceGameSearchSuggestionsQuery } from './use-balance-game-search-suggestions-query';
+export {
+ useCancelVoteBalanceGameMutation,
+ useCreateBalanceGameCommentMutation,
+ useCreateBalanceGameMutation,
+ useDeleteBalanceGameCommentMutation,
+ useDeleteBalanceGameMutation,
+ useUpdateBalanceGameCommentMutation,
+ useUpdateBalanceGameMutation,
+ useVoteBalanceGameMutation,
+} from './use-balance-game-mutation';
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
index ac9a0ac6..3a0bc6f5 100644
--- 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
@@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { BALANCE_GAME_QUERY_KEYS } from '@/features/study/one-to-one/balance-game/model/balance-game-keys';
import { UpdateBalanceGameRequest } from '@/types/balance-game';
-import { BALANCE_GAME_KEYS } from './use-balance-game-query';
import {
cancelVoteBalanceGame,
createBalanceGame,
@@ -19,7 +19,7 @@ export const useCreateBalanceGameMutation = () => {
mutationFn: createBalanceGame,
onSuccess: () => {
queryClient
- .invalidateQueries({ queryKey: BALANCE_GAME_KEYS.lists() })
+ .invalidateQueries({ queryKey: BALANCE_GAME_QUERY_KEYS.lists() })
.catch(() => {
// 쿼리 무효화 실패 시 무시
});
@@ -35,13 +35,13 @@ export const useVoteBalanceGameMutation = (gameId: number) => {
onSuccess: () => {
queryClient
.invalidateQueries({
- queryKey: BALANCE_GAME_KEYS.detail(gameId),
+ queryKey: BALANCE_GAME_QUERY_KEYS.detail(gameId),
})
.catch(() => {
// 쿼리 무효화 실패 시 무시
});
queryClient
- .invalidateQueries({ queryKey: BALANCE_GAME_KEYS.lists() })
+ .invalidateQueries({ queryKey: BALANCE_GAME_QUERY_KEYS.lists() })
.catch(() => {
// 쿼리 무효화 실패 시 무시
});
@@ -57,13 +57,13 @@ export const useCancelVoteBalanceGameMutation = (gameId: number) => {
onSuccess: () => {
queryClient
.invalidateQueries({
- queryKey: BALANCE_GAME_KEYS.detail(gameId),
+ queryKey: BALANCE_GAME_QUERY_KEYS.detail(gameId),
})
.catch(() => {
// 쿼리 무효화 실패 시 무시
});
queryClient
- .invalidateQueries({ queryKey: BALANCE_GAME_KEYS.lists() })
+ .invalidateQueries({ queryKey: BALANCE_GAME_QUERY_KEYS.lists() })
.catch(() => {
// 쿼리 무효화 실패 시 무시
});
@@ -80,14 +80,14 @@ export const useCreateBalanceGameCommentMutation = (gameId: number) => {
onSuccess: () => {
queryClient
.invalidateQueries({
- queryKey: BALANCE_GAME_KEYS.comments(gameId),
+ queryKey: BALANCE_GAME_QUERY_KEYS.comments(gameId),
})
.catch(() => {
// 쿼리 무효화 실패 시 무시
});
queryClient
.invalidateQueries({
- queryKey: BALANCE_GAME_KEYS.detail(gameId),
+ queryKey: BALANCE_GAME_QUERY_KEYS.detail(gameId),
})
.catch(() => {
// 쿼리 무효화 실패 시 무시
@@ -110,7 +110,7 @@ export const useUpdateBalanceGameCommentMutation = (gameId: number) => {
onSuccess: () => {
queryClient
.invalidateQueries({
- queryKey: BALANCE_GAME_KEYS.comments(gameId),
+ queryKey: BALANCE_GAME_QUERY_KEYS.comments(gameId),
})
.catch(() => {
// 쿼리 무효화 실패 시 무시
@@ -128,14 +128,14 @@ export const useDeleteBalanceGameCommentMutation = (gameId: number) => {
onSuccess: () => {
queryClient
.invalidateQueries({
- queryKey: BALANCE_GAME_KEYS.comments(gameId),
+ queryKey: BALANCE_GAME_QUERY_KEYS.comments(gameId),
})
.catch(() => {
// 쿼리 무효화 실패 시 무시
});
queryClient
.invalidateQueries({
- queryKey: BALANCE_GAME_KEYS.detail(gameId),
+ queryKey: BALANCE_GAME_QUERY_KEYS.detail(gameId),
})
.catch(() => {
// 쿼리 무효화 실패 시 무시
@@ -153,13 +153,13 @@ export const useUpdateBalanceGameMutation = (gameId: number) => {
onSuccess: () => {
queryClient
.invalidateQueries({
- queryKey: BALANCE_GAME_KEYS.detail(gameId),
+ queryKey: BALANCE_GAME_QUERY_KEYS.detail(gameId),
})
.catch(() => {
// 쿼리 무효화 실패 시 무시
});
queryClient
- .invalidateQueries({ queryKey: BALANCE_GAME_KEYS.lists() })
+ .invalidateQueries({ queryKey: BALANCE_GAME_QUERY_KEYS.lists() })
.catch(() => {
// 쿼리 무효화 실패 시 무시
});
@@ -174,11 +174,13 @@ export const useDeleteBalanceGameMutation = (gameId: number) => {
mutationFn: () => deleteBalanceGame(gameId),
onSuccess: () => {
queryClient
- .invalidateQueries({ queryKey: BALANCE_GAME_KEYS.lists() })
+ .invalidateQueries({ queryKey: BALANCE_GAME_QUERY_KEYS.lists() })
.catch(() => {
// 쿼리 무효화 실패 시 무시
});
- queryClient.removeQueries({ queryKey: BALANCE_GAME_KEYS.detail(gameId) });
+ queryClient.removeQueries({
+ queryKey: BALANCE_GAME_QUERY_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
index bf1eb080..300efa2e 100644
--- 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
@@ -1,32 +1,30 @@
-import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
+import {
+ keepPreviousData,
+ useInfiniteQuery,
+ useQuery,
+} from '@tanstack/react-query';
+import { BALANCE_GAME_QUERY_KEYS } from '@/features/study/one-to-one/balance-game/model/balance-game-keys';
import {
getBalanceGameComments,
getBalanceGameDetail,
getBalanceGameList,
+ getBalanceGameTagSuggestions,
} 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',
+ tags?: string[],
+ q?: string,
options?: {
initialPage?: Awaited>;
},
) => {
return useInfiniteQuery({
- queryKey: BALANCE_GAME_KEYS.list({ sort, status }),
+ queryKey: BALANCE_GAME_QUERY_KEYS.list({ sort, status, tags, q }),
queryFn: ({ pageParam = 1 }) =>
- getBalanceGameList({ page: pageParam, size: 10, sort, status }),
+ getBalanceGameList({ page: pageParam, size: 10, sort, status, tags, q }),
+ placeholderData: keepPreviousData,
getNextPageParam: (lastPage) => {
// lastPage가 유효하고 pageable 정보가 있는지 확인
if (
@@ -62,7 +60,7 @@ export const useBalanceGameListQuery = (
export const useBalanceGameDetailQuery = (gameId: number) => {
return useQuery({
- queryKey: BALANCE_GAME_KEYS.detail(gameId),
+ queryKey: BALANCE_GAME_QUERY_KEYS.detail(gameId),
queryFn: () => getBalanceGameDetail(gameId),
enabled: !!gameId,
});
@@ -73,7 +71,7 @@ export const useBalanceGameCommentsQuery = (
options?: { enabled?: boolean },
) => {
return useInfiniteQuery({
- queryKey: BALANCE_GAME_KEYS.comments(gameId),
+ queryKey: BALANCE_GAME_QUERY_KEYS.comments(gameId),
queryFn: ({ pageParam = 0 }) =>
getBalanceGameComments(gameId, { page: pageParam, size: 10 }),
getNextPageParam: (lastPage) => {
@@ -100,3 +98,26 @@ export const useBalanceGameCommentsQuery = (
enabled: !!gameId && options?.enabled !== false,
});
};
+
+export const useBalanceGameTagSuggestionsQuery = (
+ query: string,
+ options?: {
+ limit?: number;
+ enabled?: boolean;
+ minLength?: number;
+ sort?: 'popular' | 'alphabetical';
+ },
+) => {
+ const limit = options?.limit ?? 10;
+ const minLength = options?.minLength ?? 1;
+ const sort = options?.sort ?? 'popular';
+ const enabled = options?.enabled ?? query.trim().length >= minLength;
+
+ return useQuery({
+ queryKey: BALANCE_GAME_QUERY_KEYS.tags(query, limit, minLength, sort),
+ queryFn: () =>
+ getBalanceGameTagSuggestions({ q: query, limit, minLength, sort }),
+ enabled,
+ staleTime: 60_000,
+ });
+};
diff --git a/src/features/study/one-to-one/balance-game/model/use-balance-game-search-suggestions-query.ts b/src/features/study/one-to-one/balance-game/model/use-balance-game-search-suggestions-query.ts
new file mode 100644
index 00000000..dc68b531
--- /dev/null
+++ b/src/features/study/one-to-one/balance-game/model/use-balance-game-search-suggestions-query.ts
@@ -0,0 +1,31 @@
+import { useQuery } from '@tanstack/react-query';
+import { getBalanceGameSearchSuggestions } from '@/features/study/one-to-one/balance-game/api/get-balance-game-search-suggestions';
+import { BALANCE_GAME_QUERY_KEYS } from '@/features/study/one-to-one/balance-game/model/balance-game-keys';
+
+export const useBalanceGameSearchSuggestionsQuery = (
+ query: string,
+ options?: {
+ size?: number;
+ minLength?: number;
+ scope?: 'title' | 'author' | 'all';
+ enabled?: boolean;
+ },
+) => {
+ const size = options?.size ?? 10;
+ const minLength = options?.minLength ?? 1;
+ const scope = options?.scope ?? 'all';
+ const enabled = options?.enabled ?? query.trim().length >= minLength;
+
+ return useQuery({
+ queryKey: BALANCE_GAME_QUERY_KEYS.searchSuggestionList({
+ q: query,
+ size,
+ minLength,
+ scope,
+ }),
+ queryFn: () =>
+ getBalanceGameSearchSuggestions({ q: query, size, minLength, scope }),
+ enabled,
+ staleTime: 60_000,
+ });
+};
diff --git a/src/features/study/one-to-one/balance-game/ui/balance-game-filters-bar.tsx b/src/features/study/one-to-one/balance-game/ui/balance-game-filters-bar.tsx
new file mode 100644
index 00000000..4fd55446
--- /dev/null
+++ b/src/features/study/one-to-one/balance-game/ui/balance-game-filters-bar.tsx
@@ -0,0 +1,363 @@
+'use client';
+
+import { ArrowUpDown, Search } from 'lucide-react';
+import React from 'react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import { useBalanceGameSearchSuggestionsQuery } from '@/features/study/one-to-one/balance-game/model/use-balance-game-search-suggestions-query';
+import { useDebounce } from '@/hooks/use-debounce';
+import FilterPillButton from './filter-pill-button';
+import TagAutocomplete from './tag-autocomplete';
+
+type StatusFilter = 'active' | 'closed' | 'all';
+type SortMode = 'latest' | 'popular';
+
+interface BalanceGameFiltersBarProps {
+ statusFilter: StatusFilter;
+ onStatusChange: (next: StatusFilter) => void;
+ sortMode: SortMode;
+ onSortChange: (next: SortMode) => void;
+ searchTerm: string;
+ onSearchChange: (value: string) => void;
+ tagValue: string;
+ onTagValueChange: (value: string) => void;
+ onAddTag: (tag: string) => void;
+ selectedTags: string[];
+ onRemoveTag: (tag: string) => void;
+ tagSuggestions: { name: string; count?: number }[];
+ isTagLoading?: boolean;
+ sortVariant?: 'pills' | 'dropdown';
+ rightSlot?: React.ReactNode;
+}
+
+export default function BalanceGameFiltersBar({
+ statusFilter,
+ onStatusChange,
+ sortMode,
+ onSortChange,
+ searchTerm,
+ onSearchChange,
+ tagValue,
+ onTagValueChange,
+ onAddTag,
+ selectedTags,
+ onRemoveTag,
+ tagSuggestions,
+ isTagLoading = false,
+ sortVariant = 'pills',
+ rightSlot,
+}: BalanceGameFiltersBarProps) {
+ const minQueryLength = 1;
+ const debouncedSearchTerm = useDebounce(searchTerm, 200);
+ const [isOpen, setIsOpen] = React.useState(false);
+ const [activeIndex, setActiveIndex] = React.useState(-1);
+ const containerRef = React.useRef(null);
+ const { data: suggestionData, isFetching } =
+ useBalanceGameSearchSuggestionsQuery(debouncedSearchTerm, {
+ minLength: minQueryLength,
+ size: 10,
+ enabled: isOpen && debouncedSearchTerm.trim().length >= minQueryLength,
+ scope: 'all',
+ });
+
+ const titleSuggestions = suggestionData?.titles ?? [];
+ const authorSuggestions = suggestionData?.authors ?? [];
+
+ const flatSuggestions = React.useMemo(
+ () => [
+ ...titleSuggestions.map((value) => ({
+ value,
+ group: 'title' as const,
+ })),
+ ...authorSuggestions.map((value) => ({
+ value,
+ group: 'author' as const,
+ })),
+ ],
+ [titleSuggestions, authorSuggestions],
+ );
+
+ React.useEffect(() => {
+ if (!isOpen) return;
+ const handleClickOutside = (event: MouseEvent) => {
+ if (!containerRef.current) return;
+ if (!containerRef.current.contains(event.target as Node)) {
+ setIsOpen(false);
+ }
+ };
+ document.addEventListener('mousedown', handleClickOutside);
+
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, [isOpen]);
+
+ React.useEffect(() => {
+ setActiveIndex(-1);
+ }, [searchTerm, flatSuggestions.length]);
+
+ const openIfEligible = (nextValue: string) => {
+ const eligible = nextValue.trim().length >= minQueryLength;
+ setIsOpen(eligible);
+ if (!eligible) {
+ setActiveIndex(-1);
+ }
+ };
+
+ const handleSelectSuggestion = (value: string) => {
+ onSearchChange(value);
+ setIsOpen(false);
+ setActiveIndex(-1);
+ };
+
+ return (
+
+
+
+
onStatusChange('active')}
+ >
+ 진행 중
+
+
onStatusChange('closed')}
+ >
+ 종료됨
+
+
onStatusChange('all')}
+ >
+ 전체
+
+
+ {sortVariant === 'pills' && (
+ <>
+
+
onSortChange('latest')}
+ >
+ 최신순
+
+
onSortChange('popular')}
+ >
+ 인기순
+
+ >
+ )}
+
+
+
+
+
+
openIfEligible(searchTerm)}
+ onChange={(e) => {
+ const nextValue = e.target.value;
+ onSearchChange(nextValue);
+ openIfEligible(nextValue);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'ArrowDown' && flatSuggestions.length) {
+ e.preventDefault();
+ setIsOpen(true);
+ setActiveIndex((prev) =>
+ prev + 1 >= flatSuggestions.length ? 0 : prev + 1,
+ );
+
+ return;
+ }
+ if (e.key === 'ArrowUp' && flatSuggestions.length) {
+ e.preventDefault();
+ setIsOpen(true);
+ setActiveIndex((prev) =>
+ prev <= 0 ? flatSuggestions.length - 1 : prev - 1,
+ );
+
+ return;
+ }
+ if (e.key === 'Enter') {
+ if (activeIndex >= 0 && flatSuggestions[activeIndex]) {
+ e.preventDefault();
+ handleSelectSuggestion(flatSuggestions[activeIndex].value);
+ }
+
+ return;
+ }
+ if (e.key === 'Escape') {
+ setIsOpen(false);
+ setActiveIndex(-1);
+ }
+ }}
+ 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"
+ />
+
+
+
+ {isOpen &&
+ (flatSuggestions.length > 0 || isFetching) &&
+ searchTerm.trim().length >= minQueryLength && (
+
+
+ {isFetching ? (
+
+ 검색 중...
+
+ ) : (
+ <>
+ {titleSuggestions.length > 0 && (
+
+
+ 제목
+
+ {titleSuggestions.map((title, index) => (
+
+ ))}
+
+ )}
+ {authorSuggestions.length > 0 && (
+
0 &&
+ 'border-border-subtle border-t pt-100',
+ )}
+ >
+
+ 작성자
+
+ {authorSuggestions.map((author, index) => {
+ const authorIndex =
+ titleSuggestions.length + index;
+
+ return (
+
+ );
+ })}
+
+ )}
+ >
+ )}
+
+
+ )}
+ {isOpen &&
+ !isFetching &&
+ flatSuggestions.length === 0 &&
+ searchTerm.trim().length >= minQueryLength && (
+
+ )}
+
+
+ {sortVariant === 'dropdown' && (
+
+
+
+
+
+
+
+
+
+ )}
+
+ {rightSlot}
+
+
+
+ {selectedTags.length > 0 && (
+
+ {selectedTags.map((tag) => (
+
+ ))}
+
+ )}
+
+ );
+}
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
deleted file mode 100644
index 26b3ce1d..00000000
--- a/src/features/study/one-to-one/balance-game/ui/balance-game-page.tsx
+++ /dev/null
@@ -1,248 +0,0 @@
-'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
index 0d4b723a..dd1e4b0a 100644
--- 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
@@ -1,27 +1,35 @@
'use client';
-import {
- Loader2,
- Vote,
- SearchX,
- Plus,
- MessageSquareText,
- ArrowUpDown,
-} from 'lucide-react';
-import React, { useState, useEffect, useRef } from 'react';
+import { Loader2, Vote, SearchX, Plus, MessageSquareText } from 'lucide-react';
+import { useRouter, useSearchParams } from 'next/navigation';
+import React, { useState, useEffect, useMemo } from 'react';
import VotingCard from '@/components/card/voting-card';
-import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import SectionHeader from '@/components/ui/section-header';
+import SectionShell from '@/components/ui/section-shell';
import Toast from '@/components/ui/toast';
import VotingCreateModal from '@/components/voting/voting-create-modal';
import VotingDetailView from '@/components/voting/voting-detail-view';
+import { BALANCE_GAME_TAG_MIN_QUERY_LEN } from '@/features/study/one-to-one/balance-game/const/tags';
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 {
+ useBalanceGameListQuery,
+ useBalanceGameTagSuggestionsQuery,
+} from '@/features/study/one-to-one/balance-game/model/use-balance-game-query';
+import { useAuth } from '@/hooks/common/use-auth';
+import { useDebounce } from '@/hooks/use-debounce';
+import {
+ useScrollToHomeContentOnChange,
+ useScrollToHomeContentWithStabilize,
+} from '@/hooks/use-scroll-to-home-content';
import type {
BalanceGameListResponse,
CreateBalanceGameRequest,
} from '@/types/balance-game';
import { VotingCreateFormData } from '@/types/schemas/zod-schema';
-import FilterPillButton from './filter-pill-button';
+import { decodeVotingId, encodeVotingId } from '@/utils/voting-id';
+import BalanceGameFiltersBar from './balance-game-filters-bar';
+import { useBalanceGameFilters } from './use-balance-game-filters';
+import { useInfiniteScroll } from './use-infinite-scroll';
interface CommunityTabClientProps {
initialList?: BalanceGameListResponse;
@@ -30,18 +38,38 @@ interface CommunityTabClientProps {
export default function CommunityTabClient({
initialList,
}: CommunityTabClientProps) {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const scrollToHomeContent = useScrollToHomeContentWithStabilize();
+ const { isAuthenticated } = useAuth();
// 상태 관리
- const [statusFilter, setStatusFilter] = useState<'active' | 'closed' | 'all'>(
- 'active',
- );
- const [sortMode, setSortMode] = useState<'latest' | 'popular'>('latest');
+ const {
+ statusFilter,
+ sortMode,
+ selectedTags,
+ setStatus,
+ setSort,
+ addTag,
+ removeTag,
+ } = useBalanceGameFilters({
+ onChange: () => requestAnimationFrame(scrollToHomeContent),
+ });
+ const [tagFilterInput, setTagFilterInput] = useState('');
+ const [searchTerm, setSearchTerm] = useState('');
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
- const [selectedVotingId, setSelectedVotingId] = useState(null);
const [showToast, setShowToast] = useState(false);
+ const debouncedTagQuery = useDebounce(tagFilterInput, 300);
+ const debouncedSearchTerm = useDebounce(searchTerm, 200);
+ useScrollToHomeContentOnChange([statusFilter, sortMode], {
+ stabilize: true,
+ });
// React Query Hooks
const shouldUseInitialList =
- sortMode === 'latest' && statusFilter === 'active';
+ sortMode === 'latest' &&
+ statusFilter === 'active' &&
+ selectedTags.length === 0 &&
+ debouncedSearchTerm.trim().length === 0;
const {
data,
fetchNextPage,
@@ -50,19 +78,32 @@ export default function CommunityTabClient({
isFetchingNextPage,
status,
isPending,
- error,
} = useBalanceGameListQuery(
sortMode,
statusFilter === 'all' ? undefined : statusFilter,
+ selectedTags.length ? selectedTags : undefined,
+ debouncedSearchTerm.trim() || undefined,
{
initialPage: shouldUseInitialList ? initialList : undefined,
},
);
const createMutation = useCreateBalanceGameMutation();
+ const trimmedTagQuery = debouncedTagQuery.trim();
+ const { data: tagSuggestions = [], isFetching: isTagLoading } =
+ useBalanceGameTagSuggestionsQuery(trimmedTagQuery, {
+ limit: 10,
+ enabled: trimmedTagQuery.length >= BALANCE_GAME_TAG_MIN_QUERY_LEN,
+ minLength: BALANCE_GAME_TAG_MIN_QUERY_LEN,
+ sort: 'popular',
+ });
- // 무한 스크롤용 ref
- const observerTarget = useRef(null);
+ const observerTarget = useInfiniteScroll({
+ hasNextPage,
+ isFetchingNextPage,
+ isFetching,
+ fetchNextPage,
+ });
// 투표 생성 핸들러
const handleCreateVoting = async (data: VotingCreateFormData) => {
@@ -85,58 +126,61 @@ export default function CommunityTabClient({
}
};
- // 무한 스크롤 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 },
- );
+ // 상세 화면으로 전환
+ const selectedVotingId = useMemo(() => {
+ const votingIdParam = searchParams.get('votingId');
+ if (!votingIdParam) return null;
- observer.observe(currentTarget);
+ const parsedId = Number(votingIdParam);
+ if (Number.isFinite(parsedId) && parsedId > 0) {
+ return parsedId;
+ }
- return () => {
- observer.disconnect();
- };
- }, [hasNextPage, isFetchingNextPage, isFetching, fetchNextPage]);
+ return decodeVotingId(votingIdParam);
+ }, [searchParams]);
- // 상세 화면으로 전환
const handleVotingClick = (votingId: number) => {
- setSelectedVotingId(votingId);
+ const params = new URLSearchParams(searchParams.toString());
+ params.set('tab', 'community');
+ params.set('votingId', encodeVotingId(votingId));
+ router.push(`/home?${params.toString()}`);
+ };
+
+ const handleAddTag = (value: string) => {
+ addTag(value);
+ setTagFilterInput('');
+ };
+
+ const handleRemoveTagFilter = (tag: string) => {
+ removeTag(tag);
+ };
+
+ const handleTagClick = (tag: string) => {
+ handleAddTag(tag);
};
// 목록으로 돌아가기
const handleBackToList = () => {
- setSelectedVotingId(null);
+ const params = new URLSearchParams(searchParams.toString());
+ params.delete('votingId');
+ params.set('tab', 'community');
+ router.push(`/home?${params.toString()}`, { scroll: false });
+ requestAnimationFrame(scrollToHomeContent);
};
// 상세 화면이 열려있으면 상세 화면 표시
- if (selectedVotingId) {
+ if (selectedVotingId !== null) {
return (
-
-
-
+
);
}
// 로딩 상태 (첫 로드만)
- if (isPending) {
+ if (isPending && !data) {
return (
@@ -170,97 +214,48 @@ export default function CommunityTabClient({
}
const votings = data?.pages.flatMap((page) => page.content) || [];
+ const visibleVotings = votings;
return (
<>
-
+
{/* Header */}
-
-
- 밸런스게임
-
-
-
-
- {/* 헤더 설명 */}
-
-
- 다양한 주제에 투표하고 댓글로 자유롭게 토론할 수 있습니다.
-
-
-
- {/* 필터 + 주제 생성 버튼 */}
-
- {/* 필터(상태) + 정렬 */}
-
-
setStatusFilter('active')}
- >
- 진행 중
-
-
setStatusFilter('closed')}
- >
- 종료됨
-
-
setStatusFilter('all')}
- >
- 전체
-
-
- {/* divider */}
-
+
}
+ description="다양한 주제에 투표하고 댓글로 자유롭게 토론할 수 있습니다."
+ />
- {/* Sort Dropdown */}
-
-
-
- {/* Dropdown */}
-
-
-
-
-
-
-
-
-
- {/* 주제 생성 버튼 */}
-
-
+ ) : null
+ }
+ />
{/* 투표 목록 */}
- {votings.length === 0 ? (
+ {visibleVotings.length === 0 ? (
@@ -275,11 +270,12 @@ export default function CommunityTabClient({
) : (
<>
- {votings.map((voting) => (
+ {visibleVotings.map((voting, index) => (
handleVotingClick(voting.id)}
+ onTagClick={handleTagClick}
/>
))}
@@ -294,7 +290,7 @@ export default function CommunityTabClient({
)}
- {!hasNextPage && votings.length > 0 && (
+ {!hasNextPage && visibleVotings.length > 0 && (
모든 투표를 불러왔습니다
@@ -302,7 +298,7 @@ export default function CommunityTabClient({
>
)}
-
+
{/* 주제 생성 모달 */}
);
}
+
+interface CommunityDetailViewProps {
+ votingId: number;
+ onBack: () => void;
+ onScrollToAnchor: () => void;
+}
+
+function CommunityDetailView({
+ votingId,
+ onBack,
+ onScrollToAnchor,
+}: CommunityDetailViewProps) {
+ useEffect(() => {
+ requestAnimationFrame(onScrollToAnchor);
+ }, [onScrollToAnchor, votingId]);
+
+ 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
index 49662159..7ea387b4 100644
--- 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
@@ -1,28 +1 @@
-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 (
-
- );
-}
+export { default } from '@/components/ui/filters/filter-pill-button';
diff --git a/src/features/study/one-to-one/balance-game/ui/tag-autocomplete.tsx b/src/features/study/one-to-one/balance-game/ui/tag-autocomplete.tsx
new file mode 100644
index 00000000..3dc036ae
--- /dev/null
+++ b/src/features/study/one-to-one/balance-game/ui/tag-autocomplete.tsx
@@ -0,0 +1,251 @@
+'use client';
+
+import { Search } from 'lucide-react';
+import React, { useEffect, useMemo, useRef, useState } from 'react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import { BALANCE_GAME_TAG_MIN_QUERY_LEN } from '@/features/study/one-to-one/balance-game/const/tags';
+import type { BalanceGameTagSuggestion } from '@/types/balance-game';
+
+interface TagAutocompleteProps {
+ value: string;
+ onValueChange: (value: string) => void;
+ onAddTag: (tag: string) => void;
+ selectedTags: string[];
+ onRemoveTag: (tag: string) => void;
+ suggestions: BalanceGameTagSuggestion[];
+ isLoading?: boolean;
+ disabled?: boolean;
+ layout?: 'inline' | 'stacked';
+ minQueryLength?: number;
+ placeholder?: string;
+ maxLength?: number;
+ emptyMessage?: string;
+ className?: string;
+ inputClassName?: string;
+ menuClassName?: string;
+ showSelectedTags?: boolean;
+}
+
+export default function TagAutocomplete({
+ value,
+ onValueChange,
+ onAddTag,
+ selectedTags,
+ onRemoveTag,
+ suggestions,
+ isLoading = false,
+ disabled = false,
+ layout = 'inline',
+ minQueryLength = BALANCE_GAME_TAG_MIN_QUERY_LEN,
+ placeholder = '태그로 검색하세요',
+ maxLength = 40,
+ emptyMessage = '검색 결과가 없습니다',
+ className,
+ inputClassName,
+ menuClassName,
+ showSelectedTags = true,
+}: TagAutocompleteProps) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [activeIndex, setActiveIndex] = useState(-1);
+ const containerRef = useRef
(null);
+
+ const filteredSuggestions = useMemo(() => {
+ const selected = new Set(selectedTags);
+
+ return suggestions.filter((item) => !selected.has(item.name));
+ }, [suggestions, selectedTags]);
+
+ useEffect(() => {
+ if (!isOpen) return;
+ const handleClickOutside = (event: MouseEvent) => {
+ if (!containerRef.current) return;
+ if (!containerRef.current.contains(event.target as Node)) {
+ setIsOpen(false);
+ }
+ };
+ document.addEventListener('mousedown', handleClickOutside);
+
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, [isOpen]);
+
+ useEffect(() => {
+ setActiveIndex(-1);
+ }, [value, filteredSuggestions.length]);
+
+ const openIfEligible = (nextValue: string) => {
+ if (disabled) {
+ setIsOpen(false);
+
+ return;
+ }
+ setIsOpen(nextValue.trim().length >= minQueryLength);
+ };
+
+ const handleAdd = (tag: string) => {
+ if (disabled) return;
+ onAddTag(tag);
+ setIsOpen(false);
+ setActiveIndex(-1);
+ };
+
+ const shouldShowMenu = isOpen && value.trim().length >= minQueryLength;
+
+ return (
+
+
+
openIfEligible(value)}
+ onChange={(e) => {
+ const nextValue = e.target.value;
+ onValueChange(nextValue);
+ openIfEligible(nextValue);
+ }}
+ onKeyDown={(e) => {
+ if (
+ e.ctrlKey &&
+ (e.code === 'Space' || e.key === ' ' || e.key === 'Spacebar')
+ ) {
+ e.preventDefault();
+ openIfEligible(value);
+
+ return;
+ }
+ if (e.key === 'ArrowDown' && filteredSuggestions.length) {
+ e.preventDefault();
+ setIsOpen(true);
+ setActiveIndex((prev) =>
+ prev + 1 >= filteredSuggestions.length ? 0 : prev + 1,
+ );
+
+ return;
+ }
+ if (e.key === 'ArrowUp' && filteredSuggestions.length) {
+ e.preventDefault();
+ setIsOpen(true);
+ setActiveIndex((prev) =>
+ prev <= 0 ? filteredSuggestions.length - 1 : prev - 1,
+ );
+
+ return;
+ }
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ if (activeIndex >= 0 && filteredSuggestions[activeIndex]) {
+ handleAdd(filteredSuggestions[activeIndex].name);
+
+ return;
+ }
+ handleAdd(value);
+
+ return;
+ }
+ if (e.key === 'Escape') {
+ setIsOpen(false);
+ setActiveIndex(-1);
+ }
+ }}
+ placeholder={placeholder}
+ maxLength={maxLength}
+ disabled={disabled}
+ className={cn(
+ 'rounded-100 bg-background-default border-border-subtle font-designer-14m text-text-default hover:bg-fill-neutral-subtle-hover w-full min-w-0 border px-200 py-150 pr-500 transition-colors disabled:cursor-not-allowed disabled:opacity-50',
+ inputClassName,
+ )}
+ />
+
+
+
+ {shouldShowMenu && (filteredSuggestions.length > 0 || isLoading) && (
+
+
+ {isLoading ? (
+
+ 검색 중...
+
+ ) : (
+ filteredSuggestions.map((tag, index) => (
+
+ ))
+ )}
+
+
+ )}
+ {shouldShowMenu &&
+ !isLoading &&
+ filteredSuggestions.length === 0 &&
+ value.trim().length >= minQueryLength && (
+
+ )}
+
+
+ {showSelectedTags && selectedTags.length > 0 && (
+
+ {selectedTags.map((tag) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/features/study/one-to-one/balance-game/ui/use-balance-game-filters.ts b/src/features/study/one-to-one/balance-game/ui/use-balance-game-filters.ts
new file mode 100644
index 00000000..8574fe60
--- /dev/null
+++ b/src/features/study/one-to-one/balance-game/ui/use-balance-game-filters.ts
@@ -0,0 +1,70 @@
+'use client';
+
+import { useState } from 'react';
+
+type StatusFilter = 'active' | 'closed' | 'all';
+type SortMode = 'latest' | 'popular';
+
+interface BalanceGameFiltersOptions {
+ onChange?: () => void;
+}
+
+export const useBalanceGameFilters = (options?: BalanceGameFiltersOptions) => {
+ const [statusFilter, setStatusFilter] = useState('active');
+ const [sortMode, setSortMode] = useState('latest');
+ const [selectedTags, setSelectedTags] = useState([]);
+
+ const triggerChange = () => {
+ options?.onChange?.();
+ };
+
+ const setStatus = (next: StatusFilter) => {
+ setStatusFilter((prev) => {
+ if (prev === next) return prev;
+ triggerChange();
+
+ return next;
+ });
+ };
+
+ const setSort = (next: SortMode) => {
+ setSortMode((prev) => {
+ if (prev === next) return prev;
+ triggerChange();
+
+ return next;
+ });
+ };
+
+ const addTag = (value: string) => {
+ const trimmed = value.trim();
+ if (!trimmed) return;
+ setSelectedTags((prev) => {
+ if (prev.includes(trimmed)) {
+ return prev;
+ }
+ triggerChange();
+
+ return [...prev, trimmed];
+ });
+ };
+
+ const removeTag = (tag: string) => {
+ setSelectedTags((prev) => {
+ if (!prev.includes(tag)) return prev;
+ triggerChange();
+
+ return prev.filter((item) => item !== tag);
+ });
+ };
+
+ return {
+ statusFilter,
+ sortMode,
+ selectedTags,
+ setStatus,
+ setSort,
+ addTag,
+ removeTag,
+ };
+};
diff --git a/src/features/study/one-to-one/balance-game/ui/use-infinite-scroll.ts b/src/features/study/one-to-one/balance-game/ui/use-infinite-scroll.ts
new file mode 100644
index 00000000..bd5a6a09
--- /dev/null
+++ b/src/features/study/one-to-one/balance-game/ui/use-infinite-scroll.ts
@@ -0,0 +1,50 @@
+'use client';
+
+import { useEffect, useRef } from 'react';
+
+interface InfiniteScrollOptions {
+ hasNextPage?: boolean;
+ isFetchingNextPage?: boolean;
+ isFetching?: boolean;
+ fetchNextPage: () => Promise;
+ threshold?: number;
+}
+
+export const useInfiniteScroll = ({
+ hasNextPage,
+ isFetchingNextPage,
+ isFetching,
+ fetchNextPage,
+ threshold = 0.1,
+}: InfiniteScrollOptions) => {
+ const observerTarget = useRef(null);
+
+ useEffect(() => {
+ const currentTarget = observerTarget.current;
+ if (!currentTarget) return;
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (
+ entries[0].isIntersecting &&
+ hasNextPage &&
+ !isFetchingNextPage &&
+ !isFetching
+ ) {
+ fetchNextPage().catch(() => {
+ // 무한 스크롤 실패 시 무시
+ });
+ }
+ },
+ { threshold },
+ );
+
+ observer.observe(currentTarget);
+
+ return () => {
+ observer.disconnect();
+ };
+ }, [fetchNextPage, hasNextPage, isFetching, isFetchingNextPage, threshold]);
+
+ return observerTarget;
+};
diff --git a/src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-constants.ts b/src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-constants.ts
new file mode 100644
index 00000000..216ba197
--- /dev/null
+++ b/src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-constants.ts
@@ -0,0 +1,57 @@
+import { Flame, FileText, Thermometer } from 'lucide-react';
+import type { Ranker } from '@/types/hall-of-fame';
+
+export type RankingType = 'ATTENDANCE' | 'STUDY_LOG' | 'SINCERITY';
+
+export interface RankerWithLabel extends Ranker {
+ scoreLabel: string;
+}
+
+export const TAB_CONFIG: Record<
+ RankingType,
+ {
+ label: string;
+ icon: typeof Flame;
+ unit: string;
+ colorClass: string;
+ }
+> = {
+ ATTENDANCE: {
+ label: '불꽃 출석왕',
+ icon: Flame,
+ unit: '회',
+ colorClass: 'text-text-brand',
+ },
+ STUDY_LOG: {
+ label: '열정 기록왕',
+ icon: FileText,
+ unit: '건',
+ colorClass: 'text-text-information',
+ },
+ SINCERITY: {
+ label: '성실 온도왕',
+ icon: Thermometer,
+ unit: '℃',
+ colorClass: 'text-text-warning',
+ },
+};
+
+export const addScoreLabel = (
+ ranker: Ranker,
+ type: RankingType,
+): RankerWithLabel => {
+ let scoreLabel = '';
+
+ if (type === 'ATTENDANCE') {
+ scoreLabel = `${ranker.score}회`;
+ } else if (type === 'STUDY_LOG') {
+ scoreLabel = `${ranker.score}건`;
+ } else {
+ scoreLabel = `${ranker.score}℃`;
+ }
+
+ return {
+ ...ranker,
+ scoreLabel,
+ };
+};
diff --git a/src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-header.tsx b/src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-header.tsx
new file mode 100644
index 00000000..a7c1d5ae
--- /dev/null
+++ b/src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-header.tsx
@@ -0,0 +1,24 @@
+'use client';
+
+import { Trophy } from 'lucide-react';
+import React from 'react';
+import SectionHeader from '@/components/ui/section-header';
+
+export default function HallOfFameHeader() {
+ return (
+
+ }
+ description={
+
+
제로원을 빛낸 열정적인 멤버들과 최고의 유저들을 소개합니다.
+
+ 꾸준한 1:1 스터디를 통해 제로원 명예의 전당에 이름을 올려보세요!
+
+
+ }
+ />
+ );
+}
diff --git a/src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-mvp-section.tsx b/src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-mvp-section.tsx
new file mode 100644
index 00000000..21d98c12
--- /dev/null
+++ b/src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-mvp-section.tsx
@@ -0,0 +1,33 @@
+'use client';
+
+import { Users } from 'lucide-react';
+import React from 'react';
+import type { MVPTeam } from '@/types/hall-of-fame';
+import MVPTeamCard from './mvp-team-card';
+
+interface HallOfFameMvpSectionProps {
+ team?: MVPTeam;
+}
+
+export default function HallOfFameMvpSection({
+ team,
+}: HallOfFameMvpSectionProps) {
+ return (
+
+
+
+ 저번 주 스터디 MVP 팀
+
+
+ {team ? (
+
+ ) : (
+
+
+ 이번 주 MVP 팀이 없습니다.
+
+
+ )}
+
+ );
+}
diff --git a/src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-ranker-section.tsx b/src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-ranker-section.tsx
new file mode 100644
index 00000000..1dde0abf
--- /dev/null
+++ b/src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-ranker-section.tsx
@@ -0,0 +1,81 @@
+'use client';
+
+import React from 'react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import type { RankingType, RankerWithLabel } from './hall-of-fame-constants';
+import { TAB_CONFIG } from './hall-of-fame-constants';
+import RankerListItem from './ranker-list-item';
+import RankingTabButton from './ranking-tab-button';
+
+interface HallOfFameRankerSectionProps {
+ rankingType: RankingType;
+ onChangeRankingType: (type: RankingType) => void;
+ baseDate: string;
+ rankers: RankerWithLabel[];
+}
+
+export default function HallOfFameRankerSection({
+ rankingType,
+ onChangeRankingType,
+ baseDate,
+ rankers,
+}: HallOfFameRankerSectionProps) {
+ return (
+
+
+
+
+
+ {(() => {
+ const Icon = TAB_CONFIG[rankingType].icon;
+
+ return ;
+ })()}
+
+ {TAB_CONFIG[rankingType].label} TOP 5
+
+
+ {baseDate} 기준
+
+
+
+
+ {(Object.keys(TAB_CONFIG) as RankingType[]).map((type) => (
+ onChangeRankingType(type)}
+ >
+
+ {(() => {
+ const Icon = TAB_CONFIG[type].icon;
+
+ return ;
+ })()}
+
+ {TAB_CONFIG[type].label}
+
+ ))}
+
+
+
+
+ {rankers.length > 0 ? (
+ rankers.map((ranker) => (
+
+ ))
+ ) : (
+
+ )}
+
+
+ );
+}
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
index 98e66b7b..8cf178e1 100644
--- 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
@@ -1,250 +1,17 @@
'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 UserAvatar from '@/components/ui/avatar';
-import UserProfileModal from '@/entities/user/ui/user-profile-modal';
+import SectionShell from '@/components/ui/section-shell';
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
-// ----------------------------------------------------------------------
+import type { HallOfFameData } from '@/types/hall-of-fame';
+import {
+ addScoreLabel,
+ type RankingType,
+ type RankerWithLabel,
+} from './hall-of-fame-constants';
+import HallOfFameHeader from './hall-of-fame-header';
+import HallOfFameMvpSection from './hall-of-fame-mvp-section';
+import HallOfFameRankerSection from './hall-of-fame-ranker-section';
interface HallOfFameTabClientProps {
initialData?: HallOfFameData;
@@ -310,90 +77,19 @@ export default function HallOfFameTabClient({
}
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/mvp-team-card.tsx b/src/features/study/one-to-one/hall-of-fame/ui/mvp-team-card.tsx
new file mode 100644
index 00000000..ab598194
--- /dev/null
+++ b/src/features/study/one-to-one/hall-of-fame/ui/mvp-team-card.tsx
@@ -0,0 +1,119 @@
+'use client';
+
+import { Trophy, Flame } from 'lucide-react';
+import React from 'react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import UserAvatar from '@/components/ui/avatar';
+import Tooltip from '@/components/ui/tooltip';
+import UserProfileModal from '@/entities/user/ui/user-profile-modal';
+import type { MVPTeam } from '@/types/hall-of-fame';
+
+interface MVPTeamCardProps {
+ team: MVPTeam;
+ className?: string;
+}
+
+export default function MVPTeamCard({ team, className }: MVPTeamCardProps) {
+ return (
+
+
+
+
+
+
+ ?
+
+ }
+ />
+
+
+
+
+
+ 1월 4주차 MVP 팀
+
+
+ 최고의 스터디 메이트
+
+
+
+
+ {team.members.map((member, index) => (
+
+
+
+
+
+
+ {member.nickname}
+
+
+
+ }
+ />
+
+ {index === 0 && (
+
+ &
+
+ )}
+
+ ))}
+
+
+
+
+
+
+ 이번 주 공유한 자료
+
+
+
+
+
+
+ );
+}
diff --git a/src/features/study/one-to-one/hall-of-fame/ui/rank-badge.tsx b/src/features/study/one-to-one/hall-of-fame/ui/rank-badge.tsx
new file mode 100644
index 00000000..414b8946
--- /dev/null
+++ b/src/features/study/one-to-one/hall-of-fame/ui/rank-badge.tsx
@@ -0,0 +1,31 @@
+'use client';
+
+import Image from 'next/image';
+import React from 'react';
+
+interface RankBadgeProps {
+ rank: number;
+}
+
+export default function RankBadge({ rank }: RankBadgeProps) {
+ const iconPath =
+ rank === 1
+ ? '/icons/gold-rank.svg'
+ : rank === 2
+ ? '/icons/silver-rank.svg'
+ : '/icons/bronze-rank.svg';
+
+ if (rank > 3) {
+ return (
+