diff --git a/src/app/preview/tech-stack3/page.tsx b/src/app/preview/tech-stack3/page.tsx new file mode 100644 index 0000000..b0f6f02 --- /dev/null +++ b/src/app/preview/tech-stack3/page.tsx @@ -0,0 +1,27 @@ +'use client'; + +import TechSelector from '@/components/ui/tech-stack/TechSelector'; +import React, { useState } from 'react'; + +export default function Page(): JSX.Element { + // 상위 컴포넌트에서 선택된 기술 목록 관리 + const [selectedTechs, setSelectedTechs] = useState([]); + + // 기술 선택 변경 핸들러 + const handleSelectionChange = (selection: string[]) => { + setSelectedTechs(selection); + console.log('Selected technologies:', selection); + + // 여기서 필요한 상태 업데이트 또는 API 호출 등을 수행할 수 있습니다. + // 예: 선택된 기술 정보를 서버에 전송 + }; + + return ( +
+ +
+ ); +} diff --git a/src/components/ui/Icon/IconData.ts b/src/components/ui/Icon/IconData.ts index 8c2d4c3..4900a15 100644 --- a/src/components/ui/Icon/IconData.ts +++ b/src/components/ui/Icon/IconData.ts @@ -42,7 +42,7 @@ export const ICON_LIST: IconConfig[] = [ }, { name: 'Next', - color: '#000000', + color: '#FFFFFF', path: getPath( 'Next.js', ), @@ -127,7 +127,7 @@ export const ICON_LIST: IconConfig[] = [ }, { name: 'Express', - color: '#000000', + color: '#FFFFFF', path: getPath( 'Express', ), @@ -159,7 +159,7 @@ export const ICON_LIST: IconConfig[] = [ }, { name: 'Django', - color: '#092E20', + color: '#098620', path: getPath( 'Django', ), diff --git a/src/components/ui/tech-stack/TechSelector.tsx b/src/components/ui/tech-stack/TechSelector.tsx new file mode 100644 index 0000000..0cf126d --- /dev/null +++ b/src/components/ui/tech-stack/TechSelector.tsx @@ -0,0 +1,68 @@ +import useTechSelection from '@/hooks/useTechSelection'; +import { getIconColor, getIconsByCategory } from '@/util/getIconDetail'; +import React, { useState } from 'react'; +import { CategoryType } from 'types/techStack'; + +import CategoryTabs from './tech-stack-components/CategoryTabs'; +import SelectedTechList from './tech-stack-components/SelectedTechList'; +import TechButtonList from './tech-stack-components/TechButtonList'; + +interface TechSelectorProps { + maxSelections?: number; + onSelectionChange?: (selection: string[]) => void; +} + +const TechSelector = ({ + maxSelections = 5, + onSelectionChange, +}: TechSelectorProps): JSX.Element => { + // 현재 선택된 카테고리 상태 + const [activeCategory, setActiveCategory] = useState('all'); + + // 기술 선택 로직 훅 사용 + const { + clickedButtons, + selectedCount, + selectedNames, + handleButtonClick, + handleReset, + handleRemoveSelection, + } = useTechSelection({ + maxSelections, + onSelectionChange, + }); + + // 현재 카테고리의 아이콘 가져오기 + const activeIcons = getIconsByCategory(activeCategory); + + return ( +
+
+ {/* 선택된 기술 목록 */} + + + {/* 카테고리 탭 */} + + + {/* 기술 버튼 목록 */} + +
+
+ ); +}; + +export default TechSelector; diff --git a/src/components/ui/tech-stack/tech-stack-components/CategoryTabs.tsx b/src/components/ui/tech-stack/tech-stack-components/CategoryTabs.tsx new file mode 100644 index 0000000..62b80a4 --- /dev/null +++ b/src/components/ui/tech-stack/tech-stack-components/CategoryTabs.tsx @@ -0,0 +1,58 @@ +import { RotateCcw } from 'lucide-react'; +import React from 'react'; +import { CategoryType } from 'types/techStack'; + +import TabButton from './TabButton'; + +interface CategoryTabsProps { + activeCategory: CategoryType; + onCategoryChange: (category: CategoryType) => void; + onReset: () => void; +} + +const CategoryTabs = ({ + activeCategory, + onCategoryChange, + onReset, +}: CategoryTabsProps): JSX.Element => { + const categories: Array<{ + id: CategoryType; + label: string; + smallText: string; + }> = [ + { id: 'all', label: '전체', smallText: 'All' }, + { id: 'frontend', label: '프론트엔드', smallText: 'Front' }, + { id: 'backend', label: '백엔드', smallText: 'Back' }, + { id: 'design', label: '디자인', smallText: 'UI/UX' }, + ]; + + return ( +
+
+ {categories.map((category) => ( + onCategoryChange(category.id)} + smallText={category.smallText} + > + {category.label} + + ))} +
+ +
+ +
+
+ ); +}; + +export default CategoryTabs; diff --git a/src/components/ui/tech-stack/tech-stack-components/SelectedTechButton.tsx b/src/components/ui/tech-stack/tech-stack-components/SelectedTechButton.tsx new file mode 100644 index 0000000..3e22a6c --- /dev/null +++ b/src/components/ui/tech-stack/tech-stack-components/SelectedTechButton.tsx @@ -0,0 +1,43 @@ +import { getIconComponent } from '@/util/getIconDetail'; +import { X } from 'lucide-react'; +import React from 'react'; + +interface SelectedTechButtonProps { + name: string; + color: string; + onRemove: (name: string) => void; +} + +const SelectedTechButton = ({ + name, + color, + onRemove, +}: SelectedTechButtonProps): JSX.Element => { + // 현재 기술의 아이콘 컴포넌트 + const TechIcon = getIconComponent(name); + + return ( +
+ + + + + {name} + + + {/* 삭제 버튼 */} + +
+ ); +}; + +export default SelectedTechButton; diff --git a/src/components/ui/tech-stack/tech-stack-components/SelectedTechList.tsx b/src/components/ui/tech-stack/tech-stack-components/SelectedTechList.tsx new file mode 100644 index 0000000..26ffd40 --- /dev/null +++ b/src/components/ui/tech-stack/tech-stack-components/SelectedTechList.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +import SelectedTechButton from './SelectedTechButton'; + +interface SelectedTechListProps { + selectedNames: string[]; + getIconColor: (name: string) => string; + onRemove: (name: string) => void; +} + +const SelectedTechList = ({ + selectedNames, + getIconColor, + onRemove, +}: SelectedTechListProps): JSX.Element => { + if (selectedNames.length === 0) { + return
; + } + + return ( +
+
+
+ {selectedNames.map((name) => ( + + ))} +
+
+
+ ); +}; + +export default SelectedTechList; diff --git a/src/components/ui/tech-stack/tech-stack-components/TabButton.tsx b/src/components/ui/tech-stack/tech-stack-components/TabButton.tsx new file mode 100644 index 0000000..2f86837 --- /dev/null +++ b/src/components/ui/tech-stack/tech-stack-components/TabButton.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +interface TabButtonProps { + active: boolean; + onClick: () => void; + children: React.ReactNode; + smallText: string; +} + +const TabButton = ({ + active, + onClick, + children, + smallText, +}: TabButtonProps): JSX.Element => { + return ( + + ); +}; + +export default TabButton; diff --git a/src/components/ui/tech-stack/tech-stack-components/TechButton.tsx b/src/components/ui/tech-stack/tech-stack-components/TechButton.tsx new file mode 100644 index 0000000..a3ddef8 --- /dev/null +++ b/src/components/ui/tech-stack/tech-stack-components/TechButton.tsx @@ -0,0 +1,43 @@ +import { isLightColor } from '@/util/getIconDetail'; +import React from 'react'; +import { IconComponent } from 'types/techStack'; + +interface TechButtonProps { + icon: IconComponent; + name: string; + color: string; + isClicked: boolean; + isMaxReached: boolean; + onClick: (name: string) => void; +} + +const TechButton = ({ + icon: Icon, + name, + color, + isClicked, + isMaxReached, + onClick, +}: TechButtonProps): JSX.Element => { + // 색상이 흰색이면 클릭 시 검정색으로 변경 + const iconColor = isClicked && isLightColor(color) ? '#000000' : color; + + return ( + + ); +}; + +export default TechButton; diff --git a/src/components/ui/tech-stack/tech-stack-components/TechButtonList.tsx b/src/components/ui/tech-stack/tech-stack-components/TechButtonList.tsx new file mode 100644 index 0000000..7b51de0 --- /dev/null +++ b/src/components/ui/tech-stack/tech-stack-components/TechButtonList.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { ClickedButtonsState, IconWithComponent } from 'types/techStack'; + +import TechButton from './TechButton'; + +interface TechButtonListProps { + icons: IconWithComponent[]; + clickedButtons: ClickedButtonsState; + selectedCount: number; + maxSelections: number; + onButtonClick: (name: string) => void; +} + +const TechButtonList = ({ + icons, + clickedButtons, + selectedCount, + maxSelections, + onButtonClick, +}: TechButtonListProps): JSX.Element => { + return ( +
+
+ +
+ {icons.map((icon) => ( + = maxSelections && !clickedButtons[icon.name] + } + onClick={onButtonClick} + /> + ))} +
+
+
+ ); +}; + +export default TechButtonList; diff --git a/src/hooks/useTechSelection.ts b/src/hooks/useTechSelection.ts new file mode 100644 index 0000000..01dff02 --- /dev/null +++ b/src/hooks/useTechSelection.ts @@ -0,0 +1,107 @@ +import { useEffect, useRef, useState } from 'react'; +import { ClickedButtonsState } from 'types/techStack'; + +interface UseTechSelectionProps { + maxSelections?: number; + initialSelection?: ClickedButtonsState; + onSelectionChange?: (selection: string[]) => void; +} + +const useTechSelection = ({ + maxSelections = 5, + initialSelection = {}, + onSelectionChange, +}: UseTechSelectionProps = {}) => { + const [clickedButtons, setClickedButtons] = + useState(initialSelection); + + // 이전 선택 값을 저장하기 위한 ref + const prevSelectionRef = useRef([]); + + // 선택된 아이콘 이름 목록 가져오기 + const getSelectedIconNames = (): string[] => { + return Object.entries(clickedButtons) + .filter(([, isClicked]) => isClicked) + .map(([name]) => name); + }; + + const selectedNames = getSelectedIconNames(); + const selectedCount = selectedNames.length; + + // 외부 콜백을 useEffect 내에서 호출하되, 선택 값이 실제로 변경됐을 때만 호출 + useEffect(() => { + // 현재 선택과 이전 선택을 비교 + const currentSelection = selectedNames; + const prevSelection = prevSelectionRef.current; + + // 선택이 변경되었는지 확인 (길이 또는 내용이 다른 경우) + const hasSelectionChanged = + currentSelection.length !== prevSelection.length || + currentSelection.some((name) => !prevSelection.includes(name)); + + // 변경된 경우에만 콜백 호출 + if (hasSelectionChanged && onSelectionChange) { + onSelectionChange(currentSelection); + // 현재 선택을 이전 선택으로 업데이트 + prevSelectionRef.current = [...currentSelection]; + } + }, [selectedNames, onSelectionChange]); + + // 컴포넌트 마운트 시 초기 선택 상태 설정 + useEffect(() => { + prevSelectionRef.current = selectedNames; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // 버튼 클릭 핸들러 + const handleButtonClick = (iconName: string): void => { + setClickedButtons((prev) => { + // 이미 선택된 상태라면 선택 해제 + if (prev[iconName]) { + const newState = { ...prev }; + delete newState[iconName]; + return newState; + } + + // 현재 선택된 항목 개수 확인 + const selectedCount = Object.values(prev).filter(Boolean).length; + + // 최대 선택 개수를 초과하는 경우 추가하지 않음 + if (selectedCount >= maxSelections) { + return prev; + } + + // 최대 개수 이내라면 추가 + return { + ...prev, + [iconName]: true, + }; + }); + }; + + // 초기화 버튼 핸들러 + const handleReset = (): void => { + setClickedButtons({}); + }; + + // 개별 선택 해제 핸들러 + const handleRemoveSelection = (iconName: string): void => { + setClickedButtons((prev) => { + const newState = { ...prev }; + delete newState[iconName]; + return newState; + }); + }; + + return { + clickedButtons, + selectedCount, + selectedNames, + handleButtonClick, + handleReset, + handleRemoveSelection, + setClickedButtons, + }; +}; + +export default useTechSelection; diff --git a/src/types/techStack.ts b/src/types/techStack.ts new file mode 100644 index 0000000..23f6fd1 --- /dev/null +++ b/src/types/techStack.ts @@ -0,0 +1,22 @@ +import { IconConfig } from '@/components/ui/Icon/IconData'; +import React from 'react'; + +// 클릭된 버튼 상태를 위한 타입 +export type ClickedButtonsState = { + [key: string]: boolean; +}; + +// 아이콘 컴포넌트를 위한 타입 +export type IconComponent = React.ComponentType<{ + size?: number; + color?: string; + className?: string; +}>; + +// 아이콘 정보를 위한 타입 (getActiveIcons에서 반환되는 객체) +export interface IconWithComponent extends IconConfig { + component: IconComponent; +} + +// 기술스택 카테고리 타입 +export type CategoryType = IconConfig['category'] | 'all'; diff --git a/src/util/getIconDetail.ts b/src/util/getIconDetail.ts new file mode 100644 index 0000000..107ab9c --- /dev/null +++ b/src/util/getIconDetail.ts @@ -0,0 +1,69 @@ +import { ICON_LIST } from '@/components/ui/Icon/IconData'; +import { + AllIcons, + backend, + design, + frontend, +} from '@/components/ui/Icon/iconRegistry'; +import { + CategoryType, + IconComponent, + IconWithComponent, +} from 'types/techStack'; + +// 선택된 아이콘의 색상 가져오기 +export const getIconColor = (iconName: string): string => { + const icon = ICON_LIST.find((icon) => icon.name === iconName); + return icon ? icon.color : '#000000'; +}; + +// 각 기술에 맞는 아이콘 컴포넌트 가져오기 +export const getIconComponent = (iconName: string): IconComponent => { + // AllIcons에서 기술 이름에 맞는 아이콘 컴포넌트 찾기 + const iconComponentName = `${iconName}Icon`; + const IconComponent = AllIcons[iconComponentName as keyof typeof AllIcons]; + + // 일치하는 아이콘이 없으면 null + return IconComponent || null; +}; + +// 현재 선택된 카테고리의 아이콘 가져오기 +export const getIconsByCategory = ( + activeCategory: CategoryType, +): IconWithComponent[] => { + switch (activeCategory) { + case 'all': + return ICON_LIST.map((icon) => ({ + ...icon, + component: AllIcons[`${icon.name}Icon` as keyof typeof AllIcons], + })); + case 'frontend': + return ICON_LIST.filter((icon) => icon.category === 'frontend').map( + (icon) => ({ + ...icon, + component: frontend[`${icon.name}Icon` as keyof typeof frontend], + }), + ); + case 'backend': + return ICON_LIST.filter((icon) => icon.category === 'backend').map( + (icon) => ({ + ...icon, + component: backend[`${icon.name}Icon` as keyof typeof backend], + }), + ); + case 'design': + return ICON_LIST.filter((icon) => icon.category === 'design').map( + (icon) => ({ + ...icon, + component: design[`${icon.name}Icon` as keyof typeof design], + }), + ); + default: + return []; + } +}; + +// 색상이 흰색인지 확인 +export const isLightColor = (color: string): boolean => { + return color === '#FFFFFF'; +};