From b2ad3ff6f5f220514625fdcb2c96b77e1e019482 Mon Sep 17 00:00:00 2001 From: hyeseong Date: Sun, 30 Nov 2025 22:35:45 +0900 Subject: [PATCH 01/22] =?UTF-8?q?refact:=20=EC=A0=84=EC=B2=B4=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/dashboard/components/ScrapSection.tsx | 5 +- .../dashboard/components/TodayTermCard.tsx | 15 +- src/app/dashboard/utils/order.ts | 36 +-- ...{categoruButton.tsx => CategoryButton.tsx} | 0 .../onboarding/components/categoryList.tsx | 2 +- src/app/terms/[slug]/page.tsx | 12 +- src/components/quiz/CategorySelection.tsx | 2 +- src/components/search/SearchBar.tsx | 6 +- src/components/search/SearchResultCard.tsx | 13 +- src/components/ui/category/config.ts | 124 +------- src/config/categories.ts | 273 ++++++++++++++++++ src/constants/theme.ts | 15 + src/contexts/ToastContext.tsx | 3 +- src/hooks/useShare.ts | 62 ++++ src/lib/category.ts | 56 +--- src/lib/scrap.ts | 10 +- src/lib/sortTerms.ts | 35 +-- src/types/category.ts | 74 +---- src/utils/date.ts | 11 + src/utils/sorting.ts | 63 ++++ 20 files changed, 504 insertions(+), 313 deletions(-) rename src/app/onboarding/components/{categoruButton.tsx => CategoryButton.tsx} (100%) create mode 100644 src/config/categories.ts create mode 100644 src/constants/theme.ts create mode 100644 src/hooks/useShare.ts create mode 100644 src/utils/date.ts create mode 100644 src/utils/sorting.ts diff --git a/src/app/dashboard/components/ScrapSection.tsx b/src/app/dashboard/components/ScrapSection.tsx index 25385f3..5fb264f 100644 --- a/src/app/dashboard/components/ScrapSection.tsx +++ b/src/app/dashboard/components/ScrapSection.tsx @@ -8,6 +8,7 @@ import CategoryTag from "./CategoryTag"; import ScrapCard from "./ScrapCard"; import { sortCards, SortType } from "../utils/order"; import SortDropdown from "@/components/ui/SortDropdown"; +import { BRAND_GRADIENT } from "@/constants/theme"; interface ScrapSectionProps { totalCount: number; @@ -33,7 +34,7 @@ export default function ScrapSection({
-
+
용어 검색하기 diff --git a/src/app/dashboard/components/TodayTermCard.tsx b/src/app/dashboard/components/TodayTermCard.tsx index 4cdd930..39ad185 100644 --- a/src/app/dashboard/components/TodayTermCard.tsx +++ b/src/app/dashboard/components/TodayTermCard.tsx @@ -4,6 +4,7 @@ import { useRouter } from "next/navigation"; import { CalendarIcon } from "@/components/icons/ic_calendar"; import { ArrowRightIcon } from "@/components/icons/ic_arrow_right"; import { LightIcon } from "@/components/icons/ic_light"; +import { BRAND_GRADIENT } from "@/constants/theme"; interface TermData { title: string; @@ -25,7 +26,9 @@ const TodayTermCard: React.FC<{ data: TermData }> = ({ data }) => {
-
+
@@ -39,7 +42,7 @@ const TodayTermCard: React.FC<{ data: TermData }> = ({ data }) => {
diff --git a/src/hooks/useShare.ts b/src/hooks/useShare.ts new file mode 100644 index 0000000..ecaf65f --- /dev/null +++ b/src/hooks/useShare.ts @@ -0,0 +1,62 @@ +/** + * 공유 기능 커스텀 훅 + */ + +interface ShareParams { + title: string; + text: string; + url: string; +} + +export function useShare() { + const share = async (params: ShareParams): Promise => { + try { + // Web Share API 지원 여부 확인 및 공유 + if (navigator.share) { + await navigator.share({ + title: params.title, + text: params.text, + url: params.url, + }); + } else { + // 지원하지 않으면 클립보드에 복사 + await navigator.clipboard.writeText(params.url); + } + } catch (error) { + // 사용자가 공유를 취소했거나 에러 발생 시 클립보드에 복사 + try { + await navigator.clipboard.writeText(params.url); + } catch { + // 클립보드 복사도 실패한 경우 조용히 무시 + } + } + }; + + /** + * 용어 상세 페이지 공유 (slug 기반) + */ + const shareTerm = async ( + title: string, + summary: string, + slug: string + ): Promise => { + const url = `${window.location.origin}/terms/${slug}`; + await share({ title, text: summary, url }); + }; + + /** + * 현재 페이지 공유 + */ + const shareCurrentPage = async ( + title: string, + text: string + ): Promise => { + await share({ title, text, url: window.location.href }); + }; + + return { + share, + shareTerm, + shareCurrentPage, + }; +} diff --git a/src/lib/category.ts b/src/lib/category.ts index 9bf0a42..25aed36 100644 --- a/src/lib/category.ts +++ b/src/lib/category.ts @@ -1,51 +1,11 @@ -import { CategoryType } from "@/components/ui/category/config"; - /** - * 영문 카테고리 타입 → 한글 카테고리명 매핑 + * 레거시 호환성 파일 */ -export const CATEGORY_LABELS: Record = { - all: "전체", - frontend: "프론트엔드", - backend: "백엔드", - uxui: "UX/UI", - ai: "AI", - cloud: "클라우드", - data: "데이터", - security: "보안/네트워크", - devops: "DevOps", - business: "IT비즈니스", -}; -/** - * 한글 카테고리명 → 영문 카테고리 타입 매핑 - */ -const LABEL_TO_CATEGORY: Record = { - 전체: "all", - 프론트엔드: "frontend", - 백엔드: "backend", - "UX/UI": "uxui", - "UI/UX": "uxui", - "UX/UI디자인": "uxui", - "UI/UX디자인": "uxui", - AI: "ai", - 클라우드: "cloud", - 데이터: "data", - "보안/네트워크": "security", - "보안-네트워크": "security", - DevOps: "devops", - IT비즈니스: "business", -}; - -/** - * 영문 카테고리 타입을 한글 카테고리명으로 변환 - */ -export function getCategoryLabel(category: string): string { - return CATEGORY_LABELS[category] || category; -} - -/** - * 한글 카테고리명 또는 태그를 영문 카테고리 타입으로 변환 - */ -export function getCategoryType(label: string): CategoryType { - return LABEL_TO_CATEGORY[label] || "all"; -} +export { + type CategoryType, + CATEGORIES, + CATEGORY_LABELS, + getCategoryLabel, + getCategoryType, +} from "@/config/categories"; diff --git a/src/lib/scrap.ts b/src/lib/scrap.ts index 2a4b15c..6135d55 100644 --- a/src/lib/scrap.ts +++ b/src/lib/scrap.ts @@ -1,6 +1,7 @@ import type { TermIndexItem } from "@/lib/terms"; import type { ScrapCardData } from "@/types/category"; import { getCategoryLabel, getCategoryType } from "@/lib/category"; +import { formatKoreanDate } from "@/utils/date"; /** * TermIndexItem을 ScrapCardData로 변환 @@ -16,13 +17,6 @@ export function termToScrapCard(term: TermIndexItem): ScrapCardData { term: term.termEn || term.termKo, tag: term.tags[0] || "", description: term.summary, - date: new Date() - .toLocaleDateString("ko-KR", { - year: "numeric", - month: "2-digit", - day: "2-digit", - }) - .replace(/\. /g, ".") - .replace(/\.$/, ""), + date: formatKoreanDate(), }; } diff --git a/src/lib/sortTerms.ts b/src/lib/sortTerms.ts index fe6a199..d1f9ba9 100644 --- a/src/lib/sortTerms.ts +++ b/src/lib/sortTerms.ts @@ -1,39 +1,18 @@ import type { TermIndexItem } from "./terms"; +import { sortByKorean, type SortType } from "@/utils/sorting"; -export type SortType = "latest" | "alphabetical"; - -function getCharTypeOrder(str: string): number { - if (!str) return 4; - const char = str.charAt(0); - - if (/[0-9]/.test(char)) return 1; - if (/[^0-9a-zA-Z가-힣]/.test(char)) return 2; - if (/[가-힣]/.test(char)) return 3; - return 4; -} +export type { SortType }; +/** + * 용어 목록 정렬 + */ export function sortTerms( terms: TermIndexItem[], sortType: SortType ): TermIndexItem[] { if (sortType === "latest") { - // For search results, "latest" maintains original order - // (search results don't have timestamps) return terms; - } else { - // Alphabetical sorting by termKo - return [...terms].sort((a, b) => { - const termA = a.termKo; - const termB = b.termKo; - - const priorityA = getCharTypeOrder(termA); - const priorityB = getCharTypeOrder(termB); - - if (priorityA !== priorityB) { - return priorityA - priorityB; - } - - return termA.localeCompare(termB, "ko", { sensitivity: "base" }); - }); } + + return sortByKorean(terms, (term) => term.termKo); } diff --git a/src/types/category.ts b/src/types/category.ts index 2b5be97..3328908 100644 --- a/src/types/category.ts +++ b/src/types/category.ts @@ -1,67 +1,17 @@ -import { ElementType } from "react"; -import { CategoryAllIcon } from "@/components/icons/ic_category_all"; -import { CategoryFrontendIcon } from "@/components/icons/ic_category_frontend"; -import { CategoryBackendIcon } from "@/components/icons/ic_category_backend"; -import { CategoryUiuxIcon } from "@/components/icons/ic_category_uiux"; -import { CategoryAiIcon } from "@/components/icons/ic_category_ai"; -import { CategoryCloudIcon } from "@/components/icons/ic_category_cloud"; -import { CategoryDataIcon } from "@/components/icons/ic_category_data"; -import { CategorySecurityIcon } from "@/components/icons/ic_category_security"; -import { CategoryDevopsIcon } from "@/components/icons/ic_category_devops"; -import { CategoryBusinessIcon } from "@/components/icons/ic_category_business"; +/** + * 카테고리 레거시 호환 및 타입 정의 + */ -export const categoryIcons: Record = { - 전체: CategoryAllIcon, - 프론트엔드: CategoryFrontendIcon, - 백엔드: CategoryBackendIcon, - "UX/UI": CategoryUiuxIcon, - AI: CategoryAiIcon, - 클라우드: CategoryCloudIcon, - 데이터: CategoryDataIcon, - "보안/네트워크": CategorySecurityIcon, - DevOps: CategoryDevopsIcon, - IT비즈니스: CategoryBusinessIcon, -}; - -export const categoryColors: Record = { - 전체: "bg-gray-400", - 프론트엔드: "bg-cyan-400", - 백엔드: "bg-green-600", - "UX/UI": "bg-rose-400", - AI: "bg-violet-400", - 클라우드: "bg-sky-400", - 데이터: "bg-teal-400", - "보안/네트워크": "bg-orange-400", - DevOps: "bg-amber-400", - IT비즈니스: "bg-blue-400", -}; - -export const categoryHoverStyles: Record = { - 전체: "hover:bg-gray-400/10 hover:outline-white-50", - 프론트엔드: "hover:bg-cyan-400/10 hover:outline-white-50", - 백엔드: "hover:bg-green-600/10 hover:outline-white-50", - "UX/UI": "hover:bg-rose-400/10 hover:outline-white-50", - AI: "hover:bg-violet-400/10 hover:outline-white-50", - 클라우드: "hover:bg-sky-400/10 hover:outline-white-50", - 데이터: "hover:bg-teal-400/10 hover:outline-white-50", - "보안/네트워크": "hover:bg-orange-400/10 hover:outline-white-50", - DevOps: "hover:bg-amber-400/10 hover:outline-white-50", - IT비즈니스: "hover:bg-blue-400/10 hover:outline-white-50", -}; - -export const categoryActiveStyles: Record = { - 전체: "bg-gray-400/50 outline-white", - 프론트엔드: "bg-cyan-400/50 outline-white", - 백엔드: "bg-green-600/50 outline-white", - "UX/UI": "bg-rose-400/50 outline-white", - AI: "bg-violet-400/50 outline-white", - 클라우드: "bg-sky-400/50 outline-white", - 데이터: "bg-teal-400/50 outline-white", - "보안/네트워크": "bg-orange-400/50 outline-white", - DevOps: "bg-amber-400/50 outline-white", - IT비즈니스: "bg-blue-400/50 outline-white", -}; +export { + categoryIcons, + categoryColors, + categoryHoverStyles, + categoryActiveStyles, +} from "@/config/categories"; +/** + * 스크랩 카드 데이터 인터페이스 + */ export interface ScrapCardData { id: string; slug?: string; diff --git a/src/utils/date.ts b/src/utils/date.ts new file mode 100644 index 0000000..cd9cab8 --- /dev/null +++ b/src/utils/date.ts @@ -0,0 +1,11 @@ +/** + * 날짜 포맷팅 유틸리티 함수 + */ + +export function formatKoreanDate(date: Date = new Date()): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + + return `${year}.${month}.${day}`; +} diff --git a/src/utils/sorting.ts b/src/utils/sorting.ts new file mode 100644 index 0000000..d716c70 --- /dev/null +++ b/src/utils/sorting.ts @@ -0,0 +1,63 @@ +/** + * 정렬 유틸리티 함수 + */ + +/** + * 문자열의 첫 글자 타입에 따른 우선순위 반환 + * + * 우선순위 순서: + * 1. 숫자 (0-9) + * 2. 특수문자 + * 3. 한글 (가-힣) + * 4. 영문 (a-zA-Z) + * + */ +export function getCharTypeOrder(str: string): number { + if (!str) return 4; + const char = str.charAt(0); + + if (/[0-9]/.test(char)) return 1; + if (/[^0-9a-zA-Z가-힣]/.test(char)) return 2; + if (/[가-힣]/.test(char)) return 3; + return 4; +} + +/** + * 한글 자모 순서를 고려한 알파벳 정렬 + */ +export function sortByKorean(items: T[], getKey: (item: T) => string): T[] { + return [...items].sort((a, b) => { + const keyA = getKey(a); + const keyB = getKey(b); + + // 문자 타입 우선순위 비교 + const priorityA = getCharTypeOrder(keyA); + const priorityB = getCharTypeOrder(keyB); + + if (priorityA !== priorityB) { + return priorityA - priorityB; + } + + // 같은 타입이면 한글 자모 순서로 정렬 + return keyA.localeCompare(keyB, "ko", { sensitivity: "base" }); + }); +} + +/** + * 날짜 기준 정렬 (최신순) + */ +export function sortByDateDesc( + items: T[], + getDate: (item: T) => string | Date +): T[] { + return [...items].sort((a, b) => { + const dateA = new Date(getDate(a)).getTime(); + const dateB = new Date(getDate(b)).getTime(); + return dateB - dateA; + }); +} + +/** + * 정렬 타입 + */ +export type SortType = "latest" | "alphabetical"; From 0d20df082512a33afbf2b6221160a9704672a511 Mon Sep 17 00:00:00 2001 From: hyeseong Date: Sun, 30 Nov 2025 22:39:16 +0900 Subject: [PATCH 02/22] =?UTF-8?q?refactor:=20=EC=82=AC=EC=9A=A9=EB=90=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EC=95=84=EC=9D=B4=EC=BD=98=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/icons/ic_chevron_up.tsx | 28 -------------- src/components/icons/ic_copy.tsx | 44 --------------------- src/components/icons/ic_external_link.tsx | 47 ----------------------- src/components/icons/ic_info2.tsx | 25 ------------ src/components/icons/index.ts | 2 - 5 files changed, 146 deletions(-) delete mode 100644 src/components/icons/ic_chevron_up.tsx delete mode 100644 src/components/icons/ic_copy.tsx delete mode 100644 src/components/icons/ic_external_link.tsx delete mode 100644 src/components/icons/ic_info2.tsx diff --git a/src/components/icons/ic_chevron_up.tsx b/src/components/icons/ic_chevron_up.tsx deleted file mode 100644 index beb5c1c..0000000 --- a/src/components/icons/ic_chevron_up.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { IconProps, getIconSize } from "./types"; - -export const ChevronUpIcon = ({ - color = "currentColor", - className, - ...props -}: IconProps) => { - const { width, height } = getIconSize(props); - - return ( - - - - ); -}; diff --git a/src/components/icons/ic_copy.tsx b/src/components/icons/ic_copy.tsx deleted file mode 100644 index 372bfe4..0000000 --- a/src/components/icons/ic_copy.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { IconProps, getIconSize } from "./types"; - -export const CopyIcon = ({ - color = "currentColor", - className, - ...props -}: IconProps) => { - const { width, height } = getIconSize(props); - - return ( - - {/* 두 장의 사각형(겹친 복사 아이콘) */} - - - - ); -}; diff --git a/src/components/icons/ic_external_link.tsx b/src/components/icons/ic_external_link.tsx deleted file mode 100644 index 74b0409..0000000 --- a/src/components/icons/ic_external_link.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { IconProps, getIconSize } from "./types"; - -export const ExternalLinkIcon = ({ - color = "currentColor", - className, - ...props -}: IconProps) => { - const { width, height } = getIconSize(props); - - return ( - - {/* 외부 링크: 화살표와 사각형 */} - - - - - ); -}; diff --git a/src/components/icons/ic_info2.tsx b/src/components/icons/ic_info2.tsx deleted file mode 100644 index 119dd51..0000000 --- a/src/components/icons/ic_info2.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { IconProps, getIconSize } from "./types"; - -export const Info2Icon = ({ - color = "currentColor", - className, - ...props -}: IconProps) => { - const { width, height } = getIconSize(props); - - return ( - - - - ); -}; diff --git a/src/components/icons/index.ts b/src/components/icons/index.ts index e0acf19..939f23e 100644 --- a/src/components/icons/index.ts +++ b/src/components/icons/index.ts @@ -16,7 +16,6 @@ export * from "./ic_category_uiux"; export * from "./ic_chevron_down"; export * from "./ic_chevron_left"; export * from "./ic_chevron_right"; -export * from "./ic_chevron_up"; export * from "./ic_chevrons_down"; export * from "./ic_chevrons_up"; export * from "./ic_comment"; @@ -24,7 +23,6 @@ export * from "./ic_edit"; export * from "./ic_fire"; export * from "./ic_hashtag"; export * from "./ic_info"; -export * from "./ic_info2"; export * from "./ic_light"; export * from "./ic_pm"; export * from "./ic_relation"; From 1e90d90a32124ac12db24a4936d79401610f83d7 Mon Sep 17 00:00:00 2001 From: hyeseong Date: Sun, 30 Nov 2025 23:08:09 +0900 Subject: [PATCH 03/22] refactor: remove unused func --- src/components/icons/ic_bang.tsx | 25 ------------- src/components/icons/ic_chevrons_up.tsx | 28 --------------- src/components/icons/ic_time.tsx | 25 ------------- src/components/icons/index.ts | 4 --- src/components/ui/category/CategoryTag.tsx | 42 ---------------------- src/components/ui/category/config.ts | 1 - src/components/ui/category/index.ts | 1 - src/config/categories.ts | 19 +--------- src/constants/theme.ts | 7 ---- src/lib/bookmarks.ts | 25 ------------- src/lib/terms.server.ts | 32 ----------------- src/lib/terms.ts | 23 ------------ 12 files changed, 1 insertion(+), 231 deletions(-) delete mode 100644 src/components/icons/ic_bang.tsx delete mode 100644 src/components/icons/ic_chevrons_up.tsx delete mode 100644 src/components/icons/ic_time.tsx delete mode 100644 src/components/ui/category/CategoryTag.tsx diff --git a/src/components/icons/ic_bang.tsx b/src/components/icons/ic_bang.tsx deleted file mode 100644 index 92f763c..0000000 --- a/src/components/icons/ic_bang.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { IconProps, getIconSize } from "./types"; - -export const BangIcon = ({ - color = "currentColor", - className, - ...props -}: IconProps) => { - const { width, height } = getIconSize(props); - - return ( - - - - ); -}; diff --git a/src/components/icons/ic_chevrons_up.tsx b/src/components/icons/ic_chevrons_up.tsx deleted file mode 100644 index 8fa41f0..0000000 --- a/src/components/icons/ic_chevrons_up.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { IconProps, getIconSize } from "./types"; - -export const ChevronsUpIcon = ({ - color = "currentColor", - className, - ...props -}: IconProps) => { - const { width, height } = getIconSize(props); - - return ( - - - - ); -}; diff --git a/src/components/icons/ic_time.tsx b/src/components/icons/ic_time.tsx deleted file mode 100644 index 76badfc..0000000 --- a/src/components/icons/ic_time.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { IconProps, getIconSize } from "./types"; - -export const TimeIcon = ({ - color = "currentColor", - className, - ...props -}: IconProps) => { - const { width, height } = getIconSize(props); - - return ( - - - - ); -}; diff --git a/src/components/icons/index.ts b/src/components/icons/index.ts index 939f23e..72d79e8 100644 --- a/src/components/icons/index.ts +++ b/src/components/icons/index.ts @@ -1,7 +1,6 @@ export * from "./types"; export * from "./ic_arrow_left"; export * from "./ic_arrow_right"; -export * from "./ic_bang"; export * from "./ic_calendar"; export * from "./ic_category_ai"; export * from "./ic_category_all"; @@ -17,7 +16,6 @@ export * from "./ic_chevron_down"; export * from "./ic_chevron_left"; export * from "./ic_chevron_right"; export * from "./ic_chevrons_down"; -export * from "./ic_chevrons_up"; export * from "./ic_comment"; export * from "./ic_edit"; export * from "./ic_fire"; @@ -33,8 +31,6 @@ export * from "./ic_send"; export * from "./ic_share"; export * from "./ic_sort"; export * from "./ic_star"; -export * from "./ic_tag"; -export * from "./ic_time"; export * from "./ic_user"; export * from "./logo_text"; diff --git a/src/components/ui/category/CategoryTag.tsx b/src/components/ui/category/CategoryTag.tsx deleted file mode 100644 index 6174250..0000000 --- a/src/components/ui/category/CategoryTag.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { cn } from "@/utils/cn"; -import { CategoryChip } from "./CategoryChip"; -import { - type CategoryType, - categoryLabels, - categoryHoverColors, - categorySelectedColors, -} from "./config"; - -interface CategoryTagProps { - category: CategoryType; - selected?: boolean; - className?: string; -} - -export function CategoryTag({ - category, - selected = false, - className, -}: CategoryTagProps) { - const label = categoryLabels[category]; - const hoverColor = categoryHoverColors[category]; - const selectedColor = categorySelectedColors[category]; - - return ( -
- -
- - #{label} - -
-
- ); -} diff --git a/src/components/ui/category/config.ts b/src/components/ui/category/config.ts index 0b3f0b8..e1a37f6 100644 --- a/src/components/ui/category/config.ts +++ b/src/components/ui/category/config.ts @@ -11,5 +11,4 @@ export { categorySelectedColors, getCategoryLabel, getCategoryType, - getCategoryIcon, } from "@/config/categories"; diff --git a/src/components/ui/category/index.ts b/src/components/ui/category/index.ts index 701965a..29be805 100644 --- a/src/components/ui/category/index.ts +++ b/src/components/ui/category/index.ts @@ -1,7 +1,6 @@ // Category Components export { CategoryChip } from "./CategoryChip"; export { CategorySquareBadge } from "./CategorySquareBadge"; -export { CategoryTag } from "./CategoryTag"; // Types and Config export type { CategoryType } from "./config"; diff --git a/src/config/categories.ts b/src/config/categories.ts index e19171b..5401c8b 100644 --- a/src/config/categories.ts +++ b/src/config/categories.ts @@ -177,23 +177,6 @@ export function getCategoryType(label: string): CategoryType { return LABEL_TO_CATEGORY[label] || "all"; } -/** - * 카테고리 타입 → 아이콘 컴포넌트 - * @example getCategoryIcon("frontend") // CategoryFrontendIcon - */ -export function getCategoryIcon(category: CategoryType): ElementType { - return CATEGORIES[category].icon; -} - -/** - * 한글 라벨 → 아이콘 컴포넌트 (기존 코드 호환성) - * @deprecated getCategoryType()과 getCategoryIcon()을 함께 사용하세요 - */ -export function getCategoryIconByLabel(label: string): ElementType { - const category = getCategoryType(label); - return CATEGORIES[category].icon; -} - // ============================================================================ // 레거시 호환성을 위한 Export // (기존 코드가 동작하도록 유지, 점진적으로 CATEGORIES 사용으로 마이그레이션) @@ -234,7 +217,7 @@ export const categorySelectedColors = Object.fromEntries( ) as Record; /** - * @deprecated getCategoryIconByLabel()을 사용하세요 + * @deprecated getCategoryType()과 CATEGORIES에서 icon을 사용하세요 */ export const categoryIcons: Record = Object.fromEntries( CATEGORY_KEYS.map((key) => [CATEGORIES[key].label, CATEGORIES[key].icon]) diff --git a/src/constants/theme.ts b/src/constants/theme.ts index 2696a89..19770a7 100644 --- a/src/constants/theme.ts +++ b/src/constants/theme.ts @@ -2,14 +2,7 @@ * 브랜드 테마 상수 */ -export const BRAND_COLORS = { - purple: "#6E50C8", - red: "#CE5E61", -} as const; - export const BRAND_GRADIENT = { bg: "bg-gradient-to-r from-brand-purple to-brand-red", text: "bg-gradient-to-r from-brand-purple to-brand-red bg-clip-text text-transparent", } as const; - -export const BRAND_GRADIENT_INLINE = `linear-gradient(90deg, ${BRAND_COLORS.purple} 0.02%, ${BRAND_COLORS.red} 99.98%)`; diff --git a/src/lib/bookmarks.ts b/src/lib/bookmarks.ts index 1c6a385..c1301be 100644 --- a/src/lib/bookmarks.ts +++ b/src/lib/bookmarks.ts @@ -43,34 +43,9 @@ export function toggleBookmark(id: number): boolean { return bookmarks.has(id); } -/** - * 북마크 추가 - */ -export function addBookmark(id: number): void { - const bookmarks = new Set(getBookmarks()); - bookmarks.add(id); - localStorage.setItem(STORAGE_KEY, JSON.stringify([...bookmarks])); -} - -/** - * 북마크 제거 - */ -export function removeBookmark(id: number): void { - const bookmarks = new Set(getBookmarks()); - bookmarks.delete(id); - localStorage.setItem(STORAGE_KEY, JSON.stringify([...bookmarks])); -} - /** * 모든 북마크 삭제 */ export function clearBookmarks(): void { localStorage.removeItem(STORAGE_KEY); } - -/** - * 북마크 개수 - */ -export function getBookmarkCount(): number { - return getBookmarks().length; -} diff --git a/src/lib/terms.server.ts b/src/lib/terms.server.ts index 6d34e1d..b16386a 100644 --- a/src/lib/terms.server.ts +++ b/src/lib/terms.server.ts @@ -34,30 +34,6 @@ export function getTermByIdServer(id: number): TermDetail | null { return JSON.parse(fileContent); } -/** - * 서버에서 slug로 용어 상세 정보 가져오기 - */ -export function getTermBySlugServer(slug: string): TermDetail | null { - const index = getTermsIndexServer(); - const item = index.find((t) => t.slug === slug); - - if (!item) return null; - - const filePath = path.join(process.cwd(), "public", item.file); - const fileContent = fs.readFileSync(filePath, "utf-8"); - return JSON.parse(fileContent); -} - -/** - * 서버에서 카테고리별 용어 목록 가져오기 - */ -export function getTermsByCategoryServer(category: number): TermIndexItem[] { - const index = getTermsIndexServer(); - return index.filter( - (t) => Math.floor(t.id / 1000) * 1000 === category || t.id === category - ); -} - /** * 서버에서 오늘의 용어 가져오기 */ @@ -70,11 +46,3 @@ export function getTodaysTermServer(): TermIndexItem | null { return index[todayIndex]; } - -/** - * 서버에서 태그로 용어 목록 필터링 - */ -export function getTermsByTagServer(tag: string): TermIndexItem[] { - const index = getTermsIndexServer(); - return index.filter((t) => t.tags.includes(tag) || t.primaryTag === tag); -} diff --git a/src/lib/terms.ts b/src/lib/terms.ts index 56b4a7a..b1926be 100644 --- a/src/lib/terms.ts +++ b/src/lib/terms.ts @@ -195,26 +195,3 @@ export async function getRelatedTerms( const index = await getTermsIndex(); return index.filter((t) => relatedIds.includes(t.id)); } - -/** - * 랜덤 용어 N개 가져오기 - */ -export async function getRandomTerms(count: number): Promise { - const index = await getTermsIndex(); - const shuffled = [...index].sort(() => Math.random() - 0.5); - return shuffled.slice(0, count); -} - -/** - * 모든 태그 목록 가져오기 - */ -export async function getAllTags(): Promise { - const index = await getTermsIndex(); - const tagSet = new Set(); - - for (const item of index) { - item.tags.forEach((tag) => tagSet.add(tag)); - } - - return Array.from(tagSet).sort(); -} From 884d030efcfff0917f20909b13b47732fff133f8 Mon Sep 17 00:00:00 2001 From: hyeseong Date: Mon, 1 Dec 2025 00:08:35 +0900 Subject: [PATCH 04/22] style: format code --- src/app/dashboard/components/ScrapSection.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/dashboard/components/ScrapSection.tsx b/src/app/dashboard/components/ScrapSection.tsx index 5fb264f..476f131 100644 --- a/src/app/dashboard/components/ScrapSection.tsx +++ b/src/app/dashboard/components/ScrapSection.tsx @@ -34,7 +34,9 @@ export default function ScrapSection({
-
+
Date: Mon, 1 Dec 2025 00:22:05 +0900 Subject: [PATCH 05/22] =?UTF-8?q?refactor:=20auth=20context=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/contexts/AuthContext.tsx | 280 +------------------------- src/contexts/auth/AuthContext.tsx | 110 ++++++++++ src/contexts/auth/ScrapContext.tsx | 85 ++++++++ src/contexts/auth/UserDataContext.tsx | 194 ++++++++++++++++++ src/contexts/auth/index.tsx | 103 ++++++++++ 5 files changed, 501 insertions(+), 271 deletions(-) create mode 100644 src/contexts/auth/AuthContext.tsx create mode 100644 src/contexts/auth/ScrapContext.tsx create mode 100644 src/contexts/auth/UserDataContext.tsx create mode 100644 src/contexts/auth/index.tsx diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index c243c31..49b5e78 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -1,275 +1,13 @@ "use client"; -import { - createContext, - useContext, - useEffect, - useState, - type ReactNode, -} from "react"; -import { - GoogleAuthProvider, - signInWithPopup, - signInWithEmailAndPassword, - signOut, - onAuthStateChanged, - type User, -} from "firebase/auth"; -import { doc, setDoc, getDoc, updateDoc } from "firebase/firestore"; -import { auth, db } from "@/utils/firebase"; -import { type CategoryType } from "@/components/ui/category/config"; -import { getBookmarks, clearBookmarks } from "@/lib/bookmarks"; +/** + * AuthContext - 레거시 호환성 파일 + */ -export type UserData = { - email: string | null; - displayName: string | null; - photoURL: string | null; - createdAt: string; - scrapList: number[]; - onboardingCompleted: boolean; - selectedCategory: CategoryType; -}; +export { + CombinedAuthProvider as AuthProvider, + useAuth, + type UserData, +} from "./auth"; -type AuthContextType = { - user: User | null; - userData: UserData | null; - loading: boolean; - isNewUser: boolean; - loginWithGoogle: () => Promise; - loginWithDemo: () => Promise; - logout: () => Promise; - completeOnboarding: (category: CategoryType) => Promise; - updateCategory: (category: CategoryType) => Promise; - toggleScrap: ( - termId: number - ) => Promise<{ success: boolean; isScraped: boolean }>; - isScraped: (termId: number) => boolean; -}; - -const AuthContext = createContext(null); - -export function AuthProvider({ children }: { children: ReactNode }) { - const [user, setUser] = useState(null); - const [userData, setUserData] = useState(null); - const [loading, setLoading] = useState(true); - const [isNewUser, setIsNewUser] = useState(false); - - useEffect(() => { - const unsubscribe = onAuthStateChanged(auth, async (currentUser) => { - setUser(currentUser); - - try { - if (currentUser) { - const userRef = doc(db, "users", currentUser.uid); - const userSnap = await getDoc(userRef); - - if (userSnap.exists()) { - const data = userSnap.data() as UserData; - setUserData(data); - setIsNewUser(!data.onboardingCompleted); - } - } else { - setUserData(null); - setIsNewUser(false); - } - } catch (error) { - console.error("사용자 데이터 로드 실패:", error); - setUserData(null); - setIsNewUser(false); - } finally { - setLoading(false); - } - }); - - return () => unsubscribe(); - }, []); - - // 로그인 후 공통 처리 로직 - const handleUserAfterLogin = async ( - user: User, - displayName?: string | null, - photoURL?: string | null - ): Promise => { - const userRef = doc(db, "users", user.uid); - const userSnap = await getDoc(userRef); - const localBookmarks = getBookmarks(); - - if (!userSnap.exists()) { - // 신규 사용자: 로컬스토리지 데이터를 포함하여 생성 - const newUserData: UserData = { - email: user.email, - displayName: displayName ?? user.displayName, - photoURL: photoURL ?? user.photoURL, - createdAt: new Date().toISOString(), - scrapList: localBookmarks, - onboardingCompleted: false, - selectedCategory: "all", - }; - await setDoc(userRef, newUserData); - setUserData(newUserData); - setIsNewUser(true); - - // 마이그레이션 후 로컬스토리지 정리 - if (localBookmarks.length > 0) { - clearBookmarks(); - } - - return true; - } else { - // 기존 사용자: 로컬스토리지 데이터와 병합 - const data = userSnap.data() as UserData; - - if (localBookmarks.length > 0) { - const mergedScrapList = [ - ...new Set([...data.scrapList, ...localBookmarks]), - ]; - await updateDoc(userRef, { scrapList: mergedScrapList }); - data.scrapList = mergedScrapList; - clearBookmarks(); - } - - setUserData(data); - setIsNewUser(!data.onboardingCompleted); - return !data.onboardingCompleted; - } - }; - - const loginWithGoogle = async (): Promise => { - const provider = new GoogleAuthProvider(); - try { - const result = await signInWithPopup(auth, provider); - return await handleUserAfterLogin( - result.user, - result.user.displayName, - result.user.photoURL - ); - } catch (error) { - console.error("Google 로그인 실패:", error); - throw error; - } - }; - - const loginWithDemo = async (): Promise => { - const demoEmail = process.env.NEXT_PUBLIC_DEMO_EMAIL; - const demoPassword = process.env.NEXT_PUBLIC_DEMO_PASSWORD; - - if (!demoEmail || !demoPassword) { - throw new Error("데모 계정 정보가 설정되지 않았습니다."); - } - - try { - const result = await signInWithEmailAndPassword( - auth, - demoEmail, - demoPassword - ); - return await handleUserAfterLogin(result.user, "데모 사용자", null); - } catch (error) { - console.error("데모 계정 로그인 실패:", error); - throw error; - } - }; - - const logout = async () => { - try { - await signOut(auth); - setUserData(null); - setIsNewUser(false); - } catch (error) { - console.error("로그아웃 실패:", error); - throw error; - } - }; - - const completeOnboarding = async (category: CategoryType) => { - if (!user) return; - - const userRef = doc(db, "users", user.uid); - await updateDoc(userRef, { - onboardingCompleted: true, - selectedCategory: category, - }); - - setUserData((prev) => - prev - ? { ...prev, onboardingCompleted: true, selectedCategory: category } - : null - ); - setIsNewUser(false); - }; - - const updateCategory = async (category: CategoryType) => { - if (!user) return; - - const userRef = doc(db, "users", user.uid); - await updateDoc(userRef, { - selectedCategory: category, - }); - - setUserData((prev) => - prev ? { ...prev, selectedCategory: category } : null - ); - }; - - const isScraped = (termId: number): boolean => { - if (!userData) return false; - return userData.scrapList.includes(termId); - }; - - const toggleScrap = async ( - termId: number - ): Promise<{ success: boolean; isScraped: boolean }> => { - if (!user || !userData) { - return { success: false, isScraped: false }; - } - - const currentlyScraped = userData.scrapList.includes(termId); - const newScrapList = currentlyScraped - ? userData.scrapList.filter((id) => id !== termId) - : [...userData.scrapList, termId]; - - try { - const userRef = doc(db, "users", user.uid); - await updateDoc(userRef, { - scrapList: newScrapList, - }); - - setUserData((prev) => - prev ? { ...prev, scrapList: newScrapList } : null - ); - - return { success: true, isScraped: !currentlyScraped }; - } catch (error) { - console.error("스크랩 토글 실패:", error); - return { success: false, isScraped: currentlyScraped }; - } - }; - - return ( - - {children} - - ); -} - -export function useAuth() { - const context = useContext(AuthContext); - if (!context) { - throw new Error("useAuth must be used within an AuthProvider"); - } - return context; -} +export { useAuthCore, useUserData, useScrap } from "./auth"; diff --git a/src/contexts/auth/AuthContext.tsx b/src/contexts/auth/AuthContext.tsx new file mode 100644 index 0000000..2fe32b7 --- /dev/null +++ b/src/contexts/auth/AuthContext.tsx @@ -0,0 +1,110 @@ +"use client"; + +/** + * AuthContext + */ + +import { + createContext, + useContext, + useEffect, + useState, + type ReactNode, +} from "react"; +import { + GoogleAuthProvider, + signInWithPopup, + signInWithEmailAndPassword, + signOut, + onAuthStateChanged, + type User, +} from "firebase/auth"; +import { auth } from "@/utils/firebase"; + +interface AuthContextType { + user: User | null; + loading: boolean; + loginWithGoogle: () => Promise; + loginWithDemo: () => Promise; + logout: () => Promise; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + // Firebase 인증 상태 구독 + useEffect(() => { + const unsubscribe = onAuthStateChanged(auth, (currentUser) => { + setUser(currentUser); + setLoading(false); + }); + + return () => unsubscribe(); + }, []); + + const loginWithGoogle = async (): Promise => { + const provider = new GoogleAuthProvider(); + try { + const result = await signInWithPopup(auth, provider); + return result.user; + } catch (error) { + console.error("Google 로그인 실패:", error); + throw error; + } + }; + + const loginWithDemo = async (): Promise => { + const demoEmail = process.env.NEXT_PUBLIC_DEMO_EMAIL; + const demoPassword = process.env.NEXT_PUBLIC_DEMO_PASSWORD; + + if (!demoEmail || !demoPassword) { + throw new Error("데모 계정 정보가 설정되지 않았습니다."); + } + + try { + const result = await signInWithEmailAndPassword( + auth, + demoEmail, + demoPassword + ); + return result.user; + } catch (error) { + console.error("데모 계정 로그인 실패:", error); + throw error; + } + }; + + const logout = async (): Promise => { + try { + await signOut(auth); + } catch (error) { + console.error("로그아웃 실패:", error); + throw error; + } + }; + + return ( + + {children} + + ); +} + +export function useAuthCore() { + const context = useContext(AuthContext); + if (!context) { + throw new Error("useAuthCore must be used within an AuthProvider"); + } + return context; +} diff --git a/src/contexts/auth/ScrapContext.tsx b/src/contexts/auth/ScrapContext.tsx new file mode 100644 index 0000000..988a5c8 --- /dev/null +++ b/src/contexts/auth/ScrapContext.tsx @@ -0,0 +1,85 @@ +"use client"; + +/** + * ScrapContext - 스크랩 관리 + */ + +import { createContext, useContext, type ReactNode } from "react"; +import { doc, updateDoc } from "firebase/firestore"; +import { db } from "@/utils/firebase"; +import { useAuthCore } from "./AuthContext"; +import { useUserData } from "./UserDataContext"; + +interface ScrapContextType { + isScraped: (termId: number) => boolean; + /** 스크랩 토글 */ + toggleScrap: ( + termId: number + ) => Promise<{ success: boolean; isScraped: boolean }>; +} + +const ScrapContext = createContext(null); + +export function ScrapProvider({ children }: { children: ReactNode }) { + const { user } = useAuthCore(); + const { userData, updateScrapList } = useUserData(); + + /** + * 해당 용어가 스크랩되어 있는지 확인 + */ + const isScraped = (termId: number): boolean => { + if (!userData) return false; + return userData.scrapList.includes(termId); + }; + + /** + * 스크랩 토글 (추가/제거) + */ + const toggleScrap = async ( + termId: number + ): Promise<{ success: boolean; isScraped: boolean }> => { + if (!user || !userData) { + return { success: false, isScraped: false }; + } + + const currentlyScraped = userData.scrapList.includes(termId); + const newScrapList = currentlyScraped + ? userData.scrapList.filter((id) => id !== termId) + : [...userData.scrapList, termId]; + + try { + // Firestore 업데이트 + const userRef = doc(db, "users", user.uid); + await updateDoc(userRef, { + scrapList: newScrapList, + }); + + // 로컬 상태 업데이트 + updateScrapList(newScrapList); + + return { success: true, isScraped: !currentlyScraped }; + } catch (error) { + console.error("스크랩 토글 실패:", error); + return { success: false, isScraped: currentlyScraped }; + } + }; + + return ( + + {children} + + ); +} + +export function useScrap() { + const context = useContext(ScrapContext); + if (!context) { + throw new Error("useScrap must be used within a ScrapProvider"); + } + return context; +} diff --git a/src/contexts/auth/UserDataContext.tsx b/src/contexts/auth/UserDataContext.tsx new file mode 100644 index 0000000..7ad99bc --- /dev/null +++ b/src/contexts/auth/UserDataContext.tsx @@ -0,0 +1,194 @@ +"use client"; + +/** + * UserDataContext - 사용자 데이터 관리 + */ + +import { + createContext, + useContext, + useEffect, + useState, + type ReactNode, +} from "react"; +import { doc, setDoc, getDoc, updateDoc } from "firebase/firestore"; +import { db } from "@/utils/firebase"; +import { type CategoryType } from "@/components/ui/category/config"; +import { getBookmarks, clearBookmarks } from "@/lib/bookmarks"; +import { useAuthCore } from "./AuthContext"; + +/** + * 사용자 데이터 타입 + */ +export interface UserData { + email: string | null; + displayName: string | null; + photoURL: string | null; + createdAt: string; + scrapList: number[]; + onboardingCompleted: boolean; + selectedCategory: CategoryType; +} + +interface UserDataContextType { + userData: UserData | null; + userDataLoading: boolean; + isNewUser: boolean; + completeOnboarding: (category: CategoryType) => Promise; + updateCategory: (category: CategoryType) => Promise; + refreshUserData: () => Promise; + updateScrapList: (newScrapList: number[]) => void; +} + +const UserDataContext = createContext(null); + +export function UserDataProvider({ children }: { children: ReactNode }) { + const { user } = useAuthCore(); + const [userData, setUserData] = useState(null); + const [userDataLoading, setUserDataLoading] = useState(true); + const [isNewUser, setIsNewUser] = useState(false); + + // user 변경 시 사용자 데이터 로드 + useEffect(() => { + if (!user) { + setUserData(null); + setIsNewUser(false); + setUserDataLoading(false); + return; + } + + loadOrCreateUserData(); + }, [user]); + + /** + * 사용자 데이터 로드 또는 생성 + */ + const loadOrCreateUserData = async () => { + if (!user) return; + + setUserDataLoading(true); + + try { + const userRef = doc(db, "users", user.uid); + const userSnap = await getDoc(userRef); + const localBookmarks = getBookmarks(); + + if (!userSnap.exists()) { + // 신규 사용자: Firestore에 데이터 생성 + const newUserData: UserData = { + email: user.email, + displayName: user.displayName, + photoURL: user.photoURL, + createdAt: new Date().toISOString(), + scrapList: localBookmarks, + onboardingCompleted: false, + selectedCategory: "all", + }; + + await setDoc(userRef, newUserData); + setUserData(newUserData); + setIsNewUser(true); + + // 로컬스토리지 마이그레이션 완료 후 정리 + if (localBookmarks.length > 0) { + clearBookmarks(); + } + } else { + // 기존 사용자: 데이터 로드 및 로컬스토리지 병합 + const data = userSnap.data() as UserData; + + if (localBookmarks.length > 0) { + const mergedScrapList = [ + ...new Set([...data.scrapList, ...localBookmarks]), + ]; + await updateDoc(userRef, { scrapList: mergedScrapList }); + data.scrapList = mergedScrapList; + clearBookmarks(); + } + + setUserData(data); + setIsNewUser(!data.onboardingCompleted); + } + } catch (error) { + console.error("사용자 데이터 로드 실패:", error); + setUserData(null); + setIsNewUser(false); + } finally { + setUserDataLoading(false); + } + }; + + /** + * 사용자 데이터 새로고침 + */ + const refreshUserData = async () => { + await loadOrCreateUserData(); + }; + + /** + * 온보딩 완료 처리 + */ + const completeOnboarding = async (category: CategoryType) => { + if (!user) return; + + const userRef = doc(db, "users", user.uid); + await updateDoc(userRef, { + onboardingCompleted: true, + selectedCategory: category, + }); + + setUserData((prev) => + prev + ? { ...prev, onboardingCompleted: true, selectedCategory: category } + : null + ); + setIsNewUser(false); + }; + + /** + * 카테고리 업데이트 + */ + const updateCategory = async (category: CategoryType) => { + if (!user) return; + + const userRef = doc(db, "users", user.uid); + await updateDoc(userRef, { + selectedCategory: category, + }); + + setUserData((prev) => + prev ? { ...prev, selectedCategory: category } : null + ); + }; + + /** + * 스크랩 리스트 업데이트 (ScrapContext에서 호출) + */ + const updateScrapList = (newScrapList: number[]) => { + setUserData((prev) => (prev ? { ...prev, scrapList: newScrapList } : null)); + }; + + return ( + + {children} + + ); +} + +export function useUserData() { + const context = useContext(UserDataContext); + if (!context) { + throw new Error("useUserData must be used within a UserDataProvider"); + } + return context; +} diff --git a/src/contexts/auth/index.tsx b/src/contexts/auth/index.tsx new file mode 100644 index 0000000..a8a1f91 --- /dev/null +++ b/src/contexts/auth/index.tsx @@ -0,0 +1,103 @@ +"use client"; + +/** + * Auth 모듈 통합 Export + */ + +import { type ReactNode } from "react"; +import { type User } from "firebase/auth"; +import { type CategoryType } from "@/components/ui/category/config"; + +// 개별 Context들 +import { AuthProvider, useAuthCore } from "./AuthContext"; +import { + UserDataProvider, + useUserData, + type UserData, +} from "./UserDataContext"; +import { ScrapProvider, useScrap } from "./ScrapContext"; + +// 타입 re-export +export type { UserData }; + +/** + * Provider + */ +export function CombinedAuthProvider({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); +} + +interface LegacyAuthContextType { + user: User | null; + userData: UserData | null; + loading: boolean; + isNewUser: boolean; + loginWithGoogle: () => Promise; + loginWithDemo: () => Promise; + logout: () => Promise; + completeOnboarding: (category: CategoryType) => Promise; + updateCategory: (category: CategoryType) => Promise; + toggleScrap: ( + termId: number + ) => Promise<{ success: boolean; isScraped: boolean }>; + isScraped: (termId: number) => boolean; +} + +/** + * 기존 useAuth() 훅과 동일한 인터페이스 제공 + */ +export function useAuth(): LegacyAuthContextType { + const auth = useAuthCore(); + const userData = useUserData(); + const scrap = useScrap(); + + const loginWithGoogle = async (): Promise => { + await auth.loginWithGoogle(); + return new Promise((resolve) => { + setTimeout(() => { + resolve(userData.isNewUser); + }, 100); + }); + }; + + const loginWithDemo = async (): Promise => { + await auth.loginWithDemo(); + return new Promise((resolve) => { + setTimeout(() => { + resolve(userData.isNewUser); + }, 100); + }); + }; + + const logout = async (): Promise => { + await auth.logout(); + }; + + const loading = auth.loading || userData.userDataLoading; + + return { + user: auth.user, + userData: userData.userData, + loading, + isNewUser: userData.isNewUser, + loginWithGoogle, + loginWithDemo, + logout, + completeOnboarding: userData.completeOnboarding, + updateCategory: userData.updateCategory, + toggleScrap: scrap.toggleScrap, + isScraped: scrap.isScraped, + }; +} + +export { useAuthCore, useUserData, useScrap }; + +export { AuthProvider } from "./AuthContext"; +export { UserDataProvider } from "./UserDataContext"; +export { ScrapProvider } from "./ScrapContext"; From b6f8050343111ccc55a8dc9ca4160e1ae2255e23 Mon Sep 17 00:00:00 2001 From: hyeseong Date: Mon, 1 Dec 2025 00:32:16 +0900 Subject: [PATCH 06/22] =?UTF-8?q?refactor:=20context=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/components/DashboardClient.tsx | 15 ++-- src/app/dashboard/components/ProfileCard.tsx | 5 +- src/app/login/auth/LoginBody.tsx | 31 +++++---- src/app/onboarding/page.tsx | 5 +- src/app/terms/[slug]/page.tsx | 5 +- src/components/layout/Header.tsx | 4 +- src/components/providers/Providers.tsx | 6 +- src/components/quiz/QuizResult.tsx | 5 +- .../search/RecommendedTermsSection.tsx | 4 +- src/contexts/AuthContext.tsx | 13 ---- src/contexts/auth/index.tsx | 69 +------------------ src/hooks/useScrapToggle.ts | 5 +- 12 files changed, 53 insertions(+), 114 deletions(-) delete mode 100644 src/contexts/AuthContext.tsx diff --git a/src/app/dashboard/components/DashboardClient.tsx b/src/app/dashboard/components/DashboardClient.tsx index dc363e1..adcb759 100644 --- a/src/app/dashboard/components/DashboardClient.tsx +++ b/src/app/dashboard/components/DashboardClient.tsx @@ -4,7 +4,7 @@ import React, { useState, useEffect, type ReactNode } from "react"; import { useRouter } from "next/navigation"; import ProfileCard from "@/app/dashboard/components/ProfileCard"; import ScrapSection from "@/app/dashboard/components/ScrapSection"; -import { useAuth } from "@/contexts/AuthContext"; +import { useAuthCore, useUserData } from "@/contexts/auth"; import { getRelatedTerms } from "@/lib/terms"; import { termToScrapCard } from "@/lib/scrap"; import { type ScrapCardData } from "@/types/category"; @@ -17,16 +17,19 @@ export default function DashboardClient({ todayTermCard, }: DashboardClientProps) { const router = useRouter(); - const { user, userData, loading } = useAuth(); + const { user, loading: authLoading } = useAuthCore(); + const { userData, userDataLoading } = useUserData(); const [selectedCategory, setSelectedCategory] = useState("전체"); const [scrapCards, setScrapCards] = useState([]); - const [isLoading, setIsLoading] = useState(true); + const [scrapLoading, setScrapLoading] = useState(true); + + const loading = authLoading || userDataLoading; useEffect(() => { async function loadScrapTerms() { if (!userData || userData.scrapList.length === 0) { setScrapCards([]); - setIsLoading(false); + setScrapLoading(false); return; } @@ -38,7 +41,7 @@ export default function DashboardClient({ console.error("스크랩 목록 로드 실패:", error); setScrapCards([]); } finally { - setIsLoading(false); + setScrapLoading(false); } } @@ -82,7 +85,7 @@ export default function DashboardClient({ selectedCategory={selectedCategory} onCategorySelect={setSelectedCategory} cards={filteredCards} - isLoading={isLoading} + isLoading={scrapLoading} />
diff --git a/src/app/dashboard/components/ProfileCard.tsx b/src/app/dashboard/components/ProfileCard.tsx index 97eb031..3d2d618 100644 --- a/src/app/dashboard/components/ProfileCard.tsx +++ b/src/app/dashboard/components/ProfileCard.tsx @@ -8,11 +8,12 @@ import { categoryConfig, categoryLabels, } from "@/components/ui/category/config"; -import { useAuth } from "@/contexts/AuthContext"; +import { useAuthCore, useUserData } from "@/contexts/auth"; import CategoryEditModal from "./CategoryEditModal"; const SimpleProfileCard: React.FC = () => { - const { userData, user, updateCategory } = useAuth(); + const { user } = useAuthCore(); + const { userData, updateCategory } = useUserData(); const [isModalOpen, setIsModalOpen] = useState(false); const selectedCategory = userData?.selectedCategory || "all"; diff --git a/src/app/login/auth/LoginBody.tsx b/src/app/login/auth/LoginBody.tsx index a5b1248..c15aa11 100644 --- a/src/app/login/auth/LoginBody.tsx +++ b/src/app/login/auth/LoginBody.tsx @@ -1,21 +1,32 @@ "use client"; +import { useEffect, useRef } from "react"; import { useRouter } from "next/navigation"; import { GoogleLoginButtonIcon } from "@/components/buttons/GoogleLoginButtonIcon"; -import { useAuth } from "@/contexts/AuthContext"; +import { useAuthCore, useUserData } from "@/contexts/auth"; export default function LoginBody() { const router = useRouter(); - const { loginWithGoogle, loginWithDemo } = useAuth(); + const { loginWithGoogle, loginWithDemo } = useAuthCore(); + const { isNewUser, userDataLoading } = useUserData(); + const isLoggingIn = useRef(false); - const handleGoogleLogin = async () => { - try { - const needsOnboarding = await loginWithGoogle(); - if (needsOnboarding) { + // 로그인 후 userData 로딩이 완료되면 라우팅 + useEffect(() => { + if (isLoggingIn.current && !userDataLoading) { + if (isNewUser) { router.push("/onboarding"); } else { router.push("/"); } + isLoggingIn.current = false; + } + }, [isNewUser, userDataLoading, router]); + + const handleGoogleLogin = async () => { + try { + await loginWithGoogle(); + isLoggingIn.current = true; } catch (error) { console.error("로그인 중 오류 발생:", error); } @@ -23,12 +34,8 @@ export default function LoginBody() { const handleDemoLogin = async () => { try { - const needsOnboarding = await loginWithDemo(); - if (needsOnboarding) { - router.push("/onboarding"); - } else { - router.push("/"); - } + await loginWithDemo(); + isLoggingIn.current = true; } catch (error) { console.error("데모 계정 로그인 중 오류 발생:", error); alert("데모 계정 로그인에 실패했습니다."); diff --git a/src/app/onboarding/page.tsx b/src/app/onboarding/page.tsx index 8112de3..a4001f7 100644 --- a/src/app/onboarding/page.tsx +++ b/src/app/onboarding/page.tsx @@ -6,11 +6,12 @@ import OnboardingHeader from "./components/onboardingHeader"; import { ArrowRightIcon } from "@/components/icons/ic_arrow_right"; import CategoryList from "./components/categoryList"; import { type CategoryType } from "@/components/ui/category/config"; -import { useAuth } from "@/contexts/AuthContext"; +import { useAuthCore, useUserData } from "@/contexts/auth"; const Page = () => { const router = useRouter(); - const { completeOnboarding, user } = useAuth(); + const { user } = useAuthCore(); + const { completeOnboarding } = useUserData(); const [selectedCategory, setSelectedCategory] = useState( null ); diff --git a/src/app/terms/[slug]/page.tsx b/src/app/terms/[slug]/page.tsx index b979a69..3575e51 100644 --- a/src/app/terms/[slug]/page.tsx +++ b/src/app/terms/[slug]/page.tsx @@ -6,7 +6,7 @@ import { type TermDetail, getTermBySlug, getRelatedTerms } from "@/lib/terms"; import type { TermIndexItem } from "@/lib/terms"; import { toggleBookmark, isBookmarked } from "@/lib/bookmarks"; import { HeroSection, TabSection, Footer } from "@/components/term-detail"; -import { useAuth } from "@/contexts/AuthContext"; +import { useAuthCore, useScrap } from "@/contexts/auth"; import { useToast } from "@/contexts/ToastContext"; import { useShare } from "@/hooks/useShare"; @@ -16,7 +16,8 @@ export default function TermDetailPage({ params: Promise<{ slug: string }>; }) { const router = useRouter(); - const { user, isScraped, toggleScrap } = useAuth(); + const { user } = useAuthCore(); + const { isScraped, toggleScrap } = useScrap(); const { showLoginToast, showToast } = useToast(); const { shareCurrentPage } = useShare(); const [term, setTerm] = useState(null); diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 262983a..e9083f3 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -7,7 +7,7 @@ import { usePathname, useRouter } from "next/navigation"; import { UserIcon, LogoText } from "@/components/icons"; import { GlassButton } from "@/components/ui/GlassButton"; import { DEFAULT_NAV_ITEMS } from "@/constants/navigation"; -import { useAuth } from "@/contexts/AuthContext"; +import { useAuthCore } from "@/contexts/auth"; type NavItemProps = { label: string; @@ -142,7 +142,7 @@ export default function Header({ }: HeaderProps) { const pathname = usePathname(); const router = useRouter(); - const { user, loading, logout } = useAuth(); + const { user, loading, logout } = useAuthCore(); if (pathname === "/login" || pathname === "/onboarding") { return null; diff --git a/src/components/providers/Providers.tsx b/src/components/providers/Providers.tsx index dc547e4..72cc51e 100644 --- a/src/components/providers/Providers.tsx +++ b/src/components/providers/Providers.tsx @@ -1,13 +1,13 @@ "use client"; import { type ReactNode } from "react"; -import { AuthProvider } from "@/contexts/AuthContext"; +import { CombinedAuthProvider } from "@/contexts/auth"; import { ToastProvider } from "@/contexts/ToastContext"; export function Providers({ children }: { children: ReactNode }) { return ( - + {children} - + ); } diff --git a/src/components/quiz/QuizResult.tsx b/src/components/quiz/QuizResult.tsx index 9eabd9e..4c7ecd3 100644 --- a/src/components/quiz/QuizResult.tsx +++ b/src/components/quiz/QuizResult.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import Link from "next/link"; -import { useAuth } from "@/contexts/AuthContext"; +import { useAuthCore, useScrap } from "@/contexts/auth"; import { useToast } from "@/contexts/ToastContext"; import { generateQuizQuestions, @@ -25,7 +25,8 @@ export default function QuizResult({ onRestart, onRetry, }: QuizResultProps) { - const { user, toggleScrap, isScraped } = useAuth(); + const { user } = useAuthCore(); + const { toggleScrap, isScraped } = useScrap(); const { showToast } = useToast(); const [isRetrying, setIsRetrying] = useState(false); diff --git a/src/components/search/RecommendedTermsSection.tsx b/src/components/search/RecommendedTermsSection.tsx index b2d6347..1ac7fd0 100644 --- a/src/components/search/RecommendedTermsSection.tsx +++ b/src/components/search/RecommendedTermsSection.tsx @@ -4,14 +4,14 @@ import { useState, useEffect } from "react"; import RecommendedTermCard from "@/components/RecommendedTermCard"; import RecommendedTermCardSkeleton from "@/components/RecommendedTermCardSkeleton"; import { ChevronsDownIcon } from "@/components/icons/ic_chevrons_down"; -import { useAuth } from "@/contexts/AuthContext"; +import { useUserData } from "@/contexts/auth"; import { getRecommendedTerms, type RecommendedTerm, } from "@/lib/recommendations"; export default function RecommendedTermsSection() { - const { userData } = useAuth(); + const { userData } = useUserData(); const [showMoreRecommended, setShowMoreRecommended] = useState(false); const [recommendedTerms, setRecommendedTerms] = useState( [] diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx deleted file mode 100644 index 49b5e78..0000000 --- a/src/contexts/AuthContext.tsx +++ /dev/null @@ -1,13 +0,0 @@ -"use client"; - -/** - * AuthContext - 레거시 호환성 파일 - */ - -export { - CombinedAuthProvider as AuthProvider, - useAuth, - type UserData, -} from "./auth"; - -export { useAuthCore, useUserData, useScrap } from "./auth"; diff --git a/src/contexts/auth/index.tsx b/src/contexts/auth/index.tsx index a8a1f91..3398442 100644 --- a/src/contexts/auth/index.tsx +++ b/src/contexts/auth/index.tsx @@ -5,8 +5,6 @@ */ import { type ReactNode } from "react"; -import { type User } from "firebase/auth"; -import { type CategoryType } from "@/components/ui/category/config"; // 개별 Context들 import { AuthProvider, useAuthCore } from "./AuthContext"; @@ -21,7 +19,7 @@ import { ScrapProvider, useScrap } from "./ScrapContext"; export type { UserData }; /** - * Provider + * 통합 Provider */ export function CombinedAuthProvider({ children }: { children: ReactNode }) { return ( @@ -33,71 +31,10 @@ export function CombinedAuthProvider({ children }: { children: ReactNode }) { ); } -interface LegacyAuthContextType { - user: User | null; - userData: UserData | null; - loading: boolean; - isNewUser: boolean; - loginWithGoogle: () => Promise; - loginWithDemo: () => Promise; - logout: () => Promise; - completeOnboarding: (category: CategoryType) => Promise; - updateCategory: (category: CategoryType) => Promise; - toggleScrap: ( - termId: number - ) => Promise<{ success: boolean; isScraped: boolean }>; - isScraped: (termId: number) => boolean; -} - -/** - * 기존 useAuth() 훅과 동일한 인터페이스 제공 - */ -export function useAuth(): LegacyAuthContextType { - const auth = useAuthCore(); - const userData = useUserData(); - const scrap = useScrap(); - - const loginWithGoogle = async (): Promise => { - await auth.loginWithGoogle(); - return new Promise((resolve) => { - setTimeout(() => { - resolve(userData.isNewUser); - }, 100); - }); - }; - - const loginWithDemo = async (): Promise => { - await auth.loginWithDemo(); - return new Promise((resolve) => { - setTimeout(() => { - resolve(userData.isNewUser); - }, 100); - }); - }; - - const logout = async (): Promise => { - await auth.logout(); - }; - - const loading = auth.loading || userData.userDataLoading; - - return { - user: auth.user, - userData: userData.userData, - loading, - isNewUser: userData.isNewUser, - loginWithGoogle, - loginWithDemo, - logout, - completeOnboarding: userData.completeOnboarding, - updateCategory: userData.updateCategory, - toggleScrap: scrap.toggleScrap, - isScraped: scrap.isScraped, - }; -} - +// Hooks export export { useAuthCore, useUserData, useScrap }; +// Individual Providers export export { AuthProvider } from "./AuthContext"; export { UserDataProvider } from "./UserDataContext"; export { ScrapProvider } from "./ScrapContext"; diff --git a/src/hooks/useScrapToggle.ts b/src/hooks/useScrapToggle.ts index 64d2361..a357095 100644 --- a/src/hooks/useScrapToggle.ts +++ b/src/hooks/useScrapToggle.ts @@ -1,5 +1,5 @@ import { useState } from "react"; -import { useAuth } from "@/contexts/AuthContext"; +import { useAuthCore, useScrap } from "@/contexts/auth"; import { useToast } from "@/contexts/ToastContext"; import { isBookmarked, toggleBookmark } from "@/lib/bookmarks"; @@ -7,7 +7,8 @@ import { isBookmarked, toggleBookmark } from "@/lib/bookmarks"; * 스크랩 토글 기능을 제공하는 커스텀 훅 */ export function useScrapToggle(termId: number) { - const { user, isScraped, toggleScrap } = useAuth(); + const { user } = useAuthCore(); + const { isScraped, toggleScrap } = useScrap(); const { showLoginToast, showToast } = useToast(); // 서버/로컬 북마크 상태 From 89bcb8db9cf7c0d3ef3d70ba3b0ee0d121c549fb Mon Sep 17 00:00:00 2001 From: hyeseong Date: Mon, 1 Dec 2025 01:10:50 +0900 Subject: [PATCH 07/22] =?UTF-8?q?refactor:=20=EB=A0=88=EA=B1=B0=EC=8B=9C?= =?UTF-8?q?=20=ED=98=B8=ED=99=98=EC=84=B1=20=ED=8C=8C=EC=9D=BC=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/CategoryEditModal.tsx | 27 +--- src/app/dashboard/components/CategoryTag.tsx | 43 ++---- .../dashboard/components/DashboardClient.tsx | 2 +- src/app/dashboard/components/ProfileCard.tsx | 10 +- src/app/dashboard/components/ScrapCard.tsx | 9 +- src/app/dashboard/components/ScrapSection.tsx | 6 +- src/app/dashboard/utils/order.ts | 2 +- .../onboarding/components/CategoryButton.tsx | 18 +-- .../onboarding/components/categoryList.tsx | 17 +-- src/app/onboarding/page.tsx | 2 +- src/app/quiz/page.tsx | 2 +- src/components/TagList.tsx | 142 ++++++------------ src/components/quiz/CategorySelection.tsx | 52 ++----- src/components/quiz/QuizResult.tsx | 2 +- src/components/quiz/QuizSession.tsx | 7 +- src/components/search/SearchResultCard.tsx | 5 +- .../term-detail/tabs/DescriptionTab.tsx | 2 +- .../term-detail/tabs/RelatedTab.tsx | 5 +- src/components/term-detail/types.ts | 5 +- src/components/ui/category/CategoryChip.tsx | 4 +- .../ui/category/CategorySquareBadge.tsx | 4 +- src/components/ui/category/config.ts | 14 -- src/components/ui/category/index.ts | 9 -- src/config/categories.ts | 99 +++--------- src/contexts/auth/UserDataContext.tsx | 2 +- src/lib/category.ts | 11 -- src/lib/quiz.ts | 7 +- src/lib/recommendations.ts | 9 +- src/lib/scrap.ts | 4 +- src/types/{category.ts => scrapCard.ts} | 11 -- 30 files changed, 145 insertions(+), 387 deletions(-) delete mode 100644 src/components/ui/category/config.ts delete mode 100644 src/lib/category.ts rename src/types/{category.ts => scrapCard.ts} (53%) diff --git a/src/app/dashboard/components/CategoryEditModal.tsx b/src/app/dashboard/components/CategoryEditModal.tsx index 6bcde99..c0c9cb1 100644 --- a/src/app/dashboard/components/CategoryEditModal.tsx +++ b/src/app/dashboard/components/CategoryEditModal.tsx @@ -3,9 +3,9 @@ import React, { useState, useEffect } from "react"; import { type CategoryType, - categoryConfig, - categoryLabels, -} from "@/components/ui/category/config"; + CATEGORIES, + CATEGORY_KEYS, +} from "@/config/categories"; interface CategoryEditModalProps { isOpen: boolean; @@ -14,19 +14,6 @@ interface CategoryEditModalProps { onSave: (category: CategoryType) => Promise; } -const selectableCategories: CategoryType[] = [ - "all", - "frontend", - "backend", - "uxui", - "ai", - "cloud", - "data", - "security", - "devops", - "business", -]; - export default function CategoryEditModal({ isOpen, onClose, @@ -73,8 +60,8 @@ export default function CategoryEditModal({
- {selectableCategories.map((category) => { - const config = categoryConfig[category]; + {CATEGORY_KEYS.map((category) => { + const config = CATEGORIES[category]; const IconComponent = config.icon; const isSelected = selectedCategory === category; @@ -93,9 +80,7 @@ export default function CategoryEditModal({ >
- - {categoryLabels[category]} - + {config.label} ); })} diff --git a/src/app/dashboard/components/CategoryTag.tsx b/src/app/dashboard/components/CategoryTag.tsx index c287b0d..a6f5094 100644 --- a/src/app/dashboard/components/CategoryTag.tsx +++ b/src/app/dashboard/components/CategoryTag.tsx @@ -1,11 +1,6 @@ "use client"; -import { - categoryIcons, - categoryColors, - categoryHoverStyles, - categoryActiveStyles, -} from "@/types/category"; +import { CATEGORIES, getCategoryType } from "@/config/categories"; interface CategoryTagProps { category: string; @@ -18,33 +13,23 @@ export default function CategoryTag({ isActive, onClick, }: CategoryTagProps) { - const IconComponent = categoryIcons[category]; - const colorClass = categoryColors[category]; - const hoverStyle = - categoryHoverStyles[category] || - "hover:bg-gray-400/10 hover:outline-white-50"; - const activeStyle = - categoryActiveStyles[category] || "bg-gray-400/50 outline-white"; - const defaultStyle = "bg-white/5 outline-white-30"; - const finalClasses = isActive - ? activeStyle + " transition-colors" - : defaultStyle + " " + hoverStyle + " transition-colors"; + const categoryType = getCategoryType(category); + const config = CATEGORIES[categoryType]; + const IconComponent = config.icon; + + const baseClasses = + "glass inline-flex cursor-pointer items-center justify-center gap-2 rounded-xl px-5 py-2 outline-[0.25px] outline-offset-[-0.25px] transition-colors shrink-0"; + + const stateClasses = isActive + ? `${config.selectedColor} outline-white` + : `bg-white/5 outline-white-30 ${config.hoverColor} hover:outline-white-50`; return ( -
+
- {IconComponent && ( - - )} +
#{category} diff --git a/src/app/dashboard/components/DashboardClient.tsx b/src/app/dashboard/components/DashboardClient.tsx index adcb759..1266e82 100644 --- a/src/app/dashboard/components/DashboardClient.tsx +++ b/src/app/dashboard/components/DashboardClient.tsx @@ -7,7 +7,7 @@ import ScrapSection from "@/app/dashboard/components/ScrapSection"; import { useAuthCore, useUserData } from "@/contexts/auth"; import { getRelatedTerms } from "@/lib/terms"; import { termToScrapCard } from "@/lib/scrap"; -import { type ScrapCardData } from "@/types/category"; +import { type ScrapCardData } from "@/types/scrapCard"; interface DashboardClientProps { todayTermCard: ReactNode; diff --git a/src/app/dashboard/components/ProfileCard.tsx b/src/app/dashboard/components/ProfileCard.tsx index 3d2d618..7756a73 100644 --- a/src/app/dashboard/components/ProfileCard.tsx +++ b/src/app/dashboard/components/ProfileCard.tsx @@ -3,11 +3,7 @@ import React, { useState } from "react"; import Image from "next/image"; import { UserIcon } from "@/components/icons/ic_user"; import { EditIcon } from "@/components/icons/ic_edit"; -import { - type CategoryType, - categoryConfig, - categoryLabels, -} from "@/components/ui/category/config"; +import { CATEGORIES, type CategoryType } from "@/config/categories"; import { useAuthCore, useUserData } from "@/contexts/auth"; import CategoryEditModal from "./CategoryEditModal"; @@ -24,7 +20,7 @@ const SimpleProfileCard: React.FC = () => { await updateCategory(category); }; - const config = categoryConfig[selectedCategory]; + const config = CATEGORIES[selectedCategory]; const IconComponent = config?.icon; return ( @@ -70,7 +66,7 @@ const SimpleProfileCard: React.FC = () => {
- #{categoryLabels[selectedCategory]} + #{config.label}
) : ( diff --git a/src/app/dashboard/components/ScrapCard.tsx b/src/app/dashboard/components/ScrapCard.tsx index cb23cad..0968861 100644 --- a/src/app/dashboard/components/ScrapCard.tsx +++ b/src/app/dashboard/components/ScrapCard.tsx @@ -3,7 +3,8 @@ import { useRouter } from "next/navigation"; import { ScrapIcon } from "@/components/icons/ic_scrap"; import { ArrowRightIcon } from "@/components/icons/ic_arrow_right"; -import { categoryIcons, categoryColors, ScrapCardData } from "@/types/category"; +import { CATEGORIES, getCategoryType } from "@/config/categories"; +import type { ScrapCardData } from "@/types/scrapCard"; interface ScrapCardProps { card: ScrapCardData; @@ -11,8 +12,10 @@ interface ScrapCardProps { export default function ScrapCard({ card }: ScrapCardProps) { const router = useRouter(); - const IconComponent = categoryIcons[card.category]; - const colorClass = categoryColors[card.category]; + const categoryType = getCategoryType(card.category); + const config = CATEGORIES[categoryType]; + const IconComponent = config.icon; + const colorClass = config.bgColor; const handleClick = () => { if (card.slug) { diff --git a/src/app/dashboard/components/ScrapSection.tsx b/src/app/dashboard/components/ScrapSection.tsx index 476f131..a86eb9c 100644 --- a/src/app/dashboard/components/ScrapSection.tsx +++ b/src/app/dashboard/components/ScrapSection.tsx @@ -3,7 +3,8 @@ import Link from "next/link"; import { useState } from "react"; import { ScrapIcon } from "@/components/icons/ic_scrap"; -import { categoryIcons, ScrapCardData } from "@/types/category"; +import { CATEGORIES, CATEGORY_KEYS } from "@/config/categories"; +import type { ScrapCardData } from "@/types/scrapCard"; import CategoryTag from "./CategoryTag"; import ScrapCard from "./ScrapCard"; import { sortCards, SortType } from "../utils/order"; @@ -25,7 +26,8 @@ export default function ScrapSection({ cards, isLoading = false, }: ScrapSectionProps) { - const categories = Object.keys(categoryIcons); + // 한글 라벨 목록 생성 + const categories = CATEGORY_KEYS.map((key) => CATEGORIES[key].label); const [sortType, setSortType] = useState("latest"); const sortedCards = sortCards(cards, sortType); diff --git a/src/app/dashboard/utils/order.ts b/src/app/dashboard/utils/order.ts index dbd3dd2..3c100bb 100644 --- a/src/app/dashboard/utils/order.ts +++ b/src/app/dashboard/utils/order.ts @@ -1,4 +1,4 @@ -import { ScrapCardData } from "@/types/category"; +import { ScrapCardData } from "@/types/scrapCard"; import { sortByKorean, sortByDateDesc, type SortType } from "@/utils/sorting"; export type { SortType }; diff --git a/src/app/onboarding/components/CategoryButton.tsx b/src/app/onboarding/components/CategoryButton.tsx index 062f166..2ae8b62 100644 --- a/src/app/onboarding/components/CategoryButton.tsx +++ b/src/app/onboarding/components/CategoryButton.tsx @@ -1,12 +1,7 @@ "use client"; import { CategorySquareBadge } from "@/components/ui/category/CategorySquareBadge"; -import { - categoryLabels, - categoryHoverColors, - categorySelectedColors, - type CategoryType, -} from "@/components/ui/category/config"; +import { CATEGORIES, type CategoryType } from "@/config/categories"; import { cn } from "@/utils/cn"; interface CategoryButtonProps { @@ -20,8 +15,7 @@ export default function CategoryButton({ isSelected, onClick, }: CategoryButtonProps) { - const hoverStyle = categoryHoverColors[category]; - const activeStyle = categorySelectedColors[category]; + const config = CATEGORIES[category]; return ( ); } diff --git a/src/app/onboarding/components/categoryList.tsx b/src/app/onboarding/components/categoryList.tsx index 2b7567a..174094f 100644 --- a/src/app/onboarding/components/categoryList.tsx +++ b/src/app/onboarding/components/categoryList.tsx @@ -1,6 +1,6 @@ "use client"; -import { type CategoryType } from "@/components/ui/category/config"; +import { type CategoryType } from "@/config/categories"; import CategoryButton from "./CategoryButton"; interface CategoryListProps { @@ -8,13 +8,10 @@ interface CategoryListProps { onSelectCategory: (category: CategoryType) => void; } -const row1Categories: CategoryType[] = ["frontend", "backend", "uxui", "ai"]; -const row2Categories: CategoryType[] = [ - "cloud", - "data", - "security", - "devops", - "business", +// 온보딩용 2줄 배열 (all 제외) +const ONBOARDING_ROWS: [CategoryType[], CategoryType[]] = [ + ["frontend", "backend", "uxui", "ai"], + ["cloud", "data", "security", "devops", "business"], ]; export default function CategoryList({ @@ -24,7 +21,7 @@ export default function CategoryList({ return (
- {row1Categories.map((category) => ( + {ONBOARDING_ROWS[0].map((category) => (
- {row2Categories.map((category) => ( + {ONBOARDING_ROWS[1].map((category) => ( { diff --git a/src/app/quiz/page.tsx b/src/app/quiz/page.tsx index 033efd2..5ebeffb 100644 --- a/src/app/quiz/page.tsx +++ b/src/app/quiz/page.tsx @@ -5,7 +5,7 @@ import CategorySelection from "@/components/quiz/CategorySelection"; import QuizSession from "@/components/quiz/QuizSession"; import QuizResult from "@/components/quiz/QuizResult"; import type { QuizQuestion, QuizResult as QuizResultType } from "@/lib/quiz"; -import type { CategoryType } from "@/components/ui/category/config"; +import type { CategoryType } from "@/config/categories"; type QuizStage = "category" | "quiz" | "result"; diff --git a/src/components/TagList.tsx b/src/components/TagList.tsx index bcffed5..c4b2617 100644 --- a/src/components/TagList.tsx +++ b/src/components/TagList.tsx @@ -1,125 +1,67 @@ "use client"; -import React from "react"; -import { ElementType } from "react"; -import { CategoryAllIcon } from "@/components/icons/ic_category_all"; -import { CategoryFrontendIcon } from "@/components/icons/ic_category_frontend"; -import { CategoryBackendIcon } from "@/components/icons/ic_category_backend"; -import { CategoryUiuxIcon } from "@/components/icons/ic_category_uiux"; -import { CategoryAiIcon } from "@/components/icons/ic_category_ai"; -import { CategoryCloudIcon } from "@/components/icons/ic_category_cloud"; -import { CategoryDataIcon } from "@/components/icons/ic_category_data"; -import { CategorySecurityIcon } from "@/components/icons/ic_category_security"; -import { CategoryDevopsIcon } from "@/components/icons/ic_category_devops"; -import { CategoryBusinessIcon } from "@/components/icons/ic_category_business"; +import { + CATEGORIES, + CATEGORY_ROWS, + type CategoryType, +} from "@/config/categories"; interface TagListProps { selectedTag: string; onTagSelect: (tagName: string) => void; } -// 2. 아이콘 컴포넌트 타입을 위한 TagData 인터페이스 수정 -interface TagData { - name: string; - color: string; - IconComponent: ElementType; -} - -// 태그 데이터 (최종 디자인 스펙 반영) -const tagData: TagData[] = [ - { name: "전체", color: "bg-gray-400", IconComponent: CategoryAllIcon }, - { - name: "프론트엔드", - color: "bg-cyan-400", - IconComponent: CategoryFrontendIcon, - }, - { name: "백엔드", color: "bg-green-600", IconComponent: CategoryBackendIcon }, - { - name: "UX/UI", - color: "bg-rose-400", - IconComponent: CategoryUiuxIcon, - }, - { name: "AI", color: "bg-violet-400", IconComponent: CategoryAiIcon }, - { name: "클라우드", color: "bg-sky-400", IconComponent: CategoryCloudIcon }, - { name: "데이터", color: "bg-teal-400", IconComponent: CategoryDataIcon }, - { - name: "보안/네트워크", - color: "bg-orange-400", - IconComponent: CategorySecurityIcon, - }, - { name: "DevOps", color: "bg-amber-400", IconComponent: CategoryDevopsIcon }, - { - name: "IT비즈니스", - color: "bg-blue-400", - IconComponent: CategoryBusinessIcon, - }, -]; - -const renderTag = ( - tag: (typeof tagData)[0], - selectedTag: string, - onTagSelect: (name: string) => void -) => { - const { IconComponent } = tag; - const isActive = selectedTag === tag.name; +function TagItem({ + category, + isActive, + onClick, +}: { + category: CategoryType; + isActive: boolean; + onClick: () => void; +}) { + const config = CATEGORIES[category]; + const IconComponent = config.icon; - // Default 스타일 (선택되지 않았을 때의 기본 배경) - const defaultStyle = "bg-white/5 outline-white-30"; + const baseClasses = + "glass inline-flex cursor-pointer items-center justify-center gap-2 rounded-xl px-5 py-2 outline outline-[0.25px] outline-offset-[-0.25px] transition-colors"; - // Hover 스타일 (고유색 10% 투명도) - const hoverStyle = `hover:${tag.color}/10 hover:outline-white-50`; - - // Active 스타일 (선택됨: 고유색 50% 투명도) - const activeStyle = `${tag.color}/50 outline-white`; - - // 최종 클래스 조합 - const finalClasses = isActive - ? activeStyle + " transition-colors" // Active: 고유색 50% 강조 - : defaultStyle + " " + hoverStyle + " transition-colors"; // Default + Hover + const stateClasses = isActive + ? `${config.selectedColor} outline-white` + : `bg-white/5 outline-white-30 ${config.hoverColor} hover:outline-white-50`; return ( -
onTagSelect(tag.name)} - className={`glass inline-flex cursor-pointer items-center justify-center gap-2 rounded-xl px-5 py-2 outline outline-[0.25px] outline-offset-[-0.25px] ${finalClasses} `} - > - {/* 아이콘 컨테이너 */} +
- {/* SVG 라인 아이콘 렌더링 */}
- - {/* 텍스트 */} -
- - # - - - {tag.name} - -
+ + #{config.label} +
); -}; +} export default function TagList({ selectedTag, onTagSelect }: TagListProps) { - // 데이터를 두 줄로 분할 - const row1 = tagData.slice(0, 5); - const row2 = tagData.slice(5); - return (
- {/* 첫 번째 줄 (중앙 정렬) */} -
- {row1.map((tag) => renderTag(tag, selectedTag, onTagSelect))} -
- - {/* 두 번째 줄 (중앙 정렬) */} -
- {row2.map((tag) => renderTag(tag, selectedTag, onTagSelect))} -
+ {CATEGORY_ROWS.map((row, rowIndex) => ( +
+ {row.map((category) => ( + onTagSelect(CATEGORIES[category].label)} + /> + ))} +
+ ))}
); } diff --git a/src/components/quiz/CategorySelection.tsx b/src/components/quiz/CategorySelection.tsx index b1336bd..75a68af 100644 --- a/src/components/quiz/CategorySelection.tsx +++ b/src/components/quiz/CategorySelection.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { type CategoryType } from "@/components/ui/category/config"; +import { type CategoryType, CATEGORY_ROWS } from "@/config/categories"; import CategoryButton from "@/app/onboarding/components/CategoryButton"; import { generateQuizQuestions, type QuizQuestion } from "@/lib/quiz"; import { ArrowRightIcon } from "@/components/icons/ic_arrow_right"; @@ -12,21 +12,6 @@ interface CategorySelectionProps { onCategorySelect: (category: CategoryType, questions: QuizQuestion[]) => void; } -const row1Categories: CategoryType[] = [ - "all", - "frontend", - "backend", - "uxui", - "ai", -]; -const row2Categories: CategoryType[] = [ - "cloud", - "data", - "security", - "devops", - "business", -]; - export default function CategorySelection({ onCategorySelect, }: CategorySelectionProps) { @@ -78,29 +63,18 @@ export default function CategorySelection({

카테고리 선택

- {/* 첫 번째 줄 */} -
- {row1Categories.map((category) => ( - handleSelectCategory(category)} - /> - ))} -
- - {/* 두 번째 줄 */} -
- {row2Categories.map((category) => ( - handleSelectCategory(category)} - /> - ))} -
+ {CATEGORY_ROWS.map((row, rowIndex) => ( +
+ {row.map((category) => ( + handleSelectCategory(category)} + /> + ))} +
+ ))}
diff --git a/src/components/quiz/QuizResult.tsx b/src/components/quiz/QuizResult.tsx index 4c7ecd3..fdefc3d 100644 --- a/src/components/quiz/QuizResult.tsx +++ b/src/components/quiz/QuizResult.tsx @@ -9,7 +9,7 @@ import { type QuizResult as QuizResultType, type QuizQuestion, } from "@/lib/quiz"; -import { type CategoryType } from "@/components/ui/category/config"; +import { type CategoryType } from "@/config/categories"; import GradientButton from "@/components/ui/buttons/GradientButton"; interface QuizResultProps { diff --git a/src/components/quiz/QuizSession.tsx b/src/components/quiz/QuizSession.tsx index 5b5300c..81ab8de 100644 --- a/src/components/quiz/QuizSession.tsx +++ b/src/components/quiz/QuizSession.tsx @@ -7,10 +7,7 @@ import { type QuizQuestion, type QuizResult, } from "@/lib/quiz"; -import { - categoryLabels, - type CategoryType, -} from "@/components/ui/category/config"; +import { CATEGORIES, type CategoryType } from "@/config/categories"; import GradientButton from "@/components/ui/buttons/GradientButton"; interface QuizSessionProps { @@ -74,7 +71,7 @@ export default function QuizSession({

- {categoryLabels[category]} 퀴즈 + {CATEGORIES[category].label} 퀴즈

문제 {currentQuestionIndex + 1} / {questions.length} diff --git a/src/components/search/SearchResultCard.tsx b/src/components/search/SearchResultCard.tsx index 637f53d..dc0d768 100644 --- a/src/components/search/SearchResultCard.tsx +++ b/src/components/search/SearchResultCard.tsx @@ -6,8 +6,7 @@ import { useShare } from "@/hooks/useShare"; import type { TermIndexItem } from "@/lib/terms"; import { ScrapIcon, ShareIcon, HashtagIcon } from "@/components/icons"; import { CategoryChip } from "@/components/ui/category/CategoryChip"; -import { getCategoryType } from "@/lib/category"; -import { categorySelectedColors } from "@/components/ui/category/config"; +import { CATEGORIES, getCategoryType } from "@/config/categories"; interface SearchResultCardProps { item: TermIndexItem; @@ -88,7 +87,7 @@ export default function SearchResultCard({ item }: SearchResultCardProps) { {item.tags?.slice(0, 3).map((tag, index) => { const isFirstTag = index === 0; const bgColor = isFirstTag - ? categorySelectedColors[category] + ? CATEGORIES[category].selectedColor : "bg-gray-900"; const textColor = isFirstTag ? "text-white" : "text-gray-300"; diff --git a/src/components/term-detail/tabs/DescriptionTab.tsx b/src/components/term-detail/tabs/DescriptionTab.tsx index 1dff6fd..576fea4 100644 --- a/src/components/term-detail/tabs/DescriptionTab.tsx +++ b/src/components/term-detail/tabs/DescriptionTab.tsx @@ -1,7 +1,7 @@ import { cn } from "@/utils/cn"; import type { TermDetail } from "@/lib/terms"; import { InfoIcon, HashtagIcon } from "@/components/icons"; -import { getCategoryType } from "@/lib/category"; +import { getCategoryType } from "@/config/categories"; interface DescriptionTabProps { term: TermDetail; diff --git a/src/components/term-detail/tabs/RelatedTab.tsx b/src/components/term-detail/tabs/RelatedTab.tsx index fca00fd..a64daf5 100644 --- a/src/components/term-detail/tabs/RelatedTab.tsx +++ b/src/components/term-detail/tabs/RelatedTab.tsx @@ -9,8 +9,7 @@ import { ScrapIcon, ChevronRightIcon, } from "@/components/icons"; -import { categoryConfig } from "@/components/ui/category/config"; -import { getCategoryType } from "@/lib/category"; +import { CATEGORIES, getCategoryType } from "@/config/categories"; import { useScrapToggle } from "@/hooks/useScrapToggle"; interface RelatedTabProps { @@ -44,7 +43,7 @@ function RelatedTermCard({ term }: { term: TermIndexItem }) { const { bookmarked, handleToggle } = useScrapToggle(term.id); const category = getCategoryType(term.primaryTag); - const config = categoryConfig[category]; + const config = CATEGORIES[category]; const CategoryIcon = config.icon; const handleBookmark = (e: React.MouseEvent) => { diff --git a/src/components/term-detail/types.ts b/src/components/term-detail/types.ts index e1162cc..ee6a7e2 100644 --- a/src/components/term-detail/types.ts +++ b/src/components/term-detail/types.ts @@ -1,11 +1,10 @@ -import { categoryConfig } from "@/components/ui/category/config"; -import { getCategoryType } from "@/lib/category"; +import { CATEGORIES, getCategoryType } from "@/config/categories"; export function getCategoryConfig(tag: string) { const category = getCategoryType(tag); return { category, - config: categoryConfig[category], + config: CATEGORIES[category], }; } diff --git a/src/components/ui/category/CategoryChip.tsx b/src/components/ui/category/CategoryChip.tsx index 99ebf47..9492601 100644 --- a/src/components/ui/category/CategoryChip.tsx +++ b/src/components/ui/category/CategoryChip.tsx @@ -1,5 +1,5 @@ import { cn } from "@/utils/cn"; -import { type CategoryType, categoryConfig } from "./config"; +import { type CategoryType, CATEGORIES } from "@/config/categories"; interface CategoryChipProps { category: CategoryType; @@ -12,7 +12,7 @@ export function CategoryChip({ disabled = false, className, }: CategoryChipProps) { - const config = categoryConfig[category]; + const config = CATEGORIES[category]; const Icon = config.icon; return ( diff --git a/src/components/ui/category/CategorySquareBadge.tsx b/src/components/ui/category/CategorySquareBadge.tsx index 631422f..e3b6cc6 100644 --- a/src/components/ui/category/CategorySquareBadge.tsx +++ b/src/components/ui/category/CategorySquareBadge.tsx @@ -1,5 +1,5 @@ import { cn } from "@/utils/cn"; -import { type CategoryType, categoryConfig } from "./config"; +import { type CategoryType, CATEGORIES } from "@/config/categories"; interface CategorySquareBadgeProps { category: CategoryType; @@ -10,7 +10,7 @@ export function CategorySquareBadge({ category, className, }: CategorySquareBadgeProps) { - const config = categoryConfig[category]; + const config = CATEGORIES[category]; const Icon = config.icon; return ( diff --git a/src/components/ui/category/config.ts b/src/components/ui/category/config.ts deleted file mode 100644 index e1a37f6..0000000 --- a/src/components/ui/category/config.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * 레거시 호환성 파일 - */ - -export { - type CategoryType, - CATEGORIES, - categoryConfig, - categoryLabels, - categoryHoverColors, - categorySelectedColors, - getCategoryLabel, - getCategoryType, -} from "@/config/categories"; diff --git a/src/components/ui/category/index.ts b/src/components/ui/category/index.ts index 29be805..8250ec9 100644 --- a/src/components/ui/category/index.ts +++ b/src/components/ui/category/index.ts @@ -1,12 +1,3 @@ // Category Components export { CategoryChip } from "./CategoryChip"; export { CategorySquareBadge } from "./CategorySquareBadge"; - -// Types and Config -export type { CategoryType } from "./config"; -export { - categoryConfig, - categoryLabels, - categoryHoverColors, - categorySelectedColors, -} from "./config"; diff --git a/src/config/categories.ts b/src/config/categories.ts index 5401c8b..95f81e8 100644 --- a/src/config/categories.ts +++ b/src/config/categories.ts @@ -134,6 +134,21 @@ export const CATEGORIES: Record = { */ export const CATEGORY_KEYS = Object.keys(CATEGORIES) as CategoryType[]; +/** + * 카테고리 화면 표시용 2줄 배열 + */ +export const CATEGORY_ROWS: [CategoryType[], CategoryType[]] = [ + ["all", "frontend", "backend", "uxui", "ai"], + ["cloud", "data", "security", "devops", "business"], +]; + +/** + * "all"을 제외한 선택 가능한 카테고리 목록 + */ +export const SELECTABLE_CATEGORIES = CATEGORY_KEYS.filter( + (key) => key !== "all" +) as Exclude[]; + // ============================================================================ // 유틸리티 함수들 // ============================================================================ @@ -148,22 +163,20 @@ export function getCategoryLabel(category: string): string { /** * 한글 라벨 → 영문 카테고리 타입 역매핑 - * - * primaryTag나 사용자 입력 등 다양한 형태의 한글 라벨을 처리합니다. */ const LABEL_TO_CATEGORY: Record = { 전체: "all", 프론트엔드: "frontend", 백엔드: "backend", "UX/UI": "uxui", - "UI/UX": "uxui", // 역순 허용 + "UI/UX": "uxui", "UX/UI디자인": "uxui", "UI/UX디자인": "uxui", AI: "ai", 클라우드: "cloud", 데이터: "data", "보안/네트워크": "security", - "보안-네트워크": "security", // 하이픈 형태 허용 + "보안-네트워크": "security", DevOps: "devops", IT비즈니스: "business", }; @@ -176,81 +189,3 @@ const LABEL_TO_CATEGORY: Record = { export function getCategoryType(label: string): CategoryType { return LABEL_TO_CATEGORY[label] || "all"; } - -// ============================================================================ -// 레거시 호환성을 위한 Export -// (기존 코드가 동작하도록 유지, 점진적으로 CATEGORIES 사용으로 마이그레이션) -// ============================================================================ - -/** - * @deprecated CATEGORIES를 직접 사용하세요 - */ -export const categoryConfig = Object.fromEntries( - CATEGORY_KEYS.map((key) => [ - key, - { - icon: CATEGORIES[key].icon, - bgColor: CATEGORIES[key].bgColor, - }, - ]) -) as Record; - -/** - * @deprecated CATEGORIES를 직접 사용하세요 - */ -export const categoryLabels = Object.fromEntries( - CATEGORY_KEYS.map((key) => [key, CATEGORIES[key].label]) -) as Record; - -/** - * @deprecated CATEGORIES를 직접 사용하세요 - */ -export const categoryHoverColors = Object.fromEntries( - CATEGORY_KEYS.map((key) => [key, CATEGORIES[key].hoverColor]) -) as Record; - -/** - * @deprecated CATEGORIES를 직접 사용하세요 - */ -export const categorySelectedColors = Object.fromEntries( - CATEGORY_KEYS.map((key) => [key, CATEGORIES[key].selectedColor]) -) as Record; - -/** - * @deprecated getCategoryType()과 CATEGORIES에서 icon을 사용하세요 - */ -export const categoryIcons: Record = Object.fromEntries( - CATEGORY_KEYS.map((key) => [CATEGORIES[key].label, CATEGORIES[key].icon]) -); - -/** - * @deprecated getCategoryLabel()과 CATEGORIES를 사용하세요 - */ -export const categoryColors: Record = Object.fromEntries( - CATEGORY_KEYS.map((key) => [CATEGORIES[key].label, CATEGORIES[key].bgColor]) -); - -/** - * @deprecated getCategoryLabel()과 CATEGORIES를 사용하세요 - */ -export const categoryHoverStyles: Record = Object.fromEntries( - CATEGORY_KEYS.map((key) => [ - CATEGORIES[key].label, - CATEGORIES[key].hoverColor + " hover:outline-white-50", - ]) -); - -/** - * @deprecated getCategoryLabel()과 CATEGORIES를 사용하세요 - */ -export const categoryActiveStyles: Record = Object.fromEntries( - CATEGORY_KEYS.map((key) => [ - CATEGORIES[key].label, - CATEGORIES[key].selectedColor + " outline-white", - ]) -); - -/** - * @deprecated 영문 카테고리명을 사용하세요 - */ -export const CATEGORY_LABELS: Record = categoryLabels; diff --git a/src/contexts/auth/UserDataContext.tsx b/src/contexts/auth/UserDataContext.tsx index 7ad99bc..15b013e 100644 --- a/src/contexts/auth/UserDataContext.tsx +++ b/src/contexts/auth/UserDataContext.tsx @@ -13,7 +13,7 @@ import { } from "react"; import { doc, setDoc, getDoc, updateDoc } from "firebase/firestore"; import { db } from "@/utils/firebase"; -import { type CategoryType } from "@/components/ui/category/config"; +import { type CategoryType } from "@/config/categories"; import { getBookmarks, clearBookmarks } from "@/lib/bookmarks"; import { useAuthCore } from "./AuthContext"; diff --git a/src/lib/category.ts b/src/lib/category.ts deleted file mode 100644 index 25aed36..0000000 --- a/src/lib/category.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * 레거시 호환성 파일 - */ - -export { - type CategoryType, - CATEGORIES, - CATEGORY_LABELS, - getCategoryLabel, - getCategoryType, -} from "@/config/categories"; diff --git a/src/lib/quiz.ts b/src/lib/quiz.ts index 3411371..435094e 100644 --- a/src/lib/quiz.ts +++ b/src/lib/quiz.ts @@ -3,10 +3,7 @@ */ import { getTermsIndex, getTermsByTag, type TermIndexItem } from "./terms"; -import { - categoryLabels, - type CategoryType, -} from "@/components/ui/category/config"; +import { CATEGORIES, type CategoryType } from "@/config/categories"; export interface QuizQuestion { term: TermIndexItem; @@ -51,7 +48,7 @@ export async function generateQuizQuestions( terms = shuffleArray(allTerms); } else { // CategoryType을 한글 이름으로 변환하여 검색 - const categoryLabel = categoryLabels[category]; + const categoryLabel = CATEGORIES[category].label; terms = await getTermsByTag(categoryLabel); terms = shuffleArray(terms); } diff --git a/src/lib/recommendations.ts b/src/lib/recommendations.ts index fcef629..391c6ec 100644 --- a/src/lib/recommendations.ts +++ b/src/lib/recommendations.ts @@ -3,8 +3,7 @@ */ import { getTermsByCategory } from "./terms"; -import type { CategoryType } from "@/components/ui/category/config"; -import { categoryLabels } from "@/components/ui/category/config"; +import { CATEGORIES, type CategoryType } from "@/config/categories"; // 카테고리 ID 매핑 export const categoryIdMap: Record, number> = { @@ -58,11 +57,13 @@ export async function getRecommendedTerms( const categoryId = categoryIdMap[targetCategory]; const terms = await getTermsByCategory(categoryId); + const categoryLabel = CATEGORIES[targetCategory].label; + if (terms.length < count) { // 용어 개수가 부족하면 있는 만큼만 반환 return terms.map((t) => ({ term: t.termKo, - category: categoryLabels[targetCategory], + category: categoryLabel, description: t.summary, iconColor: categoryColors[categoryId], slug: t.slug, @@ -77,7 +78,7 @@ export async function getRecommendedTerms( } return shuffled.slice(0, count).map((t) => ({ term: t.termKo, - category: categoryLabels[targetCategory], + category: categoryLabel, description: t.summary, iconColor: categoryColors[categoryId], slug: t.slug, diff --git a/src/lib/scrap.ts b/src/lib/scrap.ts index 6135d55..488adac 100644 --- a/src/lib/scrap.ts +++ b/src/lib/scrap.ts @@ -1,6 +1,6 @@ import type { TermIndexItem } from "@/lib/terms"; -import type { ScrapCardData } from "@/types/category"; -import { getCategoryLabel, getCategoryType } from "@/lib/category"; +import type { ScrapCardData } from "@/types/scrapCard"; +import { getCategoryLabel, getCategoryType } from "@/config/categories"; import { formatKoreanDate } from "@/utils/date"; /** diff --git a/src/types/category.ts b/src/types/scrapCard.ts similarity index 53% rename from src/types/category.ts rename to src/types/scrapCard.ts index 3328908..87e7817 100644 --- a/src/types/category.ts +++ b/src/types/scrapCard.ts @@ -1,14 +1,3 @@ -/** - * 카테고리 레거시 호환 및 타입 정의 - */ - -export { - categoryIcons, - categoryColors, - categoryHoverStyles, - categoryActiveStyles, -} from "@/config/categories"; - /** * 스크랩 카드 데이터 인터페이스 */ From 2b35998a0f4cc1ca51fd71736be47d09af470a9f Mon Sep 17 00:00:00 2001 From: hyeseong Date: Mon, 1 Dec 2025 07:23:15 +0900 Subject: [PATCH 08/22] =?UTF-8?q?refactor:=20UserDataContext=20=EC=B1=85?= =?UTF-8?q?=EC=9E=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/contexts/auth/UserDataContext.tsx | 104 +++++++------------------- src/lib/userService.ts | 96 ++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 76 deletions(-) create mode 100644 src/lib/userService.ts diff --git a/src/contexts/auth/UserDataContext.tsx b/src/contexts/auth/UserDataContext.tsx index 15b013e..e3c2243 100644 --- a/src/contexts/auth/UserDataContext.tsx +++ b/src/contexts/auth/UserDataContext.tsx @@ -1,7 +1,7 @@ "use client"; /** - * UserDataContext - 사용자 데이터 관리 + * UserDataContext - 사용자 데이터 상태 관리 */ import { @@ -11,24 +11,19 @@ import { useState, type ReactNode, } from "react"; -import { doc, setDoc, getDoc, updateDoc } from "firebase/firestore"; -import { db } from "@/utils/firebase"; import { type CategoryType } from "@/config/categories"; import { getBookmarks, clearBookmarks } from "@/lib/bookmarks"; +import { + fetchUserData, + createUserData, + mergeScrapList, + completeUserOnboarding, + updateUserCategory, + type UserData, +} from "@/lib/userService"; import { useAuthCore } from "./AuthContext"; -/** - * 사용자 데이터 타입 - */ -export interface UserData { - email: string | null; - displayName: string | null; - photoURL: string | null; - createdAt: string; - scrapList: number[]; - onboardingCompleted: boolean; - selectedCategory: CategoryType; -} +export type { UserData }; interface UserDataContextType { userData: UserData | null; @@ -48,7 +43,6 @@ export function UserDataProvider({ children }: { children: ReactNode }) { const [userDataLoading, setUserDataLoading] = useState(true); const [isNewUser, setIsNewUser] = useState(false); - // user 변경 시 사용자 데이터 로드 useEffect(() => { if (!user) { setUserData(null); @@ -57,58 +51,37 @@ export function UserDataProvider({ children }: { children: ReactNode }) { return; } - loadOrCreateUserData(); + loadUserData(); }, [user]); - /** - * 사용자 데이터 로드 또는 생성 - */ - const loadOrCreateUserData = async () => { + const loadUserData = async () => { if (!user) return; setUserDataLoading(true); try { - const userRef = doc(db, "users", user.uid); - const userSnap = await getDoc(userRef); const localBookmarks = getBookmarks(); + let data = await fetchUserData(user.uid); - if (!userSnap.exists()) { - // 신규 사용자: Firestore에 데이터 생성 - const newUserData: UserData = { - email: user.email, - displayName: user.displayName, - photoURL: user.photoURL, - createdAt: new Date().toISOString(), - scrapList: localBookmarks, - onboardingCompleted: false, - selectedCategory: "all", - }; - - await setDoc(userRef, newUserData); - setUserData(newUserData); + if (!data) { + // 신규 사용자 + data = await createUserData(user, localBookmarks); setIsNewUser(true); - - // 로컬스토리지 마이그레이션 완료 후 정리 - if (localBookmarks.length > 0) { - clearBookmarks(); - } + if (localBookmarks.length > 0) clearBookmarks(); } else { - // 기존 사용자: 데이터 로드 및 로컬스토리지 병합 - const data = userSnap.data() as UserData; - + // 기존 사용자 - 로컬 북마크 병합 if (localBookmarks.length > 0) { - const mergedScrapList = [ - ...new Set([...data.scrapList, ...localBookmarks]), - ]; - await updateDoc(userRef, { scrapList: mergedScrapList }); - data.scrapList = mergedScrapList; + data.scrapList = await mergeScrapList( + user.uid, + data.scrapList, + localBookmarks + ); clearBookmarks(); } - - setUserData(data); setIsNewUser(!data.onboardingCompleted); } + + setUserData(data); } catch (error) { console.error("사용자 데이터 로드 실패:", error); setUserData(null); @@ -118,25 +91,14 @@ export function UserDataProvider({ children }: { children: ReactNode }) { } }; - /** - * 사용자 데이터 새로고침 - */ const refreshUserData = async () => { - await loadOrCreateUserData(); + await loadUserData(); }; - /** - * 온보딩 완료 처리 - */ const completeOnboarding = async (category: CategoryType) => { if (!user) return; - const userRef = doc(db, "users", user.uid); - await updateDoc(userRef, { - onboardingCompleted: true, - selectedCategory: category, - }); - + await completeUserOnboarding(user.uid, category); setUserData((prev) => prev ? { ...prev, onboardingCompleted: true, selectedCategory: category } @@ -145,25 +107,15 @@ export function UserDataProvider({ children }: { children: ReactNode }) { setIsNewUser(false); }; - /** - * 카테고리 업데이트 - */ const updateCategory = async (category: CategoryType) => { if (!user) return; - const userRef = doc(db, "users", user.uid); - await updateDoc(userRef, { - selectedCategory: category, - }); - + await updateUserCategory(user.uid, category); setUserData((prev) => prev ? { ...prev, selectedCategory: category } : null ); }; - /** - * 스크랩 리스트 업데이트 (ScrapContext에서 호출) - */ const updateScrapList = (newScrapList: number[]) => { setUserData((prev) => (prev ? { ...prev, scrapList: newScrapList } : null)); }; diff --git a/src/lib/userService.ts b/src/lib/userService.ts new file mode 100644 index 0000000..aa00f41 --- /dev/null +++ b/src/lib/userService.ts @@ -0,0 +1,96 @@ +/** + * 사용자 데이터 Firestore 서비스 + */ + +import { doc, setDoc, getDoc, updateDoc } from "firebase/firestore"; +import { db } from "@/utils/firebase"; +import { type CategoryType } from "@/config/categories"; +import type { User } from "firebase/auth"; + +export interface UserData { + email: string | null; + displayName: string | null; + photoURL: string | null; + createdAt: string; + scrapList: number[]; + onboardingCompleted: boolean; + selectedCategory: CategoryType; +} + +/** + * Firestore에서 사용자 데이터 조회 + */ +export async function fetchUserData(uid: string): Promise { + const userRef = doc(db, "users", uid); + const userSnap = await getDoc(userRef); + + if (!userSnap.exists()) { + return null; + } + + return userSnap.data() as UserData; +} + +/** + * 신규 사용자 데이터 생성 + */ +export async function createUserData( + user: User, + initialScrapList: number[] = [] +): Promise { + const newUserData: UserData = { + email: user.email, + displayName: user.displayName, + photoURL: user.photoURL, + createdAt: new Date().toISOString(), + scrapList: initialScrapList, + onboardingCompleted: false, + selectedCategory: "all", + }; + + const userRef = doc(db, "users", user.uid); + await setDoc(userRef, newUserData); + + return newUserData; +} + +/** + * 스크랩 리스트 병합 및 업데이트 + */ +export async function mergeScrapList( + uid: string, + existingList: number[], + newItems: number[] +): Promise { + const mergedList = [...new Set([...existingList, ...newItems])]; + + const userRef = doc(db, "users", uid); + await updateDoc(userRef, { scrapList: mergedList }); + + return mergedList; +} + +/** + * 온보딩 완료 처리 + */ +export async function completeUserOnboarding( + uid: string, + category: CategoryType +): Promise { + const userRef = doc(db, "users", uid); + await updateDoc(userRef, { + onboardingCompleted: true, + selectedCategory: category, + }); +} + +/** + * 카테고리 업데이트 + */ +export async function updateUserCategory( + uid: string, + category: CategoryType +): Promise { + const userRef = doc(db, "users", uid); + await updateDoc(userRef, { selectedCategory: category }); +} From 151eb1a6b4050df77ad2281f07ca2536a921dbf9 Mon Sep 17 00:00:00 2001 From: hyeseong Date: Mon, 1 Dec 2025 07:24:02 +0900 Subject: [PATCH 09/22] =?UTF-8?q?Refactor:=20QuizResult=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=B6=84=ED=95=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/quiz/QuizResult.tsx | 87 +++---------------------- src/components/quiz/QuizScoreCard.tsx | 38 +++++++++++ src/components/quiz/WrongAnswerCard.tsx | 51 +++++++++++++++ 3 files changed, 97 insertions(+), 79 deletions(-) create mode 100644 src/components/quiz/QuizScoreCard.tsx create mode 100644 src/components/quiz/WrongAnswerCard.tsx diff --git a/src/components/quiz/QuizResult.tsx b/src/components/quiz/QuizResult.tsx index fdefc3d..2db9ce9 100644 --- a/src/components/quiz/QuizResult.tsx +++ b/src/components/quiz/QuizResult.tsx @@ -1,7 +1,6 @@ "use client"; import { useState } from "react"; -import Link from "next/link"; import { useAuthCore, useScrap } from "@/contexts/auth"; import { useToast } from "@/contexts/ToastContext"; import { @@ -11,6 +10,8 @@ import { } from "@/lib/quiz"; import { type CategoryType } from "@/config/categories"; import GradientButton from "@/components/ui/buttons/GradientButton"; +import { QuizScoreCard } from "./QuizScoreCard"; +import { WrongAnswerCard } from "./WrongAnswerCard"; interface QuizResultProps { result: QuizResultType; @@ -43,8 +44,7 @@ export default function QuizResult({ try { let scrapCount = 0; for (const question of wrongQuestions) { - const alreadyScraped = isScraped(question.term.id); - if (!alreadyScraped) { + if (!isScraped(question.term.id)) { await toggleScrap(question.term.id); scrapCount++; } @@ -76,41 +76,8 @@ export default function QuizResult({ return (

- {/* 점수 카드 */} -
-
-
-

총점

-

- {result.score} -

-

-
-
- -
-
-

전체 문제

-

- {result.totalQuestions} -

-
-
-

정답

-

- {result.correctAnswers} -

-
-
-

오답

-

- {result.wrongAnswers} -

-
-
-
+ - {/* 오답 노트 */} {wrongQuestions.length > 0 && (
@@ -130,56 +97,18 @@ export default function QuizResult({
{wrongQuestions.map((question, idx) => { const originalIdx = result.questions.indexOf(question); - const userAnswer = result.userAnswers[originalIdx]; - return ( -
-
-
-
- - 오답 - - - {question.term.termKo} - -
-

- {question.questionType === "summary" - ? question.term.summary - : `"${question.term.termKo}"의 설명`} -

-
-
- -
-
- 내 답변: - - {userAnswer || "(미응답)"} - -
-
- 정답: - - {question.correctAnswer} - -
-
-
+ question={question} + userAnswer={result.userAnswers[originalIdx]} + /> ); })}
)} - {/* 액션 버튼 */}
+ ); +} diff --git a/src/components/quiz/WrongAnswerCard.tsx b/src/components/quiz/WrongAnswerCard.tsx new file mode 100644 index 0000000..8d5b61a --- /dev/null +++ b/src/components/quiz/WrongAnswerCard.tsx @@ -0,0 +1,51 @@ +"use client"; + +import Link from "next/link"; +import type { QuizQuestion } from "@/lib/quiz"; + +interface WrongAnswerCardProps { + question: QuizQuestion; + userAnswer: string | null; +} + +export function WrongAnswerCard({ question, userAnswer }: WrongAnswerCardProps) { + return ( +
+
+
+
+ + 오답 + + + {question.term.termKo} + +
+

+ {question.questionType === "summary" + ? question.term.summary + : `"${question.term.termKo}"의 설명`} +

+
+
+ +
+
+ 내 답변: + + {userAnswer || "(미응답)"} + +
+
+ 정답: + + {question.correctAnswer} + +
+
+
+ ); +} From 0726a210223dfe5317aa40dc9b31b96eb73cd560 Mon Sep 17 00:00:00 2001 From: hyeseong Date: Mon, 1 Dec 2025 07:30:12 +0900 Subject: [PATCH 10/22] =?UTF-8?q?Refactor:=20Header=20=EB=93=9C=EB=A1=AD?= =?UTF-8?q?=EB=8B=A4=EC=9A=B4=20=EB=A1=9C=EC=A7=81=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/layout/Header.tsx | 25 +++++----------------- src/hooks/useDropdown.ts | 36 ++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 20 deletions(-) create mode 100644 src/hooks/useDropdown.ts diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index e9083f3..98cbd41 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -1,6 +1,5 @@ "use client"; -import { useState, useRef, useEffect } from "react"; import Link from "next/link"; import Image from "next/image"; import { usePathname, useRouter } from "next/navigation"; @@ -8,6 +7,7 @@ import { UserIcon, LogoText } from "@/components/icons"; import { GlassButton } from "@/components/ui/GlassButton"; import { DEFAULT_NAV_ITEMS } from "@/constants/navigation"; import { useAuthCore } from "@/contexts/auth"; +import { useDropdown } from "@/hooks/useDropdown"; type NavItemProps = { label: string; @@ -62,31 +62,16 @@ const ProfileDropdown = ({ email?: string | null; onLogout: () => void; }) => { - const [isOpen, setIsOpen] = useState(false); - const dropdownRef = useRef(null); + const { isOpen, toggle, close, dropdownRef } = useDropdown(); const router = useRouter(); - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - dropdownRef.current && - !dropdownRef.current.contains(event.target as Node) - ) { - setIsOpen(false); - } - }; - - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, []); - const handleDashboardClick = () => { - setIsOpen(false); + close(); router.push("/dashboard"); }; const handleLogoutClick = () => { - setIsOpen(false); + close(); onLogout(); }; @@ -96,7 +81,7 @@ const ProfileDropdown = ({ variant="rounded" className="p-1.5" aria-label="프로필" - onClick={() => setIsOpen(!isOpen)} + onClick={toggle} > {photoURL ? ( { + isOpen: boolean; + toggle: () => void; + close: () => void; + dropdownRef: RefObject; +} + +export function useDropdown< + T extends HTMLElement = HTMLDivElement, +>(): UseDropdownReturn { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const toggle = () => setIsOpen((prev) => !prev); + const close = () => setIsOpen(false); + + return { isOpen, toggle, close, dropdownRef }; +} From e4e3ba2a61ec723fe3050caa4871a038a969daa9 Mon Sep 17 00:00:00 2001 From: hyeseong Date: Mon, 1 Dec 2025 07:30:34 +0900 Subject: [PATCH 11/22] =?UTF-8?q?Refactor:=20QuizSession=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/quiz/QuizSession.tsx | 77 +++++++------------------- src/hooks/useQuizState.ts | 83 +++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 57 deletions(-) create mode 100644 src/hooks/useQuizState.ts diff --git a/src/components/quiz/QuizSession.tsx b/src/components/quiz/QuizSession.tsx index 81ab8de..974a2ed 100644 --- a/src/components/quiz/QuizSession.tsx +++ b/src/components/quiz/QuizSession.tsx @@ -1,14 +1,10 @@ "use client"; -import { useState } from "react"; import Link from "next/link"; -import { - calculateQuizResult, - type QuizQuestion, - type QuizResult, -} from "@/lib/quiz"; +import { type QuizQuestion, type QuizResult } from "@/lib/quiz"; import { CATEGORIES, type CategoryType } from "@/config/categories"; import GradientButton from "@/components/ui/buttons/GradientButton"; +import { useQuizState } from "@/hooks/useQuizState"; interface QuizSessionProps { questions: QuizQuestion[]; @@ -21,48 +17,18 @@ export default function QuizSession({ category, onComplete, }: QuizSessionProps) { - const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); - const [userAnswers, setUserAnswers] = useState<(string | null)[]>( - new Array(questions.length).fill(null) - ); - const [selectedAnswer, setSelectedAnswer] = useState(null); - - const currentQuestion = questions[currentQuestionIndex]; - const progress = ((currentQuestionIndex + 1) / questions.length) * 100; - - const handleAnswerSelect = (answer: string) => { - setSelectedAnswer(answer); - }; - - const handleNext = () => { - // 현재 답변 저장 - const newAnswers = [...userAnswers]; - newAnswers[currentQuestionIndex] = selectedAnswer; - setUserAnswers(newAnswers); - - if (currentQuestionIndex < questions.length - 1) { - // 다음 문제로 - setCurrentQuestionIndex(currentQuestionIndex + 1); - setSelectedAnswer(newAnswers[currentQuestionIndex + 1]); - } else { - // 퀴즈 완료 - const result = calculateQuizResult(questions, newAnswers); - onComplete(result); - } - }; - - const handlePrevious = () => { - if (currentQuestionIndex > 0) { - // 현재 답변 저장 - const newAnswers = [...userAnswers]; - newAnswers[currentQuestionIndex] = selectedAnswer; - setUserAnswers(newAnswers); - - // 이전 문제로 - setCurrentQuestionIndex(currentQuestionIndex - 1); - setSelectedAnswer(newAnswers[currentQuestionIndex - 1]); - } - }; + const { + currentQuestionIndex, + currentQuestion, + selectedAnswer, + progress, + answeredCount, + isLastQuestion, + isFirstQuestion, + selectAnswer, + goNext, + goPrevious, + } = useQuizState({ questions, onComplete }); return (
@@ -80,8 +46,7 @@ export default function QuizSession({

답변한 문제

- {userAnswers.filter((a) => a !== null).length} /{" "} - {questions.length} + {answeredCount} / {questions.length}

@@ -143,7 +108,7 @@ export default function QuizSession({ return (
diff --git a/src/hooks/useQuizState.ts b/src/hooks/useQuizState.ts new file mode 100644 index 0000000..e06dcfb --- /dev/null +++ b/src/hooks/useQuizState.ts @@ -0,0 +1,83 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { calculateQuizResult, type QuizQuestion, type QuizResult } from "@/lib/quiz"; + +interface UseQuizStateProps { + questions: QuizQuestion[]; + onComplete: (result: QuizResult) => void; +} + +interface UseQuizStateReturn { + currentQuestionIndex: number; + currentQuestion: QuizQuestion; + selectedAnswer: string | null; + userAnswers: (string | null)[]; + progress: number; + answeredCount: number; + isLastQuestion: boolean; + isFirstQuestion: boolean; + selectAnswer: (answer: string) => void; + goNext: () => void; + goPrevious: () => void; +} + +export function useQuizState({ + questions, + onComplete, +}: UseQuizStateProps): UseQuizStateReturn { + const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); + const [userAnswers, setUserAnswers] = useState<(string | null)[]>( + new Array(questions.length).fill(null) + ); + const [selectedAnswer, setSelectedAnswer] = useState(null); + + const currentQuestion = questions[currentQuestionIndex]; + const progress = ((currentQuestionIndex + 1) / questions.length) * 100; + const answeredCount = userAnswers.filter((a) => a !== null).length; + const isLastQuestion = currentQuestionIndex === questions.length - 1; + const isFirstQuestion = currentQuestionIndex === 0; + + const selectAnswer = useCallback((answer: string) => { + setSelectedAnswer(answer); + }, []); + + const goNext = useCallback(() => { + const newAnswers = [...userAnswers]; + newAnswers[currentQuestionIndex] = selectedAnswer; + setUserAnswers(newAnswers); + + if (!isLastQuestion) { + setCurrentQuestionIndex(currentQuestionIndex + 1); + setSelectedAnswer(newAnswers[currentQuestionIndex + 1]); + } else { + const result = calculateQuizResult(questions, newAnswers); + onComplete(result); + } + }, [currentQuestionIndex, selectedAnswer, userAnswers, isLastQuestion, questions, onComplete]); + + const goPrevious = useCallback(() => { + if (!isFirstQuestion) { + const newAnswers = [...userAnswers]; + newAnswers[currentQuestionIndex] = selectedAnswer; + setUserAnswers(newAnswers); + + setCurrentQuestionIndex(currentQuestionIndex - 1); + setSelectedAnswer(newAnswers[currentQuestionIndex - 1]); + } + }, [currentQuestionIndex, selectedAnswer, userAnswers, isFirstQuestion]); + + return { + currentQuestionIndex, + currentQuestion, + selectedAnswer, + userAnswers, + progress, + answeredCount, + isLastQuestion, + isFirstQuestion, + selectAnswer, + goNext, + goPrevious, + }; +} From f8309b396cca8d60885a0f5353a9d86fb8cd2ce8 Mon Sep 17 00:00:00 2001 From: hyeseong Date: Mon, 1 Dec 2025 07:30:58 +0900 Subject: [PATCH 12/22] style: format code --- src/components/quiz/QuizScoreCard.tsx | 8 ++++++-- src/components/quiz/WrongAnswerCard.tsx | 5 ++++- src/hooks/useQuizState.ts | 15 +++++++++++++-- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/components/quiz/QuizScoreCard.tsx b/src/components/quiz/QuizScoreCard.tsx index 4e30426..819b8a9 100644 --- a/src/components/quiz/QuizScoreCard.tsx +++ b/src/components/quiz/QuizScoreCard.tsx @@ -20,7 +20,9 @@ export function QuizScoreCard({ result }: QuizScoreCardProps) {

전체 문제

-

{result.totalQuestions}

+

+ {result.totalQuestions} +

정답

@@ -30,7 +32,9 @@ export function QuizScoreCard({ result }: QuizScoreCardProps) {

오답

-

{result.wrongAnswers}

+

+ {result.wrongAnswers} +

diff --git a/src/components/quiz/WrongAnswerCard.tsx b/src/components/quiz/WrongAnswerCard.tsx index 8d5b61a..557f15a 100644 --- a/src/components/quiz/WrongAnswerCard.tsx +++ b/src/components/quiz/WrongAnswerCard.tsx @@ -8,7 +8,10 @@ interface WrongAnswerCardProps { userAnswer: string | null; } -export function WrongAnswerCard({ question, userAnswer }: WrongAnswerCardProps) { +export function WrongAnswerCard({ + question, + userAnswer, +}: WrongAnswerCardProps) { return (
diff --git a/src/hooks/useQuizState.ts b/src/hooks/useQuizState.ts index e06dcfb..1fa448d 100644 --- a/src/hooks/useQuizState.ts +++ b/src/hooks/useQuizState.ts @@ -1,7 +1,11 @@ "use client"; import { useState, useCallback } from "react"; -import { calculateQuizResult, type QuizQuestion, type QuizResult } from "@/lib/quiz"; +import { + calculateQuizResult, + type QuizQuestion, + type QuizResult, +} from "@/lib/quiz"; interface UseQuizStateProps { questions: QuizQuestion[]; @@ -54,7 +58,14 @@ export function useQuizState({ const result = calculateQuizResult(questions, newAnswers); onComplete(result); } - }, [currentQuestionIndex, selectedAnswer, userAnswers, isLastQuestion, questions, onComplete]); + }, [ + currentQuestionIndex, + selectedAnswer, + userAnswers, + isLastQuestion, + questions, + onComplete, + ]); const goPrevious = useCallback(() => { if (!isFirstQuestion) { From 50481a8328b8958a6e189a03b75da0be8788bef3 Mon Sep 17 00:00:00 2001 From: hyeseong Date: Mon, 1 Dec 2025 07:34:46 +0900 Subject: [PATCH 13/22] =?UTF-8?q?Refactor:=20ChatBot=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/chatbot/components/ChatBot.tsx | 64 ++++----------------- src/hooks/useChatBot.ts | 80 ++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 54 deletions(-) create mode 100644 src/hooks/useChatBot.ts diff --git a/src/app/chatbot/components/ChatBot.tsx b/src/app/chatbot/components/ChatBot.tsx index 2d0a5ef..4ec55bf 100644 --- a/src/app/chatbot/components/ChatBot.tsx +++ b/src/app/chatbot/components/ChatBot.tsx @@ -1,66 +1,22 @@ "use client"; -import { useState, useRef, useEffect } from "react"; -import { getChatResponse } from "@/app/chatbot/utils/actions"; import { FireIcon, StarIcon, SearchIcon, SendIcon } from "@/components/icons"; +import { useChatBot } from "@/hooks/useChatBot"; import ChatMessage from "./ChatMessage"; import UserMessage from "./UserMessage"; import QuickActionButton from "./QuickActionButton"; import BotLoading from "./BotLoading"; -interface Message { - role: "user" | "bot"; - content: string; - recommendations?: string[]; -} - export default function ChatBot() { - const [messages, setMessages] = useState([ - { - role: "bot", - content: "안녕하세요! 기술 용어에 대해 궁금한 점을 물어보세요.", - recommendations: ["REST API란?", "Docker는 뭐야?", "GraphQL 설명해줘"], - }, - ]); - const [input, setInput] = useState(""); - const [isLoading, setIsLoading] = useState(false); - - const messagesEndRef = useRef(null); - - const scrollToBottom = () => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }; - - useEffect(() => { - scrollToBottom(); - }, [messages]); - - const handleSubmit = async (e?: React.FormEvent, customInput?: string) => { - e?.preventDefault(); - const userMessage = customInput || input; - - if (!userMessage.trim() || isLoading) return; - - setMessages((prev) => [...prev, { role: "user", content: userMessage }]); - setInput(""); - setIsLoading(true); - - const result = await getChatResponse(userMessage); - - setMessages((prev) => [ - ...prev, - { - role: "bot", - content: result.answer, - recommendations: result.recommendations, - }, - ]); - setIsLoading(false); - }; - - const handleRecommendationClick = (question: string) => { - handleSubmit(undefined, question); - }; + const { + messages, + input, + isLoading, + messagesEndRef, + setInput, + handleSubmit, + handleRecommendationClick, + } = useChatBot(); return (
diff --git a/src/hooks/useChatBot.ts b/src/hooks/useChatBot.ts new file mode 100644 index 0000000..90aa2b2 --- /dev/null +++ b/src/hooks/useChatBot.ts @@ -0,0 +1,80 @@ +"use client"; + +import { useState, useRef, useEffect, useCallback } from "react"; +import { getChatResponse } from "@/app/chatbot/utils/actions"; + +interface Message { + role: "user" | "bot"; + content: string; + recommendations?: string[]; +} + +interface UseChatBotReturn { + messages: Message[]; + input: string; + isLoading: boolean; + messagesEndRef: React.RefObject; + setInput: (value: string) => void; + handleSubmit: (e?: React.FormEvent, customInput?: string) => Promise; + handleRecommendationClick: (question: string) => void; +} + +const INITIAL_MESSAGE: Message = { + role: "bot", + content: "안녕하세요! 기술 용어에 대해 궁금한 점을 물어보세요.", + recommendations: ["REST API란?", "Docker는 뭐야?", "GraphQL 설명해줘"], +}; + +export function useChatBot(): UseChatBotReturn { + const [messages, setMessages] = useState([INITIAL_MESSAGE]); + const [input, setInput] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const messagesEndRef = useRef(null); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + const handleSubmit = useCallback( + async (e?: React.FormEvent, customInput?: string) => { + e?.preventDefault(); + const userMessage = customInput || input; + + if (!userMessage.trim() || isLoading) return; + + setMessages((prev) => [...prev, { role: "user", content: userMessage }]); + setInput(""); + setIsLoading(true); + + const result = await getChatResponse(userMessage); + + setMessages((prev) => [ + ...prev, + { + role: "bot", + content: result.answer, + recommendations: result.recommendations, + }, + ]); + setIsLoading(false); + }, + [input, isLoading] + ); + + const handleRecommendationClick = useCallback( + (question: string) => { + handleSubmit(undefined, question); + }, + [handleSubmit] + ); + + return { + messages, + input, + isLoading, + messagesEndRef, + setInput, + handleSubmit, + handleRecommendationClick, + }; +} From d2ac0d14eb074e2ef3fb7a6e813d4be1ce047859 Mon Sep 17 00:00:00 2001 From: hyeseong Date: Mon, 1 Dec 2025 07:35:18 +0900 Subject: [PATCH 14/22] =?UTF-8?q?refactor:=20terms=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/terms.ts | 60 +++++++--------------------------------------- src/types/terms.ts | 55 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 51 deletions(-) create mode 100644 src/types/terms.ts diff --git a/src/lib/terms.ts b/src/lib/terms.ts index b1926be..f840df9 100644 --- a/src/lib/terms.ts +++ b/src/lib/terms.ts @@ -2,57 +2,15 @@ * 용어 데이터 fetch 및 검색 헬퍼 */ -// Index 아이템 타입 -export interface TermIndexItem { - id: number; - slug: string; - termKo: string; - termEn?: string; - summary: string; - tags: string[]; - primaryTag: string; - level: "beginner" | "intermediate" | "advanced"; - file: string; -} - -// 역할 타입 -export type Role = "PM" | "Dev" | "Design" | "Marketer" | "Other"; - -// 사용 사례 타입 -export interface UseCase { - role: Role; - text: string; -} - -// 대화 상황 타입 (deprecated - useCases로 대체) -export interface Conversation { - role: "pm" | "developer" | "designer"; - message: string; -} - -// 상세 용어 타입 -export interface TermDetail { - id: number; - slug: string; - term: { - ko: string; - en: string; - }; - aliases?: string[]; - summary: string; - onelinerForNonTech?: string; - description: string; - tags: string[]; - primaryTag: string; - relatedIds?: number[]; - confusableIds?: number[]; - useCases?: UseCase[]; - conversations?: Conversation[]; - keywords?: string[]; - level: "beginner" | "intermediate" | "advanced"; - updatedAt: string; - status?: "draft" | "published"; -} +export type { + TermIndexItem, + TermDetail, + Role, + UseCase, + Conversation, +} from "@/types/terms"; + +import type { TermIndexItem, TermDetail } from "@/types/terms"; // 캐시 let indexCache: TermIndexItem[] | null = null; diff --git a/src/types/terms.ts b/src/types/terms.ts new file mode 100644 index 0000000..ce1b182 --- /dev/null +++ b/src/types/terms.ts @@ -0,0 +1,55 @@ +/** + * 용어 관련 타입 정의 + */ + +// Index 아이템 타입 +export interface TermIndexItem { + id: number; + slug: string; + termKo: string; + termEn?: string; + summary: string; + tags: string[]; + primaryTag: string; + level: "beginner" | "intermediate" | "advanced"; + file: string; +} + +// 역할 타입 +export type Role = "PM" | "Dev" | "Design" | "Marketer" | "Other"; + +// 사용 사례 타입 +export interface UseCase { + role: Role; + text: string; +} + +// 대화 상황 타입 (deprecated - useCases로 대체) +export interface Conversation { + role: "pm" | "developer" | "designer"; + message: string; +} + +// 상세 용어 타입 +export interface TermDetail { + id: number; + slug: string; + term: { + ko: string; + en: string; + }; + aliases?: string[]; + summary: string; + onelinerForNonTech?: string; + description: string; + tags: string[]; + primaryTag: string; + relatedIds?: number[]; + confusableIds?: number[]; + useCases?: UseCase[]; + conversations?: Conversation[]; + keywords?: string[]; + level: "beginner" | "intermediate" | "advanced"; + updatedAt: string; + status?: "draft" | "published"; +} From f8495956da974bfe3b49b642f6c9506c1ac07521 Mon Sep 17 00:00:00 2001 From: hyeseong Date: Mon, 1 Dec 2025 07:50:33 +0900 Subject: [PATCH 15/22] =?UTF-8?q?refactor:=20quiz=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/quiz.ts | 17 ++--------------- src/types/quiz.ts | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 15 deletions(-) create mode 100644 src/types/quiz.ts diff --git a/src/lib/quiz.ts b/src/lib/quiz.ts index 435094e..ec3053e 100644 --- a/src/lib/quiz.ts +++ b/src/lib/quiz.ts @@ -5,21 +5,8 @@ import { getTermsIndex, getTermsByTag, type TermIndexItem } from "./terms"; import { CATEGORIES, type CategoryType } from "@/config/categories"; -export interface QuizQuestion { - term: TermIndexItem; - correctAnswer: string; - choices: string[]; - questionType: "summary" | "term"; -} - -export interface QuizResult { - totalQuestions: number; - correctAnswers: number; - wrongAnswers: number; - score: number; - questions: QuizQuestion[]; - userAnswers: (string | null)[]; -} +export type { QuizQuestion, QuizResult } from "@/types/quiz"; +import type { QuizQuestion, QuizResult } from "@/types/quiz"; /** * 배열을 랜덤하게 섞기 diff --git a/src/types/quiz.ts b/src/types/quiz.ts new file mode 100644 index 0000000..a160a1c --- /dev/null +++ b/src/types/quiz.ts @@ -0,0 +1,21 @@ +/** + * 퀴즈 관련 타입 정의 + */ + +import type { TermIndexItem } from "./terms"; + +export interface QuizQuestion { + term: TermIndexItem; + correctAnswer: string; + choices: string[]; + questionType: "summary" | "term"; +} + +export interface QuizResult { + totalQuestions: number; + correctAnswers: number; + wrongAnswers: number; + score: number; + questions: QuizQuestion[]; + userAnswers: (string | null)[]; +} From 7305da32912791e8707816054f2ef39ab5978d70 Mon Sep 17 00:00:00 2001 From: hyeseong Date: Mon, 1 Dec 2025 07:51:05 +0900 Subject: [PATCH 16/22] =?UTF-8?q?refactor:=20useCaseTab=20roleConfig=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../term-detail/tabs/UseCaseTab.tsx | 47 ++----------------- src/config/roles.ts | 46 ++++++++++++++++++ 2 files changed, 50 insertions(+), 43 deletions(-) create mode 100644 src/config/roles.ts diff --git a/src/components/term-detail/tabs/UseCaseTab.tsx b/src/components/term-detail/tabs/UseCaseTab.tsx index d9a16be..871a575 100644 --- a/src/components/term-detail/tabs/UseCaseTab.tsx +++ b/src/components/term-detail/tabs/UseCaseTab.tsx @@ -1,51 +1,12 @@ import { cn } from "@/utils/cn"; -import type { TermDetail, UseCase, Role } from "@/lib/terms"; -import { CommentIcon, PmIcon, EditIcon } from "@/components/icons"; +import type { TermDetail, UseCase } from "@/lib/terms"; +import { CommentIcon } from "@/components/icons"; +import { ROLE_CONFIG } from "@/config/roles"; interface UseCaseTabProps { term: TermDetail; } -interface RoleConfig { - label: string; - color: string; - bgColor: string; - icon: typeof PmIcon; -} - -const roleConfig: Record = { - PM: { - label: "PM", - color: "#FACC15", - bgColor: "bg-[rgba(234,179,8,0.2)]", - icon: PmIcon, - }, - Dev: { - label: "Dev", - color: "#22D3EE", - bgColor: "bg-[rgba(6,182,212,0.2)]", - icon: EditIcon, - }, - Design: { - label: "Design", - color: "#F472B6", - bgColor: "bg-[rgba(236,72,153,0.2)]", - icon: EditIcon, - }, - Marketer: { - label: "Marketer", - color: "#A78BFA", - bgColor: "bg-[rgba(167,139,250,0.2)]", - icon: CommentIcon, - }, - Other: { - label: "Other", - color: "#9CA3AF", - bgColor: "bg-[rgba(156,163,175,0.2)]", - icon: CommentIcon, - }, -}; - export function UseCaseTab({ term }: UseCaseTabProps) { const hasUseCases = term.useCases && term.useCases.length > 0; @@ -79,7 +40,7 @@ function UseCaseBubble({ useCase: UseCase; term: TermDetail; }) { - const config = roleConfig[useCase.role]; + const config = ROLE_CONFIG[useCase.role]; const RoleIcon = config.icon; const highlightTerm = (text: string) => { diff --git a/src/config/roles.ts b/src/config/roles.ts new file mode 100644 index 0000000..de5afc2 --- /dev/null +++ b/src/config/roles.ts @@ -0,0 +1,46 @@ +/** + * 역할(Role) 관련 설정 + */ + +import type { Role } from "@/types/terms"; +import { CommentIcon, PmIcon, EditIcon } from "@/components/icons"; + +export interface RoleConfig { + label: string; + color: string; + bgColor: string; + icon: typeof PmIcon; +} + +export const ROLE_CONFIG: Record = { + PM: { + label: "PM", + color: "#FACC15", + bgColor: "bg-[rgba(234,179,8,0.2)]", + icon: PmIcon, + }, + Dev: { + label: "Dev", + color: "#22D3EE", + bgColor: "bg-[rgba(6,182,212,0.2)]", + icon: EditIcon, + }, + Design: { + label: "Design", + color: "#F472B6", + bgColor: "bg-[rgba(236,72,153,0.2)]", + icon: EditIcon, + }, + Marketer: { + label: "Marketer", + color: "#A78BFA", + bgColor: "bg-[rgba(167,139,250,0.2)]", + icon: CommentIcon, + }, + Other: { + label: "Other", + color: "#9CA3AF", + bgColor: "bg-[rgba(156,163,175,0.2)]", + icon: CommentIcon, + }, +}; From 205e681e87daa366d1dc4251db842c3dae382682 Mon Sep 17 00:00:00 2001 From: hyeseong Date: Mon, 1 Dec 2025 08:03:18 +0900 Subject: [PATCH 17/22] =?UTF-8?q?refactor:=20header=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/layout/Header.tsx | 115 +----------------- .../layout/header-parts/LoginButton.tsx | 15 +++ src/components/layout/header-parts/Logo.tsx | 10 ++ .../layout/header-parts/NavItem.tsx | 25 ++++ .../layout/header-parts/ProfileDropdown.tsx | 77 ++++++++++++ src/components/layout/header-parts/index.ts | 4 + 6 files changed, 133 insertions(+), 113 deletions(-) create mode 100644 src/components/layout/header-parts/LoginButton.tsx create mode 100644 src/components/layout/header-parts/Logo.tsx create mode 100644 src/components/layout/header-parts/NavItem.tsx create mode 100644 src/components/layout/header-parts/ProfileDropdown.tsx create mode 100644 src/components/layout/header-parts/index.ts diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 98cbd41..d3d833f 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -1,124 +1,13 @@ "use client"; -import Link from "next/link"; -import Image from "next/image"; import { usePathname, useRouter } from "next/navigation"; -import { UserIcon, LogoText } from "@/components/icons"; -import { GlassButton } from "@/components/ui/GlassButton"; import { DEFAULT_NAV_ITEMS } from "@/constants/navigation"; import { useAuthCore } from "@/contexts/auth"; -import { useDropdown } from "@/hooks/useDropdown"; - -type NavItemProps = { - label: string; - href: string; -}; +import { Logo, NavItem, LoginButton, ProfileDropdown } from "./header-parts"; type HeaderProps = { showNav?: boolean; - navItems?: readonly NavItemProps[]; -}; - -const Logo = () => ( - - - -); - -const NavItem = ({ - label, - href, - isActive, -}: NavItemProps & { isActive: boolean }) => ( - - - {label} - - -); - -const LoginButton = ({ onClick }: { onClick: () => void }) => ( - - - 로그인 - - -); - -const ProfileDropdown = ({ - photoURL, - email, - onLogout, -}: { - photoURL?: string | null; - email?: string | null; - onLogout: () => void; -}) => { - const { isOpen, toggle, close, dropdownRef } = useDropdown(); - const router = useRouter(); - - const handleDashboardClick = () => { - close(); - router.push("/dashboard"); - }; - - const handleLogoutClick = () => { - close(); - onLogout(); - }; - - return ( -
- - {photoURL ? ( - 프로필 - ) : ( - - )} - - - {isOpen && ( -
-
-

{email}

-
-
- - -
-
- )} -
- ); + navItems?: readonly { label: string; href: string }[]; }; export default function Header({ diff --git a/src/components/layout/header-parts/LoginButton.tsx b/src/components/layout/header-parts/LoginButton.tsx new file mode 100644 index 0000000..303ddde --- /dev/null +++ b/src/components/layout/header-parts/LoginButton.tsx @@ -0,0 +1,15 @@ +import { GlassButton } from "@/components/ui/GlassButton"; + +interface LoginButtonProps { + onClick: () => void; +} + +export function LoginButton({ onClick }: LoginButtonProps) { + return ( + + + 로그인 + + + ); +} diff --git a/src/components/layout/header-parts/Logo.tsx b/src/components/layout/header-parts/Logo.tsx new file mode 100644 index 0000000..d680d17 --- /dev/null +++ b/src/components/layout/header-parts/Logo.tsx @@ -0,0 +1,10 @@ +import Link from "next/link"; +import { LogoText } from "@/components/icons"; + +export function Logo() { + return ( + + + + ); +} diff --git a/src/components/layout/header-parts/NavItem.tsx b/src/components/layout/header-parts/NavItem.tsx new file mode 100644 index 0000000..b958096 --- /dev/null +++ b/src/components/layout/header-parts/NavItem.tsx @@ -0,0 +1,25 @@ +import Link from "next/link"; + +interface NavItemProps { + label: string; + href: string; + isActive: boolean; +} + +export function NavItem({ label, href, isActive }: NavItemProps) { + return ( + + + {label} + + + ); +} diff --git a/src/components/layout/header-parts/ProfileDropdown.tsx b/src/components/layout/header-parts/ProfileDropdown.tsx new file mode 100644 index 0000000..978a8ee --- /dev/null +++ b/src/components/layout/header-parts/ProfileDropdown.tsx @@ -0,0 +1,77 @@ +"use client"; + +import Image from "next/image"; +import { useRouter } from "next/navigation"; +import { UserIcon } from "@/components/icons"; +import { GlassButton } from "@/components/ui/GlassButton"; +import { useDropdown } from "@/hooks/useDropdown"; + +interface ProfileDropdownProps { + photoURL?: string | null; + email?: string | null; + onLogout: () => void; +} + +export function ProfileDropdown({ + photoURL, + email, + onLogout, +}: ProfileDropdownProps) { + const { isOpen, toggle, close, dropdownRef } = useDropdown(); + const router = useRouter(); + + const handleDashboardClick = () => { + close(); + router.push("/dashboard"); + }; + + const handleLogoutClick = () => { + close(); + onLogout(); + }; + + return ( +
+ + {photoURL ? ( + 프로필 + ) : ( + + )} + + + {isOpen && ( +
+
+

{email}

+
+
+ + +
+
+ )} +
+ ); +} diff --git a/src/components/layout/header-parts/index.ts b/src/components/layout/header-parts/index.ts new file mode 100644 index 0000000..16db464 --- /dev/null +++ b/src/components/layout/header-parts/index.ts @@ -0,0 +1,4 @@ +export { Logo } from "./Logo"; +export { NavItem } from "./NavItem"; +export { LoginButton } from "./LoginButton"; +export { ProfileDropdown } from "./ProfileDropdown"; From c8f4891f64b3f25bc55e96c0cbeca3bc537d3263 Mon Sep 17 00:00:00 2001 From: hyeseong Date: Mon, 1 Dec 2025 08:11:17 +0900 Subject: [PATCH 18/22] =?UTF-8?q?refactor:=20CategoryChip=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/dashboard/components/ScrapCard.tsx | 19 +++---------------- .../term-detail/tabs/RelatedTab.tsx | 15 +++------------ 2 files changed, 6 insertions(+), 28 deletions(-) diff --git a/src/app/dashboard/components/ScrapCard.tsx b/src/app/dashboard/components/ScrapCard.tsx index 0968861..23b4102 100644 --- a/src/app/dashboard/components/ScrapCard.tsx +++ b/src/app/dashboard/components/ScrapCard.tsx @@ -3,7 +3,8 @@ import { useRouter } from "next/navigation"; import { ScrapIcon } from "@/components/icons/ic_scrap"; import { ArrowRightIcon } from "@/components/icons/ic_arrow_right"; -import { CATEGORIES, getCategoryType } from "@/config/categories"; +import { getCategoryType } from "@/config/categories"; +import { CategoryChip } from "@/components/ui/category"; import type { ScrapCardData } from "@/types/scrapCard"; interface ScrapCardProps { @@ -13,9 +14,6 @@ interface ScrapCardProps { export default function ScrapCard({ card }: ScrapCardProps) { const router = useRouter(); const categoryType = getCategoryType(card.category); - const config = CATEGORIES[categoryType]; - const IconComponent = config.icon; - const colorClass = config.bgColor; const handleClick = () => { if (card.slug) { @@ -30,18 +28,7 @@ export default function ScrapCard({ card }: ScrapCardProps) { >
-
- {IconComponent && ( - - )} -
- +
{card.term} diff --git a/src/components/term-detail/tabs/RelatedTab.tsx b/src/components/term-detail/tabs/RelatedTab.tsx index a64daf5..2b8a2bd 100644 --- a/src/components/term-detail/tabs/RelatedTab.tsx +++ b/src/components/term-detail/tabs/RelatedTab.tsx @@ -9,7 +9,8 @@ import { ScrapIcon, ChevronRightIcon, } from "@/components/icons"; -import { CATEGORIES, getCategoryType } from "@/config/categories"; +import { getCategoryType } from "@/config/categories"; +import { CategoryChip } from "@/components/ui/category"; import { useScrapToggle } from "@/hooks/useScrapToggle"; interface RelatedTabProps { @@ -43,8 +44,6 @@ function RelatedTermCard({ term }: { term: TermIndexItem }) { const { bookmarked, handleToggle } = useScrapToggle(term.id); const category = getCategoryType(term.primaryTag); - const config = CATEGORIES[category]; - const CategoryIcon = config.icon; const handleBookmark = (e: React.MouseEvent) => { e.stopPropagation(); @@ -60,15 +59,7 @@ function RelatedTermCard({ term }: { term: TermIndexItem }) {
- {/* Category Icon */} -
- -
+ {/* Term Name */} {term.termEn || term.termKo} From 4c1d75b1deaf9b7bd0c5cd561ec05f9df6dcc9a4 Mon Sep 17 00:00:00 2001 From: hyeseong Date: Mon, 1 Dec 2025 08:15:38 +0900 Subject: [PATCH 19/22] =?UTF-8?q?refactor:=20ScrapButton=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=ED=99=94=20=EB=B0=8F=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/search/SearchResultCard.tsx | 14 ++--- src/components/term-detail/HeroSection.tsx | 19 ++----- .../term-detail/tabs/RelatedTab.tsx | 24 ++------- src/components/ui/buttons/ScrapButton.tsx | 52 +++++++++++++++++++ 4 files changed, 63 insertions(+), 46 deletions(-) create mode 100644 src/components/ui/buttons/ScrapButton.tsx diff --git a/src/components/search/SearchResultCard.tsx b/src/components/search/SearchResultCard.tsx index dc0d768..e0b623a 100644 --- a/src/components/search/SearchResultCard.tsx +++ b/src/components/search/SearchResultCard.tsx @@ -4,8 +4,9 @@ import { useRouter } from "next/navigation"; import { useScrapToggle } from "@/hooks/useScrapToggle"; import { useShare } from "@/hooks/useShare"; import type { TermIndexItem } from "@/lib/terms"; -import { ScrapIcon, ShareIcon, HashtagIcon } from "@/components/icons"; +import { ShareIcon, HashtagIcon } from "@/components/icons"; import { CategoryChip } from "@/components/ui/category/CategoryChip"; +import { ScrapButton } from "@/components/ui/buttons/ScrapButton"; import { CATEGORIES, getCategoryType } from "@/config/categories"; interface SearchResultCardProps { @@ -47,16 +48,7 @@ export default function SearchResultCard({ item }: SearchResultCardProps) {
- + + size="lg" + /> + diff --git a/src/components/ui/buttons/ScrapButton.tsx b/src/components/ui/buttons/ScrapButton.tsx new file mode 100644 index 0000000..c5171e2 --- /dev/null +++ b/src/components/ui/buttons/ScrapButton.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { cn } from "@/utils/cn"; +import { ScrapIcon } from "@/components/icons"; + +type ScrapButtonSize = "sm" | "lg"; + +interface ScrapButtonProps { + bookmarked: boolean; + onClick: (e: React.MouseEvent) => void; + size?: ScrapButtonSize; + className?: string; +} + +const sizeConfig = { + sm: { + button: "h-6 w-6 rounded", + icon: 16, + }, + lg: { + button: "h-9 w-9 rounded-md", + icon: 24, + }, +} as const; + +export function ScrapButton({ + bookmarked, + onClick, + size = "sm", + className, +}: ScrapButtonProps) { + const config = sizeConfig[size]; + + return ( + + ); +} From 79224a4cd620d43f43ad75a2aafaf541e13a1e02 Mon Sep 17 00:00:00 2001 From: hyeseong Date: Mon, 1 Dec 2025 08:15:58 +0900 Subject: [PATCH 20/22] style: format coe --- src/components/term-detail/HeroSection.tsx | 6 +----- src/components/term-detail/tabs/RelatedTab.tsx | 6 +++++- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/term-detail/HeroSection.tsx b/src/components/term-detail/HeroSection.tsx index c9d95b2..5eea78e 100644 --- a/src/components/term-detail/HeroSection.tsx +++ b/src/components/term-detail/HeroSection.tsx @@ -58,11 +58,7 @@ export function HeroSection({ {/* Action Buttons */}
- +