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/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 dc363e1..1266e82 100644 --- a/src/app/dashboard/components/DashboardClient.tsx +++ b/src/app/dashboard/components/DashboardClient.tsx @@ -4,10 +4,10 @@ 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"; +import { type ScrapCardData } from "@/types/scrapCard"; interface DashboardClientProps { todayTermCard: ReactNode; @@ -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..7756a73 100644 --- a/src/app/dashboard/components/ProfileCard.tsx +++ b/src/app/dashboard/components/ProfileCard.tsx @@ -3,16 +3,13 @@ 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 { useAuth } from "@/contexts/AuthContext"; +import { CATEGORIES, type CategoryType } from "@/config/categories"; +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"; @@ -23,7 +20,7 @@ const SimpleProfileCard: React.FC = () => { await updateCategory(category); }; - const config = categoryConfig[selectedCategory]; + const config = CATEGORIES[selectedCategory]; const IconComponent = config?.icon; return ( @@ -69,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..23b4102 100644 --- a/src/app/dashboard/components/ScrapCard.tsx +++ b/src/app/dashboard/components/ScrapCard.tsx @@ -3,7 +3,9 @@ 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 { getCategoryType } from "@/config/categories"; +import { CategoryChip } from "@/components/ui/category"; +import type { ScrapCardData } from "@/types/scrapCard"; interface ScrapCardProps { card: ScrapCardData; @@ -11,8 +13,7 @@ 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 handleClick = () => { if (card.slug) { @@ -27,18 +28,7 @@ export default function ScrapCard({ card }: ScrapCardProps) { >
-
- {IconComponent && ( - - )} -
- +
{card.term} diff --git a/src/app/dashboard/components/ScrapSection.tsx b/src/app/dashboard/components/ScrapSection.tsx index 25385f3..a86eb9c 100644 --- a/src/app/dashboard/components/ScrapSection.tsx +++ b/src/app/dashboard/components/ScrapSection.tsx @@ -3,11 +3,13 @@ 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"; import SortDropdown from "@/components/ui/SortDropdown"; +import { BRAND_GRADIENT } from "@/constants/theme"; interface ScrapSectionProps { totalCount: number; @@ -24,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); @@ -33,7 +36,9 @@ 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/app/onboarding/components/categoryList.tsx b/src/app/onboarding/components/categoryList.tsx index 924a109..174094f 100644 --- a/src/app/onboarding/components/categoryList.tsx +++ b/src/app/onboarding/components/categoryList.tsx @@ -1,20 +1,17 @@ "use client"; -import { type CategoryType } from "@/components/ui/category/config"; -import CategoryButton from "./categoruButton"; +import { type CategoryType } from "@/config/categories"; +import CategoryButton from "./CategoryButton"; interface CategoryListProps { selectedCategory: CategoryType | null; 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) => ( { const router = useRouter(); - const { completeOnboarding, user } = useAuth(); + const { user } = useAuthCore(); + const { completeOnboarding } = useUserData(); const [selectedCategory, setSelectedCategory] = useState( null ); 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/app/terms/[slug]/page.tsx b/src/app/terms/[slug]/page.tsx index 8be43b0..3575e51 100644 --- a/src/app/terms/[slug]/page.tsx +++ b/src/app/terms/[slug]/page.tsx @@ -6,8 +6,9 @@ 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"; export default function TermDetailPage({ params, @@ -15,8 +16,10 @@ 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); const [relatedTerms, setRelatedTerms] = useState([]); const [bookmarked, setBookmarked] = useState(false); @@ -70,15 +73,7 @@ export default function TermDetailPage({ const handleShare = async () => { if (!term) return; - try { - await navigator.share({ - title: term.term.en || term.term.ko, - text: term.summary, - url: window.location.href, - }); - } catch { - await navigator.clipboard.writeText(window.location.href); - } + await shareCurrentPage(term.term.en || term.term.ko, term.summary); }; if (loading) { 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/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_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_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_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/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 e0acf19..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"; @@ -16,15 +15,12 @@ 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"; 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"; @@ -35,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/layout/Header.tsx b/src/components/layout/Header.tsx index 262983a..d3d833f 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -1,139 +1,13 @@ "use client"; -import { useState, useRef, useEffect } from "react"; -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 { useAuth } from "@/contexts/AuthContext"; - -type NavItemProps = { - label: string; - href: string; -}; +import { useAuthCore } from "@/contexts/auth"; +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, setIsOpen] = useState(false); - const dropdownRef = useRef(null); - 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); - router.push("/dashboard"); - }; - - const handleLogoutClick = () => { - setIsOpen(false); - onLogout(); - }; - - return ( -
- setIsOpen(!isOpen)} - > - {photoURL ? ( - 프로필 - ) : ( - - )} - - - {isOpen && ( -
-
-

{email}

-
-
- - -
-
- )} -
- ); + navItems?: readonly { label: string; href: string }[]; }; export default function Header({ @@ -142,7 +16,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/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"; 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/CategorySelection.tsx b/src/components/quiz/CategorySelection.tsx index ebb277e..75a68af 100644 --- a/src/components/quiz/CategorySelection.tsx +++ b/src/components/quiz/CategorySelection.tsx @@ -1,8 +1,8 @@ "use client"; import { useState } from "react"; -import { type CategoryType } from "@/components/ui/category/config"; -import CategoryButton from "@/app/onboarding/components/categoruButton"; +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"; import { useToast } from "@/contexts/ToastContext"; @@ -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 9eabd9e..2db9ce9 100644 --- a/src/components/quiz/QuizResult.tsx +++ b/src/components/quiz/QuizResult.tsx @@ -1,16 +1,17 @@ "use client"; 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, 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"; +import { QuizScoreCard } from "./QuizScoreCard"; +import { WrongAnswerCard } from "./WrongAnswerCard"; interface QuizResultProps { result: QuizResultType; @@ -25,7 +26,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); @@ -42,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++; } @@ -75,41 +76,8 @@ export default function QuizResult({ return (
- {/* 점수 카드 */} -
-
-
-

총점

-

- {result.score} -

-

-
-
- -
-
-

전체 문제

-

- {result.totalQuestions} -

-
-
-

정답

-

- {result.correctAnswers} -

-
-
-

오답

-

- {result.wrongAnswers} -

-
-
-
+ - {/* 오답 노트 */} {wrongQuestions.length > 0 && (
@@ -129,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/QuizSession.tsx b/src/components/quiz/QuizSession.tsx index 5b5300c..974a2ed 100644 --- a/src/components/quiz/QuizSession.tsx +++ b/src/components/quiz/QuizSession.tsx @@ -1,17 +1,10 @@ "use client"; -import { useState } from "react"; import Link from "next/link"; -import { - calculateQuizResult, - type QuizQuestion, - type QuizResult, -} from "@/lib/quiz"; -import { - categoryLabels, - type CategoryType, -} from "@/components/ui/category/config"; +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[]; @@ -24,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 (
@@ -74,7 +37,7 @@ export default function QuizSession({

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

문제 {currentQuestionIndex + 1} / {questions.length} @@ -83,8 +46,7 @@ export default function QuizSession({

답변한 문제

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

@@ -146,7 +108,7 @@ export default function QuizSession({ return (
diff --git a/src/components/quiz/WrongAnswerCard.tsx b/src/components/quiz/WrongAnswerCard.tsx new file mode 100644 index 0000000..557f15a --- /dev/null +++ b/src/components/quiz/WrongAnswerCard.tsx @@ -0,0 +1,54 @@ +"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} + +
+
+
+ ); +} 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/components/search/SearchBar.tsx b/src/components/search/SearchBar.tsx index 31b807f..92687b3 100644 --- a/src/components/search/SearchBar.tsx +++ b/src/components/search/SearchBar.tsx @@ -3,6 +3,7 @@ import { SearchIcon } from "@/components/icons/ic_search"; import { useTypingAnimation } from "@/hooks/useTypingAnimation"; import { LogoText } from "../icons"; +import { BRAND_GRADIENT } from "@/constants/theme"; interface SearchBarProps { value: string; @@ -19,8 +20,9 @@ export default function SearchBar({ value, onChange }: SearchBarProps) { {/* 검색 입력창 컨테이너 */}
- {/* 1. 아우라 레이어 (opacity 30 + blur + gradient border) */} -
+
{/* 2. 실제 입력창 (내부) */}
diff --git a/src/components/search/SearchResultCard.tsx b/src/components/search/SearchResultCard.tsx index aa339e2..e0b623a 100644 --- a/src/components/search/SearchResultCard.tsx +++ b/src/components/search/SearchResultCard.tsx @@ -2,11 +2,12 @@ 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 { getCategoryType } from "@/lib/category"; -import { categorySelectedColors } from "@/components/ui/category/config"; +import { ScrapButton } from "@/components/ui/buttons/ScrapButton"; +import { CATEGORIES, getCategoryType } from "@/config/categories"; interface SearchResultCardProps { item: TermIndexItem; @@ -15,6 +16,7 @@ interface SearchResultCardProps { export default function SearchResultCard({ item }: SearchResultCardProps) { const router = useRouter(); const { bookmarked, handleToggle } = useScrapToggle(item.id); + const { shareTerm } = useShare(); const category = getCategoryType(item.primaryTag); const handleBookmark = (e: React.MouseEvent) => { @@ -24,16 +26,7 @@ export default function SearchResultCard({ item }: SearchResultCardProps) { const handleShare = async (e: React.MouseEvent) => { e.stopPropagation(); - const url = `${window.location.origin}/terms/${item.slug}`; - try { - await navigator.share({ - title: item.termEn || item.termKo, - text: item.summary, - url: url, - }); - } catch { - await navigator.clipboard.writeText(url); - } + await shareTerm(item.termEn || item.termKo, item.summary, item.slug); }; const handleClick = () => { @@ -55,16 +48,7 @@ export default function SearchResultCard({ item }: SearchResultCardProps) {
- + + + 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/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/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 ( + + ); +} 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/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 deleted file mode 100644 index fe0cd93..0000000 --- a/src/components/ui/category/config.ts +++ /dev/null @@ -1,109 +0,0 @@ -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"; - -// Category Types -export type CategoryType = - | "all" - | "frontend" - | "backend" - | "uxui" - | "ai" - | "cloud" - | "data" - | "security" - | "devops" - | "business"; - -// Category Configuration (Icons & Colors) -export const categoryConfig = { - all: { - icon: CategoryAllIcon, - bgColor: "bg-category-all", - }, - frontend: { - icon: CategoryFrontendIcon, - bgColor: "bg-category-frontend", - }, - backend: { - icon: CategoryBackendIcon, - bgColor: "bg-category-backend", - }, - uxui: { - icon: CategoryUiuxIcon, - bgColor: "bg-category-uxui", - }, - ai: { - icon: CategoryAiIcon, - bgColor: "bg-category-ai", - }, - cloud: { - icon: CategoryCloudIcon, - bgColor: "bg-category-cloud", - }, - data: { - icon: CategoryDataIcon, - bgColor: "bg-category-data", - }, - security: { - icon: CategorySecurityIcon, - bgColor: "bg-category-security", - }, - devops: { - icon: CategoryDevopsIcon, - bgColor: "bg-category-devops", - }, - business: { - icon: CategoryBusinessIcon, - bgColor: "bg-category-business", - }, -} as const; - -// Category Labels (Korean) -export const categoryLabels = { - all: "전체", - frontend: "프론트엔드", - backend: "백엔드", - uxui: "UX/UI", - ai: "AI", - cloud: "클라우드", - data: "데이터", - security: "보안/네트워크", - devops: "DevOps", - business: "IT비즈니스", -} as const; - -// Category Hover Colors (for CategoryTag) -export const categoryHoverColors = { - all: "hover:bg-[rgba(170,177,188,0.1)]", - frontend: "hover:bg-[rgba(38,199,239,0.1)]", - backend: "hover:bg-[rgba(18,168,73,0.1)]", - uxui: "hover:bg-[rgba(244,94,143,0.1)]", - ai: "hover:bg-[rgba(174,119,250,0.1)]", - cloud: "hover:bg-[rgba(55,173,233,0.1)]", - data: "hover:bg-[rgba(38,205,174,0.1)]", - security: "hover:bg-[rgba(246,114,64,0.1)]", - devops: "hover:bg-[rgba(251,175,33,0.1)]", - business: "hover:bg-[rgba(98,135,246,0.1)]", -} as const; - -// Category Selected Colors (for CategoryTag) -export const categorySelectedColors = { - all: "bg-[rgba(170,177,188,0.5)]", - frontend: "bg-[rgba(38,199,239,0.5)]", - backend: "bg-[rgba(18,168,73,0.5)]", - uxui: "bg-[rgba(244,94,143,0.5)]", - ai: "bg-[rgba(174,119,250,0.5)]", - cloud: "bg-[rgba(55,173,233,0.5)]", - data: "bg-[rgba(38,205,174,0.5)]", - security: "bg-[rgba(246,114,64,0.5)]", - devops: "bg-[rgba(251,175,33,0.5)]", - business: "bg-[rgba(98,135,246,0.5)]", -} as const; diff --git a/src/components/ui/category/index.ts b/src/components/ui/category/index.ts index 701965a..8250ec9 100644 --- a/src/components/ui/category/index.ts +++ b/src/components/ui/category/index.ts @@ -1,13 +1,3 @@ // Category Components export { CategoryChip } from "./CategoryChip"; export { CategorySquareBadge } from "./CategorySquareBadge"; -export { CategoryTag } from "./CategoryTag"; - -// 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 new file mode 100644 index 0000000..95f81e8 --- /dev/null +++ b/src/config/categories.ts @@ -0,0 +1,191 @@ +/** + * 카테고리 설정 + * + * 모든 카테고리 관련 설정(아이콘, 색상, 라벨 등)을 한 곳에서 관리합니다. + * 이 파일은 다음 파일들의 중복을 제거하기 위해 만들어졌습니다: + * - src/components/ui/category/config.ts + * - src/lib/category.ts + * - src/types/category.ts + */ + +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 type CategoryType = + | "all" + | "frontend" + | "backend" + | "uxui" + | "ai" + | "cloud" + | "data" + | "security" + | "devops" + | "business"; + +/** + * 카테고리별 세부 설정 + */ +interface CategoryConfig { + /** 한글 라벨 */ + label: string; + /** 아이콘 컴포넌트 */ + icon: ElementType; + /** Tailwind 배경 색상 클래스 */ + bgColor: string; + /** Tailwind 호버 색상 클래스 (투명도 10%) */ + hoverColor: string; + /** Tailwind 선택 색상 클래스 (투명도 50%) */ + selectedColor: string; +} + +/** + * 통합 카테고리 설정 + * + * 모든 카테고리의 아이콘, 색상, 라벨을 한 곳에서 관리합니다. + */ +export const CATEGORIES: Record = { + all: { + label: "전체", + icon: CategoryAllIcon, + bgColor: "bg-category-all", + hoverColor: "hover:bg-[rgba(170,177,188,0.1)]", + selectedColor: "bg-[rgba(170,177,188,0.5)]", + }, + frontend: { + label: "프론트엔드", + icon: CategoryFrontendIcon, + bgColor: "bg-category-frontend", + hoverColor: "hover:bg-[rgba(38,199,239,0.1)]", + selectedColor: "bg-[rgba(38,199,239,0.5)]", + }, + backend: { + label: "백엔드", + icon: CategoryBackendIcon, + bgColor: "bg-category-backend", + hoverColor: "hover:bg-[rgba(18,168,73,0.1)]", + selectedColor: "bg-[rgba(18,168,73,0.5)]", + }, + uxui: { + label: "UX/UI", + icon: CategoryUiuxIcon, + bgColor: "bg-category-uxui", + hoverColor: "hover:bg-[rgba(244,94,143,0.1)]", + selectedColor: "bg-[rgba(244,94,143,0.5)]", + }, + ai: { + label: "AI", + icon: CategoryAiIcon, + bgColor: "bg-category-ai", + hoverColor: "hover:bg-[rgba(174,119,250,0.1)]", + selectedColor: "bg-[rgba(174,119,250,0.5)]", + }, + cloud: { + label: "클라우드", + icon: CategoryCloudIcon, + bgColor: "bg-category-cloud", + hoverColor: "hover:bg-[rgba(55,173,233,0.1)]", + selectedColor: "bg-[rgba(55,173,233,0.5)]", + }, + data: { + label: "데이터", + icon: CategoryDataIcon, + bgColor: "bg-category-data", + hoverColor: "hover:bg-[rgba(38,205,174,0.1)]", + selectedColor: "bg-[rgba(38,205,174,0.5)]", + }, + security: { + label: "보안/네트워크", + icon: CategorySecurityIcon, + bgColor: "bg-category-security", + hoverColor: "hover:bg-[rgba(246,114,64,0.1)]", + selectedColor: "bg-[rgba(246,114,64,0.5)]", + }, + devops: { + label: "DevOps", + icon: CategoryDevopsIcon, + bgColor: "bg-category-devops", + hoverColor: "hover:bg-[rgba(251,175,33,0.1)]", + selectedColor: "bg-[rgba(251,175,33,0.5)]", + }, + business: { + label: "IT비즈니스", + icon: CategoryBusinessIcon, + bgColor: "bg-category-business", + hoverColor: "hover:bg-[rgba(98,135,246,0.1)]", + selectedColor: "bg-[rgba(98,135,246,0.5)]", + }, +} as const; + +/** + * 영문 카테고리 타입 목록 + */ +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[]; + +// ============================================================================ +// 유틸리티 함수들 +// ============================================================================ + +/** + * 영문 카테고리 타입 → 한글 라벨 매핑 + * @example getCategoryLabel("frontend") // "프론트엔드" + */ +export function getCategoryLabel(category: string): string { + return CATEGORIES[category as CategoryType]?.label || category; +} + +/** + * 한글 라벨 → 영문 카테고리 타입 역매핑 + */ +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", +}; + +/** + * 한글 라벨 → 영문 카테고리 타입 변환 + * @example getCategoryType("프론트엔드") // "frontend" + * @example getCategoryType("존재하지않는값") // "all" (기본값) + */ +export function getCategoryType(label: string): CategoryType { + return LABEL_TO_CATEGORY[label] || "all"; +} 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, + }, +}; diff --git a/src/constants/theme.ts b/src/constants/theme.ts new file mode 100644 index 0000000..19770a7 --- /dev/null +++ b/src/constants/theme.ts @@ -0,0 +1,8 @@ +/** + * 브랜드 테마 상수 + */ + +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; diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx deleted file mode 100644 index c243c31..0000000 --- a/src/contexts/AuthContext.tsx +++ /dev/null @@ -1,275 +0,0 @@ -"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"; - -export type UserData = { - email: string | null; - displayName: string | null; - photoURL: string | null; - createdAt: string; - scrapList: number[]; - onboardingCompleted: boolean; - selectedCategory: CategoryType; -}; - -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; -} diff --git a/src/contexts/ToastContext.tsx b/src/contexts/ToastContext.tsx index adc7bbd..23dbc3a 100644 --- a/src/contexts/ToastContext.tsx +++ b/src/contexts/ToastContext.tsx @@ -8,6 +8,7 @@ import { type ReactNode, } from "react"; import { useRouter } from "next/navigation"; +import { BRAND_GRADIENT } from "@/constants/theme"; type ToastType = "info" | "success" | "error" | "login"; @@ -75,7 +76,7 @@ export function ToastProvider({ children }: { children: ReactNode }) { {toast.message} 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..1d71647 --- /dev/null +++ b/src/contexts/auth/ScrapContext.tsx @@ -0,0 +1,99 @@ +"use client"; + +/** + * ScrapContext - 스크랩 관리 + */ + +import { createContext, useContext, useState, 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 [pendingTerms, setPendingTerms] = useState>(new Set()); + + /** + * 해당 용어가 스크랩되어 있는지 확인 + */ + 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 }; + } + + // 이미 처리 중인 경우 무시 + if (pendingTerms.has(termId)) { + return { success: false, isScraped: userData.scrapList.includes(termId) }; + } + + setPendingTerms((prev) => new Set(prev).add(termId)); + + 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 }; + } finally { + setPendingTerms((prev) => { + const next = new Set(prev); + next.delete(termId); + return next; + }); + } + }; + + 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..0777fd4 --- /dev/null +++ b/src/contexts/auth/UserDataContext.tsx @@ -0,0 +1,157 @@ +"use client"; + +/** + * UserDataContext - 사용자 데이터 상태 관리 + */ + +import { + createContext, + useCallback, + useContext, + useEffect, + useState, + type ReactNode, +} from "react"; +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 type { UserData }; + +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); + + const loadUserData = useCallback(async () => { + if (!user) return; + + setUserDataLoading(true); + + try { + const localBookmarks = getBookmarks(); + let data = await fetchUserData(user.uid); + + if (!data) { + // 신규 사용자 + data = await createUserData(user, localBookmarks); + setIsNewUser(true); + if (localBookmarks.length > 0) clearBookmarks(); + } else { + // 기존 사용자 - 로컬 북마크 병합 + if (localBookmarks.length > 0) { + data.scrapList = await mergeScrapList( + user.uid, + data.scrapList, + localBookmarks + ); + clearBookmarks(); + } + setIsNewUser(!data.onboardingCompleted); + } + + setUserData(data); + } catch (error) { + console.error("사용자 데이터 로드 실패:", error); + setUserData(null); + setIsNewUser(false); + } finally { + setUserDataLoading(false); + } + }, [user]); + + useEffect(() => { + if (!user) { + setUserData(null); + setIsNewUser(false); + setUserDataLoading(false); + return; + } + + loadUserData(); + }, [user, loadUserData]); + + const refreshUserData = async () => { + await loadUserData(); + }; + + const completeOnboarding = async (category: CategoryType) => { + if (!user) return; + + try { + await completeUserOnboarding(user.uid, category); + setUserData((prev) => + prev + ? { ...prev, onboardingCompleted: true, selectedCategory: category } + : null + ); + setIsNewUser(false); + } catch (error) { + console.error("온보딩 완료 실패:", error); + throw error; + } + }; + + const updateCategory = async (category: CategoryType) => { + if (!user) return; + + try { + await updateUserCategory(user.uid, category); + setUserData((prev) => + prev ? { ...prev, selectedCategory: category } : null + ); + } catch (error) { + console.error("카테고리 업데이트 실패:", error); + throw error; + } + }; + + 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..3398442 --- /dev/null +++ b/src/contexts/auth/index.tsx @@ -0,0 +1,40 @@ +"use client"; + +/** + * Auth 모듈 통합 Export + */ + +import { type ReactNode } from "react"; + +// 개별 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} + + + ); +} + +// 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/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, + }; +} diff --git a/src/hooks/useDropdown.ts b/src/hooks/useDropdown.ts new file mode 100644 index 0000000..04c37b6 --- /dev/null +++ b/src/hooks/useDropdown.ts @@ -0,0 +1,36 @@ +"use client"; + +import { useState, useRef, useEffect, type RefObject } from "react"; + +interface UseDropdownReturn { + 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 }; +} diff --git a/src/hooks/useQuizState.ts b/src/hooks/useQuizState.ts new file mode 100644 index 0000000..1fa448d --- /dev/null +++ b/src/hooks/useQuizState.ts @@ -0,0 +1,94 @@ +"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, + }; +} 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(); // 서버/로컬 북마크 상태 diff --git a/src/hooks/useShare.ts b/src/hooks/useShare.ts new file mode 100644 index 0000000..0d9c513 --- /dev/null +++ b/src/hooks/useShare.ts @@ -0,0 +1,64 @@ +"use client"; + +/** + * 공유 기능 커스텀 훅 + */ + +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 { + // 사용자가 공유를 취소했거나 에러 발생 시 클립보드에 복사 + 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/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/category.ts b/src/lib/category.ts deleted file mode 100644 index 9bf0a42..0000000 --- a/src/lib/category.ts +++ /dev/null @@ -1,51 +0,0 @@ -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"; -} diff --git a/src/lib/quiz.ts b/src/lib/quiz.ts index 3411371..ec3053e 100644 --- a/src/lib/quiz.ts +++ b/src/lib/quiz.ts @@ -3,26 +3,10 @@ */ import { getTermsIndex, getTermsByTag, type TermIndexItem } from "./terms"; -import { - categoryLabels, - type CategoryType, -} from "@/components/ui/category/config"; - -export interface QuizQuestion { - term: TermIndexItem; - correctAnswer: string; - choices: string[]; - questionType: "summary" | "term"; -} +import { CATEGORIES, type CategoryType } from "@/config/categories"; -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"; /** * 배열을 랜덤하게 섞기 @@ -51,7 +35,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 2a4b15c..488adac 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 type { ScrapCardData } from "@/types/scrapCard"; +import { getCategoryLabel, getCategoryType } from "@/config/categories"; +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/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..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; -} +export type { + TermIndexItem, + TermDetail, + Role, + UseCase, + Conversation, +} from "@/types/terms"; -// 대화 상황 타입 (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"; -} +import type { TermIndexItem, TermDetail } from "@/types/terms"; // 캐시 let indexCache: TermIndexItem[] | null = null; @@ -195,26 +153,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(); -} diff --git a/src/lib/userService.ts b/src/lib/userService.ts new file mode 100644 index 0000000..c5a8174 --- /dev/null +++ b/src/lib/userService.ts @@ -0,0 +1,105 @@ +/** + * 사용자 데이터 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; + } + + const data = userSnap.data(); + return { + email: data.email ?? null, + displayName: data.displayName ?? null, + photoURL: data.photoURL ?? null, + createdAt: data.createdAt ?? "", + scrapList: data.scrapList ?? [], + onboardingCompleted: data.onboardingCompleted ?? false, + selectedCategory: data.selectedCategory ?? "all", + }; +} + +/** + * 신규 사용자 데이터 생성 + */ +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 }); +} diff --git a/src/types/category.ts b/src/types/category.ts deleted file mode 100644 index 2b5be97..0000000 --- a/src/types/category.ts +++ /dev/null @@ -1,73 +0,0 @@ -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 interface ScrapCardData { - id: string; - slug?: string; - category: string; - term: string; - tag: string; - description: string; - date: string; -} 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)[]; +} diff --git a/src/types/scrapCard.ts b/src/types/scrapCard.ts new file mode 100644 index 0000000..87e7817 --- /dev/null +++ b/src/types/scrapCard.ts @@ -0,0 +1,12 @@ +/** + * 스크랩 카드 데이터 인터페이스 + */ +export interface ScrapCardData { + id: string; + slug?: string; + category: string; + term: string; + tag: string; + description: string; + date: string; +} 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"; +} 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";