From 1aa5335b303ae95f5376195386b7b2b5b711683d Mon Sep 17 00:00:00 2001 From: Hyeonjun0527 Date: Thu, 5 Feb 2026 00:13:21 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=95=84=EC=B9=B4=EC=9D=B4=EB=B8=8C=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EB=B2=84=EA=B7=B8=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor: 코드래빗반영 --- docs/balance-game-tag-filter-request.md | 46 ------------- src/api/client/api-logger.ts | 65 +++++++++++++++---- src/components/ui/filters/sort-dropdown.tsx | 6 +- .../ui/filters/view-mode-toggle.tsx | 3 + src/components/ui/modal-shell.tsx | 2 +- src/components/voting/voting-create-modal.tsx | 2 +- .../api/get-archive-search-suggestions.ts | 4 +- .../one-to-one/archive/api/update-archive.ts | 2 + .../one-to-one/archive/model/archive-keys.ts | 2 +- .../use-archive-search-suggestions-query.ts | 8 +-- .../one-to-one/archive/ui/archive-filters.tsx | 2 +- .../one-to-one/archive/ui/archive-grid.tsx | 15 ++++- .../one-to-one/archive/ui/archive-list.tsx | 15 ++++- .../balance-game/api/balance-game-api.ts | 6 +- .../balance-game/model/balance-game-keys.ts | 4 +- .../model/use-balance-game-query.ts | 8 +-- .../balance-game/ui/community-tab-client.tsx | 2 +- .../hall-of-fame/ui/mvp-team-card.tsx | 20 +++++- .../one-to-one/hall-of-fame/ui/rank-badge.tsx | 14 ++-- .../hall-of-fame/ui/ranker-list-item.tsx | 8 ++- .../history/ui/study-history-header.tsx | 6 ++ src/types/archive.ts | 2 +- src/utils/voting-id.ts | 12 +++- 23 files changed, 158 insertions(+), 96 deletions(-) delete mode 100644 docs/balance-game-tag-filter-request.md diff --git a/docs/balance-game-tag-filter-request.md b/docs/balance-game-tag-filter-request.md deleted file mode 100644 index 9aa89e15..00000000 --- a/docs/balance-game-tag-filter-request.md +++ /dev/null @@ -1,46 +0,0 @@ -# 밸런스게임 태그 검색/필터 요청서 - -## 목적 - -- 태그 필터를 드롭다운이 아닌 **검색 입력 + Enter 적용** 방식으로 변경. -- **다중 태그 필터** 지원 (입력 후 Enter로 태그 추가). -- 필터 적용 시 **입력된 모든 태그를 포함**하는 밸런스게임 목록만 반환. - -## 변경 요약 - -- 프론트는 태그 입력 후 Enter 또는 "적용" 버튼 클릭 시 `tag` 쿼리로 요청 -- 입력 길이 제한: **최대 40자** -- 태그 필터 해제 시 `tag` 파라미터 제거 - -## API 요청 - -### GET /api/v1/balance-game - -- 기존 목록 API 유지 -- Query Params: - - `page` (int, 1-based) - - `size` (int) - - `sort` (`latest` | `popular`) - - `status` (`active` | `closed`, optional) -- `tags` (string, optional) — `tag1,tag2,tag3` 형태의 comma-separated - -### 태그 필터 동작 - -- `tags`가 전달되면 **모든 태그를 포함**한 투표만 반환 -- 대소문자/공백 처리 정책은 백엔드에서 일관되게 적용 -- `tags`가 비어있거나 누락되면 전체 반환 - -## 입력 제약 (프론트 기준) - -- 태그 길이: 1~40자 -- 다중 태그 필터 지원 - -## 응답 - -- 기존 밸런스게임 목록 응답 스키마 그대로 - -## 예시 - -``` -GET /api/v1/balance-game?page=1&size=10&sort=latest&status=active&tags=frontend,react -``` diff --git a/src/api/client/api-logger.ts b/src/api/client/api-logger.ts index 717fec65..61a56101 100644 --- a/src/api/client/api-logger.ts +++ b/src/api/client/api-logger.ts @@ -5,6 +5,9 @@ import type { } from 'axios'; const shouldLog = process.env.NODE_ENV !== 'production'; +const shouldLogPayloads = + shouldLog && process.env.NEXT_PUBLIC_API_LOG_PAYLOADS === 'true'; +const MAX_LOG_LENGTH = 500; const normalizeUrl = (config: InternalAxiosRequestConfig) => { const base = config.baseURL ?? ''; @@ -16,26 +19,60 @@ const normalizeUrl = (config: InternalAxiosRequestConfig) => { return `${base.replace(/\/$/, '')}/${url.replace(/^\//, '')}`; }; -const stringifyParams = (params: InternalAxiosRequestConfig['params']) => { - if (!params) return ''; +const truncate = (value: string) => + value.length > MAX_LOG_LENGTH + ? `${value.slice(0, MAX_LOG_LENGTH)}...` + : value; + +const sanitizeValue = (value: unknown): unknown => { + const seen = new WeakSet(); + + const walk = (input: unknown): unknown => { + if (input === null || input === undefined) return input; + + if (typeof input === 'string') return truncate(input); + if (typeof input === 'number' || typeof input === 'boolean') return input; + if (typeof input === 'bigint') return truncate(input.toString()); + + if (Array.isArray(input)) { + return input.slice(0, 50).map((item) => walk(item)); + } + if (typeof input === 'object') { + const obj = input as object; + if (seen.has(obj)) return '[Circular]'; + seen.add(obj); + + const entries = Object.entries(obj as Record); + + return Object.fromEntries(entries.map(([key, val]) => [key, walk(val)])); + } + + return truncate(String(input)); + }; + + return walk(value); +}; + +const safeStringify = (value: unknown) => { try { - return JSON.stringify(params); + return JSON.stringify(sanitizeValue(value)); } catch { - return ''; + return truncate(String(value)); } }; +const stringifyParams = (params: InternalAxiosRequestConfig['params']) => { + if (!params) return ''; + + return safeStringify(params); +}; + const stringifyData = (data: unknown) => { if (data === undefined) return ''; + if (typeof data === 'string') return truncate(data); - if (typeof data === 'string') return data; - - try { - return JSON.stringify(data); - } catch { - return String(data); - } + return safeStringify(data); }; export const attachApiLogger = (instance: AxiosInstance, label: string) => { @@ -44,7 +81,7 @@ export const attachApiLogger = (instance: AxiosInstance, label: string) => { instance.interceptors.request.use((config) => { const method = (config.method || 'get').toUpperCase(); const url = normalizeUrl(config); - const params = stringifyParams(config.params); + const params = shouldLogPayloads ? stringifyParams(config.params) : ''; console.log( `[API ${label}] ${method} ${url}${params ? ` params=${params}` : ''}`, @@ -57,7 +94,7 @@ export const attachApiLogger = (instance: AxiosInstance, label: string) => { (response) => { const method = (response.config.method || 'get').toUpperCase(); const url = normalizeUrl(response.config); - const data = stringifyData(response.data); + const data = shouldLogPayloads ? stringifyData(response.data) : ''; console.log(`[API ${label}] ${method} ${url} -> ${response.status}`); if (data) { @@ -71,7 +108,7 @@ export const attachApiLogger = (instance: AxiosInstance, label: string) => { const method = config?.method?.toUpperCase() || 'UNKNOWN'; const url = config ? normalizeUrl(config) : 'unknown'; const status = error.response?.status; - const data = stringifyData(error.response?.data); + const data = shouldLogPayloads ? stringifyData(error.response?.data) : ''; console.log( `[API ${label}] ${method} ${url} -> ERROR${status ? ` ${status}` : ''}`, diff --git a/src/components/ui/filters/sort-dropdown.tsx b/src/components/ui/filters/sort-dropdown.tsx index 6e511205..1d7f815f 100644 --- a/src/components/ui/filters/sort-dropdown.tsx +++ b/src/components/ui/filters/sort-dropdown.tsx @@ -27,7 +27,10 @@ export default function SortDropdown({ return (
- @@ -40,6 +43,7 @@ export default function SortDropdown({ > {options.map((option) => ( diff --git a/src/components/ui/modal-shell.tsx b/src/components/ui/modal-shell.tsx index 5aa004fc..ab4d838f 100644 --- a/src/components/ui/modal-shell.tsx +++ b/src/components/ui/modal-shell.tsx @@ -29,7 +29,7 @@ export default function ModalShell({ {title} - + diff --git a/src/components/voting/voting-create-modal.tsx b/src/components/voting/voting-create-modal.tsx index 7cd6e676..90448bdd 100644 --- a/src/components/voting/voting-create-modal.tsx +++ b/src/components/voting/voting-create-modal.tsx @@ -86,7 +86,7 @@ function VotingCreateForm({ onClose, onSubmit }: VotingCreateFormProps) { const trimmedTagQuery = debouncedTagQuery.trim(); const { data: tagSuggestions = [], isFetching: isTagLoading } = useBalanceGameTagSuggestionsQuery(trimmedTagQuery, { - limit: 10, + size: 10, enabled: trimmedTagQuery.length >= BALANCE_GAME_TAG_MIN_QUERY_LEN, minLength: BALANCE_GAME_TAG_MIN_QUERY_LEN, sort: 'popular', diff --git a/src/features/study/one-to-one/archive/api/get-archive-search-suggestions.ts b/src/features/study/one-to-one/archive/api/get-archive-search-suggestions.ts index 4656420f..fe097d97 100644 --- a/src/features/study/one-to-one/archive/api/get-archive-search-suggestions.ts +++ b/src/features/study/one-to-one/archive/api/get-archive-search-suggestions.ts @@ -15,7 +15,7 @@ const isArchiveSearchSuggestionResponse = ( export const getArchiveSearchSuggestions = async ({ q, minLength = 1, - limit = 10, + size = 10, }: GetArchiveSearchSuggestionsParams): Promise => { const response = await axiosInstance.get< | ArchiveSearchSuggestionResponse @@ -27,7 +27,7 @@ export const getArchiveSearchSuggestions = async ({ message?: string; } >('/archive/suggestions', { - params: { q, minLength, limit }, + params: { q, minLength, size }, }); const payload = diff --git a/src/features/study/one-to-one/archive/api/update-archive.ts b/src/features/study/one-to-one/archive/api/update-archive.ts index fcee4886..96642617 100644 --- a/src/features/study/one-to-one/archive/api/update-archive.ts +++ b/src/features/study/one-to-one/archive/api/update-archive.ts @@ -3,6 +3,7 @@ import { axiosInstance } from '@/api/client/axios'; export interface UpdateArchiveRequest { title?: string; description?: string; + link?: string; isPrivate?: boolean; } @@ -10,6 +11,7 @@ export interface UpdateArchiveResponse { id: number; title: string; description: string; + link: string; isPrivate: boolean; } diff --git a/src/features/study/one-to-one/archive/model/archive-keys.ts b/src/features/study/one-to-one/archive/model/archive-keys.ts index d37e1ee7..9f0664b3 100644 --- a/src/features/study/one-to-one/archive/model/archive-keys.ts +++ b/src/features/study/one-to-one/archive/model/archive-keys.ts @@ -8,7 +8,7 @@ export const ARCHIVE_QUERY_KEYS = { [...ARCHIVE_QUERY_KEYS.all, 'search-suggestions'] as const, searchSuggestionList: (params: { q: string; - limit: number; + size: number; minLength: number; }) => [...ARCHIVE_QUERY_KEYS.searchSuggestions(), params] as const, }; diff --git a/src/features/study/one-to-one/archive/model/use-archive-search-suggestions-query.ts b/src/features/study/one-to-one/archive/model/use-archive-search-suggestions-query.ts index 6ac1e32b..799b06d1 100644 --- a/src/features/study/one-to-one/archive/model/use-archive-search-suggestions-query.ts +++ b/src/features/study/one-to-one/archive/model/use-archive-search-suggestions-query.ts @@ -5,22 +5,22 @@ import { ARCHIVE_QUERY_KEYS } from '@/features/study/one-to-one/archive/model/ar export const useArchiveSearchSuggestionsQuery = ( query: string, options?: { - limit?: number; + size?: number; minLength?: number; enabled?: boolean; }, ) => { - const limit = options?.limit ?? 10; + const size = options?.size ?? 10; const minLength = options?.minLength ?? 1; const enabled = options?.enabled ?? query.trim().length >= minLength; return useQuery({ queryKey: ARCHIVE_QUERY_KEYS.searchSuggestionList({ q: query, - limit, + size, minLength, }), - queryFn: () => getArchiveSearchSuggestions({ q: query, limit, minLength }), + queryFn: () => getArchiveSearchSuggestions({ q: query, size, minLength }), enabled, staleTime: 60_000, }); diff --git a/src/features/study/one-to-one/archive/ui/archive-filters.tsx b/src/features/study/one-to-one/archive/ui/archive-filters.tsx index ee6ba96d..5a6638a1 100644 --- a/src/features/study/one-to-one/archive/ui/archive-filters.tsx +++ b/src/features/study/one-to-one/archive/ui/archive-filters.tsx @@ -56,7 +56,7 @@ export default function ArchiveFilters({ debouncedSearchTerm, { minLength: minQueryLength, - limit: 10, + size: 10, enabled: isOpen && debouncedSearchTerm.trim().length >= minQueryLength, }, ); 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 2689de81..a331e39d 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 @@ -41,25 +41,30 @@ const LibraryCard = ({ const [isEditing, setIsEditing] = React.useState(false); const [title, setTitle] = React.useState(item.title); const [description, setDescription] = React.useState(item.description ?? ''); + const [link, setLink] = React.useState(item.link ?? ''); const [nextPrivate, setNextPrivate] = React.useState(!!item.isPrivate); React.useEffect(() => { if (!isEditing) { setTitle(item.title); setDescription(item.description ?? ''); + setLink(item.link ?? ''); setNextPrivate(!!item.isPrivate); } - }, [isEditing, item.title, item.description, item.isPrivate]); + }, [isEditing, item.title, item.description, item.link, item.isPrivate]); const handleSave = (e: React.MouseEvent) => { e.stopPropagation(); const request: UpdateArchiveRequest = {}; const trimmedTitle = title.trim(); const trimmedDesc = description.trim(); + const trimmedLink = link.trim(); const currentDesc = item.description ?? ''; + const currentLink = item.link ?? ''; if (trimmedTitle !== item.title) request.title = trimmedTitle; if (trimmedDesc !== currentDesc) request.description = trimmedDesc; + if (trimmedLink !== currentLink) request.link = trimmedLink; if (nextPrivate !== !!item.isPrivate) request.isPrivate = nextPrivate; if (Object.keys(request).length === 0) { @@ -77,6 +82,7 @@ const LibraryCard = ({ setIsEditing(false); setTitle(item.title); setDescription(item.description); + setLink(item.link ?? ''); setNextPrivate(!!item.isPrivate); }; @@ -127,6 +133,13 @@ const LibraryCard = ({ className="h-[96px]" maxLength={100} /> + setLink(e.target.value)} + placeholder="링크" + className="w-full" + /> ) : ( <> 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 63652dd8..c1d3bcfb 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 @@ -41,25 +41,30 @@ const LibraryRow = ({ const [isEditing, setIsEditing] = React.useState(false); const [title, setTitle] = React.useState(item.title); const [description, setDescription] = React.useState(item.description ?? ''); + const [link, setLink] = React.useState(item.link ?? ''); const [nextPrivate, setNextPrivate] = React.useState(!!item.isPrivate); React.useEffect(() => { if (!isEditing) { setTitle(item.title); setDescription(item.description ?? ''); + setLink(item.link ?? ''); setNextPrivate(!!item.isPrivate); } - }, [isEditing, item.title, item.description, item.isPrivate]); + }, [isEditing, item.title, item.description, item.link, item.isPrivate]); const handleSave = (e: React.MouseEvent) => { e.stopPropagation(); const request: UpdateArchiveRequest = {}; const trimmedTitle = title.trim(); const trimmedDesc = description.trim(); + const trimmedLink = link.trim(); const currentDesc = item.description ?? ''; + const currentLink = item.link ?? ''; if (trimmedTitle !== item.title) request.title = trimmedTitle; if (trimmedDesc !== currentDesc) request.description = trimmedDesc; + if (trimmedLink !== currentLink) request.link = trimmedLink; if (nextPrivate !== !!item.isPrivate) request.isPrivate = nextPrivate; if (Object.keys(request).length === 0) { @@ -77,6 +82,7 @@ const LibraryRow = ({ setIsEditing(false); setTitle(item.title); setDescription(item.description ?? ''); + setLink(item.link ?? ''); setNextPrivate(!!item.isPrivate); }; @@ -127,6 +133,13 @@ const LibraryRow = ({ className="h-[96px]" maxLength={100} /> + setLink(e.target.value)} + placeholder="링크" + className="w-full" + />
=> { export const getBalanceGameTagSuggestions = async (params: { q?: string; minLength?: number; - limit?: number; + size?: number; sort?: 'popular' | 'alphabetical'; }): Promise => { - const { q, minLength = 1, limit = 10, sort = 'popular' } = params; + const { q, minLength = 1, size = 10, sort = 'popular' } = params; const response = await axiosInstance.get< ApiResponse<{ suggestions?: BalanceGameTagSuggestion[] | string[]; tags?: BalanceGameTagSuggestion[] | string[]; }> >('/balance-games/tags', { - params: { q, minLength, limit, sort }, + params: { q, minLength, size, sort }, }); const payload = 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 index e20c2896..1e310acd 100644 --- 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 @@ -11,12 +11,12 @@ export const BALANCE_GAME_QUERY_KEYS = { 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) => + tags: (query: string, size: number, minLength: number, sort: string) => [ ...BALANCE_GAME_QUERY_KEYS.all, 'tags', query, - limit, + size, minLength, sort, ] as const, 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 300efa2e..fe9a0235 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 @@ -102,21 +102,21 @@ export const useBalanceGameCommentsQuery = ( export const useBalanceGameTagSuggestionsQuery = ( query: string, options?: { - limit?: number; + size?: number; enabled?: boolean; minLength?: number; sort?: 'popular' | 'alphabetical'; }, ) => { - const limit = options?.limit ?? 10; + const size = options?.size ?? 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), + queryKey: BALANCE_GAME_QUERY_KEYS.tags(query, size, minLength, sort), queryFn: () => - getBalanceGameTagSuggestions({ q: query, limit, minLength, sort }), + getBalanceGameTagSuggestions({ q: query, size, minLength, sort }), enabled, staleTime: 60_000, }); 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 dd1e4b0a..dbd5fbad 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 @@ -92,7 +92,7 @@ export default function CommunityTabClient({ const trimmedTagQuery = debouncedTagQuery.trim(); const { data: tagSuggestions = [], isFetching: isTagLoading } = useBalanceGameTagSuggestionsQuery(trimmedTagQuery, { - limit: 10, + size: 10, enabled: trimmedTagQuery.length >= BALANCE_GAME_TAG_MIN_QUERY_LEN, minLength: BALANCE_GAME_TAG_MIN_QUERY_LEN, sort: 'popular', 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 index ab598194..5cf11c47 100644 --- 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 @@ -14,6 +14,24 @@ interface MVPTeamCardProps { } export default function MVPTeamCard({ team, className }: MVPTeamCardProps) { + const weekLabel = (() => { + const dateSource = team.weekDate || team.weekStartDate; + if (!dateSource) return 'MVP 팀'; + + const date = new Date(dateSource); + if (Number.isNaN(date.getTime())) return 'MVP 팀'; + + const firstDayOfMonth = new Date( + date.getFullYear(), + date.getMonth(), + 1, + ).getDay(); + const weekOfMonth = + Math.floor((date.getDate() + firstDayOfMonth - 1) / 7) + 1; + + return `${date.getMonth() + 1}월 ${weekOfMonth}주차 MVP 팀`; + })(); + return (
- 1월 4주차 MVP 팀 + {weekLabel}

최고의 스터디 메이트 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 index 414b8946..5787ae59 100644 --- 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 @@ -1,19 +1,17 @@ 'use client'; -import Image from 'next/image'; import React from 'react'; +import BronzeRankIcon from 'public/icons/bronze-rank.svg'; +import GoldRankIcon from 'public/icons/gold-rank.svg'; +import SilverRankIcon from 'public/icons/silver-rank.svg'; 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'; + const Icon = + rank === 1 ? GoldRankIcon : rank === 2 ? SilverRankIcon : BronzeRankIcon; if (rank > 3) { return ( @@ -25,7 +23,7 @@ export default function RankBadge({ rank }: RankBadgeProps) { return (
- {`${rank}위`} +
); } diff --git a/src/features/study/one-to-one/hall-of-fame/ui/ranker-list-item.tsx b/src/features/study/one-to-one/hall-of-fame/ui/ranker-list-item.tsx index 0e28867b..43e1ff83 100644 --- a/src/features/study/one-to-one/hall-of-fame/ui/ranker-list-item.tsx +++ b/src/features/study/one-to-one/hall-of-fame/ui/ranker-list-item.tsx @@ -16,7 +16,11 @@ export default function RankerListItem({ ranker }: RankerListItemProps) { +

-
+ } /> ); diff --git a/src/features/study/one-to-one/history/ui/study-history-header.tsx b/src/features/study/one-to-one/history/ui/study-history-header.tsx index 8d4f0abe..eeb94535 100644 --- a/src/features/study/one-to-one/history/ui/study-history-header.tsx +++ b/src/features/study/one-to-one/history/ui/study-history-header.tsx @@ -58,6 +58,7 @@ export default function StudyHistoryHeader({ rightSlot={