diff --git a/src/app/assets/icon.svg b/public/logo.svg similarity index 100% rename from src/app/assets/icon.svg rename to public/logo.svg diff --git a/src/app/index.html b/src/app/index.html index 94a79be..6a0cda3 100644 --- a/src/app/index.html +++ b/src/app/index.html @@ -2,7 +2,7 @@ - + - + diff --git a/src/app/public/logo.svg b/src/app/public/logo.svg new file mode 100644 index 0000000..54452bf --- /dev/null +++ b/src/app/public/logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/app/routes/App.tsx b/src/app/routes/App.tsx index c27472f..a96ed81 100644 --- a/src/app/routes/App.tsx +++ b/src/app/routes/App.tsx @@ -7,7 +7,8 @@ import MainLayout from "@app/routes/MainLayout"; import PrivateRoute from "@app/routes/PrivateRoute"; import { useAuthObserver } from "@shared/hooks/useAuthObserver"; -import LoadingSpinner from "@shared/ui/loading-spinner/LoadingSpinner"; +import { useLoadingCursor } from "@shared/hooks/useLoadingCursor"; +import PageTransitionLoader from "@shared/ui/loading-spinner/PageTransitionLoader"; const HomePage = lazy(() => import("@pages/home/ui/HomePage")); const NotFoundPage = lazy(() => import("@pages/not-found/ui/NotFoundPage")); @@ -26,47 +27,55 @@ const ProjectListPage = lazy( () => import("@pages/project-list/ui/ProjectListPage") ); +function AppContent(): JSX.Element { + useLoadingCursor(); + + return ( + }> + + {/* 헤더 없는 레이아웃 (로그인/회원가입 전용) */} + }> + } /> + } /> + + + {/* 헤더 포함 레이아웃 (메인 페이지) */} + }> + {/* 공개 페이지 */} + } /> + } /> + } /> + } /> + + {/* 비공개 페이지 */} + + + + } + /> + + + + } + /> + + + + ); +} + function App(): JSX.Element { useAuthObserver(); return ( - }> - - {/* 헤더 없는 레이아웃 (로그인/회원가입 전용) */} - }> - } /> - } /> - - - {/* 헤더 포함 레이아웃 (메인 페이지) */} - }> - {/* 공개 페이지 */} - } /> - } /> - } /> - } /> - - {/* 비공개 페이지 */} - - - - } - /> - - - - } - /> - - - + ); } diff --git a/src/app/styles/global.css b/src/app/styles/global.css index 481083a..ef8f4e1 100644 --- a/src/app/styles/global.css +++ b/src/app/styles/global.css @@ -59,3 +59,11 @@ body { *::after { box-sizing: inherit; } + +body.page-loading { + cursor: wait; +} + +body.page-loading * { + cursor: wait !important; +} diff --git a/src/entities/projects/ui/projects-card/ProjectCard.tsx b/src/entities/projects/ui/projects-card/ProjectCard.tsx index 250a97f..9a49b19 100644 --- a/src/entities/projects/ui/projects-card/ProjectCard.tsx +++ b/src/entities/projects/ui/projects-card/ProjectCard.tsx @@ -1,6 +1,3 @@ -import AccessTimeIcon from "@mui/icons-material/AccessTime"; -import LocationPinIcon from "@mui/icons-material/LocationPin"; -import PeopleAltIcon from "@mui/icons-material/PeopleAlt"; import { Button, Card, @@ -18,30 +15,38 @@ import type { JSX } from "react"; import { memo } from "react"; import { Link } from "react-router-dom"; -import { type ProjectListRes } from "@shared/types/project"; +import { RecruitmentStatus, type ProjectListRes } from "@shared/types/project"; import DragScrollContainer from "@shared/ui/DragScrollContainer"; +import { + AccessTimeIcon, + LocationPinIcon, + PeopleAltIcon, +} from "@shared/ui/icons/CommonIcons"; import UserProfileAvatar from "@shared/ui/user/UserProfileAvatar"; import UserProfileWithNamePosition from "@shared/ui/user/UserProfileWithNamePosition"; interface ProjectCardProps { project: ProjectListRes; simple?: boolean; - sx?: any; } const ProjectCard = ({ project, simple = false, - sx, }: ProjectCardProps): JSX.Element => { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.up("sm")); + const isRecruiting = project.status === RecruitmentStatus.recruiting; return ( - + - + @@ -59,7 +64,7 @@ const ProjectCard = ({ )} - + {isMobile ? ( )} - + {!simple && ( {project.techStack.map((stack, index) => ( @@ -125,7 +130,9 @@ const ProjectCard = ({ export default memo(ProjectCard); -const StyledCard = styled(Card)(({ theme }) => ({ +const StyledCard = styled(Card, { + shouldForwardProp: (prop) => prop !== "simple", +})<{ simple?: boolean }>(({ theme, simple }) => ({ height: "100%", flex: 1, width: "100%", @@ -134,6 +141,7 @@ const StyledCard = styled(Card)(({ theme }) => ({ transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)", cursor: "pointer", border: `1px solid ${theme.palette.divider}`, + ...(simple && { minHeight: 260 }), "&:hover": { transform: "translateY(-0.4rem)", @@ -172,15 +180,9 @@ const ProjectHeader = styled(Box)(() => ({ alignItems: "center", })); -const StatusChip = styled(Chip)(({ theme }) => ({ +const StatusChip = styled(Chip)(() => ({ fontWeight: 600, letterSpacing: "0.025em", - backgroundColor: theme.palette.primary.main, - color: theme.palette.primary.contrastText, - - "&:hover": { - backgroundColor: theme.palette.primary.dark, - }, })); const ContentSection = styled(Box)(({ theme }) => ({ @@ -208,6 +210,12 @@ const SimpleInfo = styled(Typography)(() => ({ WebkitBoxOrient: "vertical", })); +const UserProfileContainer = styled(Stack)(({ theme }) => ({ + flexDirection: "row", + gap: theme.spacing(1), + alignItems: "flex-start", +})); + const TechChip = styled(Chip)(({ theme }) => ({ backgroundColor: theme.palette.background.default, border: `1px solid ${theme.palette.divider}`, diff --git a/src/entities/search/api/getFilteredProjectLists.ts b/src/entities/search/api/getFilteredProjectLists.ts deleted file mode 100644 index d5e4130..0000000 --- a/src/entities/search/api/getFilteredProjectLists.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { getDocs, getCountFromServer } from "firebase/firestore"; -import type { QueryDocumentSnapshot, DocumentData } from "firebase/firestore"; - -import { SearchFilterBuilder } from "@entities/search/model/searchFilterBuilder"; -import type { ProjectSearchFilterOption } from "@entities/search/types"; - -import type { ProjectListRes } from "@shared/types/project"; - -interface GetFilteredProjectListsOptions { - cursor?: QueryDocumentSnapshot | null; - pageSize?: number; -} - -interface ProjectListWithPagination { - projects: ProjectListRes[]; - lastVisible: QueryDocumentSnapshot | null; - hasMore: boolean; - totalCount?: number; -} - -export const getFilteredProjectCount = async ( - collectionName: string, - filter: ProjectSearchFilterOption -): Promise => { - const queryBuilder = new SearchFilterBuilder(collectionName) - .setTitle(filter.title || undefined) - .setCategory(filter.category === "all" ? undefined : filter.category) - .setStatus(filter.status === "all" ? undefined : filter.status) - .setWorkflow(filter.workflow === "all" ? undefined : filter.workflow); - - const query = queryBuilder.build(); - - const snapshot = await getCountFromServer(query); - - if (filter.position && filter.position !== "all") { - const allDocs = await getDocs(query); - - const filteredProjects = allDocs.docs.filter((doc) => { - const data = doc.data() as ProjectListRes; - return data.positions.some( - (position) => position.position === filter.position - ); - }); - - return filteredProjects.length; - } - - return snapshot.data().count; -}; - -export const getFilteredProjectsByPage = async ( - collectionName: string, - filter: ProjectSearchFilterOption, - page: number = 1, - pageSize: number = 6 -): Promise => { - const queryBuilder = new SearchFilterBuilder(collectionName) - .setTitle(filter.title || undefined) - .setCategory(filter.category === "all" ? undefined : filter.category) - .setStatus(filter.status === "all" ? undefined : filter.status) - .setWorkflow(filter.workflow === "all" ? undefined : filter.workflow) - .setSortBy(filter.sortBy || "latest"); - - if (filter.position && filter.position !== "all") { - queryBuilder.addLimit(pageSize * 5); - } else { - const offset = (page - 1) * pageSize; - queryBuilder.addLimit(offset + pageSize * 2); - } - - const query = queryBuilder.build(); - - const snapshot = await getDocs(query); - - let projects = snapshot.docs.map((doc) => ({ - id: doc.id, - ...doc.data(), - })) as ProjectListRes[]; - - if (filter.position && filter.position !== "all") { - projects = projects.filter((project) => - project.positions.some( - (position) => position.position === filter.position - ) - ); - } - - const offset = (page - 1) * pageSize; - const startIndex = offset; - const endIndex = startIndex + pageSize; - - const result = projects.slice(startIndex, endIndex); - - return result; -}; - -const getFilteredProjectLists = async ( - collectionName: string, - filter: ProjectSearchFilterOption, - options: GetFilteredProjectListsOptions = {} -): Promise => { - const { cursor, pageSize = 6 } = options; - - let queryBuilder = new SearchFilterBuilder(collectionName) - .setTitle(filter.title || undefined) - .setCategory(filter.category === "all" ? undefined : filter.category) - .setStatus(filter.status === "all" ? undefined : filter.status) - .setWorkflow(filter.workflow === "all" ? undefined : filter.workflow) - .setSortBy(filter.sortBy || "latest"); - - if (cursor) { - queryBuilder.addStartAfter(cursor); - } - - queryBuilder.addLimit(pageSize + 1); - - const query = queryBuilder.build(); - - const snapshot = await getDocs(query); - let projects = snapshot.docs.map((doc) => ({ - id: doc.id, - ...doc.data(), - })) as ProjectListRes[]; - - if (filter.position && filter.position !== "all") { - projects = projects.filter((project) => - project.positions.some( - (position) => position.position === filter.position - ) - ); - } - - const hasMore = projects.length > pageSize; - if (hasMore) { - projects = projects.slice(0, pageSize); - } - - const lastVisible = - projects.length > 0 ? snapshot.docs[projects.length - 1] : null; - - return { - projects, - lastVisible, - hasMore, - }; -}; - -export const getFilteredProjectListsSimple = async ( - collectionName: string, - filter: ProjectSearchFilterOption -): Promise => { - const result = await getFilteredProjectLists(collectionName, filter); - return result.projects; -}; - -export default getFilteredProjectLists; diff --git a/src/entities/search/api/projectSearchApi.ts b/src/entities/search/api/projectSearchApi.ts new file mode 100644 index 0000000..f4e3a59 --- /dev/null +++ b/src/entities/search/api/projectSearchApi.ts @@ -0,0 +1,134 @@ +import { getDocs, getCountFromServer } from "firebase/firestore"; + +import { SearchQueryBuilder } from "@entities/search/model/searchQueryBuilder"; + +import type { ProjectListRes } from "@shared/types/project"; +import type { ProjectSearchFilterOption } from "@shared/types/search"; + +const DEFAULT_PAGE_SIZE = 6; +const MEMORY_FETCH_MULTIPLIER = 10; +const PREFETCH_BUFFER_SIZE = 2; + +const shouldApplyFilter = (value: T | "all" | undefined): value is T => { + return value !== undefined && value !== "all"; +}; + +const getFilteredValue = (value: T | "all" | undefined): T | undefined => { + return shouldApplyFilter(value) ? value : undefined; +}; + +const createBaseQuery = ( + collectionName: string, + filter: ProjectSearchFilterOption +): SearchQueryBuilder => { + return new SearchQueryBuilder(collectionName) + .setCategory(getFilteredValue(filter.category)) + .setStatus(getFilteredValue(filter.status)) + .setWorkflow(getFilteredValue(filter.workflow)); +}; + +const needsInMemoryFiltering = (filter: ProjectSearchFilterOption): boolean => { + const hasTitle = filter.title && filter.title.trim(); + const hasPosition = filter.position && filter.position !== "all"; + return !!(hasTitle || hasPosition); +}; + +const filterByTitle = ( + projects: ProjectListRes[], + searchTitle: string +): ProjectListRes[] => { + const normalizedSearchTitle = searchTitle.toLowerCase().trim(); + return projects.filter((project) => + project.title.toLowerCase().includes(normalizedSearchTitle) + ); +}; + +const filterByPosition = ( + projects: ProjectListRes[], + position: string +): ProjectListRes[] => { + return projects.filter((project) => + project.positions.some((pos) => pos.position === position) + ); +}; + +const applyInMemoryFilters = ( + projects: ProjectListRes[], + filter: ProjectSearchFilterOption +): ProjectListRes[] => { + let filteredProjects = projects; + + if (filter.title && filter.title.trim()) { + filteredProjects = filterByTitle(filteredProjects, filter.title); + } + + if (filter.position && filter.position !== "all") { + filteredProjects = filterByPosition(filteredProjects, filter.position); + } + + return filteredProjects; +}; + +const fetchAndTransformProjects = async ( + queryBuilder: SearchQueryBuilder +): Promise => { + const query = queryBuilder.build(); + const snapshot = await getDocs(query); + + return snapshot.docs.map((doc) => ({ + id: doc.id, + ...doc.data(), + })) as ProjectListRes[]; +}; + +const applyPagination = ( + projects: ProjectListRes[], + page: number, + pageSize: number +): ProjectListRes[] => { + const offset = (page - 1) * pageSize; + const startIndex = offset; + const endIndex = startIndex + pageSize; + + return projects.slice(startIndex, endIndex); +}; + +export const getProjectsCount = async ( + collectionName: string, + filter: ProjectSearchFilterOption +): Promise => { + const queryBuilder = createBaseQuery(collectionName, filter); + + if (needsInMemoryFiltering(filter)) { + const projects = await fetchAndTransformProjects(queryBuilder); + const filteredProjects = applyInMemoryFilters(projects, filter); + return filteredProjects.length; + } + + const query = queryBuilder.build(); + const snapshot = await getCountFromServer(query); + return snapshot.data().count; +}; + +export const getProjectsByPage = async ( + collectionName: string, + filter: ProjectSearchFilterOption, + page: number = 1, + pageSize: number = DEFAULT_PAGE_SIZE +): Promise => { + const queryBuilder = createBaseQuery(collectionName, filter).setSortBy( + filter.sortBy || "latest" + ); + + if (needsInMemoryFiltering(filter)) { + queryBuilder.addLimit(pageSize * MEMORY_FETCH_MULTIPLIER); + } else { + const offset = (page - 1) * pageSize; + queryBuilder.addLimit(offset + pageSize * PREFETCH_BUFFER_SIZE); + } + + const projects = await fetchAndTransformProjects(queryBuilder); + const filteredProjects = applyInMemoryFilters(projects, filter); + + return applyPagination(filteredProjects, page, pageSize); +}; diff --git a/src/entities/search/hooks/useFilteredProjects.ts b/src/entities/search/hooks/useFilteredProjects.ts index 02f6249..d9d63cc 100644 --- a/src/entities/search/hooks/useFilteredProjects.ts +++ b/src/entities/search/hooks/useFilteredProjects.ts @@ -1,12 +1,11 @@ -import { useState } from "react"; - -import type { ProjectSearchFilterOption, SortBy } from "@entities/search/types"; +import { useState, useMemo, useCallback } from "react"; import { RecruitmentStatus, type ProjectCategory, type Workflow, } from "@shared/types/project"; +import type { ProjectSearchFilterOption, SortBy } from "@shared/types/search"; import type { UserRole } from "@shared/types/user"; interface UseFilteredProjects { @@ -25,7 +24,7 @@ interface UseFilteredProjects { updateSortBy: (newSortBy: SortBy | "latest") => void; resetFilters: () => void; getActiveFiltersCount: () => number; - getCleanFilter: () => ProjectSearchFilterOption; + getFilterStatus: () => ProjectSearchFilterOption; } const useFilteredProjects = (): UseFilteredProjects => { @@ -79,7 +78,7 @@ const useFilteredProjects = (): UseFilteredProjects => { return count; }; - const getCleanFilter = (): ProjectSearchFilterOption => { + const getFilterStatus = useCallback((): ProjectSearchFilterOption => { const cleanFilter: ProjectSearchFilterOption = { title: title || "", category: category === "all" ? undefined : category, @@ -92,16 +91,19 @@ const useFilteredProjects = (): UseFilteredProjects => { return Object.fromEntries( Object.entries(cleanFilter).filter(([_, value]) => value !== undefined) ) as ProjectSearchFilterOption; - }; - - const filterState = { - title, - category, - position, - status, - workflow, - sortBy, - }; + }, [title, category, position, status, workflow, sortBy]); + + const filterState = useMemo( + () => ({ + title, + category, + position, + status, + workflow, + sortBy, + }), + [title, category, position, status, workflow, sortBy] + ); return { filterState, @@ -119,7 +121,7 @@ const useFilteredProjects = (): UseFilteredProjects => { updateSortBy, resetFilters, getActiveFiltersCount, - getCleanFilter, + getFilterStatus, }; }; diff --git a/src/entities/search/hooks/useProjectListPage.ts b/src/entities/search/hooks/useProjectSearch.ts similarity index 63% rename from src/entities/search/hooks/useProjectListPage.ts rename to src/entities/search/hooks/useProjectSearch.ts index 8f511ea..c13c788 100644 --- a/src/entities/search/hooks/useProjectListPage.ts +++ b/src/entities/search/hooks/useProjectSearch.ts @@ -1,17 +1,17 @@ -import { useState } from "react"; +import { useState, useCallback } from "react"; import { - useGetFilteredProjectsByPage, - useGetFilteredProjectsCount, -} from "@entities/search/queries/useGetFilteredProjectLists"; -import type { ProjectSearchFilterOption } from "@entities/search/types"; + useProjectsByPage, + useProjectsCount, +} from "@entities/search/queries/useProjectSearchQueries"; import { usePaginationWithState } from "@shared/hooks/usePagination"; import type { ProjectListRes } from "@shared/types/project"; +import type { ProjectSearchFilterOption } from "@shared/types/search"; const ITEMS_PER_PAGE = 6; -interface UseProjectListPageReturn { +interface UseProjectSearchReturn { projects: ProjectListRes[]; totalCount: number; currentFilter: ProjectSearchFilterOption; @@ -26,16 +26,22 @@ interface UseProjectListPageReturn { handlePageChange: (page: number) => void; } -const useProjectListPage = (): UseProjectListPageReturn => { +const useProjectSearch = (): UseProjectSearchReturn => { const [currentFilter, setCurrentFilter] = useState( - {} + { + category: "all", + status: "all", + workflow: "all", + position: "all", + sortBy: "latest", + } ); const { data: totalCount = 0, isLoading: isCountLoading, isError: isCountError, - } = useGetFilteredProjectsCount(currentFilter); + } = useProjectsCount(currentFilter); const { currentPage, totalPages, setPage, goToReset } = usePaginationWithState({ @@ -47,15 +53,18 @@ const useProjectListPage = (): UseProjectListPageReturn => { data: projects = [], isLoading: isProjectsLoading, isError: isProjectsError, - } = useGetFilteredProjectsByPage(currentFilter, currentPage, ITEMS_PER_PAGE); + } = useProjectsByPage(currentFilter, currentPage, ITEMS_PER_PAGE); const isLoading = isProjectsLoading || isCountLoading; const isError = isProjectsError || isCountError; - const handleSearch = (filter: ProjectSearchFilterOption): void => { - setCurrentFilter(filter); - goToReset(); - }; + const handleSearch = useCallback( + (filter: ProjectSearchFilterOption): void => { + setCurrentFilter(filter); + goToReset(); + }, + [goToReset] + ); const handlePageChange = (page: number): void => { if (page === currentPage || isLoading) return; @@ -78,4 +87,4 @@ const useProjectListPage = (): UseProjectListPageReturn => { }; }; -export default useProjectListPage; +export default useProjectSearch; diff --git a/src/entities/search/hooks/useSearchHistory.ts b/src/entities/search/hooks/useSearchHistory.ts new file mode 100644 index 0000000..b5ef075 --- /dev/null +++ b/src/entities/search/hooks/useSearchHistory.ts @@ -0,0 +1,65 @@ +import { useCallback } from "react"; + +import { + useSearchHistory as useSearchHistoryStore, + useSearchHistoryActions, + useIsHistoryEnabled, +} from "@shared/stores/searchStore"; + +interface UseSearchHistoryReturn { + searchHistory: string[]; + isHistoryEnabled: boolean; + handleRemoveHistoryItem: (historyItem: string, e: React.MouseEvent) => void; + handleClearAllHistory: () => void; + handleToggleHistory: () => void; + handleHistoryItemClick: ( + historyItem: string, + onItemClick: (item: string) => void, + onClose: () => void + ) => void; +} + +export const useSearchHistory = (): UseSearchHistoryReturn => { + const searchHistory = useSearchHistoryStore(); + const isHistoryEnabled = useIsHistoryEnabled(); + const { clearHistory, removeFromHistory, toggleHistoryEnabled } = + useSearchHistoryActions(); + + const handleRemoveHistoryItem = useCallback( + (historyItem: string, e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + removeFromHistory(historyItem); + }, + [removeFromHistory] + ); + + const handleClearAllHistory = useCallback(() => { + clearHistory(); + }, [clearHistory]); + + const handleToggleHistory = useCallback(() => { + toggleHistoryEnabled(); + }, [toggleHistoryEnabled]); + + const handleHistoryItemClick = useCallback( + ( + historyItem: string, + onItemClick: (item: string) => void, + onClose: () => void + ) => { + onItemClick(historyItem); + onClose(); + }, + [] + ); + + return { + searchHistory, + isHistoryEnabled, + handleRemoveHistoryItem, + handleClearAllHistory, + handleToggleHistory, + handleHistoryItemClick, + }; +}; diff --git a/src/entities/search/hooks/useSearchInput.ts b/src/entities/search/hooks/useSearchInput.ts new file mode 100644 index 0000000..13a5247 --- /dev/null +++ b/src/entities/search/hooks/useSearchInput.ts @@ -0,0 +1,114 @@ +import { useCallback, useState, useRef } from "react"; + +import { + useSearchTitle, + useSearchTitleActions, + useSearchHistoryActions, +} from "@shared/stores/searchStore"; + +interface UseSearchInputReturn { + title: string; + isHistoryOpen: boolean; + isFocused: boolean; + inputRef: React.RefObject; + containerRef: React.RefObject; + showHistory: boolean; + handleKeyDown: (e: React.KeyboardEvent) => void; + handleFocus: () => void; + handleBlur: () => void; + handleHistoryItemClick: (historyItem: string) => void; + handleHistoryClose: () => void; + handleClickAway: (event: Event | React.SyntheticEvent) => void; + updateTitle: (title: string) => void; +} + +interface UseSearchInputProps { + onEnterPress?: () => void; +} + +export const useSearchInput = ({ + onEnterPress, +}: UseSearchInputProps): UseSearchInputReturn => { + const title = useSearchTitle(); + const { updateTitle } = useSearchTitleActions(); + const { addToHistory } = useSearchHistoryActions(); + + const [isHistoryOpen, setIsHistoryOpen] = useState(false); + const [isFocused, setIsFocused] = useState(false); + const inputRef = useRef(null); + const containerRef = useRef(null); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && onEnterPress) { + e.preventDefault(); + if (title.trim()) { + addToHistory(title); + } + onEnterPress(); + setIsHistoryOpen(false); + } else if (e.key === "Escape") { + setIsHistoryOpen(false); + inputRef.current?.blur(); + } + }, + [onEnterPress, title, addToHistory] + ); + + const handleFocus = useCallback(() => { + setIsFocused(true); + setIsHistoryOpen(true); + }, []); + + const handleBlur = useCallback(() => { + setTimeout(() => { + setIsFocused(false); + }, 150); + }, []); + + const handleHistoryItemClick = useCallback( + (historyItem: string) => { + updateTitle(historyItem); + inputRef.current?.focus(); + }, + [updateTitle] + ); + + const handleHistoryClose = useCallback(() => { + setIsHistoryOpen(false); + }, []); + + const handleClickAway = useCallback((event: Event | React.SyntheticEvent) => { + const target = event.target as Element; + + const isInsideDropdown = + target?.closest("[data-history-dropdown]") || + target?.hasAttribute("data-history-dropdown") || + target?.parentElement?.closest("[data-history-dropdown]") || + containerRef.current?.contains(target as Node); + + if (isInsideDropdown) { + return; + } + + setIsHistoryOpen(false); + }, []); + + const showHistory = isHistoryOpen && isFocused; + + return { + title, + isHistoryOpen, + isFocused, + inputRef, + containerRef, + showHistory, + handleKeyDown, + handleFocus, + handleBlur, + handleHistoryItemClick, + handleHistoryClose, + handleClickAway, + updateTitle, + }; +}; diff --git a/src/entities/search/model/searchOptions.ts b/src/entities/search/model/searchConstants.ts similarity index 100% rename from src/entities/search/model/searchOptions.ts rename to src/entities/search/model/searchConstants.ts diff --git a/src/entities/search/model/selectFieldConfigs.ts b/src/entities/search/model/searchFormConfig.ts similarity index 97% rename from src/entities/search/model/selectFieldConfigs.ts rename to src/entities/search/model/searchFormConfig.ts index d0045fb..b2836c4 100644 --- a/src/entities/search/model/selectFieldConfigs.ts +++ b/src/entities/search/model/searchFormConfig.ts @@ -3,7 +3,7 @@ import { POSITION_OPTIONS, SEARCH_FORM_LABELS, SORT_OPTIONS, -} from "@entities/search/model/searchOptions"; +} from "@entities/search/model/searchConstants"; import { ProjectCategory, diff --git a/src/entities/search/model/searchFilterBuilder.ts b/src/entities/search/model/searchQueryBuilder.ts similarity index 88% rename from src/entities/search/model/searchFilterBuilder.ts rename to src/entities/search/model/searchQueryBuilder.ts index 7d1b720..c5d52f1 100644 --- a/src/entities/search/model/searchFilterBuilder.ts +++ b/src/entities/search/model/searchQueryBuilder.ts @@ -10,8 +10,6 @@ import { type DocumentData, } from "firebase/firestore"; -import type { ProjectSearchFilterOption, SortBy } from "@entities/search/types"; - import { db } from "@shared/firebase/firebase"; import type { FilterBuilder } from "@shared/types/firebase"; import type { @@ -19,9 +17,10 @@ import type { RecruitmentStatus, Workflow, } from "@shared/types/project"; +import type { ProjectSearchFilterOption, SortBy } from "@shared/types/search"; import type { UserRole } from "@shared/types/user"; -export class SearchFilterBuilder implements FilterBuilder { +export class SearchQueryBuilder implements FilterBuilder { private filter: ProjectSearchFilterOption; private baseQuery: Query; private limitValue?: number; @@ -87,15 +86,6 @@ export class SearchFilterBuilder implements FilterBuilder { build(): Query { let builtQuery: Query = this.baseQuery; - if (this.filter.title) { - const titleLower = this.filter.title.toLowerCase(); - builtQuery = query( - builtQuery, - where("title", ">=", titleLower), - where("title", "<", titleLower + "\uf8ff") - ); - } - if (this.filter.category) { builtQuery = query( builtQuery, diff --git a/src/entities/search/queries/useGetFilteredProjectLists.ts b/src/entities/search/queries/useGetFilteredProjectLists.ts deleted file mode 100644 index 30f1691..0000000 --- a/src/entities/search/queries/useGetFilteredProjectLists.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { useQuery, type UseQueryResult } from "@tanstack/react-query"; -import type { QueryDocumentSnapshot, DocumentData } from "firebase/firestore"; - -import getFilteredProjectLists, { - getFilteredProjectListsSimple, - getFilteredProjectCount, - getFilteredProjectsByPage, -} from "@entities/search/api/getFilteredProjectLists"; -import type { ProjectSearchFilterOption } from "@entities/search/types"; - -import type { ProjectListRes } from "@shared/types/project"; - -interface PaginatedSearchOptions { - filter: ProjectSearchFilterOption; - cursor?: QueryDocumentSnapshot | null; - pageSize?: number; -} - -interface PaginatedSearchResult { - projects: ProjectListRes[]; - lastVisible: QueryDocumentSnapshot | null; - hasMore: boolean; -} - -const useGetFilteredProjectLists = ( - filter: ProjectSearchFilterOption, - enabled: boolean = true -): UseQueryResult => { - return useQuery({ - queryKey: ["filteredProjects", filter], - queryFn: () => getFilteredProjectListsSimple("projects", filter), - enabled, - staleTime: 5 * 60 * 1000, - gcTime: 10 * 60 * 1000, - placeholderData: (previousData) => previousData, - }); -}; - -export const useGetFilteredProjectListsWithPagination = ( - options: PaginatedSearchOptions, - enabled: boolean = true -): UseQueryResult => { - return useQuery({ - queryKey: [ - "filteredProjectsPaginated", - options.filter, - options.cursor, - options.pageSize, - ], - queryFn: () => - getFilteredProjectLists("projects", options.filter, { - cursor: options.cursor, - pageSize: options.pageSize, - }), - enabled, - staleTime: 5 * 60 * 1000, - gcTime: 10 * 60 * 1000, - placeholderData: (previousData) => previousData, - }); -}; - -export const useGetFilteredProjectsCount = ( - filter: ProjectSearchFilterOption, - enabled: boolean = true -): UseQueryResult => { - return useQuery({ - queryKey: ["filteredProjectsCount", filter], - queryFn: () => getFilteredProjectCount("projects", filter), - enabled, - staleTime: 10 * 60 * 1000, - gcTime: 15 * 60 * 1000, - placeholderData: (previousData) => previousData, - }); -}; - -export const useGetFilteredProjectsByPage = ( - filter: ProjectSearchFilterOption, - page: number, - pageSize: number = 6, - enabled: boolean = true -): UseQueryResult => { - return useQuery({ - queryKey: ["filteredProjectsByPage", filter, page, pageSize], - queryFn: () => - getFilteredProjectsByPage("projects", filter, page, pageSize), - enabled, - staleTime: 3 * 60 * 1000, - gcTime: 10 * 60 * 1000, - refetchOnWindowFocus: false, - retry: 1, - placeholderData: (previousData) => previousData, - }); -}; - -export default useGetFilteredProjectLists; diff --git a/src/entities/search/queries/useProjectSearchQueries.ts b/src/entities/search/queries/useProjectSearchQueries.ts new file mode 100644 index 0000000..030d4f0 --- /dev/null +++ b/src/entities/search/queries/useProjectSearchQueries.ts @@ -0,0 +1,33 @@ +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; + +import { + getProjectsCount, + getProjectsByPage, +} from "@entities/search/api/projectSearchApi"; + +import type { ProjectListRes } from "@shared/types/project"; +import type { ProjectSearchFilterOption } from "@shared/types/search"; + +const STALE_TIME_MINUTES = 5; + +export const useProjectsCount = ( + filter: ProjectSearchFilterOption +): UseQueryResult => { + return useQuery({ + queryKey: ["projects", "count", filter], + queryFn: () => getProjectsCount("projects", filter), + staleTime: 1000 * 60 * STALE_TIME_MINUTES, + }); +}; + +export const useProjectsByPage = ( + filter: ProjectSearchFilterOption, + page: number, + pageSize: number +): UseQueryResult => { + return useQuery({ + queryKey: ["projects", "page", filter, page, pageSize], + queryFn: () => getProjectsByPage("projects", filter, page, pageSize), + staleTime: 1000 * 60 * STALE_TIME_MINUTES, + }); +}; diff --git a/src/entities/search/ui/ProjectSearchForm.tsx b/src/entities/search/ui/ProjectSearchForm.tsx deleted file mode 100644 index d4e676b..0000000 --- a/src/entities/search/ui/ProjectSearchForm.tsx +++ /dev/null @@ -1,369 +0,0 @@ -import FilterListIcon from "@mui/icons-material/FilterList"; -import Search from "@mui/icons-material/Search"; -import TuneIcon from "@mui/icons-material/Tune"; -import { - Box, - Button, - TextField, - Paper, - alpha, - Typography, - Chip, - styled, - useMediaQuery, - useTheme, - Divider, -} from "@mui/material"; -import type { JSX } from "react"; - -import useFilteredProjects from "@entities/search/hooks/useFilteredProjects"; -import { SELECT_FIELD_CONFIGS } from "@entities/search/model/selectFieldConfigs"; -import type { ProjectSearchFilterOption } from "@entities/search/types"; -import ProjectSearchSelectBox from "@entities/search/ui/project-search-input/ProjectSearchSelectBox"; - -interface ProjectSearchFormProps { - onSearch: (filter: ProjectSearchFilterOption) => void; - isLoading?: boolean; -} - -const ProjectSearchForm = ({ - onSearch, - isLoading = false, -}: ProjectSearchFormProps): JSX.Element => { - const theme = useTheme(); - const isMobile = useMediaQuery(theme.breakpoints.down("sm")); - - const { - title, - category, - position, - status, - workflow, - sortBy, - updateTitle, - updateCategory, - updatePosition, - updateStatus, - updateWorkflow, - updateSortBy, - resetFilters, - getActiveFiltersCount, - getCleanFilter, - } = useFilteredProjects(); - - const activeFiltersCount = getActiveFiltersCount(); - - return ( - - - - - - 프로젝트 찾기 - - - 원하는 조건으로 프로젝트를 검색하고 필터링하세요 - - - - - - {activeFiltersCount > 0 && ( - } - label={`${activeFiltersCount}개 활성 필터`} - size="medium" - /> - )} - } - onClick={resetFilters} - disabled={isLoading} - > - 필터 초기화 - - - - - - - updateTitle(e.target.value)} - variant="outlined" - InputProps={{ - startAdornment: , - }} - /> - - - - - - 상세 필터 - - - - - updateCategory(value as any)} - /> - - updatePosition(value as any)} - /> - - updateStatus(value as any)} - /> - - updateWorkflow(value as any)} - /> - - updateSortBy(value as any)} - /> - - - - - } - onClick={() => onSearch(getCleanFilter())} - disabled={isLoading} - > - {isLoading ? "검색 중..." : "프로젝트 검색"} - - - - ); -}; - -export default ProjectSearchForm; - -const StyledContainer = styled(Paper)(({ theme }) => ({ - width: "100%", - borderRadius: theme.spacing(3), - overflow: "hidden", - marginBottom: theme.spacing(4), - backgroundColor: theme.palette.background.paper, - border: `1px solid ${alpha(theme.palette.primary.main, 0.08)}`, - boxShadow: `0 8px 32px ${alpha(theme.palette.common.black, 0.04)}`, -})); - -const HeaderSection = styled(Box)(({ theme }) => ({ - display: "flex", - justifyContent: "space-between", - alignItems: "flex-start", - padding: theme.spacing(4), - backgroundColor: theme.palette.background.paper, - - [theme.breakpoints.down("md")]: { - flexDirection: "column", - gap: theme.spacing(3), - alignItems: "stretch", - }, -})); - -const TitleArea = styled(Box)(() => ({ - display: "flex", - alignItems: "center", -})); - -const TextContainer = styled(Box)(() => ({ - display: "flex", - flexDirection: "column", - gap: 4, -})); - -const MainTitle = styled(Typography)(({ theme }) => ({ - fontWeight: 800, - color: theme.palette.text.primary, - letterSpacing: "-0.02em", -})); - -const SubTitle = styled(Typography)(({ theme }) => ({ - color: theme.palette.text.secondary, - fontWeight: 500, -})); - -const StatusArea = styled(Box)(({ theme }) => ({ - display: "flex", - alignItems: "center", - gap: theme.spacing(2), - - [theme.breakpoints.down("md")]: { - justifyContent: "space-between", - }, -})); - -const ActiveFiltersChip = styled(Chip)(({ theme }) => ({ - fontWeight: 600, - fontSize: "0.875rem", - height: 40, - backgroundColor: alpha(theme.palette.primary.main, 0.12), - color: theme.palette.primary.main, - border: `1px solid ${alpha(theme.palette.primary.main, 0.2)}`, - - "& .MuiChip-icon": { - color: theme.palette.primary.main, - }, -})); - -const ResetButton = styled(Button)(({ theme }) => ({ - height: 40, - fontWeight: 600, - borderRadius: theme.spacing(1.5), - borderColor: alpha(theme.palette.text.secondary, 0.3), - color: theme.palette.text.secondary, - - "&:hover": { - borderColor: theme.palette.primary.main, - backgroundColor: alpha(theme.palette.primary.main, 0.04), - color: theme.palette.primary.main, - }, -})); - -const SearchSection = styled(Box)(({ theme }) => ({ - padding: `${theme.spacing(2)} ${theme.spacing(4)}`, - backgroundColor: theme.palette.background.paper, -})); - -const SearchContainer = styled(Box)(() => ({ - width: "100%", -})); - -const StyledTextField = styled(TextField)(({ theme }) => ({ - "& .MuiOutlinedInput-root": { - borderRadius: theme.spacing(2), - fontSize: "1.6rem", - backgroundColor: theme.palette.background.paper, - transition: "all 0.2s ease-in-out", - padding: theme.spacing(0.5, 1.5), - - "&:hover": { - boxShadow: `0 4px 20px ${alpha(theme.palette.primary.main, 0.08)}`, - - "& .MuiOutlinedInput-notchedOutline": { - borderColor: alpha(theme.palette.primary.main, 0.4), - }, - }, - - "&.Mui-focused": { - boxShadow: `0 4px 24px ${alpha(theme.palette.primary.main, 0.12)}`, - - "& .MuiOutlinedInput-notchedOutline": { - borderColor: theme.palette.primary.main, - borderWidth: 2, - }, - }, - }, - - "& .MuiInputLabel-root": { - fontWeight: 700, - fontSize: "1.5rem", - }, - - "& .MuiOutlinedInput-input": { - fontSize: "1.5rem", - padding: theme.spacing(2.5), - fontWeight: 500, - }, -})); - -const StyledSearchIcon = styled(Search)(({ theme }) => ({ - color: theme.palette.text.secondary, - marginRight: theme.spacing(1.5), - fontSize: "1.5rem", -})); - -const FiltersSection = styled(Box)(({ theme }) => ({ - padding: `0 ${theme.spacing(4)} ${theme.spacing(3)}`, - backgroundColor: theme.palette.background.paper, -})); - -const SectionHeader = styled(Box)(({ theme }) => ({ - display: "flex", - alignItems: "center", - marginBottom: theme.spacing(3), - gap: theme.spacing(2), -})); - -const SectionTitle = styled(Typography)(({ theme }) => ({ - fontWeight: 700, - color: theme.palette.text.primary, - flexShrink: 0, -})); - -const SectionDivider = styled(Divider)(({ theme }) => ({ - flex: 1, - backgroundColor: alpha(theme.palette.primary.main, 0.1), -})); - -const FiltersGrid = styled(Box)(({ theme }) => ({ - display: "grid", - gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))", - gap: theme.spacing(3), - - [theme.breakpoints.up("md")]: { - gridTemplateColumns: "repeat(3, 1fr)", - }, - - [theme.breakpoints.up("lg")]: { - gridTemplateColumns: "repeat(5, 1fr)", - }, -})); - -const ActionSection = styled(Box)(({ theme }) => ({ - padding: theme.spacing(4), - paddingTop: theme.spacing(2), - display: "flex", - justifyContent: "center", - backgroundColor: theme.palette.background.paper, -})); - -const SearchButton = styled(Button)(({ theme }) => ({ - minWidth: 240, - height: 56, - fontSize: "1.1rem", - fontWeight: 700, - borderRadius: theme.spacing(2), - background: `linear-gradient(135deg, ${theme.palette.primary.main} 0%, ${theme.palette.primary.dark} 100%)`, - boxShadow: `0 8px 24px ${alpha(theme.palette.primary.main, 0.3)}`, - transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)", - textTransform: "none", - - "&:hover": { - transform: "translateY(-2px)", - boxShadow: `0 12px 32px ${alpha(theme.palette.primary.main, 0.4)}`, - background: `linear-gradient(135deg, ${theme.palette.primary.dark} 0%, ${theme.palette.primary.main} 100%)`, - }, - - "&:active": { - transform: "translateY(0px)", - }, - - "&:disabled": { - transform: "none", - background: theme.palette.action.disabledBackground, - boxShadow: "none", - }, -})); diff --git a/src/entities/search/ui/SearchActions.tsx b/src/entities/search/ui/SearchActions.tsx new file mode 100644 index 0000000..6023945 --- /dev/null +++ b/src/entities/search/ui/SearchActions.tsx @@ -0,0 +1,131 @@ +import { Button, Chip, alpha, styled } from "@mui/material"; +import { memo } from "react"; +import type { JSX } from "react"; + +import { + FilterListIcon, + SearchIcon as Search, + TuneIcon, +} from "@shared/ui/icons/CommonIcons"; + +export const MemoizedActiveFiltersChip = memo( + ({ count }: { count: number }): JSX.Element => ( + } + label={`${count}개 활성 필터`} + size="medium" + /> + ) +); + +export const MemoizedResetButton = memo( + ({ + onClick, + disabled, + }: { + onClick: () => void; + disabled: boolean; + }): JSX.Element => ( + } + onClick={onClick} + disabled={disabled} + > + 필터 초기화 + + ) +); + +export const MemoizedSearchButton = memo( + ({ + isLoading, + isMobile, + }: { + isLoading: boolean; + isMobile: boolean; + }): JSX.Element => ( + } + disabled={isLoading} + > + {isLoading ? "검색 중..." : "프로젝트 검색"} + + ) +); + +const ActiveFiltersChip = styled(Chip)(({ theme }) => ({ + fontWeight: 600, + fontSize: "1.4rem", + height: 40, + backgroundColor: alpha(theme.palette.primary.main, 0.12), + color: theme.palette.primary.main, + border: `1px solid ${alpha(theme.palette.primary.main, 0.2)}`, + + "& .MuiChip-icon": { + color: theme.palette.primary.main, + }, + + [theme.breakpoints.down("sm")]: { + fontSize: "1.2rem", + }, +})); + +const ResetButton = styled(Button)(({ theme }) => ({ + height: 40, + fontWeight: 600, + borderRadius: theme.spacing(1.5), + borderColor: alpha(theme.palette.text.secondary, 0.3), + color: theme.palette.text.secondary, + + "&:hover": { + borderColor: theme.palette.primary.main, + backgroundColor: alpha(theme.palette.primary.main, 0.04), + color: theme.palette.primary.main, + }, + + [theme.breakpoints.down("sm")]: { + height: 36, + fontSize: "1.2rem", + marginLeft: "auto", + }, +})); + +const SearchButton = styled(Button)(({ theme }) => ({ + minWidth: 240, + height: 56, + fontSize: "1.1rem", + fontWeight: 700, + borderRadius: theme.spacing(2), + background: `linear-gradient(135deg, ${theme.palette.primary.main} 0%, ${theme.palette.primary.dark} 100%)`, + boxShadow: `0 8px 24px ${alpha(theme.palette.primary.main, 0.3)}`, + transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)", + textTransform: "none", + + [theme.breakpoints.down("sm")]: { + minWidth: "auto", + height: 52, + fontSize: "1.2rem", + padding: theme.spacing(1.5, 2), + }, + + "&:hover": { + transform: "translateY(-2px)", + boxShadow: `0 12px 32px ${alpha(theme.palette.primary.main, 0.4)}`, + background: `linear-gradient(135deg, ${theme.palette.primary.dark} 0%, ${theme.palette.primary.main} 100%)`, + }, + + "&:active": { + transform: "translateY(0px)", + }, + + "&:disabled": { + transform: "none", + background: theme.palette.action.disabledBackground, + boxShadow: "none", + }, +})); diff --git a/src/entities/search/ui/SearchFilters.tsx b/src/entities/search/ui/SearchFilters.tsx new file mode 100644 index 0000000..fca2ec8 --- /dev/null +++ b/src/entities/search/ui/SearchFilters.tsx @@ -0,0 +1,106 @@ +import { Box, styled } from "@mui/material"; +import { memo } from "react"; +import type { JSX } from "react"; + +import { SELECT_FIELD_CONFIGS } from "@entities/search/model/searchFormConfig"; +import SelectBox from "@entities/search/ui/SelectBox"; + +import { + useSearchCategory, + useSearchPosition, + useSearchStatus, + useSearchWorkflow, + useSearchSortBy, + useSearchFilterActions, +} from "@shared/stores/searchStore"; + +export const MemoizedCategoryFilter = memo((): JSX.Element => { + const category = useSearchCategory(); + const { updateCategory } = useSearchFilterActions(); + + return ( + void} + /> + ); +}); + +export const MemoizedPositionFilter = memo((): JSX.Element => { + const position = useSearchPosition(); + const { updatePosition } = useSearchFilterActions(); + + return ( + void} + /> + ); +}); + +export const MemoizedStatusFilter = memo((): JSX.Element => { + const status = useSearchStatus(); + const { updateStatus } = useSearchFilterActions(); + + return ( + void} + /> + ); +}); + +export const MemoizedWorkflowFilter = memo((): JSX.Element => { + const workflow = useSearchWorkflow(); + const { updateWorkflow } = useSearchFilterActions(); + + return ( + void} + /> + ); +}); + +export const MemoizedSortByFilter = memo((): JSX.Element => { + const sortBy = useSearchSortBy(); + const { updateSortBy } = useSearchFilterActions(); + + return ( + void} + /> + ); +}); + +export const MemoizedFiltersGrid = memo( + (): JSX.Element => ( + + + + + + + + ) +); + +const FiltersGrid = styled(Box)(({ theme }) => ({ + display: "grid", + gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))", + gap: theme.spacing(3), + + [theme.breakpoints.up("md")]: { + gridTemplateColumns: "repeat(3, 1fr)", + }, + + [theme.breakpoints.up("lg")]: { + gridTemplateColumns: "repeat(5, 1fr)", + }, +})); diff --git a/src/entities/search/ui/SearchForm.tsx b/src/entities/search/ui/SearchForm.tsx new file mode 100644 index 0000000..63372d2 --- /dev/null +++ b/src/entities/search/ui/SearchForm.tsx @@ -0,0 +1,163 @@ +import { + Box, + Paper, + alpha, + styled, + useMediaQuery, + useTheme, +} from "@mui/material"; +import type { JSX } from "react"; +import { memo, useCallback } from "react"; + +import { + MemoizedActiveFiltersChip, + MemoizedResetButton, + MemoizedSearchButton, +} from "@entities/search/ui/SearchActions"; +import { MemoizedFiltersGrid } from "@entities/search/ui/SearchFilters"; +import SearchInput from "@entities/search/ui/SearchInput"; +import { + MemoizedTitleSection, + MemoizedSectionHeader, +} from "@entities/search/ui/SearchLabels"; + +import { + useSearchUtils, + useActiveFiltersCount, + useSearchTitle, + useSearchHistoryActions, +} from "@shared/stores/searchStore"; +import type { ProjectSearchFilterOption } from "@shared/types/search"; + +interface SearchFormProps { + onSearch: (filter: ProjectSearchFilterOption) => void; + isLoading?: boolean; +} + +const SearchForm = memo( + ({ onSearch, isLoading = false }: SearchFormProps): JSX.Element => { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("sm")); + + const title = useSearchTitle(); + + const { resetFilters, getFilterStatus } = useSearchUtils(); + const { addToHistory } = useSearchHistoryActions(); + + const activeFiltersCount = useActiveFiltersCount(); + + const handleSearch = useCallback((): void => { + const searchFilter = getFilterStatus(); + + if (title.trim()) { + addToHistory(title); + } + + onSearch(searchFilter); + }, [getFilterStatus, title, addToHistory, onSearch]); + + return ( + { + e.preventDefault(); + handleSearch(); + }} + > + + + + + 0}> + {activeFiltersCount > 0 && ( + + )} + + + + + + + + + + + + + + + + + + + + + + ); + } +); + +export default SearchForm; + +const StyledForm = styled("form")(() => ({ + width: "100%", +})); + +const StyledContainer = styled(Paper)(({ theme }) => ({ + width: "100%", + borderRadius: theme.spacing(3), + marginBottom: theme.spacing(4), + backgroundColor: theme.palette.background.paper, + border: `1px solid ${alpha(theme.palette.primary.main, 0.08)}`, + boxShadow: `0 8px 32px ${alpha(theme.palette.common.black, 0.04)}`, +})); + +const HeaderSection = styled(Box)(({ theme }) => ({ + display: "flex", + justifyContent: "space-between", + alignItems: "flex-start", + padding: theme.spacing(4), + backgroundColor: theme.palette.background.paper, + + [theme.breakpoints.down("md")]: { + flexDirection: "column", + gap: theme.spacing(3), + alignItems: "stretch", + }, +})); + +const StatusArea = styled(Box, { + shouldForwardProp: (prop) => prop !== "$hasActiveFilters", +})<{ $hasActiveFilters: boolean }>(({ theme, $hasActiveFilters }) => ({ + display: "flex", + alignItems: "center", + gap: theme.spacing(2), + + [theme.breakpoints.down("md")]: { + justifyContent: $hasActiveFilters ? "space-between" : "flex-end", + }, +})); + +const SearchSection = styled(Box)(({ theme }) => ({ + padding: `${theme.spacing(2)} ${theme.spacing(4)}`, + backgroundColor: theme.palette.background.paper, +})); + +const SearchContainer = styled(Box)(() => ({ + width: "100%", +})); + +const FiltersSection = styled(Box)(({ theme }) => ({ + padding: `0 ${theme.spacing(4)} ${theme.spacing(3)}`, + backgroundColor: theme.palette.background.paper, +})); + +const ActionSection = styled(Box)(({ theme }) => ({ + padding: theme.spacing(4), + paddingTop: theme.spacing(2), + display: "flex", + justifyContent: "center", + backgroundColor: theme.palette.background.paper, +})); diff --git a/src/entities/search/ui/SearchInput.tsx b/src/entities/search/ui/SearchInput.tsx new file mode 100644 index 0000000..fc6c60c --- /dev/null +++ b/src/entities/search/ui/SearchInput.tsx @@ -0,0 +1,91 @@ +import { TextField, Box, ClickAwayListener } from "@mui/material"; +import { styled } from "@mui/material/styles"; +import { memo } from "react"; +import type { JSX } from "react"; + +import { useSearchInput } from "@entities/search/hooks/useSearchInput"; +import SearchInputHistory from "@entities/search/ui/SearchInputHistory"; + +import { SearchIcon } from "@shared/ui/icons/CommonIcons"; + +interface SearchInputProps { + onEnterPress?: () => void; +} + +const SearchInput = memo(({ onEnterPress }: SearchInputProps): JSX.Element => { + const { + title, + inputRef, + containerRef, + showHistory, + handleKeyDown, + handleFocus, + handleBlur, + handleHistoryItemClick, + handleHistoryClose, + handleClickAway, + updateTitle, + } = useSearchInput({ onEnterPress }); + + return ( + + + updateTitle(e.target.value)} + onKeyDown={handleKeyDown} + onFocus={handleFocus} + onBlur={handleBlur} + variant="outlined" + autoComplete="off" + showHistory={showHistory} + InputProps={{ + startAdornment: , + }} + /> + + {showHistory && ( + + )} + + + ); +}); + +const SearchContainer = styled(Box)(() => ({ + position: "relative", + width: "100%", +})); + +const StyledSearchIcon = styled(SearchIcon)(({ theme }) => ({ + color: theme.palette.text.secondary, + marginRight: theme.spacing(1.5), + fontSize: "1.5rem", +})); + +const StyledTextField = styled(TextField, { + shouldForwardProp: (prop) => prop !== "showHistory", +})<{ showHistory: boolean }>(({ theme, showHistory }) => ({ + "& .MuiOutlinedInput-root": { + borderRadius: theme.spacing(1.5), + backgroundColor: theme.palette.background.paper, + transition: "all 0.2s ease-in-out", + borderBottomLeftRadius: showHistory ? 0 : undefined, + borderBottomRightRadius: showHistory ? 0 : undefined, + "&:hover": { + boxShadow: `0 2px 8px ${theme.palette.action.hover}`, + }, + "&.Mui-focused": { + boxShadow: `0 4px 12px ${theme.palette.primary.main}25`, + }, + }, +})); + +export default SearchInput; diff --git a/src/entities/search/ui/SearchInputHistory.tsx b/src/entities/search/ui/SearchInputHistory.tsx new file mode 100644 index 0000000..b492e23 --- /dev/null +++ b/src/entities/search/ui/SearchInputHistory.tsx @@ -0,0 +1,344 @@ +import { + Paper, + List, + ListItem, + ListItemButton, + ListItemIcon, + ListItemText, + IconButton, + Typography, + Divider, + Box, + Switch, + FormControlLabel, + alpha, +} from "@mui/material"; +import { styled } from "@mui/material/styles"; +import { memo } from "react"; +import type { JSX } from "react"; + +import { useSearchHistory } from "@entities/search/hooks/useSearchHistory"; +import SearchInputHistoryToggle from "@entities/search/ui/SearchInputHistoryToggle"; + +import { + HistoryIcon as History, + HistoryToggleOffIcon as HistoryToggleOff, + ClearIcon as Clear, +} from "@shared/ui/icons/CommonIcons"; + +interface SearchInputHistoryProps { + onItemClick: (item: string) => void; + onClose: () => void; +} + +const HistoryDisabledMessage = (): JSX.Element => ( + + + + 검색 히스토리가 비활성화되어 있습니다 + + +); + +const HistoryEmptyMessage = (): JSX.Element => ( + + + + 검색 히스토리가 없습니다 + + +); + +interface HistoryListContentProps { + searchHistory: string[]; + onItemClick: (item: string) => void; + onClose: () => void; + handleHistoryItemClick: ( + historyItem: string, + onItemClick: (item: string) => void, + onClose: () => void + ) => void; + handleRemoveHistoryItem: (historyItem: string, e: React.MouseEvent) => void; +} + +const HistoryListContent = ({ + searchHistory, + onItemClick, + onClose, + handleHistoryItemClick, + handleRemoveHistoryItem, +}: HistoryListContentProps): JSX.Element => ( + <> + {searchHistory.map((historyItem, index) => ( + + + handleHistoryItemClick(historyItem, onItemClick, onClose) + } + > + + + + + handleRemoveHistoryItem(historyItem, e)} + onMouseDown={(e) => e.stopPropagation()} + data-history-dropdown + title="삭제" + > + + + + + ))} + +); +// 헤더 컴포넌트 +interface SearchHistoryHeaderProps { + isHistoryEnabled: boolean; + searchHistory: string[]; + handleToggleHistory: (event: React.ChangeEvent) => void; + handleClearAllHistory: () => void; +} + +const SearchHistoryHeader = ({ + isHistoryEnabled, + searchHistory, + handleToggleHistory, + handleClearAllHistory, +}: SearchHistoryHeaderProps): JSX.Element => ( + + + + + 검색 히스토리 + + + + + } + label="" + onMouseDown={(e: React.MouseEvent) => e.preventDefault()} + data-history-dropdown + /> + {isHistoryEnabled && searchHistory.length > 0 && ( + e.preventDefault()} + data-history-dropdown + > + 전체삭제 + + )} + + +); + +interface HistoryContentProps { + isHistoryEnabled: boolean; + searchHistory: string[]; + onItemClick: (item: string) => void; + onClose: () => void; + handleHistoryItemClick: ( + historyItem: string, + onItemClick: (item: string) => void, + onClose: () => void + ) => void; + handleRemoveHistoryItem: (historyItem: string, e: React.MouseEvent) => void; +} + +const HistoryContent = ({ + isHistoryEnabled, + searchHistory, + onItemClick, + onClose, + handleHistoryItemClick, + handleRemoveHistoryItem, +}: HistoryContentProps): JSX.Element => { + if (!isHistoryEnabled) { + return ; + } + + if (searchHistory.length === 0) { + return ; + } + + return ( + + ); +}; + +const SearchInputHistory = memo( + ({ + onItemClick: _onItemClick, + onClose: _onClose, + }: SearchInputHistoryProps): JSX.Element => { + const { + searchHistory, + isHistoryEnabled, + handleRemoveHistoryItem, + handleClearAllHistory, + handleToggleHistory, + handleHistoryItemClick, + } = useSearchHistory(); + + return ( + e.preventDefault()} + > + + + + + + + + + ); + } +); + +const HistoryDropdown = styled(Paper)(({ theme }) => ({ + position: "absolute", + top: "100%", + left: 0, + right: 0, + zIndex: 1300, + borderTopLeftRadius: 0, + borderTopRightRadius: 0, + borderBottomLeftRadius: theme.spacing(1.5), + borderBottomRightRadius: theme.spacing(1.5), + overflow: "hidden", + border: `1px solid ${alpha(theme.palette.divider, 0.12)}`, + borderTop: "none", + boxShadow: `0 8px 32px ${alpha(theme.palette.common.black, 0.12)}`, + backgroundColor: theme.palette.background.paper, +})); + +const HistoryHeader = styled(Box)(({ theme }) => ({ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + padding: theme.spacing(1.5, 2), + backgroundColor: alpha(theme.palette.primary.main, 0.04), +})); + +const HeaderContent = styled(Box)(({ theme }) => ({ + display: "flex", + alignItems: "center", + gap: theme.spacing(1), + color: theme.palette.text.secondary, +})); + +const HeaderActions = styled(Box)(({ theme }) => ({ + display: "flex", + alignItems: "center", + gap: theme.spacing(1), +})); + +const DisabledMessage = styled(Box)(({ theme }) => ({ + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + padding: theme.spacing(3, 2), + gap: theme.spacing(1), + textAlign: "center", +})); + +const EmptyMessage = styled(Box)(({ theme }) => ({ + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + padding: theme.spacing(3, 2), + gap: theme.spacing(1), + textAlign: "center", +})); + +const HistoryList = styled(List)(() => ({ + padding: 0, +})); + +const HistoryListItem = styled(ListItem)(({ theme }) => ({ + "&:hover": { + backgroundColor: alpha(theme.palette.primary.main, 0.04), + }, +})); + +const HistoryListItemButton = styled(ListItemButton)(({ theme }) => ({ + paddingLeft: theme.spacing(2), + paddingRight: theme.spacing(1), + minHeight: 48, + + "& .MuiListItemIcon-root": { + minWidth: 36, + color: theme.palette.text.secondary, + }, + + "&:hover": { + backgroundColor: "transparent", + }, +})); + +const StyledFormControlLabel = styled(FormControlLabel)(() => ({ + margin: 0, +})); + +const ClearAllButton = styled(Box)(({ theme }) => ({ + cursor: "pointer", + padding: "4px 8px", + borderRadius: theme.spacing(1), + "&:hover": { + backgroundColor: theme.palette.action.hover, + }, +})); + +const ClearAllText = styled(Typography)(({ theme }) => ({ + color: theme.palette.text.secondary, + fontSize: "0.75rem", + "&:hover": { + color: theme.palette.primary.main, + }, +})); + +const StyledDeleteButton = styled(IconButton)(({ theme }) => ({ + "&:hover": { + backgroundColor: theme.palette.action.hover, + }, +})); + +export default SearchInputHistory; diff --git a/src/entities/search/ui/SearchInputHistoryToggle.tsx b/src/entities/search/ui/SearchInputHistoryToggle.tsx new file mode 100644 index 0000000..c36d176 --- /dev/null +++ b/src/entities/search/ui/SearchInputHistoryToggle.tsx @@ -0,0 +1,18 @@ +import { History, HistoryToggleOff } from "@mui/icons-material"; +import type { JSX } from "react"; + +interface SearchInputHistoryToggleProps { + isHistoryEnabled: boolean; +} + +const SearchInputHistoryToggle = ({ + isHistoryEnabled, +}: SearchInputHistoryToggleProps): JSX.Element => { + return isHistoryEnabled ? ( + + ) : ( + + ); +}; + +export default SearchInputHistoryToggle; diff --git a/src/entities/search/ui/SearchLabels.tsx b/src/entities/search/ui/SearchLabels.tsx new file mode 100644 index 0000000..a5f1284 --- /dev/null +++ b/src/entities/search/ui/SearchLabels.tsx @@ -0,0 +1,65 @@ +import { Box, Typography, Divider, alpha, styled } from "@mui/material"; +import { memo } from "react"; +import type { JSX } from "react"; + +export const MemoizedTitleSection = memo( + ({ isMobile }: { isMobile: boolean }): JSX.Element => ( + + + 프로젝트 찾기 + + 원하는 조건으로 프로젝트를 검색하고 필터링하세요 + + + + ) +); + +export const MemoizedSectionHeader = memo( + (): JSX.Element => ( + + 상세 필터 + + + ) +); + +const TitleArea = styled(Box)(() => ({ + display: "flex", + alignItems: "center", +})); + +const TextContainer = styled(Box)(() => ({ + display: "flex", + flexDirection: "column", + gap: 4, +})); + +const MainTitle = styled(Typography)(({ theme }) => ({ + fontWeight: 800, + color: theme.palette.text.primary, + letterSpacing: "-0.02em", +})); + +const SubTitle = styled(Typography)(({ theme }) => ({ + color: theme.palette.text.secondary, + fontWeight: 500, +})); + +const SectionHeader = styled(Box)(({ theme }) => ({ + display: "flex", + alignItems: "center", + marginBottom: theme.spacing(3), + gap: theme.spacing(2), +})); + +const SectionTitle = styled(Typography)(({ theme }) => ({ + fontWeight: 700, + color: theme.palette.text.primary, + flexShrink: 0, +})); + +const SectionDivider = styled(Divider)(({ theme }) => ({ + flex: 1, + backgroundColor: alpha(theme.palette.primary.main, 0.1), +})); diff --git a/src/entities/search/ui/SearchListResultHandler.tsx b/src/entities/search/ui/SearchListResultHandler.tsx new file mode 100644 index 0000000..ea7df8f --- /dev/null +++ b/src/entities/search/ui/SearchListResultHandler.tsx @@ -0,0 +1,82 @@ +import { Box, Typography, styled } from "@mui/material"; +import type { JSX } from "react"; + +import SearchLoadingSpinner from "@entities/search/ui/SearchLoadingSpinner"; +import SearchPagination from "@entities/search/ui/SearchPagination"; + +interface SearchListResultHandlerProps { + isLoading: boolean; + isEmpty: boolean; + isError: boolean; + totalPages: number; + currentPage: number; + onPageChange: (page: number) => void; +} + +const SearchListResultHandler = ({ + isLoading, + isEmpty, + isError, + totalPages, + currentPage, + onPageChange, +}: SearchListResultHandlerProps): JSX.Element | null => { + if (isLoading) { + return ( + <> + + + + ); + } + + if (isEmpty) { + return ( + + 검색 조건에 맞는 프로젝트가 없습니다. 다른 조건으로 검색해보세요. + + ); + } + + if (isError) { + return ( + + + 프로젝트를 불러오는 중 오류가 발생했습니다. + + + ); + } + + return ( + + ); +}; + +const EmptyState = styled(Typography)(({ theme }) => ({ + textAlign: "center", + color: theme.palette.text.secondary, + padding: "4rem 0", + fontWeight: 500, +})); + +const ErrorContainer = styled(Box)(({ theme }) => ({ + display: "flex", + justifyContent: "center", + alignItems: "center", + padding: "4rem 0rem", + [theme.breakpoints.up("sm")]: { + padding: "6rem 2rem", + }, +})); + +export default SearchListResultHandler; diff --git a/src/entities/search/ui/SearchLoadingSpinner.tsx b/src/entities/search/ui/SearchLoadingSpinner.tsx new file mode 100644 index 0000000..2aba2b8 --- /dev/null +++ b/src/entities/search/ui/SearchLoadingSpinner.tsx @@ -0,0 +1,30 @@ +import { Box, styled } from "@mui/material"; +import type { JSX } from "react"; + +import LoadingSpinner from "@shared/ui/loading-spinner/LoadingSpinner"; + +const SearchLoadingSpinner = (): JSX.Element => { + return ( + + + + ); +}; + +export default SearchLoadingSpinner; + +const LoadingContainer = styled(Box)(({ theme }) => ({ + display: "flex", + flexDirection: "column", + justifyContent: "center", + alignItems: "center", + padding: "4rem 0rem", + minHeight: "400px", + [theme.breakpoints.up("sm")]: { + padding: "6rem 2rem", + }, +})); diff --git a/src/entities/search/ui/SearchPagination.tsx b/src/entities/search/ui/SearchPagination.tsx new file mode 100644 index 0000000..276a58d --- /dev/null +++ b/src/entities/search/ui/SearchPagination.tsx @@ -0,0 +1,35 @@ +import { Box } from "@mui/material"; +import type { JSX } from "react"; + +import Pagination from "@shared/ui/pagination/Pagination"; + +interface SearchPaginationProps { + totalPages: number; + currentPage: number; + onPageChange: (page: number) => void; + disabled?: boolean; +} + +const SearchPagination = ({ + totalPages, + currentPage, + onPageChange, + disabled = false, +}: SearchPaginationProps): JSX.Element | null => { + if (totalPages <= 0) { + return null; + } + + return ( + + + + ); +}; + +export default SearchPagination; diff --git a/src/entities/search/ui/project-search-input/ProjectSearchSelectBox.tsx b/src/entities/search/ui/SearchSelectBox.tsx similarity index 77% rename from src/entities/search/ui/project-search-input/ProjectSearchSelectBox.tsx rename to src/entities/search/ui/SearchSelectBox.tsx index f181c5f..5ceb238 100644 --- a/src/entities/search/ui/project-search-input/ProjectSearchSelectBox.tsx +++ b/src/entities/search/ui/SearchSelectBox.tsx @@ -2,16 +2,16 @@ import { Select, InputLabel, FormControl, MenuItem } from "@mui/material"; import type { JSX } from "react"; import { memo } from "react"; -import type { SelectFieldConfig } from "@entities/search/model/selectFieldConfigs"; +import type { SelectFieldConfig } from "@entities/search/model/searchFormConfig"; -interface ProjectSearchSelectBoxProps { +interface SearchSelectBoxProps { config: SelectFieldConfig; value: string; onChange: (value: string) => void; } -const ProjectSearchSelectBox = memo( - ({ config, value, onChange }: ProjectSearchSelectBoxProps): JSX.Element => { +const SearchSelectBox = memo( + ({ config, value, onChange }: SearchSelectBoxProps): JSX.Element => { return ( {config.label} @@ -31,4 +31,4 @@ const ProjectSearchSelectBox = memo( } ); -export default ProjectSearchSelectBox; +export default SearchSelectBox; diff --git a/src/entities/search/ui/SearchStatusField.tsx b/src/entities/search/ui/SearchStatusField.tsx new file mode 100644 index 0000000..d0d8827 --- /dev/null +++ b/src/entities/search/ui/SearchStatusField.tsx @@ -0,0 +1,45 @@ +import { FormControl, InputLabel, MenuItem, Select } from "@mui/material"; +import type { JSX } from "react"; +import { memo } from "react"; + +import { + DROPDOWN_DEFAULTS, + SEARCH_FORM_LABELS, +} from "@entities/search/model/searchConstants"; + +import { RecruitmentStatus } from "@shared/types/project"; + +interface SearchStatusFieldProps { + value: RecruitmentStatus | "all"; + onChange: (status: RecruitmentStatus | "all") => void; +} + +const SearchStatusField = memo( + ({ value, onChange }: SearchStatusFieldProps): JSX.Element => { + return ( + + {SEARCH_FORM_LABELS.STATUS} + + + ); + } +); + +export default SearchStatusField; diff --git a/src/entities/search/ui/SelectBox.tsx b/src/entities/search/ui/SelectBox.tsx new file mode 100644 index 0000000..c7cce3c --- /dev/null +++ b/src/entities/search/ui/SelectBox.tsx @@ -0,0 +1,21 @@ +import { memo } from "react"; +import type { JSX } from "react"; + +import type { SelectFieldConfig } from "@entities/search/model/searchFormConfig"; +import SearchSelectBox from "@entities/search/ui/SearchSelectBox"; + +interface SelectBoxProps { + config: SelectFieldConfig; + value: string; + onChange: (value: string) => void; +} + +const SelectBox = memo( + ({ config, value, onChange }: SelectBoxProps): JSX.Element => { + return ( + + ); + } +); + +export default SelectBox; diff --git a/src/entities/search/ui/project-search-input/StatusSelectField.tsx b/src/entities/search/ui/project-search-input/StatusSelectField.tsx deleted file mode 100644 index 2ac06e1..0000000 --- a/src/entities/search/ui/project-search-input/StatusSelectField.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { FormControl, InputLabel, MenuItem, Select } from "@mui/material"; -import type { JSX } from "react"; - -import { - DROPDOWN_DEFAULTS, - SEARCH_FORM_LABELS, -} from "@entities/search/model/searchOptions"; - -import { RecruitmentStatus } from "@shared/types/project"; - -interface StatusSelectFieldProps { - value: RecruitmentStatus | "all"; - onChange: (status: RecruitmentStatus | "all") => void; -} - -const StatusSelectField = ({ - value, - onChange, -}: StatusSelectFieldProps): JSX.Element => { - return ( - - {SEARCH_FORM_LABELS.STATUS} - - - ); -}; - -export default StatusSelectField; diff --git a/src/features/auth/ui/LoginButton.tsx b/src/features/auth/ui/LoginButton.tsx index 89ed306..ce40ae2 100644 --- a/src/features/auth/ui/LoginButton.tsx +++ b/src/features/auth/ui/LoginButton.tsx @@ -6,7 +6,7 @@ const LoginButton = (): JSX.Element => { const navigate = useNavigate(); return ( - ); diff --git a/src/features/auth/ui/LogoutButton.tsx b/src/features/auth/ui/LogoutButton.tsx index 8049c16..19d0e97 100644 --- a/src/features/auth/ui/LogoutButton.tsx +++ b/src/features/auth/ui/LogoutButton.tsx @@ -14,7 +14,7 @@ const LogoutButton = (): JSX.Element => { }; return ( - ); diff --git a/src/features/projects/ui/ProjectLike.tsx b/src/features/projects/ui/ProjectLike.tsx index d722906..02d2b6a 100644 --- a/src/features/projects/ui/ProjectLike.tsx +++ b/src/features/projects/ui/ProjectLike.tsx @@ -1,7 +1,4 @@ import type { JSX } from "@emotion/react/jsx-runtime"; -import FavoriteBorderIcon from "@mui/icons-material/FavoriteBorder"; -import FavoriteOutlinedIcon from "@mui/icons-material/FavoriteOutlined"; -import ShareIcon from "@mui/icons-material/Share"; import { Box, styled } from "@mui/material"; import { useParams } from "react-router-dom"; @@ -9,6 +6,11 @@ import useLike from "@features/projects/hook/useLike"; import { getStatusClassname } from "@shared/libs/utils/projectDetail"; import type { ProjectListRes } from "@shared/types/project"; +import { + FavoriteBorderIcon, + FavoriteOutlinedIcon, + ShareIcon, +} from "@shared/ui/icons/CommonIcons"; type ProjectLikeType = Pick; diff --git a/src/pages/project-list/ui/ProjectListPage.tsx b/src/pages/project-list/ui/ProjectListPage.tsx index f4c35cc..3c5b1d8 100644 --- a/src/pages/project-list/ui/ProjectListPage.tsx +++ b/src/pages/project-list/ui/ProjectListPage.tsx @@ -1,12 +1,12 @@ -import { Box, Typography, Container, styled } from "@mui/material"; +import { Box, Typography, Container, styled, keyframes } from "@mui/material"; import { type JSX } from "react"; import ProjectCard from "@entities/projects/ui/projects-card/ProjectCard"; -import useProjectListPage from "@entities/search/hooks/useProjectListPage"; -import ProjectSearchForm from "@entities/search/ui/ProjectSearchForm"; +import useProjectSearch from "@entities/search/hooks/useProjectSearch"; +import SearchForm from "@entities/search/ui/SearchForm"; +import SearchListResultHandler from "@entities/search/ui/SearchListResultHandler"; -import LoadingSpinner from "@shared/ui/loading-spinner/LoadingSpinner"; -import Pagination from "@shared/ui/pagination"; +import type { ProjectListRes } from "@shared/types/project"; const ProjectListPage = (): JSX.Element => { const { @@ -18,59 +18,43 @@ const ProjectListPage = (): JSX.Element => { isError, handleSearch, handlePageChange, - } = useProjectListPage(); + } = useProjectSearch(); + + const isEmpty = !isLoading && !isError && projects.length === 0; return ( - + - {isLoading && ( - - - - )} - - {!isLoading && ( - - - 📊 전체 프로젝트: 총 {totalCount}개{" "} - {totalPages > 1 && `(${currentPage}/${totalPages} 페이지)`} - - - {projects.length > 0 ? ( - <> - - {projects.map((project) => ( - - ))} - - - {totalPages > 1 && ( - - )} - - ) : ( - - 조건에 맞는 프로젝트가 없습니다. 다른 조건으로 검색해보세요. - - )} - - )} - - {isError && ( - - - 데이터를 불러오는 중 오류가 발생했습니다. 다시 시도해주세요. - - - )} + + + {isLoading ? "검색 중..." : `총 ${totalCount}개의 프로젝트가 있어요`} + + + + {projects.map((project: ProjectListRes, index: number) => ( + + + + ))} + + + + ); }; @@ -78,9 +62,12 @@ const ProjectListPage = (): JSX.Element => { export default ProjectListPage; const MainContainer = styled(Container)(({ theme }) => ({ + display: "flex", + flexDirection: "column", flexGrow: 1, minHeight: "100vh", backgroundColor: theme.palette.background.default, + paddingTop: "3rem", })); const SearchContainer = styled(Box)(({ theme }) => ({ @@ -124,38 +111,18 @@ const ProjectListContainer = styled(Box)(({ theme }) => ({ }, })); -const EmptyState = styled(Typography)(({ theme }) => ({ - textAlign: "center", - color: theme.palette.text.secondary, - padding: "4rem 0", - fontWeight: 500, -})); - -const LoadingContainer = styled(Box)(({ theme }) => ({ - display: "flex", - justifyContent: "center", - alignItems: "center", - padding: "4rem 0rem", - [theme.breakpoints.up("sm")]: { - padding: "6rem 2rem", - }, - [theme.breakpoints.up("md")]: { - padding: "8rem 2.4rem", - }, -})); - -const ErrorContainer = styled(Box)(({ theme }) => ({ - display: "flex", - justifyContent: "center", - alignItems: "center", - padding: "4rem 0rem", - [theme.breakpoints.up("sm")]: { - padding: "6rem 2rem", - }, -})); - -const ErrorText = styled(Typography)(({ theme }) => ({ - textAlign: "center", - color: theme.palette.error.main, - fontWeight: 500, +const fadeInUp = keyframes` + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +`; + +const AnimatedProjectCard = styled(Box)(() => ({ + animation: `${fadeInUp} 0.6s ease-out forwards`, + opacity: 0, })); diff --git a/src/shared/hooks/useLoadingCursor.ts b/src/shared/hooks/useLoadingCursor.ts new file mode 100644 index 0000000..18968f6 --- /dev/null +++ b/src/shared/hooks/useLoadingCursor.ts @@ -0,0 +1,18 @@ +import { useEffect } from "react"; +import { useLocation } from "react-router-dom"; + +export const useLoadingCursor = (): void => { + const location = useLocation(); + + useEffect(() => { + document.body.classList.add("page-loading"); + const timer = setTimeout(() => { + document.body.classList.remove("page-loading"); + }, 300); + + return () => { + clearTimeout(timer); + document.body.classList.remove("page-loading"); + }; + }, [location.pathname]); +}; diff --git a/src/shared/stores/searchStore.ts b/src/shared/stores/searchStore.ts new file mode 100644 index 0000000..bba33eb --- /dev/null +++ b/src/shared/stores/searchStore.ts @@ -0,0 +1,254 @@ +import { create } from "zustand"; +import { subscribeWithSelector } from "zustand/middleware"; +import { useShallow } from "zustand/react/shallow"; + +import { + RecruitmentStatus, + type ProjectCategory, + type Workflow, +} from "@shared/types/project"; +import type { ProjectSearchFilterOption, SortBy } from "@shared/types/search"; +import type { UserRole } from "@shared/types/user"; + +interface SearchState { + title: string; + category: ProjectCategory | "all"; + position: UserRole | "all"; + status: RecruitmentStatus | "all"; + workflow: Workflow | "all"; + sortBy: SortBy | "latest"; + searchHistory: string[]; + isHistoryEnabled: boolean; + + updateTitle: (title: string) => void; + updateCategory: (category: ProjectCategory | "all") => void; + updatePosition: (position: UserRole | "all") => void; + updateStatus: (status: RecruitmentStatus | "all") => void; + updateWorkflow: (workflow: Workflow | "all") => void; + updateSortBy: (sortBy: SortBy | "latest") => void; + resetFilters: () => void; + addToHistory: (searchTerm: string) => void; + clearHistory: () => void; + removeFromHistory: (searchTerm: string) => void; + toggleHistoryEnabled: () => void; + + getActiveFiltersCount: () => number; + getFilterStatus: () => ProjectSearchFilterOption; +} + +const getInitialSearchHistory = (): string[] | undefined => { + try { + const stored = localStorage.getItem("search-history"); + if (stored) { + return JSON.parse(stored); + } + } catch { + return []; + } +}; + +const getInitialHistoryEnabled = (): boolean => { + try { + const stored = localStorage.getItem("search-history-enabled"); + return stored ? JSON.parse(stored) : true; + } catch { + return true; + } +}; + +const saveHistoryEnabled = (enabled: boolean): void => { + try { + localStorage.setItem("search-history-enabled", JSON.stringify(enabled)); + } catch {} +}; + +const saveSearchHistory = (history: string[]): void => { + try { + localStorage.setItem("search-history", JSON.stringify(history)); + } catch { + return; + } +}; + +export const useSearchStore = create()( + subscribeWithSelector((set, get) => ({ + title: "", + category: "all", + position: "all", + status: "all", + workflow: "all", + sortBy: "latest", + searchHistory: getInitialSearchHistory() || [], + isHistoryEnabled: getInitialHistoryEnabled(), + + updateTitle: (title) => set({ title }), + updateCategory: (category) => set({ category }), + updatePosition: (position) => set({ position }), + updateStatus: (status) => set({ status }), + updateWorkflow: (workflow) => set({ workflow }), + updateSortBy: (sortBy) => set({ sortBy }), + + resetFilters: () => + set({ + title: "", + category: "all", + position: "all", + status: "all", + workflow: "all", + sortBy: "latest", + }), + + addToHistory: (searchTerm) => { + const { isHistoryEnabled } = get(); + if (!isHistoryEnabled) return; + + const trimmedTerm = searchTerm.trim(); + if (!trimmedTerm) return; + + const currentHistory = get().searchHistory; + const newHistory = [ + trimmedTerm, + ...currentHistory.filter((term) => term !== trimmedTerm), + ].slice(0, 10); + + set({ searchHistory: newHistory }); + saveSearchHistory(newHistory); + }, + + clearHistory: () => { + set({ searchHistory: [] }); + saveSearchHistory([]); + }, + + removeFromHistory: (searchTerm) => { + const currentHistory = get().searchHistory; + const newHistory = currentHistory.filter((term) => term !== searchTerm); + set({ searchHistory: newHistory }); + saveSearchHistory(newHistory); + }, + + toggleHistoryEnabled: () => { + const currentEnabled = get().isHistoryEnabled; + const newEnabled = !currentEnabled; + set({ isHistoryEnabled: newEnabled }); + saveHistoryEnabled(newEnabled); + + if (!newEnabled) { + set({ searchHistory: [] }); + saveSearchHistory([]); + } + }, + + getActiveFiltersCount: () => { + const state = get(); + let count = 0; + if (state.title.trim() !== "") count++; + if (state.category !== "all") count++; + if (state.position !== "all") count++; + if (state.status !== "all") count++; + if (state.workflow !== "all") count++; + return count; + }, + + getFilterStatus: () => { + const state = get(); + const cleanFilter: ProjectSearchFilterOption = { + title: state.title || "", + category: state.category === "all" ? undefined : state.category, + position: state.position === "all" ? undefined : state.position, + status: state.status === "all" ? undefined : state.status, + workflow: state.workflow === "all" ? undefined : state.workflow, + sortBy: state.sortBy || "latest", + }; + + return Object.fromEntries( + Object.entries(cleanFilter).filter(([_, value]) => value !== undefined) + ) as ProjectSearchFilterOption; + }, + })) +); + +export const useSearchTitle = (): string => + useSearchStore((state) => state.title); + +export const useSearchTitleActions = (): { + updateTitle: (title: string) => void; +} => + useSearchStore( + useShallow((state) => ({ + updateTitle: state.updateTitle, + })) + ); + +export const useSearchCategory = (): ProjectCategory | "all" => + useSearchStore((state) => state.category); +export const useSearchPosition = (): UserRole | "all" => + useSearchStore((state) => state.position); +export const useSearchStatus = (): RecruitmentStatus | "all" => + useSearchStore((state) => state.status); +export const useSearchWorkflow = (): Workflow | "all" => + useSearchStore((state) => state.workflow); +export const useSearchSortBy = (): SortBy | "latest" => + useSearchStore((state) => state.sortBy); + +export const useSearchFilterActions = (): { + updateCategory: (category: ProjectCategory | "all") => void; + updatePosition: (position: UserRole | "all") => void; + updateStatus: (status: RecruitmentStatus | "all") => void; + updateWorkflow: (workflow: Workflow | "all") => void; + updateSortBy: (sortBy: SortBy | "latest") => void; +} => + useSearchStore( + useShallow((state) => ({ + updateCategory: state.updateCategory, + updatePosition: state.updatePosition, + updateStatus: state.updateStatus, + updateWorkflow: state.updateWorkflow, + updateSortBy: state.updateSortBy, + })) + ); + +export const useSearchUtils = (): { + resetFilters: () => void; + getActiveFiltersCount: () => number; + getFilterStatus: () => ProjectSearchFilterOption; +} => + useSearchStore( + useShallow((state) => ({ + resetFilters: state.resetFilters, + getActiveFiltersCount: state.getActiveFiltersCount, + getFilterStatus: state.getFilterStatus, + })) + ); + +export const useActiveFiltersCount = (): number => + useSearchStore((state) => { + let count = 0; + if (state.title.trim() !== "") count++; + if (state.category !== "all") count++; + if (state.position !== "all") count++; + if (state.status !== "all") count++; + if (state.workflow !== "all") count++; + return count; + }); + +export const useSearchHistory = (): string[] => + useSearchStore((state) => state.searchHistory); + +export const useSearchHistoryActions = (): { + addToHistory: (searchTerm: string) => void; + clearHistory: () => void; + removeFromHistory: (searchTerm: string) => void; + toggleHistoryEnabled: () => void; +} => + useSearchStore( + useShallow((state) => ({ + addToHistory: state.addToHistory, + clearHistory: state.clearHistory, + removeFromHistory: state.removeFromHistory, + toggleHistoryEnabled: state.toggleHistoryEnabled, + })) + ); + +export const useIsHistoryEnabled = (): boolean => + useSearchStore((state) => state.isHistoryEnabled); diff --git a/src/entities/search/types/index.ts b/src/shared/types/search.ts similarity index 100% rename from src/entities/search/types/index.ts rename to src/shared/types/search.ts diff --git a/src/shared/ui/icons/CommonIcons.tsx b/src/shared/ui/icons/CommonIcons.tsx new file mode 100644 index 0000000..c6673b8 --- /dev/null +++ b/src/shared/ui/icons/CommonIcons.tsx @@ -0,0 +1,32 @@ +// 공통으로 사용되는 아이콘들을 한 곳에서 관리 +// 성능 최적화: 중복 import 방지 및 번들 크기 감소 + +// 📊 Project & Stats 관련 아이콘들 +export { default as PeopleAltIcon } from "@mui/icons-material/PeopleAlt"; +export { default as AccessTimeIcon } from "@mui/icons-material/AccessTime"; +export { default as RocketLaunchIcon } from "@mui/icons-material/RocketLaunch"; +export { default as LocationPinIcon } from "@mui/icons-material/LocationPin"; + +// 🔍 Search 관련 아이콘들 +export { default as SearchIcon } from "@mui/icons-material/Search"; +export { default as FilterListIcon } from "@mui/icons-material/FilterList"; +export { default as TuneIcon } from "@mui/icons-material/Tune"; + +// 🧭 Navigation 관련 아이콘들 +export { default as ArrowBackIcon } from "@mui/icons-material/ArrowBack"; +export { default as AddIcon } from "@mui/icons-material/Add"; + +// 💝 Action 관련 아이콘들 +export { default as FavoriteBorderIcon } from "@mui/icons-material/FavoriteBorder"; +export { default as FavoriteOutlinedIcon } from "@mui/icons-material/FavoriteOutlined"; +export { default as ShareIcon } from "@mui/icons-material/Share"; + +// 📋 Form & UI 관련 아이콘들 +export { default as ClearIcon } from "@mui/icons-material/Clear"; +export { default as HistoryIcon } from "@mui/icons-material/History"; +export { default as HistoryToggleOffIcon } from "@mui/icons-material/HistoryToggleOff"; + +// 📱 Pagination 관련 아이콘들 +export { default as ChevronLeftIcon } from "@mui/icons-material/ChevronLeft"; +export { default as ChevronRightIcon } from "@mui/icons-material/ChevronRight"; +export { default as MoreHorizIcon } from "@mui/icons-material/MoreHoriz"; diff --git a/src/shared/ui/loading-spinner/PageTransitionFallback.tsx b/src/shared/ui/loading-spinner/PageTransitionFallback.tsx new file mode 100644 index 0000000..3e3a89b --- /dev/null +++ b/src/shared/ui/loading-spinner/PageTransitionFallback.tsx @@ -0,0 +1,172 @@ +import { Box, Typography, styled, keyframes } from "@mui/material"; +import type { JSX } from "react"; + +interface PageTransitionFallbackProps { + message?: string; + variant?: "minimal" | "standard"; +} + +const PageTransitionFallback = ({ + message = "페이지 이동 중...", + variant = "standard", +}: PageTransitionFallbackProps): JSX.Element => { + if (variant === "minimal") { + return ( + + + + ); + } + + return ( + + + + + + + + + {message} + + + + + ); +}; + +export default PageTransitionFallback; + +const spin = keyframes` + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +`; + +const fadeIn = keyframes` + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +`; + +const ripple = keyframes` + 0% { + transform: scale(0.8); + opacity: 1; + } + 100% { + transform: scale(2.4); + opacity: 0; + } +`; + +const TransitionContainer = styled(Box)(() => ({ + position: "fixed", + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: "rgba(255, 255, 255, 0.9)", + backdropFilter: "blur(8px)", + display: "flex", + alignItems: "center", + justifyContent: "center", + zIndex: 9998, +})); + +const MinimalContainer = styled(Box)(() => ({ + position: "fixed", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + zIndex: 9998, +})); + +const ContentWrapper = styled(Box)(({ theme }) => ({ + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: theme.spacing(2), + position: "relative", + animation: `${fadeIn} 0.3s ease-out`, +})); + +const SpinnerWrapper = styled(Box)(() => ({ + position: "relative", + width: "60px", + height: "60px", +})); + +const Ring = styled(Box)(() => ({ + position: "absolute", + borderRadius: "50%", + border: "2px solid transparent", +})); + +const OuterRing = styled(Ring)(({ theme }) => ({ + width: "60px", + height: "60px", + borderTopColor: theme.palette.primary.main, + borderRightColor: theme.palette.primary.main, + animation: `${spin} 1.2s linear infinite`, + opacity: 0.8, +})); + +const MiddleRing = styled(Ring)(({ theme }) => ({ + width: "45px", + height: "45px", + top: "7.5px", + left: "7.5px", + borderTopColor: theme.palette.secondary.main, + borderLeftColor: theme.palette.secondary.main, + animation: `${spin} 1s linear infinite reverse`, + opacity: 0.6, +})); + +const InnerRing = styled(Ring)(({ theme }) => ({ + width: "30px", + height: "30px", + top: "15px", + left: "15px", + borderTopColor: theme.palette.primary.light, + borderBottomColor: theme.palette.primary.light, + animation: `${spin} 0.8s linear infinite`, + opacity: 0.4, +})); + +const MinimalSpinner = styled(Box)(({ theme }) => ({ + width: "24px", + height: "24px", + border: `2px solid ${theme.palette.grey[300]}`, + borderTop: `2px solid ${theme.palette.primary.main}`, + borderRadius: "50%", + animation: `${spin} 1s linear infinite`, +})); + +const TransitionMessage = styled(Typography)(({ theme }) => ({ + color: theme.palette.text.secondary, + fontWeight: 500, + textAlign: "center", + animation: `${fadeIn} 0.5s ease-out 0.1s both`, +})); + +const PulseEffect = styled(Box)(({ theme }) => ({ + position: "absolute", + top: "50%", + left: "50%", + width: "60px", + height: "60px", + transform: "translate(-50%, -50%)", + border: `1px solid ${theme.palette.primary.main}`, + borderRadius: "50%", + animation: `${ripple} 2s ease-out infinite`, + pointerEvents: "none", +})); diff --git a/src/shared/ui/loading-spinner/PageTransitionLoader.tsx b/src/shared/ui/loading-spinner/PageTransitionLoader.tsx new file mode 100644 index 0000000..13f56f7 --- /dev/null +++ b/src/shared/ui/loading-spinner/PageTransitionLoader.tsx @@ -0,0 +1,85 @@ +import { + Box, + CircularProgress, + Typography, + styled, + keyframes, +} from "@mui/material"; +import type { JSX } from "react"; + +const PageTransitionLoader = (): JSX.Element => { + return ( + + + + + 페이지로 이동 중입니다... + + + + ); +}; + +export default PageTransitionLoader; + +// 애니메이션 키프레임 +const fadeInUp = keyframes` + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +`; + +const textPulse = keyframes` + 0%, 100% { + opacity: 0.7; + } + 50% { + opacity: 1; + } +`; + +// 페이지 로딩용 풀스크린 컨테이너 +const PageLoadingContainer = styled(Box)(() => ({ + position: "fixed", + top: 0, + left: 0, + right: 0, + bottom: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", + backgroundColor: "rgba(255, 255, 255, 0.95)", + backdropFilter: "blur(8px)", + zIndex: 9999, +})); + +// 애니메이션이 적용된 스피너 래퍼 +const AnimatedSpinnerWrapper = styled(Box)(({ theme }) => ({ + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: theme.spacing(3), + animation: `${fadeInUp} 0.5s ease-out`, +})); + +// 애니메이션이 적용된 텍스트 +const AnimatedLoadingText = styled(Typography)(({ theme }) => ({ + color: theme.palette.text.primary, + fontWeight: 600, + userSelect: "none", + animation: `${textPulse} 1.5s ease-in-out infinite`, +})); + +// 스타일드 CircularProgress +const StyledCircularProgress = styled(CircularProgress)(({ theme }) => ({ + color: theme.palette.primary.main, + animationDuration: "1s", + "& .MuiCircularProgress-circle": { + strokeLinecap: "round", + }, +})); diff --git a/src/shared/ui/pagination.tsx b/src/shared/ui/pagination/Pagination.tsx similarity index 72% rename from src/shared/ui/pagination.tsx rename to src/shared/ui/pagination/Pagination.tsx index 704f634..26a10c7 100644 --- a/src/shared/ui/pagination.tsx +++ b/src/shared/ui/pagination/Pagination.tsx @@ -1,10 +1,19 @@ -import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; -import ChevronRightIcon from "@mui/icons-material/ChevronRight"; -import MoreHorizIcon from "@mui/icons-material/MoreHoriz"; -import { Box, Button, styled, IconButton } from "@mui/material"; +import { + Box, + Button, + styled, + IconButton, + useMediaQuery, + useTheme, +} from "@mui/material"; import { type JSX } from "react"; import usePagination from "@shared/hooks/usePagination"; +import { + ChevronLeftIcon, + ChevronRightIcon, + MoreHorizIcon, +} from "@shared/ui/icons/CommonIcons"; interface PaginationProps { currentPage: number; @@ -18,14 +27,17 @@ const Pagination = ({ totalPages, onPageChange, disabled = false, -}: PaginationProps): JSX.Element => { +}: PaginationProps): JSX.Element | null => { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("sm")); + const { pageNumbers, canGoPrev, canGoNext } = usePagination({ currentPage, totalPages, }); if (totalPages <= 1) { - return <>; + return null; } return ( @@ -33,7 +45,7 @@ const Pagination = ({ onPageChange(currentPage - 1)} disabled={!canGoPrev || disabled} - size="medium" + size={isMobile ? "large" : "medium"} > @@ -41,16 +53,17 @@ const Pagination = ({ {pageNumbers.map((page, index) => ( {page === "ellipsis" ? ( - + ) : ( onPageChange(page)} disabled={disabled} $isActive={page === currentPage} + $isMobile={isMobile} > {page} @@ -61,7 +74,7 @@ const Pagination = ({ onPageChange(currentPage + 1)} disabled={!canGoNext || disabled} - size="medium" + size={isMobile ? "large" : "medium"} > @@ -85,12 +98,15 @@ const PageNumbersContainer = styled(Box)(() => ({ gap: "0.5rem", })); -const PageButton = styled(Button)<{ $isActive: boolean }>( - ({ theme, $isActive }) => ({ - minWidth: "3rem", - height: "3rem", +const PageButton = styled(Button, { + shouldForwardProp: (prop) => + !["$isActive", "$isMobile"].includes(prop as string), +})<{ $isActive: boolean; $isMobile: boolean }>( + ({ theme, $isActive, $isMobile }) => ({ + minWidth: $isMobile ? "3.5rem" : "3rem", + height: $isMobile ? "3.5rem" : "3rem", padding: "0", - fontSize: "1rem", + fontSize: $isMobile ? "1.1rem" : "1rem", fontWeight: $isActive ? 600 : 500, borderRadius: "0.5rem", ...($isActive && { @@ -119,6 +135,11 @@ const NavButton = styled(IconButton)(({ theme }) => ({ boxShadow: theme.shadows[1], }, transition: "all 0.2s ease-in-out", + + [theme.breakpoints.down("sm")]: { + minWidth: "3.5rem", + height: "3.5rem", + }, })); const EllipsisButton = styled(IconButton)(() => ({ diff --git a/src/shared/ui/user/UserProfileAvatar.tsx b/src/shared/ui/user/UserProfileAvatar.tsx index 0757443..971d2e9 100644 --- a/src/shared/ui/user/UserProfileAvatar.tsx +++ b/src/shared/ui/user/UserProfileAvatar.tsx @@ -1,5 +1,6 @@ import { Avatar, Box, styled } from "@mui/material"; import type { CSSProperties, JSX } from "react"; +import { useState, useCallback, useMemo, memo } from "react"; import type { User } from "@shared/types/user"; import UserProfileWithNamePosition from "@shared/ui/user/UserProfileWithNamePosition"; @@ -15,9 +16,21 @@ const UserProfileAvatar = ({ avatar, flexDirection = "row", }: UserProfileAvatarProps): JSX.Element => { + const [imageError, setImageError] = useState(false); + + const handleImageError = useCallback((): void => { + setImageError(true); + }, []); + + const avatarProps = useMemo(() => { + return imageError || !avatar + ? { children: name.charAt(0).toUpperCase() } + : { src: avatar, onError: handleImageError }; + }, [imageError, avatar, name, handleImageError]); + return ( - + ({ display: "flex", diff --git a/src/widgets/Header/Header.tsx b/src/widgets/Header/Header.tsx index 228a4cb..f3f19cc 100644 --- a/src/widgets/Header/Header.tsx +++ b/src/widgets/Header/Header.tsx @@ -1,6 +1,6 @@ -import { Box, styled, Avatar, Button } from "@mui/material"; +import { Box, styled, Avatar, Button, alpha } from "@mui/material"; import type { JSX } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, useLocation } from "react-router-dom"; import LoginButton from "@features/auth/ui/LoginButton"; import LogoutButton from "@features/auth/ui/LogoutButton"; @@ -10,69 +10,201 @@ import { useAuthStore } from "@shared/stores/authStore"; const Header = (): JSX.Element => { const user = useAuthStore((state) => state.user); const navigate = useNavigate(); + const location = useLocation(); + + const isActive = (path: string): boolean => location.pathname === path; return ( - navigate("/")}> - {/* 로고 이미지가 있으면 아래 img src 수정 */} - {/* 로고 */} - 프로젝트 잼 - - - navigate("/project")}> - 프로젝트 찾기 - - navigate("/project/insert")}> - 프로젝트 등록 - - {user ? ( - <> - navigate("/profile")} - /> - - - ) : ( - - )} - + + + navigate("/")}> + + 프로젝트 잼 + + + + + navigate("/project")} + $isActive={isActive("/project")} + > + 프로젝트 찾기 + + navigate("/project/insert")} + $isActive={isActive("/project/insert")} + > + 프로젝트 등록 + + + + + {user ? ( + + navigate("/profile")} + /> + + + ) : ( + + )} + + ); }; export default Header; -const HeaderContainer = styled(Box)({ +const HeaderContainer = styled(Box)(({ theme }) => ({ display: "flex", - justifyContent: "space-between", + justifyContent: "center", alignItems: "center", padding: "0 2rem", - backgroundColor: "#f5f5f5", - height: "64px", -}); + backgroundColor: theme.palette.background.paper, + borderBottom: `1px solid ${alpha(theme.palette.divider, 0.1)}`, + height: "8rem", + boxShadow: `0 0.2rem 1.2rem ${alpha(theme.palette.common.black, 0.04)}`, + backdropFilter: "blur(0.8rem)", + position: "sticky", + top: 0, + zIndex: 8000, + + [theme.breakpoints.down("md")]: { + height: "6.4rem", + padding: "0 1rem", + }, +})); -const LogoBox = styled(Box)({ +const HeaderContent = styled(Box)(() => ({ display: "flex", + justifyContent: "space-between", alignItems: "center", - cursor: "pointer", -}); + width: "100%", + maxWidth: "1280px", + margin: "0 auto", +})); + +const LeftSection = styled(Box)(() => ({ + display: "flex", + alignItems: "center", + flex: "0 0 auto", +})); + +const CenterSection = styled(Box)(() => ({ + display: "flex", + alignItems: "center", + gap: "2rem", + flex: "1 1 auto", + justifyContent: "center", +})); + +const RightSection = styled(Box)(() => ({ + display: "flex", + alignItems: "center", + flex: "0 0 auto", +})); -const NavBox = styled(Box)({ +const LogoBox = styled(Box)(({ theme }) => ({ display: "flex", alignItems: "center", - gap: "1.5rem", -}); + cursor: "pointer", + padding: "0.5rem 1rem", + borderRadius: theme.spacing(1.5), + transition: "all 0.2s ease-in-out", + "&:hover": { + backgroundColor: alpha(theme.palette.primary.main, 0.08), + transform: "translateY(-1px)", + }, +})); -const NavButton = styled(Button)({ +const LogoImage = styled("img")(({ theme }) => ({ + height: "4rem", + width: "4rem", + marginRight: theme.spacing(1.5), + filter: "drop-shadow(0 0.2rem 0.4rem rgba(0,0,0,0.1))", + transition: "transform 0.2s ease-in-out", + + [theme.breakpoints.down("md")]: { + height: "3.2rem", + width: "3.2rem", + marginRight: theme.spacing(1), + }, +})); + +const LogoText = styled("span")(({ theme }) => ({ + fontWeight: 700, + fontSize: "1.875rem", + background: `linear-gradient(45deg, ${theme.palette.primary.main}, ${theme.palette.secondary.main})`, + backgroundClip: "text", + WebkitBackgroundClip: "text", + WebkitTextFillColor: "transparent", + letterSpacing: "-0.5px", + + [theme.breakpoints.down("md")]: { + fontSize: "1.5rem", + }, + [theme.breakpoints.down("sm")]: { + fontSize: "1.2rem", + }, +})); + +const NavButton = styled(Button, { + shouldForwardProp: (prop) => prop !== "$isActive", +})<{ $isActive?: boolean }>(({ theme, $isActive }) => ({ background: "none", - color: "#3b36f4", - fontWeight: 600, - fontSize: "1.1rem", + color: $isActive ? theme.palette.primary.main : theme.palette.text.primary, + fontWeight: $isActive ? 700 : 600, + fontSize: "1rem", boxShadow: "none", + padding: "0.75rem 1.5rem", + borderRadius: theme.spacing(2), + position: "relative", + transition: "all 0.2s ease-in-out", + + "&:hover": { + backgroundColor: alpha(theme.palette.primary.main, 0.08), + transform: "translateY(-1px)", + color: theme.palette.primary.main, + }, + + "&:active": { + transform: "translateY(0)", + }, + + "&::after": { + content: '""', + position: "absolute", + bottom: "-0.8rem", // -8px = -0.8rem + left: "50%", + transform: "translateX(-50%)", + width: $isActive ? "60%" : "0%", + height: "0.3rem", // 3px = 0.3rem + backgroundColor: theme.palette.primary.main, + borderRadius: "0.2rem", // 2px = 0.2rem + transition: "width 0.3s ease-in-out", + }, +})); + +const UserSection = styled(Box)(({ theme }) => ({ + display: "flex", + alignItems: "center", + gap: theme.spacing(1.5), +})); + +const StyledAvatar = styled(Avatar)(({ theme }) => ({ + width: "4rem", // 40px = 4rem + height: "4rem", + cursor: "pointer", + border: `0.2rem solid ${alpha(theme.palette.primary.main, 0.2)}`, // 2px = 0.2rem + transition: "all 0.2s ease-in-out", "&:hover": { - background: "rgba(59,54,244,0.08)", + transform: "scale(1.05)", + borderColor: theme.palette.primary.main, + boxShadow: `0 0.4rem 1.2rem ${alpha(theme.palette.primary.main, 0.3)}`, // 4px 12px = 0.4rem 1.2rem }, -}); +})); diff --git a/src/widgets/hero/ui/Hero.tsx b/src/widgets/hero/ui/Hero.tsx index fd88a1a..0e03fdc 100644 --- a/src/widgets/hero/ui/Hero.tsx +++ b/src/widgets/hero/ui/Hero.tsx @@ -1,5 +1,3 @@ -import AddIcon from "@mui/icons-material/Add"; -import SearchIcon from "@mui/icons-material/Search"; import { Box, Button, @@ -10,6 +8,8 @@ import { import type { JSX } from "react"; import { Link } from "react-router-dom"; +import { AddIcon, SearchIcon } from "@shared/ui/icons/CommonIcons"; + const Hero = (): JSX.Element => { return ( <>