diff --git a/.vscode/settings.json b/.vscode/settings.json index 985d918..a245526 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,6 +9,7 @@ "Neue", "Pretendard", "Segoe", + "treemap", "TTFB" ], "css.lint.unknownAtRules": "ignore", diff --git a/src/entities/projects/api/getProjectLikeApi.ts b/src/entities/projects/api/getProjectLikeApi.ts new file mode 100644 index 0000000..a8b1df7 --- /dev/null +++ b/src/entities/projects/api/getProjectLikeApi.ts @@ -0,0 +1,130 @@ +import type { User } from "firebase/auth"; +import { collection, getDocs, query, where } from "firebase/firestore"; + +import { db } from "@shared/firebase/firebase"; +import type { ProjectListRes } from "@shared/types/project"; + +export const getProjectLikeStatus = async ( + userId: User["uid"] | undefined, + projectId: string | undefined +): Promise => { + if (!userId) { + throw new Error("유저 정보가 없습니다 로그인 해주세요."); + } + + if (!projectId) { + throw new Error("유효하지 않은 프로젝트 정보입니다."); + } + + const q = query( + collection(db, "likes"), + where("userId", "==", userId), + where("projectId", "==", projectId) + ); + + try { + const snapshot = await getDocs(q); + return snapshot.docs.length > 0; + } catch { + throw new Error("좋아요 상태를 가져오는데 실패했습니다."); + } +}; + +export const getProjectLikedUsers = async ( + projectId: string +): Promise => { + if (!projectId) { + throw new Error("유효하지 않은 프로젝트 정보입니다."); + } + + const q = query(collection(db, "likes"), where("projectId", "==", projectId)); + + try { + const snapshot = await getDocs(q); + return snapshot.docs.map((doc) => doc.data().userId); + } catch { + throw new Error("좋아요 유저 정보를 가져오는데 실패했습니다."); + } +}; + +export const getMyLikedProjectsIds = async ( + userId: User["uid"] | undefined +): Promise => { + if (!userId) { + throw new Error("유저 정보가 없습니다 로그인 해주세요."); + } + + const q = query(collection(db, "likes"), where("userId", "==", userId)); + + try { + const snapshot = await getDocs(q); + return snapshot.docs.map((doc) => doc.data().projectId); + } catch { + throw new Error("내가 좋아요한 프로젝트 ID를 가져오는데 실패했습니다."); + } +}; + +export const getProjectsByIds = async ( + projectIds: string[] +): Promise => { + if (!projectIds || projectIds.length === 0) { + return []; + } + + try { + const projects: ProjectListRes[] = []; + + const BATCH_SIZE = 10; + const batches = []; + + for (let i = 0; i < projectIds.length; i += BATCH_SIZE) { + const batch = projectIds.slice(i, i + BATCH_SIZE); + batches.push(batch); + } + + const batchPromises = batches.map(async (batch) => { + const q = query( + collection(db, "projects"), + where("__name__", "in", batch) + ); + + const snapshot = await getDocs(q); + return snapshot.docs.map( + (doc) => + ({ + id: doc.id, + ...doc.data(), + }) as ProjectListRes + ); + }); + + const batchResults = await Promise.all(batchPromises); + + batchResults.forEach((batch) => { + projects.push(...batch); + }); + + return projects; + } catch (error) { + console.error("프로젝트 정보 조회 에러:", error); + throw new Error("프로젝트 정보를 가져오는데 실패했습니다."); + } +}; + +export const getMyLikedProjectsWithDetails = async ( + userId: User["uid"] | undefined +): Promise => { + if (!userId) { + throw new Error("유저 정보가 없습니다 로그인 해주세요."); + } + + try { + const projectIds = await getMyLikedProjectsIds(userId); + const projects = await getProjectsByIds(projectIds); + + return projects; + } catch (error) { + console.error("내가 좋아요한 프로젝트 조회 에러:", error); + throw new Error("내가 좋아요한 프로젝트 정보를 가져오는데 실패했습니다."); + } +}; diff --git a/src/entities/projects/hook/useGetProjects.ts b/src/entities/projects/hook/useGetProjects.ts index ddf0ad8..63730f9 100644 --- a/src/entities/projects/hook/useGetProjects.ts +++ b/src/entities/projects/hook/useGetProjects.ts @@ -3,6 +3,7 @@ import type { DocumentData, QueryDocumentSnapshot } from "firebase/firestore"; import { getProjectList } from "@entities/projects/api/projectsAPi"; +import queryKeys from "@shared/react-query/queryKey"; import type { ProjectListRes } from "@shared/types/project"; const useGetProjects = ({ @@ -14,7 +15,7 @@ const useGetProjects = ({ lastVisible: QueryDocumentSnapshot | null; }> => { return useQuery({ - queryKey: ["projects"], + queryKey: [queryKeys.projects], queryFn: () => getProjectList({ pageSize, lastDoc: null }), }); }; diff --git a/src/entities/projects/queries/useGetProjectLike.ts b/src/entities/projects/queries/useGetProjectLike.ts new file mode 100644 index 0000000..01d9e93 --- /dev/null +++ b/src/entities/projects/queries/useGetProjectLike.ts @@ -0,0 +1,64 @@ +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { useParams } from "react-router-dom"; + +import { + getMyLikedProjectsIds, + getMyLikedProjectsWithDetails, + getProjectLikedUsers, + getProjectLikeStatus, +} from "@entities/projects/api/getProjectLikeApi"; + +import queryKeys from "@shared/react-query/queryKey"; +import { useAuthStore } from "@shared/stores/authStore"; +import type { ProjectListRes } from "@shared/types/project"; + +export const useGetProjectLike = (): UseQueryResult => { + const user = useAuthStore((state) => state.user); + const { id: projectId } = useParams(); + + return useQuery({ + queryKey: [queryKeys.projectLike, projectId], + queryFn: () => getProjectLikeStatus(user?.uid, projectId), + enabled: !!user && !!projectId, + staleTime: 1000 * 60 * 5, + }); +}; + +export const useGetProjectLikedUsers = ( + projectId?: string +): UseQueryResult => { + const { id: paramsId } = useParams(); + const finalProjectId = projectId || paramsId; + + const query = useQuery({ + queryKey: [queryKeys.projectLikedUser, finalProjectId], + queryFn: () => getProjectLikedUsers(finalProjectId as string), + enabled: !!finalProjectId, + staleTime: 1000 * 60 * 5, + }); + + return query; +}; + +export const useGetMyLikedProjectsIds = (): UseQueryResult => { + const user = useAuthStore((state) => state.user); + + return useQuery({ + queryKey: [queryKeys.myLikedProjects, "ids"], + queryFn: () => getMyLikedProjectsIds(user?.uid), + enabled: !!user, + }); +}; + +export const useGetMyLikedProjectsWithDetails = (): UseQueryResult< + ProjectListRes[], + Error +> => { + const user = useAuthStore((state) => state.user); + + return useQuery({ + queryKey: [queryKeys.myLikedProjects, "details"], + queryFn: () => getMyLikedProjectsWithDetails(user?.uid), + enabled: !!user, + }); +}; diff --git a/src/entities/projects/ui/liked-projects/LikedProjectsEmpty.tsx b/src/entities/projects/ui/liked-projects/LikedProjectsEmpty.tsx new file mode 100644 index 0000000..762660e --- /dev/null +++ b/src/entities/projects/ui/liked-projects/LikedProjectsEmpty.tsx @@ -0,0 +1,46 @@ +import FavoriteIcon from "@mui/icons-material/Favorite"; +import { Box, Typography, styled } from "@mui/material"; +import type { JSX } from "react"; + +const LikedProjectsEmpty = (): JSX.Element => { + return ( + + + + + + 아직 좋아요한 프로젝트가 없습니다 + + + 마음에 드는 프로젝트에 좋아요를 눌러보세요! + + + ); +}; + +export default LikedProjectsEmpty; + +const EmptyContainer = styled(Box)(({ theme }) => ({ + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + padding: theme.spacing(8, 2), + textAlign: "center", + minHeight: "300px", +})); + +const IconWrapper = styled(Box)(({ theme }) => ({ + marginBottom: theme.spacing(2), + opacity: 0.6, +})); + +const EmptyTitle = styled(Typography)(({ theme }) => ({ + marginBottom: theme.spacing(1), + fontWeight: 600, +})); + +const EmptyDescription = styled(Typography)(() => ({ + maxWidth: "400px", + lineHeight: 1.6, +})); diff --git a/src/entities/projects/ui/liked-projects/LikedProjectsList.tsx b/src/entities/projects/ui/liked-projects/LikedProjectsList.tsx new file mode 100644 index 0000000..be4a6ba --- /dev/null +++ b/src/entities/projects/ui/liked-projects/LikedProjectsList.tsx @@ -0,0 +1,97 @@ +import { Box, styled, Typography } from "@mui/material"; +import type { JSX } from "react"; + +import LikedProjectsEmpty from "@entities/projects/ui/liked-projects/LikedProjectsEmpty"; + +import { useArrayPagination } from "@shared/hooks/usePagination"; +import type { ProjectListRes } from "@shared/types/project"; +import LoadingSpinner from "@shared/ui/loading-spinner/LoadingSpinner"; +import Pagination from "@shared/ui/pagination/Pagination"; +import ProjectCard from "@shared/ui/ProjectCard"; + +interface LikedProjectsListProps { + projects: ProjectListRes[]; + loading?: boolean; + itemsPerPage?: number; + editMode?: boolean; + selectedIds?: string[]; + onSelectProject?: (id: string) => void; +} + +const LikedProjectsList = ({ + projects, + loading = false, + itemsPerPage = 6, + editMode = false, + selectedIds = [], + onSelectProject, +}: LikedProjectsListProps): JSX.Element => { + const { + currentPage, + totalPages, + paginatedData: paginatedProjects, + isEmpty, + setPage, + } = useArrayPagination({ + data: projects, + itemsPerPage, + }); + + if (loading) { + return ( + + + 좋아요한 프로젝트를 불러오는 중... + + ); + } + + if (isEmpty) { + return ; + } + + return ( + + + {paginatedProjects.map((project, index) => ( + onSelectProject && onSelectProject(project.id)} + /> + ))} + + + {projects.length > itemsPerPage && ( + + )} + + ); +}; + +export default LikedProjectsList; + +const Container = styled(Box)(({ theme }) => ({ + display: "flex", + flexDirection: "column", + gap: theme.spacing(3), +})); + +const ProjectGrid = styled(Box)(({ theme }) => ({ + display: "grid", + gridTemplateColumns: "1fr", + gap: theme.spacing(2), + [theme.breakpoints.up("sm")]: { + gridTemplateColumns: "repeat(2, 1fr)", + }, + [theme.breakpoints.up("md")]: { + gridTemplateColumns: "repeat(3, 1fr)", + }, +})); diff --git a/src/entities/projects/ui/post-info/ProjectPostInfo.tsx b/src/entities/projects/ui/post-info/ProjectPostInfo.tsx index bdb2829..1e16071 100644 --- a/src/entities/projects/ui/post-info/ProjectPostInfo.tsx +++ b/src/entities/projects/ui/post-info/ProjectPostInfo.tsx @@ -1,6 +1,8 @@ import StarBorderOutlinedIcon from "@mui/icons-material/StarBorderOutlined"; import type { JSX } from "react"; +import { useGetProjectLikedUsers } from "@entities/projects/queries/useGetProjectLike"; + import { formatDate } from "@shared/libs/utils/projectDetail"; import type { ProjectListRes } from "@shared/types/project"; import InfoRow from "@shared/ui/project-detail/InfoRow"; @@ -16,6 +18,7 @@ const ProjectPostInfo = ({ }: { values: PostInfoType | null; }): JSX.Element | null => { + const { data: likedUsers } = useGetProjectLikedUsers(); if (!values) return null; return ( @@ -34,7 +37,7 @@ const ProjectPostInfo = ({ /> diff --git a/src/entities/projects/ui/project-collection-tab/ProjectCollectionContainer.tsx b/src/entities/projects/ui/project-collection-tab/ProjectCollectionContainer.tsx new file mode 100644 index 0000000..a562351 --- /dev/null +++ b/src/entities/projects/ui/project-collection-tab/ProjectCollectionContainer.tsx @@ -0,0 +1,176 @@ +import { Box, Button, styled } from "@mui/material"; +import type { JSX } from "react"; +import { useState, useCallback } from "react"; + +import type { ProjectListRes } from "@shared/types/project"; +import DeleteButton from "@shared/ui/DeleteButton"; + +import ProjectCollectionTab from "./ProjectCollectionTab"; +import ProjectCollectionTabPanel from "./ProjectCollectionTabPanel"; + +interface ProjectCollectionContainerProps { + likedProjects: ProjectListRes[]; + appliedProjects: ProjectListRes[]; + createdProjects: ProjectListRes[]; + loading?: boolean; + onDeleteProjects?: ( + type: "liked" | "applied" | "created", + ids: string[] + ) => Promise; +} + +const ProjectCollectionContainer = ({ + likedProjects, + appliedProjects, + createdProjects, + loading = false, + onDeleteProjects, +}: ProjectCollectionContainerProps): JSX.Element => { + const [currentTab, setCurrentTab] = useState(0); + const [editMode, setEditMode] = useState(false); + const [selectedIds, setSelectedIds] = useState([]); + + const getCurrentProjects = useCallback((): ProjectListRes[] => { + switch (currentTab) { + case 0: + return likedProjects; + case 1: + return appliedProjects; + case 2: + return createdProjects; + default: + return []; + } + }, [currentTab, likedProjects, appliedProjects, createdProjects]); + + const handleTabChange = useCallback((tabIndex: number) => { + setCurrentTab(tabIndex); + setEditMode(false); + setSelectedIds([]); + }, []); + + const handleSelectProject = useCallback((id: string) => { + setSelectedIds((prev) => + prev.includes(id) ? prev.filter((pid) => pid !== id) : [...prev, id] + ); + }, []); + + const handleToggleAll = useCallback(() => { + const currentProjects = getCurrentProjects(); + const allIds = currentProjects.map((p) => p.id); + const isAllSelected = + selectedIds.length === allIds.length && allIds.length > 0; + setSelectedIds(isAllSelected ? [] : allIds); + }, [selectedIds, getCurrentProjects]); + + const handleDelete = useCallback(async () => { + if (!onDeleteProjects || selectedIds.length === 0) return; + + const type = + currentTab === 0 ? "liked" : currentTab === 1 ? "applied" : "created"; + await onDeleteProjects(type, selectedIds); + setSelectedIds([]); + }, [currentTab, selectedIds, onDeleteProjects]); + + const currentProjects = getCurrentProjects(); + const allIds = currentProjects.map((p) => p.id); + const isAllSelected = + selectedIds.length === allIds.length && allIds.length > 0; + + return ( + + + + + + {editMode ? ( + <> + + + 삭제 + + + + ) : ( + + )} + + + + + + + + + + ); +}; + +export default ProjectCollectionContainer; + +const Container = styled(Box)(() => ({ + display: "flex", + flex: 1, + flexDirection: "column", +})); + +const TabHeader = styled(Box)(({ theme }) => ({ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + marginBottom: theme.spacing(2), + gap: theme.spacing(2), +})); + +const ActionButtons = styled(Box)(({ theme }) => ({ + display: "flex", + alignItems: "center", + gap: theme.spacing(1), + flexShrink: 0, +})); diff --git a/src/entities/projects/ui/project-collection-tab/ProjectCollectionTab.tsx b/src/entities/projects/ui/project-collection-tab/ProjectCollectionTab.tsx new file mode 100644 index 0000000..57606fe --- /dev/null +++ b/src/entities/projects/ui/project-collection-tab/ProjectCollectionTab.tsx @@ -0,0 +1,50 @@ +import { Tabs, Tab, Box } from "@mui/material"; +import type { JSX } from "react"; +import { useState } from "react"; + +const PROJECT_COLLECTION_TABS = [ + { label: "관심있는 프로젝트", color: "primary" }, + { label: "지원한 프로젝트", color: "secondary" }, + { label: "만든 프로젝트", color: "success" }, +] as const; + +interface ProjectCollectionTabProps { + currentTab?: number; + onTabChange?: (tabIndex: number) => void; +} + +const ProjectCollectionTab = ({ + currentTab = 0, + onTabChange, +}: ProjectCollectionTabProps): JSX.Element => { + const [tab, setTab] = useState(currentTab); + + const handleTabChange = (newValue: number): void => { + setTab(newValue); + onTabChange?.(newValue); + }; + + return ( + + handleTabChange(newValue)} + variant="fullWidth" + indicatorColor="primary" + textColor="primary" + > + {PROJECT_COLLECTION_TABS.map((tabInfo, index) => ( + + ))} + + + ); +}; + +export default ProjectCollectionTab; +export { PROJECT_COLLECTION_TABS }; diff --git a/src/entities/projects/ui/project-collection-tab/ProjectCollectionTabPanel.tsx b/src/entities/projects/ui/project-collection-tab/ProjectCollectionTabPanel.tsx new file mode 100644 index 0000000..2686ff7 --- /dev/null +++ b/src/entities/projects/ui/project-collection-tab/ProjectCollectionTabPanel.tsx @@ -0,0 +1,59 @@ +import { Box, styled } from "@mui/material"; +import type { JSX } from "react"; + +import LikedProjectsList from "@entities/projects/ui/liked-projects/LikedProjectsList"; + +import type { ProjectListRes } from "@shared/types/project"; + +interface ProjectCollectionTabPanelProps { + value: number; + index: number; + projects: ProjectListRes[]; + loading?: boolean; + editMode?: boolean; + selectedIds?: string[]; + onSelectProject?: (id: string) => void; + children?: React.ReactNode; +} + +const ProjectCollectionTabPanel = ({ + value, + index, + projects, + loading = false, + editMode = false, + selectedIds = [], + onSelectProject, + children, +}: ProjectCollectionTabPanelProps): JSX.Element => { + const isActive = value === index; + + if (!isActive) { + return <>; + } + // console.log("projects: ", projects); + return ( + + {children || ( + + )} + + ); +}; + +export default ProjectCollectionTabPanel; + +const TabPanelContainer = styled(Box)(({ theme }) => ({ + paddingTop: theme.spacing(3), + minHeight: "400px", +})); diff --git a/src/entities/projects/ui/projects-card/ProjectCard.tsx b/src/entities/projects/ui/projects-card/ProjectCard.tsx index 9a49b19..ac7d437 100644 --- a/src/entities/projects/ui/projects-card/ProjectCard.tsx +++ b/src/entities/projects/ui/projects-card/ProjectCard.tsx @@ -15,12 +15,15 @@ import type { JSX } from "react"; import { memo } from "react"; import { Link } from "react-router-dom"; +import { useGetProjectLikedUsers } from "@entities/projects/queries/useGetProjectLike"; + import { RecruitmentStatus, type ProjectListRes } from "@shared/types/project"; import DragScrollContainer from "@shared/ui/DragScrollContainer"; import { AccessTimeIcon, LocationPinIcon, PeopleAltIcon, + FavoriteBorderIcon, } from "@shared/ui/icons/CommonIcons"; import UserProfileAvatar from "@shared/ui/user/UserProfileAvatar"; import UserProfileWithNamePosition from "@shared/ui/user/UserProfileWithNamePosition"; @@ -37,6 +40,7 @@ const ProjectCard = ({ const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.up("sm")); const isRecruiting = project.status === RecruitmentStatus.recruiting; + const { data: likedUsers } = useGetProjectLikedUsers(project.id); return ( @@ -47,6 +51,19 @@ const ProjectCard = ({ color={isRecruiting ? "primary" : "default"} size="small" /> + + + + {likedUsers?.length || 0} + + @@ -134,13 +151,15 @@ const StyledCard = styled(Card, { shouldForwardProp: (prop) => prop !== "simple", })<{ simple?: boolean }>(({ theme, simple }) => ({ height: "100%", - flex: 1, - width: "100%", + maxWidth: "100%", + minWidth: 0, display: "flex", flexDirection: "column", transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)", cursor: "pointer", border: `1px solid ${theme.palette.divider}`, + boxSizing: "border-box", + overflow: "hidden", ...(simple && { minHeight: 260 }), "&:hover": { @@ -149,25 +168,13 @@ const StyledCard = styled(Card, { "0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1)", borderColor: theme.palette.primary.light, }, - - [theme.breakpoints.up("sm")]: { - flex: 1, - "&:hover": { - transform: "translateY(-0.6rem)", - }, - }, - - [theme.breakpoints.up("md")]: { - maxWidth: "48rem", - maxHeight: "54rem", - }, })); const StyledCardContent = styled(CardContent)(({ theme }) => ({ height: "100%", display: "flex", flexDirection: "column", - gap: theme.spacing(1), + gap: theme.spacing(1.5), [theme.breakpoints.up("sm")]: { gap: theme.spacing(2), @@ -176,7 +183,7 @@ const StyledCardContent = styled(CardContent)(({ theme }) => ({ const ProjectHeader = styled(Box)(() => ({ display: "flex", - justifyContent: "flex-start", + justifyContent: "space-between", alignItems: "center", })); @@ -195,11 +202,15 @@ const ProjectTitle = styled(Typography)(({ theme }) => ({ lineHeight: 1.3, letterSpacing: "-0.015em", color: theme.palette.text.primary, + wordBreak: "break-word", + overflowWrap: "break-word", })); const OneLineInfo = styled(Typography)(() => ({ lineHeight: 1.4, fontWeight: 600, + wordBreak: "break-word", + overflowWrap: "break-word", })); const SimpleInfo = styled(Typography)(() => ({ @@ -208,6 +219,8 @@ const SimpleInfo = styled(Typography)(() => ({ display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", + wordBreak: "break-word", + overflowWrap: "break-word", })); const UserProfileContainer = styled(Stack)(({ theme }) => ({ diff --git a/src/entities/projects/ui/projects-stats/ProjectsStats.tsx b/src/entities/projects/ui/projects-stats/ProjectsStats.tsx index 7bbdca3..7bbbf07 100644 --- a/src/entities/projects/ui/projects-stats/ProjectsStats.tsx +++ b/src/entities/projects/ui/projects-stats/ProjectsStats.tsx @@ -4,53 +4,57 @@ import RocketLaunchIcon from "@mui/icons-material/RocketLaunch"; import { Card, CardContent, Stack, styled, Typography } from "@mui/material"; import type { JSX } from "react"; +import FadeInUpOnView from "@shared/ui/animations/FadeInUpOnView"; + const ProjectsStats = (): JSX.Element => { const mock = [ { id: "a", title: "진행중인 프로젝트", value: 110, - icon: , + icon: , color: "#2563eb", }, { id: "b", title: "활성 사용자", value: 120, - icon: , + icon: , color: "#16a34a", }, { id: "c", title: "완성된 프로젝트", value: 130, - icon: , + icon: , color: "#eab308", }, ]; return ( - <> - {mock.map((stat) => { + + {mock.map((stat, index) => { return ( - - - - - {stat.icon} - - - {`${stat.value}+`} - - - {stat.title} - - - - + + + + + + {stat.icon} + + + {`${stat.value}+`} + + + {stat.title} + + + + + ); })} - + ); }; @@ -60,20 +64,48 @@ interface ProjectStatsIconProps { color: string; } -const ProjectStatsCard = styled(Card)(() => ({ - flex: 1, +const ProjectStatsContainer = styled("div")(({ theme }) => ({ + display: "grid", + gridTemplateColumns: "1fr 1fr 1fr", + gap: "3.2rem", + alignItems: "stretch", + width: "100%", + [theme.breakpoints.down("sm")]: { + gridTemplateColumns: "1fr", + gap: "2rem", + }, })); -const ProjectStatsIcon = styled("span")(({ color }) => ({ - color: color, +const ProjectStatsCard = styled(Card)(() => ({ display: "flex", - alignItems: "center", justifyContent: "center", - borderRadius: "12px", - padding: "1.6rem", - backgroundColor: `${color}10`, + flex: 1, + width: "100%", })); +const ProjectStatsIcon = styled("span")( + ({ theme, color }) => ({ + color: color, + display: "flex", + alignItems: "center", + justifyContent: "center", + borderRadius: "12px", + padding: "1.2rem", + backgroundColor: `${color}10`, + + "& svg": { + fontSize: "3.2rem", + }, + + [theme.breakpoints.up("sm")]: { + padding: "1.6rem", + "& svg": { + fontSize: "4.8rem", + }, + }, + }) +); + const ProjectStatsStack = styled(Stack)(() => ({ display: "flex", alignItems: "center", @@ -81,11 +113,19 @@ const ProjectStatsStack = styled(Stack)(() => ({ gap: "0.8rem", })); -const ProjectStatsCount = styled(Typography)(() => ({ - fontSize: "2.4rem", +const ProjectStatsCount = styled(Typography)(({ theme }) => ({ + fontSize: "2rem", fontWeight: "bold", + + [theme.breakpoints.up("sm")]: { + fontSize: "2.4rem", + }, })); -const ProjectStatsTitle = styled(Typography)(() => ({ - fontSize: "1.6rem", +const ProjectStatsTitle = styled(Typography)(({ theme }) => ({ + fontSize: "1.4rem", + + [theme.breakpoints.up("sm")]: { + fontSize: "1.6rem", + }, })); diff --git a/src/entities/user/ui/user-profile/ProjectTabPanel.tsx b/src/entities/user/ui/user-profile/ProjectTabPanel.tsx index 7124fae..93634aa 100644 --- a/src/entities/user/ui/user-profile/ProjectTabPanel.tsx +++ b/src/entities/user/ui/user-profile/ProjectTabPanel.tsx @@ -1,17 +1,22 @@ import { Box, styled } from "@mui/material"; import type { JSX } from "react"; +import { useMemo } from "react"; +import EmptyProjectCard from "@entities/user/ui/user-profile/EmptyProjectCard"; + +import { useLocalPagination } from "@shared/hooks/usePagination"; +import { paginateArray } from "@shared/libs/utils/pagination"; import type { ProjectListRes } from "@shared/types/project"; +import Pagination from "@shared/ui/pagination/Pagination"; import ProjectCard from "@shared/ui/ProjectCard"; -import EmptyProjectCard from "./EmptyProjectCard"; - interface ProjectTabPanelProps { projects: ProjectListRes[]; emptyMessage: string; editMode?: boolean; selectedIds?: string[]; onSelectProject?: (id: string) => void; + itemsPerPage?: number; } const ProjectTabPanel = ({ @@ -20,26 +25,57 @@ const ProjectTabPanel = ({ editMode = false, selectedIds = [], onSelectProject, -}: ProjectTabPanelProps): JSX.Element => - projects && projects.length > 0 ? ( - - {projects.map((project) => ( - onSelectProject && onSelectProject(project.id)} + itemsPerPage = 6, +}: ProjectTabPanelProps): JSX.Element => { + const isEmpty = !projects.length; + + const pagination = useLocalPagination({ + totalCount: projects.length, + perPage: itemsPerPage, + }); + + const paginatedProjects = useMemo(() => { + return paginateArray(projects, pagination.currentPage, itemsPerPage); + }, [projects, pagination.currentPage, itemsPerPage]); + + if (isEmpty) { + return ; + } + + return ( + + + {paginatedProjects.map((project) => ( + onSelectProject && onSelectProject(project.id)} + /> + ))} + + + {projects.length > itemsPerPage && ( + - ))} - - ) : ( - + )} + ); +}; export default ProjectTabPanel; +const Container = styled(Box)(({ theme }) => ({ + display: "flex", + flexDirection: "column", + gap: theme.spacing(3), +})); + const StyledGridContainer = styled(Box)(({ theme }) => ({ display: "grid", gridTemplateColumns: "1fr", diff --git a/src/entities/user/ui/user-profile/UserProfileCard.tsx b/src/entities/user/ui/user-profile/UserProfileCard.tsx index cdda2b0..4fb4321 100644 --- a/src/entities/user/ui/user-profile/UserProfileCard.tsx +++ b/src/entities/user/ui/user-profile/UserProfileCard.tsx @@ -7,21 +7,14 @@ import { CardContent, IconButton, Divider, - Dialog, - DialogContent, - Tooltip, } from "@mui/material"; import { styled as muiStyled } from "@mui/material/styles"; import type { ComponentType, JSX } from "react"; -import { useState } from "react"; - -import { useUpdateUser } from "@entities/user/hooks/useUpdateUser"; -import UpdateUserForm from "@entities/user/ui/UpdateUserForm"; +import { useLikeStore } from "@shared/stores/likeStore"; import { useProjectStore } from "@shared/stores/projectStore"; -import type { User, UserInput } from "@shared/types/user"; +import type { User } from "@shared/types/user"; import { UserExperience } from "@shared/types/user"; -import SnackbarAlert from "@shared/ui/SnackbarAlert"; import TabWithBadge from "./TapWithBadge"; @@ -56,147 +49,56 @@ const UserProfileCard = ({ setTab, ProfileTabChip, }: UserProfileCardProps): JSX.Element => { - const { likeProjects, appliedProjects } = useProjectStore(); - const [openModal, setOpenModal] = useState(false); - const [snackbarOpen, setSnackbarOpen] = useState(false); - - const updateUserMutation = useUpdateUser(); - - const handleOpenModal = (): void => { - setOpenModal(true); - }; - - const handleCloseModal = (): void => { - setOpenModal(false); - }; - - const handleSubmitUpdate = async (userInfo: UserInput): Promise => { - try { - await updateUserMutation.mutateAsync({ - uid: userProfile.id, - userInfo, - }); - - setOpenModal(false); - setSnackbarOpen(true); - } catch (error) { - console.error("프로필 업데이트 실패:", error); - // 에러 처리 로직 추가 가능 - } - }; + const { appliedProjects } = useProjectStore(); + const { likedProjectsCount } = useLikeStore(); return ( - <> - - - - - - - - - - - - - - {userProfile.name} - - - {userRoleMap[userProfile.userRole] || userProfile.userRole} - - - {experienceMap[userProfile.experience] || - userProfile.experience} - - - - + + + + + + + + + + - {userProfile.introduceMyself} + {userProfile.name} - - - - {PROFILE_TABS.map((tabInfo, idx) => ( - setTab(idx)} - ProfileTabChip={ProfileTabChip} - /> - ))} - - 💌 • {userProfile.email} - - - - {/* 프로필 수정 모달 */} - - - - - - - {/* 스낵바 알림 */} - setSnackbarOpen(false)} - message="프로필 정보가 업데이트되었습니다! ✨" - severity="success" - /> - + + {userRoleMap[userProfile.userRole] || userProfile.userRole} + + + {experienceMap[userProfile.experience] || userProfile.experience} + + + + + {userProfile.introduceMyself} + + + + {PROFILE_TABS.map((tabInfo, idx) => ( + setTab(idx)} + ProfileTabChip={ProfileTabChip} + /> + ))} + + 💌 • {userProfile.email} + + ); }; diff --git a/src/entities/user/ui/user-profile/UserProfileProjectList.tsx b/src/entities/user/ui/user-profile/UserProfileProjectList.tsx deleted file mode 100644 index f43edf5..0000000 --- a/src/entities/user/ui/user-profile/UserProfileProjectList.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import { - Box, - Tabs, - Tab, - Button, - Dialog, - DialogTitle, - DialogContent, - DialogActions, - Typography, -} from "@mui/material"; -import type { JSX } from "react"; -import { useState, useCallback } from "react"; - -import ProjectTabPanel from "@entities/user/ui/user-profile/ProjectTabPanel"; - -import { useProjectStore } from "@shared/stores/projectStore"; -import DeleteButton from "@shared/ui/DeleteButton"; -import SnackbarAlert from "@shared/ui/SnackbarAlert"; - -interface UserProfileProjectListProps { - PROFILE_TABS: { label: string; color: string }[]; - tab: number; - setTab: (idx: number) => void; - onDeleteProjects: ( - type: "likeProjects" | "appliedProjects", - ids: string[] - ) => Promise; -} - -const UserProfileProjectList = ({ - PROFILE_TABS, - tab, - setTab, - onDeleteProjects, -}: UserProfileProjectListProps): JSX.Element => { - const [editMode, setEditMode] = useState(false); - const [selectedIds, setSelectedIds] = useState([]); - const [openDialog, setOpenDialog] = useState(false); - const [snackbarOpen, setSnackbarOpen] = useState(false); - - const { - likeProjects, - appliedProjects, - removeLikeProjects, - removeAppliedProjects, - } = useProjectStore(); - - const currentProjects = tab === 0 ? likeProjects : appliedProjects; - const allIds = currentProjects.map((p) => p.id); - const isAllSelected = - selectedIds.length === allIds.length && allIds.length > 0; - - const handleToggleAll = useCallback(() => { - setSelectedIds(isAllSelected ? [] : allIds); - }, [isAllSelected, allIds]); - - const handleSelectProject = useCallback((id: string) => { - setSelectedIds((prev) => - prev.includes(id) ? prev.filter((pid) => pid !== id) : [...prev, id] - ); - }, []); - - const handleDelete = useCallback(() => setOpenDialog(true), []); - const handleCancelDelete = useCallback(() => setOpenDialog(false), []); - - const handleConfirmDelete = useCallback(async () => { - if (tab === 0) { - await onDeleteProjects("likeProjects", selectedIds); - removeLikeProjects(selectedIds); - } else { - await onDeleteProjects("appliedProjects", selectedIds); - removeAppliedProjects(selectedIds); - } - setOpenDialog(false); - setSelectedIds([]); - setSnackbarOpen(true); - }, [ - tab, - selectedIds, - onDeleteProjects, - removeLikeProjects, - removeAppliedProjects, - ]); - - return ( - - {/* Tabs와 버튼을 한 줄에 배치 */} - - setTab(v)}> - {PROFILE_TABS.map((tabInfo, _idx) => ( - - ))} - - - {editMode ? ( - <> - - - 삭제 - - - - ) : ( - - )} - - - {tab === 0 && ( - - )} - {tab === 1 && ( - - )} - {/* 삭제 확인 다이얼로그 */} - - 정말로 삭제하시겠습니까? - - - 선택한 프로젝트를 삭제하면 되돌릴 수 없습니다. - - - - - 삭제 - - - setSnackbarOpen(false)} - message="선택한 프로젝트가 성공적으로 삭제되었습니다." - severity="success" - duration={2500} - anchorOrigin={{ vertical: "top", horizontal: "center" }} - /> - - ); -}; - -export default UserProfileProjectList; diff --git a/src/features/projects/api/createProjectLikeApi.ts b/src/features/projects/api/createProjectLikeApi.ts new file mode 100644 index 0000000..fc43cae --- /dev/null +++ b/src/features/projects/api/createProjectLikeApi.ts @@ -0,0 +1,44 @@ +import type { User } from "firebase/auth"; +import { deleteDoc, doc, getDoc, setDoc } from "firebase/firestore"; + +import { db } from "@shared/firebase/firebase"; +import type { ToggleProjectLikeResponse } from "@shared/types/like"; + +export const toggleProjectLike = async ( + userId: User["uid"] | undefined, + projectId: string | undefined +): Promise => { + if (!userId) { + throw new Error("유저 정보가 없습니다 로그인 해주세요."); + } + + if (!projectId) { + throw new Error("유효하지 않은 프로젝트 정보입니다."); + } + + const LIKE_ID = `${userId}_${projectId}`; + + const likeRef = doc(db, "likes", LIKE_ID); + const snapshot = await getDoc(likeRef); + + if (snapshot.exists()) { + await deleteDoc(likeRef); + + return { + success: true, + message: "좋아요 취소 완료", + liked: false, + }; + } + + await setDoc(likeRef, { + userId, + projectId, + }); + + return { + success: true, + message: "좋아요 완료", + liked: true, + }; +}; diff --git a/src/features/projects/hook/useLike.tsx b/src/features/projects/hook/useLike.tsx deleted file mode 100644 index 41a6f19..0000000 --- a/src/features/projects/hook/useLike.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { useState } from "react"; -import { useParams } from "react-router-dom"; - -import useProjectLike from "@features/projects/queries/useProjectLike"; -import useProjectUnLike from "@features/projects/queries/useProjectUnLike"; - -import { useAuthStore } from "@shared/stores/authStore"; - -interface LikeResult { - isLike: boolean; - likeFn: () => void; -} - -const useLike = ({ likedUsers }: { likedUsers: string[] }): LikeResult => { - const { id: projectID } = useParams(); - const user = useAuthStore((state) => state.user); - const { mutate: updateLike, isPending: likePending } = useProjectLike(); - const { mutate: updateUnLike, isPending: unLikePending } = useProjectUnLike(); - - const initLike = user && likedUsers.includes(user.uid); - const [isLike, setIsLike] = useState(initLike || false); - - const updateLikeStatus = (): void => { - if (!projectID) return; - - if (!user) { - alert("로그인 해주세요."); - return; - } - - if (likePending || unLikePending) { - alert("동작이 너무 빠릅니다. 잠시 후에 시도해주십시오."); - return; - } - - if (!isLike) { - setIsLike(true); - updateLike(projectID); - } else { - setIsLike(false); - updateUnLike(projectID); - } - }; - - return { - isLike, - likeFn: updateLikeStatus, - }; -}; - -export default useLike; diff --git a/src/features/projects/queries/useCreateProjectLike.ts b/src/features/projects/queries/useCreateProjectLike.ts new file mode 100644 index 0000000..e3df99e --- /dev/null +++ b/src/features/projects/queries/useCreateProjectLike.ts @@ -0,0 +1,128 @@ +import { + useMutation, + useQueryClient, + type UseMutationResult, +} from "@tanstack/react-query"; +import { useCallback, useRef, useState, useEffect } from "react"; +import { useParams } from "react-router-dom"; + +import { toggleProjectLike } from "@features/projects/api/createProjectLikeApi"; + +import { useGetProjectLike } from "@entities/projects/queries/useGetProjectLike"; + +import queryKeys from "@shared/react-query/queryKey"; +import { useAuthStore } from "@shared/stores/authStore"; +import { useLikeStore } from "@shared/stores/likeStore"; +import type { ToggleProjectLikeResponse } from "@shared/types/like"; + +const DEBOUNCE_DELAY_MS = 100; + +export const useToggleProjectLikeSync = (): UseMutationResult< + ToggleProjectLikeResponse, + Error, + string +> => { + const user = useAuthStore((state) => state.user); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (projectId: string) => toggleProjectLike(user?.uid, projectId), + + onSettled: (_data, _error, projectId) => { + queryClient.invalidateQueries({ + queryKey: [queryKeys.projectLike, projectId], + }); + queryClient.invalidateQueries({ + queryKey: [queryKeys.projectLikedUser, projectId], + }); + queryClient.invalidateQueries({ + queryKey: [queryKeys.projects], + }); + queryClient.invalidateQueries({ + queryKey: [queryKeys.project, projectId], + }); + queryClient.invalidateQueries({ + queryKey: [queryKeys.myLikedProjects, "ids"], + }); + queryClient.invalidateQueries({ + queryKey: [queryKeys.myLikedProjects, "details"], + }); + }, + }); +}; + +interface UseOptimisticProjectLikeProps { + isLiked: boolean; + isLoading: boolean; + toggleLike: () => void; +} + +export const useOptimisticProjectLike = (): UseOptimisticProjectLikeProps => { + const { id: projectId } = useParams(); + const { data: serverLikeStatus, isLoading } = useGetProjectLike(); + const { mutate: syncToServer } = useToggleProjectLikeSync(); + const { addLikedProject, removeLikedProject } = useLikeStore(); + + const [optimisticLikeStatus, setOptimisticLikeStatus] = useState< + boolean | undefined + >(serverLikeStatus); + const debounceTimerRef = useRef(null); + const pendingServerSync = useRef(false); + + useEffect(() => { + if (serverLikeStatus !== undefined && !pendingServerSync.current) { + setOptimisticLikeStatus(serverLikeStatus); + } + }, [serverLikeStatus]); + + useEffect(() => { + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + pendingServerSync.current = false; + }; + }, [projectId]); + + const toggleLike = useCallback(() => { + if (!projectId || isLoading) return; + + const newLikeStatus = !optimisticLikeStatus; + setOptimisticLikeStatus(newLikeStatus); + + // 전역 상태도 업데이트 + if (newLikeStatus) { + addLikedProject(projectId); + } else { + removeLikedProject(projectId); + } + + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + + pendingServerSync.current = true; + debounceTimerRef.current = setTimeout(() => { + syncToServer(projectId, { + onSettled: () => { + pendingServerSync.current = false; + }, + }); + }, DEBOUNCE_DELAY_MS); + }, [ + projectId, + isLoading, + optimisticLikeStatus, + addLikedProject, + removeLikedProject, + syncToServer, + ]); + + const displayLikeStatus = optimisticLikeStatus ?? serverLikeStatus ?? false; + + return { + isLiked: displayLikeStatus, + isLoading, + toggleLike, + }; +}; diff --git a/src/features/projects/queries/useProjectLike.ts b/src/features/projects/queries/useProjectLike.ts deleted file mode 100644 index 70591be..0000000 --- a/src/features/projects/queries/useProjectLike.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { useMutation, type UseMutationResult } from "@tanstack/react-query"; - -import { updateApplyOrLike } from "@features/projects/api/projectsApi"; - -import type { ApiResMessage } from "@entities/projects/types/firebase"; - -import queryClient from "@shared/react-query/queryClient"; -import { useAuthStore } from "@shared/stores/authStore"; - -const useProjectLike = (): UseMutationResult => { - const user = useAuthStore((state) => state.user); - - return useMutation({ - mutationFn: (projectID: string) => { - if (!user) { - throw new Error("로그인을 해주세요."); - } - return updateApplyOrLike(user.uid, projectID, "like"); - }, - onSuccess: (data, projectID) => { - if (data.success) { - queryClient.invalidateQueries({ - queryKey: ["project-detail", projectID], - }); - return; - } - }, - onError: (err) => { - alert(err); - console.log(err); - }, - }); -}; -export default useProjectLike; diff --git a/src/features/projects/queries/useProjectUnLike.ts b/src/features/projects/queries/useProjectUnLike.ts deleted file mode 100644 index 1794717..0000000 --- a/src/features/projects/queries/useProjectUnLike.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { useMutation, type UseMutationResult } from "@tanstack/react-query"; - -import { updateUnLike } from "@features/projects/api/projectsApi"; - -import type { ApiResMessage } from "@entities/projects/types/firebase"; - -import queryClient from "@shared/react-query/queryClient"; -import { useAuthStore } from "@shared/stores/authStore"; - -const useProjectUnLike = (): UseMutationResult< - ApiResMessage, - Error, - string -> => { - const user = useAuthStore((state) => state.user); - - return useMutation({ - mutationFn: (projectID: string) => { - if (!user) { - throw new Error("로그인을 해주세요."); - } - return updateUnLike(user.uid, projectID); - }, - onSuccess: (data, projectID) => { - if (data.success) { - queryClient.invalidateQueries({ - queryKey: ["project-detail", projectID], - }); - return; - } - }, - onError: (err) => { - alert(err); - console.log(err); - }, - }); -}; -export default useProjectUnLike; diff --git a/src/features/projects/ui/ProjectLike.tsx b/src/features/projects/ui/ProjectLike.tsx index b7d3b62..0771df4 100644 --- a/src/features/projects/ui/ProjectLike.tsx +++ b/src/features/projects/ui/ProjectLike.tsx @@ -1,7 +1,7 @@ -import type { JSX } from "@emotion/react/jsx-runtime"; import { Box, styled } from "@mui/material"; +import type { JSX } from "react"; -import useLike from "@features/projects/hook/useLike"; +import { useOptimisticProjectLike } from "@features/projects/queries/useCreateProjectLike"; import { getStatusClassname, @@ -14,12 +14,19 @@ import { ShareIcon, } from "@shared/ui/icons/CommonIcons"; -type ProjectLikeType = Pick; +type ProjectLikeType = Pick; -const ProjectLike = ({ values }: { values: ProjectLikeType }): JSX.Element => { - const { likeFn, isLike } = useLike({ - likedUsers: values.likedUsers, - }); +interface ProjectLikeProps { + values: ProjectLikeType; +} + +const ProjectLike = ({ values }: ProjectLikeProps): JSX.Element => { + const { isLiked, isLoading, toggleLike } = useOptimisticProjectLike(); + + const handleLikeClick = (): void => { + if (isLoading) return; + toggleLike(); + }; return ( @@ -28,8 +35,8 @@ const ProjectLike = ({ values }: { values: ProjectLikeType }): JSX.Element => { - - {isLike ? ( + + {isLiked ? ( ) : ( @@ -42,6 +49,7 @@ const ProjectLike = ({ values }: { values: ProjectLikeType }): JSX.Element => { ); }; + export default ProjectLike; const HeadIconBox = styled(Box)` diff --git a/src/pages/home/ui/HomePage.tsx b/src/pages/home/ui/HomePage.tsx index a07c3c3..e50069b 100644 --- a/src/pages/home/ui/HomePage.tsx +++ b/src/pages/home/ui/HomePage.tsx @@ -7,6 +7,8 @@ import useGetProjects from "@entities/projects/hook/useGetProjects"; import ProjectCard from "@entities/projects/ui/projects-card/ProjectCard"; import ProjectsStats from "@entities/projects/ui/projects-stats/ProjectsStats"; +import FadeInUpOnView from "@shared/ui/animations/FadeInUpOnView"; + const HomePage = (): JSX.Element => { const { data: projects } = useGetProjects({ pageSize: 3 }); @@ -15,14 +17,20 @@ const HomePage = (): JSX.Element => { + - - {projects?.projects.map((project) => ( - - ))} - + + + + {projects?.projects.map((project, index) => ( + + + + ))} + + ); }; @@ -41,41 +49,56 @@ const HeroContainer = styled(Box)(({ theme }) => ({ alignItems: "center", justifyContent: "center", backgroundColor: theme.palette.background.default, - padding: "2rem 0rem", + padding: "2rem 0", [theme.breakpoints.up("sm")]: { - padding: "4rem 2rem", + padding: "4rem 0", }, [theme.breakpoints.up("md")]: { - padding: "6rem 2.4rem", + padding: "6rem 0", }, })); const ProjectStatsContainer = styled(Box)(({ theme }) => ({ display: "flex", - flexDirection: "column", - gap: "3.2rem", - justifyContent: "center", - padding: "2rem 0rem", + width: "100%", + padding: "2rem 0", [theme.breakpoints.up("sm")]: { - flexDirection: "row", - padding: "4rem 2rem", + padding: "4rem 0", }, [theme.breakpoints.up("md")]: { - padding: "6rem 2.4rem", + padding: "6rem 0", }, })); const ProjectCardContainer = styled(Box)(({ theme }) => ({ display: "grid", gridTemplateColumns: "1fr", + gridAutoRows: "1fr", gap: "1.6rem", - padding: "2rem 0rem", + alignItems: "stretch", + [theme.breakpoints.up("sm")]: { - gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))", - padding: "4rem 2rem", + gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", }, + [theme.breakpoints.up("md")]: { gridTemplateColumns: "repeat(auto-fit, minmax(320px, 1fr))", - padding: "6rem 2.4rem", + }, + + "& > div": { + height: "100%", + display: "flex", + flexDirection: "column", + minWidth: 0, + }, +})); + +const ProjectSectionContainer = styled(Box)(({ theme }) => ({ + padding: "2rem 0", + [theme.breakpoints.up("sm")]: { + padding: "4rem 0", + }, + [theme.breakpoints.up("md")]: { + padding: "6rem 0", }, })); diff --git a/src/pages/project-detail/ui/ProjectDetailPage.tsx b/src/pages/project-detail/ui/ProjectDetailPage.tsx index d72590b..c3d2a11 100644 --- a/src/pages/project-detail/ui/ProjectDetailPage.tsx +++ b/src/pages/project-detail/ui/ProjectDetailPage.tsx @@ -32,11 +32,6 @@ const ProjectDetailPage = (): JSX.Element | null => { isError, } = useProjectsItem({ id: id || null }); - const projectLikeValues = { - status: (project?.status as RecruitmentStatus) || "모집중", - likedUsers: project?.likedUsers || [], - }; - const projectInfoValues = !project ? null : { @@ -53,6 +48,10 @@ const ProjectDetailPage = (): JSX.Element | null => { techStack: project?.techStack || [], }; + const projectLikeValues = { + status: (project?.status as RecruitmentStatus) || "모집중", + }; + const descriptionlValues = { description: project?.description || "", }; diff --git a/src/pages/user-profile/ui/UserProfilePage.tsx b/src/pages/user-profile/ui/UserProfilePage.tsx index cd5fcb9..0b65068 100644 --- a/src/pages/user-profile/ui/UserProfilePage.tsx +++ b/src/pages/user-profile/ui/UserProfilePage.tsx @@ -1,23 +1,23 @@ import { Box, Container, Chip as MuiChip } from "@mui/material"; import { styled as muiStyled } from "@mui/material/styles"; import type { JSX } from "react"; -import { useState, useEffect } from "react"; - -import { removeProjectsFromUser } from "@features/projects/api/projectsApi"; +import { useState, useEffect, useCallback } from "react"; import { useProjectsByIds } from "@entities/projects/hook/useProjectsByIds"; +import { useGetMyLikedProjectsWithDetails } from "@entities/projects/queries/useGetProjectLike"; +import ProjectCollectionContainer from "@entities/projects/ui/project-collection-tab/ProjectCollectionContainer"; import UserProfileCard from "@entities/user/ui/user-profile/UserProfileCard"; import UserProfileHeader from "@entities/user/ui/user-profile/UserProfileHeader"; -import UserProfileProjectList from "@entities/user/ui/user-profile/UserProfileProjectList"; import { useUserProfile } from "@shared/queries/useUserProfile"; import { useAuthStore } from "@shared/stores/authStore"; +import { useLikeStore } from "@shared/stores/likeStore"; import { useProjectStore } from "@shared/stores/projectStore"; import LoadingSpinner from "@shared/ui/loading-spinner/LoadingSpinner"; import UserNotFound from "./UserNotFound"; -// 탭 이름 상수 배열 +// 탭 이름 상수 배열 (UserProfileCard용으로 유지) const PROFILE_TABS = [ { label: "관심있는 프로젝트", color: "primary" }, { label: "지원한 프로젝트", color: "secondary" }, @@ -26,37 +26,56 @@ const PROFILE_TABS = [ const UserProfilePage = (): JSX.Element => { const { user } = useAuthStore(); const uid = user?.uid; - const { data: userProfile, isLoading } = useUserProfile(uid ?? ""); + const { data: userProfile, isLoading: userProfileLoading } = useUserProfile( + uid ?? "" + ); // zustand store 사용 const { setLikeProjects, setAppliedProjects } = useProjectStore(); + const { setLikedProjectIds } = useLikeStore(); // 관심있는/지원한 프로젝트 id 배열 - const likeIds = userProfile?.likeProjects ?? []; const appliedIds = userProfile?.appliedProjects ?? []; // 프로젝트 데이터 가져오기 - const { data: likeProjectsData } = useProjectsByIds(likeIds); - const { data: appliedProjectsData } = useProjectsByIds(appliedIds); + const { data: appliedProjectsData, isLoading: appliedProjectsLoading } = + useProjectsByIds(appliedIds); + const { data: myLikedProjectsData, isLoading: myLikedProjectsLoading } = + useGetMyLikedProjectsWithDetails(); // zustand store에 동기화 useEffect(() => { - if (likeProjectsData) setLikeProjects(likeProjectsData); - }, [likeProjectsData, setLikeProjects]); + if (myLikedProjectsData) { + setLikeProjects(myLikedProjectsData); + // 좋아요 프로젝트 ID들도 likeStore에 동기화 + const likedIds = myLikedProjectsData.map((project) => project.id); + setLikedProjectIds(likedIds); + } + }, [myLikedProjectsData, setLikeProjects, setLikedProjectIds]); useEffect(() => { if (appliedProjectsData) setAppliedProjects(appliedProjectsData); }, [appliedProjectsData, setAppliedProjects]); const [tab, setTab] = useState(0); - const handleDeleteProjects = async ( - type: "likeProjects" | "appliedProjects", - ids: string[] - ): Promise => { - if (!user) return; - await removeProjectsFromUser(user.uid, type, ids); - }; - - if (isLoading) { + + // 프로젝트 삭제 핸들러 + const handleDeleteProjects = useCallback( + async (type: "liked" | "applied" | "created", ids: string[]) => { + // TODO: 실제 삭제 API 호출 + console.log(`Deleting ${type} projects:`, ids); + + // 임시로 store에서 제거 + if (type === "liked") { + // removeLikeProjects 호출 또는 API 후 refetch + } else if (type === "applied") { + // removeAppliedProjects 호출 또는 API 후 refetch + } + }, + [] + ); + + // 사용자 프로필이 로딩 중이거나 없으면 early return + if (userProfileLoading) { return ; } if (!userProfile) { @@ -81,11 +100,13 @@ const UserProfilePage = (): JSX.Element => { setTab={setTab} ProfileTabChip={ProfileTabChip} /> - {/* 오른쪽 메인: 탭 + 프로젝트 카드 */} - diff --git a/src/shared/hooks/useIntersectionObserver.ts b/src/shared/hooks/useIntersectionObserver.ts new file mode 100644 index 0000000..507468d --- /dev/null +++ b/src/shared/hooks/useIntersectionObserver.ts @@ -0,0 +1,62 @@ +import { useEffect, useRef, useState } from "react"; + +interface UseIntersectionObserverOptions { + threshold?: number | number[]; + rootMargin?: string; + triggerOnce?: boolean; +} + +interface UseIntersectionObserverReturn { + ref: React.RefObject; + isIntersecting: boolean; + hasIntersected: boolean; +} + +const useIntersectionObserver = ( + options: UseIntersectionObserverOptions = {} +): UseIntersectionObserverReturn => { + const { threshold = 0.1, rootMargin = "0px", triggerOnce = true } = options; + + const ref = useRef(null); + const [isIntersecting, setIsIntersecting] = useState(false); + const [hasIntersected, setHasIntersected] = useState(false); + + useEffect(() => { + const element = ref.current; + if (!element) return; + + const observer = new IntersectionObserver( + ([entry]) => { + const isCurrentlyIntersecting = entry.isIntersecting; + + setIsIntersecting(isCurrentlyIntersecting); + + if (isCurrentlyIntersecting && !hasIntersected) { + setHasIntersected(true); + + if (triggerOnce) { + observer.unobserve(element); + } + } + }, + { + threshold, + rootMargin, + } + ); + + observer.observe(element); + + return () => { + observer.unobserve(element); + }; + }, [threshold, rootMargin, triggerOnce, hasIntersected]); + + return { + ref, + isIntersecting, + hasIntersected, + }; +}; + +export default useIntersectionObserver; diff --git a/src/shared/hooks/usePagination.ts b/src/shared/hooks/usePagination.ts index 61a57c7..7c7d8d6 100644 --- a/src/shared/hooks/usePagination.ts +++ b/src/shared/hooks/usePagination.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback } from "react"; +import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { useSearchParams } from "react-router-dom"; interface UsePaginationProps { @@ -11,6 +11,8 @@ interface UsePaginationReturn { pageNumbers: (number | "ellipsis")[]; canGoPrev: boolean; canGoNext: boolean; + canGoFastPrev: boolean; + canGoFastNext: boolean; } interface UsePaginationWithStateProps { @@ -24,59 +26,75 @@ interface UsePaginationWithStateReturn extends UsePaginationReturn { totalPages: number; setPage: (page: number) => void; goToReset: () => void; + goFastPrev: () => void; + goFastNext: () => void; } +interface UseArrayPaginationProps { + data: T[]; + itemsPerPage?: number; + maxVisiblePages?: number; +} + +interface UseArrayPaginationReturn extends UsePaginationReturn { + currentPage: number; + totalPages: number; + paginatedData: T[]; + isEmpty: boolean; + setPage: (page: number) => void; + goToReset: () => void; + goFastPrev: () => void; + goFastNext: () => void; +} + +const range = (start: number, end: number): number[] => { + return Array.from({ length: end - start + 1 }, (_, i) => start + i); +}; + +const generateBlockPageNumbers = ( + currentPage: number, + totalPages: number, + maxVisiblePages: number +): (number | "ellipsis")[] => { + if (totalPages <= maxVisiblePages) { + return range(1, totalPages); + } + + const currentBlock = Math.floor((currentPage - 1) / maxVisiblePages); + + const blockStartPage = currentBlock * maxVisiblePages + 1; + const blockEndPage = Math.min( + blockStartPage + maxVisiblePages - 1, + totalPages + ); + + return range(blockStartPage, blockEndPage); +}; + const usePagination = ({ currentPage, totalPages, maxVisiblePages = 5, }: UsePaginationProps): UsePaginationReturn => { - const generatePageNumbers = (): (number | "ellipsis")[] => { - const pages: (number | "ellipsis")[] = []; - - if (totalPages <= maxVisiblePages) { - for (let i = 1; i <= totalPages; i++) { - pages.push(i); - } - } else { - if (currentPage <= 3) { - for (let i = 1; i <= maxVisiblePages; i++) { - pages.push(i); - } - if (totalPages > maxVisiblePages) { - pages.push("ellipsis"); - pages.push(totalPages); - } - } else if (currentPage >= totalPages - 2) { - pages.push(1); - if (totalPages > maxVisiblePages) { - pages.push("ellipsis"); - } - for (let i = totalPages - (maxVisiblePages - 1); i <= totalPages; i++) { - pages.push(i); - } - } else { - pages.push(1); - pages.push("ellipsis"); - for (let i = currentPage - 1; i <= currentPage + 1; i++) { - pages.push(i); - } - pages.push("ellipsis"); - pages.push(totalPages); - } - } - - return pages; - }; + const pageNumbers = generateBlockPageNumbers( + currentPage, + totalPages, + maxVisiblePages + ); - const pageNumbers = generatePageNumbers(); const canGoPrev = currentPage > 1; const canGoNext = currentPage < totalPages; + const currentBlock = Math.floor((currentPage - 1) / maxVisiblePages); + const canGoFastPrev = currentBlock > 0; + const canGoFastNext = (currentBlock + 1) * maxVisiblePages < totalPages; + return { pageNumbers, canGoPrev, canGoNext, + canGoFastPrev, + canGoFastNext, }; }; @@ -121,6 +139,20 @@ export const usePaginationWithState = ({ updatePageInURL(1); }; + const goFastPrev = (): void => { + const currentBlock = Math.floor((currentPage - 1) / maxVisiblePages); + const prevBlockLastPage = currentBlock * maxVisiblePages; + const newPage = Math.max(1, prevBlockLastPage); + setPage(newPage); + }; + + const goFastNext = (): void => { + const currentBlock = Math.floor((currentPage - 1) / maxVisiblePages); + const nextBlockFirstPage = (currentBlock + 1) * maxVisiblePages + 1; + const newPage = Math.min(totalPages, nextBlockFirstPage); + setPage(newPage); + }; + useEffect(() => { if (isInternalUpdate.current) { isInternalUpdate.current = false; @@ -132,10 +164,7 @@ export const usePaginationWithState = ({ const validPage = urlPage > 0 ? urlPage : 1; setCurrentPage((prevPage) => { - if (validPage !== prevPage) { - return validPage; - } - return prevPage; + return validPage !== prevPage ? validPage : prevPage; }); }, [searchParams]); @@ -151,20 +180,157 @@ export const usePaginationWithState = ({ } }, [totalPages, updatePageInURL]); - const { pageNumbers, canGoPrev, canGoNext } = usePagination({ + const { pageNumbers, canGoPrev, canGoNext, canGoFastPrev, canGoFastNext } = + usePagination({ + currentPage, + totalPages, + maxVisiblePages, + }); + + return { currentPage, totalPages, - maxVisiblePages, - }); + pageNumbers, + canGoPrev, + canGoNext, + canGoFastPrev, + canGoFastNext, + setPage, + goToReset, + goFastPrev, + goFastNext, + }; +}; + +export const useLocalPagination = ({ + totalCount, + perPage = 6, + maxVisiblePages = 5, +}: UsePaginationWithStateProps): UsePaginationWithStateReturn => { + const [currentPage, setCurrentPage] = useState(1); + const totalPages = Math.ceil(totalCount / perPage); + + const setPage = (page: number): void => { + if (page < 1 || page > totalPages) return; + setCurrentPage(page); + }; + + const goToReset = (): void => { + setCurrentPage(1); + }; + + const goFastPrev = (): void => { + const currentBlock = Math.floor((currentPage - 1) / maxVisiblePages); + const prevBlockLastPage = currentBlock * maxVisiblePages; + const newPage = Math.max(1, prevBlockLastPage); + setPage(newPage); + }; + + const goFastNext = (): void => { + const currentBlock = Math.floor((currentPage - 1) / maxVisiblePages); + const nextBlockFirstPage = (currentBlock + 1) * maxVisiblePages + 1; + const newPage = Math.min(totalPages, nextBlockFirstPage); + setPage(newPage); + }; + + useEffect(() => { + if (totalPages > 0 && currentPage > totalPages) { + setCurrentPage(totalPages); + } + }, [totalPages, currentPage]); + + const { pageNumbers, canGoPrev, canGoNext, canGoFastPrev, canGoFastNext } = + usePagination({ + currentPage, + totalPages, + maxVisiblePages, + }); + + return { + currentPage, + totalPages, + pageNumbers, + canGoPrev, + canGoNext, + canGoFastPrev, + canGoFastNext, + setPage, + goToReset, + goFastPrev, + goFastNext, + }; +}; + +export const useArrayPagination = ({ + data, + itemsPerPage = 6, + maxVisiblePages = 5, +}: UseArrayPaginationProps): UseArrayPaginationReturn => { + const [currentPage, setCurrentPage] = useState(1); + + const isEmpty = !data || data.length === 0; + const totalPages = isEmpty ? 0 : Math.ceil(data.length / itemsPerPage); + + const paginatedData = useMemo(() => { + if (isEmpty) return []; + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = startIndex + itemsPerPage; + return data.slice(startIndex, endIndex); + }, [data, currentPage, itemsPerPage, isEmpty]); + + const setPage = useCallback( + (page: number): void => { + if (page < 1 || page > totalPages) return; + setCurrentPage(page); + }, + [totalPages] + ); + + const goToReset = useCallback((): void => { + setCurrentPage(1); + }, []); + + const goFastPrev = useCallback((): void => { + const currentBlock = Math.floor((currentPage - 1) / maxVisiblePages); + const prevBlockLastPage = currentBlock * maxVisiblePages; + const newPage = Math.max(1, prevBlockLastPage); + setPage(newPage); + }, [currentPage, maxVisiblePages, setPage]); + + const goFastNext = useCallback((): void => { + const currentBlock = Math.floor((currentPage - 1) / maxVisiblePages); + const nextBlockFirstPage = (currentBlock + 1) * maxVisiblePages + 1; + const newPage = Math.min(totalPages, nextBlockFirstPage); + setPage(newPage); + }, [currentPage, maxVisiblePages, totalPages, setPage]); + + useEffect(() => { + if (totalPages > 0 && currentPage > totalPages) { + setCurrentPage(totalPages); + } + }, [totalPages, currentPage]); + + const { pageNumbers, canGoPrev, canGoNext, canGoFastPrev, canGoFastNext } = + usePagination({ + currentPage, + totalPages, + maxVisiblePages, + }); return { currentPage, totalPages, + paginatedData, + isEmpty, pageNumbers, canGoPrev, canGoNext, + canGoFastPrev, + canGoFastNext, setPage, goToReset, + goFastPrev, + goFastNext, }; }; diff --git a/src/shared/libs/utils/pagination.ts b/src/shared/libs/utils/pagination.ts new file mode 100644 index 0000000..27d7451 --- /dev/null +++ b/src/shared/libs/utils/pagination.ts @@ -0,0 +1,20 @@ +export const paginateArray = ( + array: T[], + currentPage: number, + itemsPerPage: number +): T[] => { + if (!array || array.length === 0) return []; + + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = startIndex + itemsPerPage; + + return array.slice(startIndex, endIndex); +}; + +export const calculateTotalPages = ( + totalItems: number, + itemsPerPage: number +): number => { + if (totalItems <= 0) return 0; + return Math.ceil(totalItems / itemsPerPage); +}; diff --git a/src/shared/react-query/queryKey.ts b/src/shared/react-query/queryKey.ts new file mode 100644 index 0000000..fbf1944 --- /dev/null +++ b/src/shared/react-query/queryKey.ts @@ -0,0 +1,10 @@ +const queryKeys = { + projectLikedUser: "project-liked-users", + projectLike: "project-like", + project: "project", + projects: "projects", + projectStats: "project-stats", + myLikedProjects: "my-liked-projects", +}; + +export default queryKeys; diff --git a/src/shared/stores/likeStore.ts b/src/shared/stores/likeStore.ts new file mode 100644 index 0000000..20b3a83 --- /dev/null +++ b/src/shared/stores/likeStore.ts @@ -0,0 +1,47 @@ +import { create } from "zustand"; + +interface LikeStore { + likedProjectIds: string[]; + likedProjectsCount: number; + setLikedProjectIds: (projectIds: string[]) => void; + addLikedProject: (projectId: string) => void; + removeLikedProject: (projectId: string) => void; + isProjectLiked: (projectId: string) => boolean; +} + +export const useLikeStore = create((set, get) => ({ + likedProjectIds: [], + likedProjectsCount: 0, + + setLikedProjectIds: (projectIds) => + set({ + likedProjectIds: projectIds, + likedProjectsCount: projectIds.length, + }), + + addLikedProject: (projectId) => + set((state) => { + if (!state.likedProjectIds.includes(projectId)) { + const newIds = [...state.likedProjectIds, projectId]; + return { + likedProjectIds: newIds, + likedProjectsCount: newIds.length, + }; + } + return state; + }), + + removeLikedProject: (projectId) => + set((state) => { + const newIds = state.likedProjectIds.filter((id) => id !== projectId); + return { + likedProjectIds: newIds, + likedProjectsCount: newIds.length, + }; + }), + + isProjectLiked: (projectId) => { + const { likedProjectIds } = get(); + return likedProjectIds.includes(projectId); + }, +})); diff --git a/src/shared/types/like.ts b/src/shared/types/like.ts new file mode 100644 index 0000000..ebffabe --- /dev/null +++ b/src/shared/types/like.ts @@ -0,0 +1,5 @@ +export interface ToggleProjectLikeResponse { + success: boolean; + message: string; + liked: boolean; +} diff --git a/src/shared/ui/ProjectCard.tsx b/src/shared/ui/ProjectCard.tsx index dc5118d..245ef67 100644 --- a/src/shared/ui/ProjectCard.tsx +++ b/src/shared/ui/ProjectCard.tsx @@ -76,9 +76,9 @@ const ProjectCard = ({ {isMobile ? ( ) : ( @@ -124,7 +124,7 @@ const ProjectCard = ({ - {project.applicants.length}명 지원 + 지원 diff --git a/src/shared/ui/animations/FadeInUp.tsx b/src/shared/ui/animations/FadeInUp.tsx new file mode 100644 index 0000000..5f00272 --- /dev/null +++ b/src/shared/ui/animations/FadeInUp.tsx @@ -0,0 +1,31 @@ +import { Box, styled, keyframes } from "@mui/material"; + +const ANIMATION_DELAY_MS = 1000; + +interface FadeInUpProps { + delay?: number; + duration?: number; + distance?: number; +} + +const fadeInUpAnimation = keyframes` + from { + opacity: 0; + transform: translateY(var(--distance, 20px)); + } + to { + opacity: 1; + transform: translateY(0); + } +`; + +const FadeInUp = styled(Box)( + ({ delay = 0, duration = 0.6, distance = 20 }) => ({ + animation: `${fadeInUpAnimation} ${duration}s ease-out forwards`, + animationDelay: `${delay * ANIMATION_DELAY_MS}ms`, + opacity: 0, + "--distance": `${distance}px`, + }) +); + +export default FadeInUp; diff --git a/src/shared/ui/animations/FadeInUpOnView.tsx b/src/shared/ui/animations/FadeInUpOnView.tsx new file mode 100644 index 0000000..754e8b8 --- /dev/null +++ b/src/shared/ui/animations/FadeInUpOnView.tsx @@ -0,0 +1,79 @@ +import { styled, keyframes } from "@mui/material"; +import { type JSX, type ReactNode } from "react"; + +import useIntersectionObserver from "@shared/hooks/useIntersectionObserver"; + +const ANIMATION_DELAY_MS = 500; + +interface FadeInUpOnViewProps { + children: ReactNode; + delay?: number; + duration?: number; + distance?: number; + threshold?: number; + rootMargin?: string; + className?: string; +} + +interface AnimatedContainerProps { + $hasIntersected: boolean; + $delay: number; + $duration: number; + $distance: number; +} + +const FadeInUpOnView = ({ + children, + delay = 0, + duration = 0.6, + distance = 20, + threshold = 0.1, + rootMargin = "0px", + className, +}: FadeInUpOnViewProps): JSX.Element => { + const { ref, hasIntersected } = useIntersectionObserver({ + threshold, + rootMargin, + triggerOnce: true, + }); + + return ( + } + className={className} + $hasIntersected={hasIntersected} + $delay={delay} + $duration={duration} + $distance={distance} + > + {children} + + ); +}; + +export default FadeInUpOnView; + +const fadeInUpAnimation = keyframes` + from { + opacity: 0; + transform: translateY(var(--distance)); + } + to { + opacity: 1; + transform: translateY(0); + } +`; + +const AnimatedContainer = styled("div")( + ({ $hasIntersected, $delay, $duration, $distance }) => ({ + "--distance": `${$distance}px`, + opacity: 0, + transform: `translateY(${$distance}px)`, + animation: $hasIntersected + ? `${fadeInUpAnimation} ${$duration}s ease-out forwards` + : "none", + animationDelay: $hasIntersected + ? `${$delay * ANIMATION_DELAY_MS}ms` + : "0ms", + }) +); diff --git a/src/shared/ui/pagination/Pagination.tsx b/src/shared/ui/pagination/Pagination.tsx index 26a10c7..9468faa 100644 --- a/src/shared/ui/pagination/Pagination.tsx +++ b/src/shared/ui/pagination/Pagination.tsx @@ -20,6 +20,7 @@ interface PaginationProps { totalPages: number; onPageChange: (page: number) => void; disabled?: boolean; + showFastNavigation?: boolean; } const Pagination = ({ @@ -27,28 +28,60 @@ const Pagination = ({ totalPages, onPageChange, disabled = false, + showFastNavigation = true, }: PaginationProps): JSX.Element | null => { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down("sm")); - const { pageNumbers, canGoPrev, canGoNext } = usePagination({ - currentPage, - totalPages, - }); + const { pageNumbers, canGoPrev, canGoNext, canGoFastPrev, canGoFastNext } = + usePagination({ + currentPage, + totalPages, + }); if (totalPages <= 1) { return null; } + const handleFastPrev = (): void => { + const currentBlock = Math.floor((currentPage - 1) / 5); + const prevBlockLastPage = currentBlock * 5; + const newPage = Math.max(1, prevBlockLastPage); + onPageChange(newPage); + }; + + const handleFastNext = (): void => { + const currentBlock = Math.floor((currentPage - 1) / 5); + const nextBlockFirstPage = (currentBlock + 1) * 5 + 1; + const newPage = Math.min(totalPages, nextBlockFirstPage); + onPageChange(newPage); + }; + return ( + {showFastNavigation && ( + + + + + + + )} + onPageChange(currentPage - 1)} disabled={!canGoPrev || disabled} size={isMobile ? "large" : "medium"} + title="이전 페이지" > + {pageNumbers.map((page, index) => ( @@ -71,13 +104,29 @@ const Pagination = ({ ))} + onPageChange(currentPage + 1)} disabled={!canGoNext || disabled} size={isMobile ? "large" : "medium"} + title="다음 페이지" > + + {showFastNavigation && ( + + + + + + + )} ); }; @@ -98,6 +147,15 @@ const PageNumbersContainer = styled(Box)(() => ({ gap: "0.5rem", })); +const DoubleChevronContainer = styled(Box)(() => ({ + display: "flex", + alignItems: "center", + marginLeft: "-2px", + "& .MuiSvgIcon-root:last-child": { + marginLeft: "-6px", + }, +})); + const PageButton = styled(Button, { shouldForwardProp: (prop) => !["$isActive", "$isMobile"].includes(prop as string), diff --git a/src/shared/ui/user/UserProfileAvatar.tsx b/src/shared/ui/user/UserProfileAvatar.tsx index 971d2e9..ef3e705 100644 --- a/src/shared/ui/user/UserProfileAvatar.tsx +++ b/src/shared/ui/user/UserProfileAvatar.tsx @@ -5,8 +5,10 @@ import { useState, useCallback, useMemo, memo } from "react"; import type { User } from "@shared/types/user"; import UserProfileWithNamePosition from "@shared/ui/user/UserProfileWithNamePosition"; -interface UserProfileAvatarProps - extends Pick { +interface UserProfileAvatarProps { + name?: string; + userRole: User["userRole"]; + avatar?: User["avatar"]; flexDirection?: CSSProperties["flexDirection"]; } @@ -23,8 +25,10 @@ const UserProfileAvatar = ({ }, []); const avatarProps = useMemo(() => { + const fallbackText = + name && name.length > 0 ? name.charAt(0).toUpperCase() : "?"; return imageError || !avatar - ? { children: name.charAt(0).toUpperCase() } + ? { children: fallbackText } : { src: avatar, onError: handleImageError }; }, [imageError, avatar, name, handleImageError]); @@ -32,7 +36,7 @@ const UserProfileAvatar = ({ diff --git a/src/shared/ui/user/UserProfileWithNamePosition.tsx b/src/shared/ui/user/UserProfileWithNamePosition.tsx index 4adf43f..a4f6fc3 100644 --- a/src/shared/ui/user/UserProfileWithNamePosition.tsx +++ b/src/shared/ui/user/UserProfileWithNamePosition.tsx @@ -3,8 +3,9 @@ import type { CSSProperties, JSX } from "react"; import type { User } from "@shared/types/user"; -interface UserProfileWithNamePositionProps - extends Pick { +interface UserProfileWithNamePositionProps { + name?: string; + userRole: User["userRole"]; flexDirection?: CSSProperties["flexDirection"]; } @@ -15,7 +16,7 @@ const UserProfileWithNamePosition = ({ }: UserProfileWithNamePositionProps): JSX.Element => { return ( - {name} + {name || "이름 없음"} {userRole} ); diff --git a/src/widgets/hero/ui/Hero.tsx b/src/widgets/hero/ui/Hero.tsx index 0e03fdc..b851265 100644 --- a/src/widgets/hero/ui/Hero.tsx +++ b/src/widgets/hero/ui/Hero.tsx @@ -8,36 +8,45 @@ import { import type { JSX } from "react"; import { Link } from "react-router-dom"; +import FadeInUpOnView from "@shared/ui/animations/FadeInUpOnView"; import { AddIcon, SearchIcon } from "@shared/ui/icons/CommonIcons"; const Hero = (): JSX.Element => { return ( <> - - 함께 만들어가는{" "} - 사이드 프로젝트 🚀 - - - 아이디어는 있지만 팀이 없나요?
- 프로젝트 잼에서 함께할 동료를 찾아보세요! -
- - 혼자서는 힘들어도 함께라면 뭐든 할 수 있어요 ✨ - - - - - - 프로젝트 찾기 - - - - - - 프로젝트 등록 - - - + + + 함께 만들어가는{" "} + + 사이드 프로젝트 🚀 + + + + + + 아이디어는 있지만 팀이 없나요?
+ 프로젝트 잼에서 함께할 동료를 찾아보세요! +
+ + 혼자서는 힘들어도 함께라면 뭐든 할 수 있어요 ✨ + +
+ + + + + + 프로젝트 찾기 + + + + + + 프로젝트 등록 + + + + ); }; diff --git a/vite.config.ts b/vite.config.ts index 5472fd0..e4b67ea 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,7 +4,7 @@ import react from "@vitejs/plugin-react"; import { visualizer } from "rollup-plugin-visualizer"; import { defineConfig } from "vite"; -export default defineConfig({ +export default defineConfig(({ mode }) => ({ plugins: [ react(), visualizer({ @@ -12,16 +12,30 @@ export default defineConfig({ open: true, gzipSize: true, brotliSize: true, + template: "treemap", }), ], root: "./src/app", build: { outDir: "../../dist", + sourcemap: mode === "development" ? true : "hidden", + reportCompressedSize: true, + chunkSizeWarningLimit: 1000, + commonjsOptions: { + include: [/node_modules/], + transformMixedEsModules: true, + }, rollupOptions: { + maxParallelFileOps: 5, output: { + sourcemapFileNames: "assets/maps/[name].[hash].js.map", + chunkFileNames: "assets/js/[name].[hash].js", + entryFileNames: "assets/js/[name].[hash].js", + assetFileNames: "assets/[ext]/[name].[hash].[ext]", manualChunks: { "react-vendor": ["react", "react-dom"], - "mui-vendor": ["@mui/material", "@mui/icons-material"], + "mui-vendor": ["@mui/material"], + "mui-icons": ["@mui/icons-material"], "firebase-vendor": [ "firebase/app", "firebase/auth", @@ -47,4 +61,8 @@ export default defineConfig({ "@shared": resolve(__dirname, "./src/shared"), }, }, -}); + optimizeDeps: { + include: ["@mui/material", "@mui/icons-material"], + exclude: ["@mui/icons-material/esm"], + }, +}));