diff --git a/src/entities/user/ui/user-profile/ProjectTabPanel.tsx b/src/entities/user/ui/user-profile/ProjectTabPanel.tsx
index 211e191..7124fae 100644
--- a/src/entities/user/ui/user-profile/ProjectTabPanel.tsx
+++ b/src/entities/user/ui/user-profile/ProjectTabPanel.tsx
@@ -1,4 +1,4 @@
-import { Box } from "@mui/material";
+import { Box, styled } from "@mui/material";
import type { JSX } from "react";
import type { ProjectListRes } from "@shared/types/project";
@@ -9,28 +9,45 @@ import EmptyProjectCard from "./EmptyProjectCard";
interface ProjectTabPanelProps {
projects: ProjectListRes[];
emptyMessage: string;
+ editMode?: boolean;
+ selectedIds?: string[];
+ onSelectProject?: (id: string) => void;
}
const ProjectTabPanel = ({
projects,
emptyMessage,
+ editMode = false,
+ selectedIds = [],
+ onSelectProject,
}: ProjectTabPanelProps): JSX.Element =>
projects && projects.length > 0 ? (
-
- {projects.slice(0, 3).map((project) => (
-
+
+ {projects.map((project) => (
+ onSelectProject && onSelectProject(project.id)}
+ />
))}
-
+
) : (
);
export default ProjectTabPanel;
+
+const StyledGridContainer = 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/user/ui/user-profile/TapWithBadge.tsx b/src/entities/user/ui/user-profile/TapWithBadge.tsx
index c43cbfe..63cc5bc 100644
--- a/src/entities/user/ui/user-profile/TapWithBadge.tsx
+++ b/src/entities/user/ui/user-profile/TapWithBadge.tsx
@@ -1,12 +1,12 @@
-import { Badge } from "@mui/material";
-import type { JSX } from "react";
+import { Badge, styled } from "@mui/material";
+import type { JSX, ComponentType } from "react";
interface TabWithBadgeProps {
label: string;
count: number;
active: boolean;
onClick: () => void;
- ProfileTabChip: any;
+ ProfileTabChip: ComponentType;
}
const TabWithBadge = ({
@@ -16,22 +16,23 @@ const TabWithBadge = ({
onClick,
ProfileTabChip,
}: TabWithBadgeProps): JSX.Element => (
-
-
+
);
export default TabWithBadge;
+
+const StyledBadge = styled(Badge)((_theme) => ({
+ "& .MuiBadge-badge": {
+ fontSize: "1.1rem",
+ fontWeight: 700,
+ minWidth: 24,
+ height: 24,
+ },
+}));
diff --git a/src/entities/user/ui/user-profile/UserProfileCard.tsx b/src/entities/user/ui/user-profile/UserProfileCard.tsx
index 71ec1b7..a366c09 100644
--- a/src/entities/user/ui/user-profile/UserProfileCard.tsx
+++ b/src/entities/user/ui/user-profile/UserProfileCard.tsx
@@ -9,20 +9,21 @@ import {
Divider,
} from "@mui/material";
import { styled as muiStyled } from "@mui/material/styles";
-import type { JSX } from "react";
+import type { ComponentType, JSX } from "react";
+
+import { useProjectStore } from "@shared/stores/projectStore";
+import type { User } from "@shared/types/user";
import TabWithBadge from "./TapWithBadge";
// Chip 컴포넌트는 상위에서 import해서 prop으로 넘겨야 함
interface UserProfileCardProps {
- userProfile: any;
+ userProfile: User;
PROFILE_TABS: { label: string; color: string }[];
- likeProjects: any[];
- appliedProjects: any[];
tab: number;
setTab: (idx: number) => void;
- ProfileTabChip: any;
+ ProfileTabChip: ComponentType;
}
const userRoleMap: Record = {
@@ -33,48 +34,51 @@ const userRoleMap: Record = {
pm: "PM",
};
const experienceMap: Record = {
- junior: "주니어 (3년 이하)",
- mid: "미들 (3년 이상 10년 이하)",
- senior: "시니어 (10년 이상)",
+ junior: "주니어 (3년 이하) 🌱",
+ mid: "미들 (3년 이상 10년 이하) 🌿",
+ senior: "시니어 (10년 이상) 🌳",
};
const UserProfileCard = ({
userProfile,
PROFILE_TABS,
- likeProjects,
- appliedProjects,
tab,
setTab,
ProfileTabChip,
}: UserProfileCardProps): JSX.Element => {
+ const { likeProjects, appliedProjects } = useProjectStore();
+
return (
-
-
+
+
-
+
{userProfile.name}
-
+
{userRoleMap[userProfile.userRole] || userProfile.userRole}
-
+
{experienceMap[userProfile.experience] || userProfile.experience}
-
+
{userProfile.introduceMyself}
- {userProfile.email}
-
+
{PROFILE_TABS.map((tabInfo, idx) => (
))}
-
+
+ 💌 • {userProfile.email}
);
@@ -100,15 +105,18 @@ export default UserProfileCard;
// 스타일 컴포넌트 재사용
const ProfileCard = muiStyled(Card)(({ theme }) => ({
minWidth: 280,
- maxWidth: 320,
+ maxWidth: "100%",
borderRadius: 12,
boxShadow: theme.shadows[2],
position: "relative",
- padding: 0,
+ padding: "0 2rem",
+ maxHeight: "350px",
+ overflow: "auto",
}));
const ProfileCardContent = muiStyled(CardContent)(({ theme }) => ({
padding: theme.spacing(3),
paddingBottom: "16px",
+ position: "relative",
"&:last-child": {
paddingBottom: "16px",
},
@@ -119,12 +127,18 @@ const ProfileCardHeader = muiStyled(Box)({
justifyContent: "flex-end",
marginBottom: 8,
minHeight: 32,
+ position: "absolute",
+ top: 10,
+ right: -10,
});
const ProfileMainRow = muiStyled(Box)({
display: "flex",
- flexDirection: "row",
alignItems: "center",
gap: 16,
+ justifyContent: "space-between",
+ flexDirection: "row-reverse",
+ marginTop: "3rem",
+ padding: "0 1rem",
});
const ProfileAvatar = muiStyled(Avatar)({
width: 100,
@@ -134,12 +148,23 @@ const ProfileAvatar = muiStyled(Avatar)({
const ProfileInfoCol = muiStyled(Box)({
display: "flex",
flexDirection: "column",
- gap: 4,
+ gap: 2,
});
const ProfileEmail = muiStyled(Typography)(({ theme }) => ({
color: theme.palette.text.disabled,
- fontSize: "0.95rem",
+ fontSize: "1.1rem",
fontStyle: "italic",
fontWeight: 400,
marginTop: theme.spacing(1),
+ textAlign: "end",
+}));
+
+const TabBadgeContainer = muiStyled(Box)(({ theme }) => ({
+ marginTop: theme.spacing(2),
+ gap: theme.spacing(1),
+ justifyContent: "center",
+ display: "none",
+ [theme.breakpoints.up("sm")]: {
+ display: "flex",
+ },
}));
diff --git a/src/entities/user/ui/user-profile/UserProfileProjectList.tsx b/src/entities/user/ui/user-profile/UserProfileProjectList.tsx
index 2fad7f8..f43edf5 100644
--- a/src/entities/user/ui/user-profile/UserProfileProjectList.tsx
+++ b/src/entities/user/ui/user-profile/UserProfileProjectList.tsx
@@ -1,44 +1,173 @@
-import { Box, Tabs, Tab } from "@mui/material";
+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 type { ProjectListRes } from "@shared/types/project";
+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;
- likeProjects: ProjectListRes[];
- appliedProjects: ProjectListRes[];
+ onDeleteProjects: (
+ type: "likeProjects" | "appliedProjects",
+ ids: string[]
+ ) => Promise;
}
const UserProfileProjectList = ({
PROFILE_TABS,
tab,
setTab,
- likeProjects,
- appliedProjects,
+ 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 (
- setTab(v)} sx={{ mb: 3 }}>
- {PROFILE_TABS.map((tabInfo, _idx) => (
-
- ))}
-
+ {/* 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" }}
+ />
);
};
diff --git a/src/features/projects/api/projectsApi.ts b/src/features/projects/api/projectsApi.ts
index 5c2c9b6..32139b2 100644
--- a/src/features/projects/api/projectsApi.ts
+++ b/src/features/projects/api/projectsApi.ts
@@ -156,3 +156,28 @@ export const updateUnLike = async (
};
}
};
+
+export type UserProjectField = "likeProjects" | "appliedProjects";
+
+export const removeProjectsFromUser = async (
+ uid: string,
+ type: UserProjectField,
+ projectIds: string[]
+): Promise<{ success: boolean; error?: string }> => {
+ if (!uid || projectIds.length === 0) {
+ return {
+ success: false,
+ error: "유저 ID 또는 삭제할 프로젝트가 없습니다.",
+ };
+ }
+ try {
+ const userRef = doc(db, "users", uid);
+ await updateDoc(userRef, {
+ [type]: arrayRemove(...projectIds),
+ });
+ return { success: true };
+ } catch (err) {
+ console.error(err);
+ return { success: false, error: "파이어베이스 업데이트 실패" };
+ }
+};
diff --git a/src/pages/user-profile/ui/UserNotFound.tsx b/src/pages/user-profile/ui/UserNotFound.tsx
new file mode 100644
index 0000000..aec59d1
--- /dev/null
+++ b/src/pages/user-profile/ui/UserNotFound.tsx
@@ -0,0 +1,24 @@
+import { Box, Typography, Button } from "@mui/material";
+import type { JSX } from "react";
+import { useNavigate } from "react-router-dom";
+
+import useCountdown from "@shared/hooks/useCountdown";
+
+const UserNotFound = (): JSX.Element => {
+ const navigate = useNavigate();
+ const count = useCountdown(3, () => navigate("/"));
+
+ return (
+
+
+ 😕 유저 정보를 찾을 수 없습니다.
+
+ {count}초 후 홈으로 이동합니다.
+
+
+ );
+};
+
+export default UserNotFound;
diff --git a/src/pages/user-profile/ui/UserProfilePage.tsx b/src/pages/user-profile/ui/UserProfilePage.tsx
index 232e6b7..99e2b46 100644
--- a/src/pages/user-profile/ui/UserProfilePage.tsx
+++ b/src/pages/user-profile/ui/UserProfilePage.tsx
@@ -1,9 +1,10 @@
import { Box, Container, Chip as MuiChip } from "@mui/material";
import { styled as muiStyled } from "@mui/material/styles";
import type { JSX } from "react";
-import { useState } from "react";
+import { useState, useEffect } from "react";
import { useUserProfile } from "@features/auth/hooks/useUserProfile";
+import { removeProjectsFromUser } from "@features/projects/api/projectsApi";
import { useProjectsByIds } from "@entities/projects/hook/useProjectsByIds";
import UserProfileCard from "@entities/user/ui/user-profile/UserProfileCard";
@@ -11,6 +12,10 @@ import UserProfileHeader from "@entities/user/ui/user-profile/UserProfileHeader"
import UserProfileProjectList from "@entities/user/ui/user-profile/UserProfileProjectList";
import { useAuthStore } from "@shared/stores/authStore";
+import { useProjectStore } from "@shared/stores/projectStore";
+import LoadingSpinner from "@shared/ui/loading-spinner/LoadingSpinner";
+
+import UserNotFound from "./UserNotFound";
// 탭 이름 상수 배열
const PROFILE_TABS = [
@@ -21,33 +26,56 @@ const PROFILE_TABS = [
const UserProfilePage = (): JSX.Element => {
const { user } = useAuthStore();
const uid = user?.uid;
- const { data: userProfile } = useUserProfile(uid ?? "");
+ const { data: userProfile, isLoading } = useUserProfile(uid ?? "");
+
+ // zustand store 사용
+ const { setLikeProjects, setAppliedProjects } = useProjectStore();
// 관심있는/지원한 프로젝트 id 배열
const likeIds = userProfile?.likeProjects ?? [];
const appliedIds = userProfile?.appliedProjects ?? [];
// 프로젝트 데이터 가져오기
- const { data: likeProjects } = useProjectsByIds(likeIds);
- const { data: appliedProjects } = useProjectsByIds(appliedIds);
- console.log(likeProjects);
- console.log(appliedProjects);
+ const { data: likeProjectsData } = useProjectsByIds(likeIds);
+ const { data: appliedProjectsData } = useProjectsByIds(appliedIds);
+
+ // zustand store에 동기화
+ useEffect(() => {
+ if (likeProjectsData) setLikeProjects(likeProjectsData);
+ }, [likeProjectsData, setLikeProjects]);
+ 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) {
+ return ;
+ }
if (!userProfile) {
- return UserProfilePage
;
+ return ;
}
return (
-
+
{/* 왼쪽 프로필 사이드바 */}
{
PROFILE_TABS={PROFILE_TABS}
tab={tab}
setTab={setTab}
- likeProjects={likeProjects ?? []}
- appliedProjects={appliedProjects ?? []}
+ onDeleteProjects={handleDeleteProjects}
/>
diff --git a/src/shared/hooks/useCountdown.ts b/src/shared/hooks/useCountdown.ts
new file mode 100644
index 0000000..86a206f
--- /dev/null
+++ b/src/shared/hooks/useCountdown.ts
@@ -0,0 +1,18 @@
+import { useEffect, useState } from "react";
+
+const useCountdown = (start: number, onEnd?: () => void): number => {
+ const [count, setCount] = useState(start);
+
+ useEffect(() => {
+ if (count === 0) {
+ if (onEnd) onEnd();
+ return;
+ }
+ const timer = setTimeout(() => setCount((prev) => prev - 1), 1000);
+ return () => clearTimeout(timer);
+ }, [count, onEnd]);
+
+ return count;
+};
+
+export default useCountdown;
diff --git a/src/shared/stores/projectStore.ts b/src/shared/stores/projectStore.ts
new file mode 100644
index 0000000..5ff6c2a
--- /dev/null
+++ b/src/shared/stores/projectStore.ts
@@ -0,0 +1,27 @@
+import { create } from "zustand";
+
+import type { ProjectListRes } from "@shared/types/project";
+
+interface ProjectStoreState {
+ likeProjects: ProjectListRes[];
+ appliedProjects: ProjectListRes[];
+ setLikeProjects: (projects: ProjectListRes[]) => void;
+ setAppliedProjects: (projects: ProjectListRes[]) => void;
+ removeLikeProjects: (ids: string[]) => void;
+ removeAppliedProjects: (ids: string[]) => void;
+}
+
+export const useProjectStore = create((set) => ({
+ likeProjects: [],
+ appliedProjects: [],
+ setLikeProjects: (projects) => set({ likeProjects: projects }),
+ setAppliedProjects: (projects) => set({ appliedProjects: projects }),
+ removeLikeProjects: (ids) =>
+ set((state) => ({
+ likeProjects: state.likeProjects.filter((p) => !ids.includes(p.id)),
+ })),
+ removeAppliedProjects: (ids) =>
+ set((state) => ({
+ appliedProjects: state.appliedProjects.filter((p) => !ids.includes(p.id)),
+ })),
+}));
diff --git a/src/shared/ui/DeleteButton.tsx b/src/shared/ui/DeleteButton.tsx
new file mode 100644
index 0000000..cb23fad
--- /dev/null
+++ b/src/shared/ui/DeleteButton.tsx
@@ -0,0 +1,41 @@
+import DeleteIcon from "@mui/icons-material/Delete";
+import { Button, styled } from "@mui/material";
+import type { JSX } from "react";
+
+interface DeleteButtonProps {
+ onClick: () => void;
+ children?: React.ReactNode;
+ sx?: any;
+ disabled?: boolean;
+}
+
+const DeleteButton = ({
+ onClick,
+ children = "삭제",
+ sx,
+ disabled = false,
+}: DeleteButtonProps): JSX.Element => (
+ }
+ onClick={onClick}
+ sx={sx}
+ disabled={disabled}
+ >
+ {children}
+
+);
+
+export default DeleteButton;
+
+const StyledButton = styled(Button)(({ theme }) => ({
+ fontWeight: 600,
+ borderRadius: theme.spacing(0.8),
+ boxShadow: "none",
+ color: theme.palette.error.main,
+ borderColor: theme.palette.error.main,
+ "&:hover": {
+ backgroundColor: theme.palette.error.light,
+ color: theme.palette.error.contrastText,
+ },
+}));
diff --git a/src/shared/ui/ProjectCard.tsx b/src/shared/ui/ProjectCard.tsx
index 5b68c63..dc5118d 100644
--- a/src/shared/ui/ProjectCard.tsx
+++ b/src/shared/ui/ProjectCard.tsx
@@ -14,6 +14,7 @@ import {
useMediaQuery,
useTheme,
} from "@mui/material";
+import Checkbox from "@mui/material/Checkbox";
import type { JSX } from "react";
import { memo } from "react";
import { Link } from "react-router-dom";
@@ -31,12 +32,18 @@ interface ProjectCardProps {
project: ProjectListRes;
simple?: boolean;
sx?: any;
+ editMode?: boolean;
+ selected?: boolean;
+ onSelect?: () => void;
}
const ProjectCard = ({
project,
simple = false,
sx,
+ editMode = false,
+ selected = false,
+ onSelect,
}: ProjectCardProps): JSX.Element => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.up("sm"));
@@ -46,6 +53,9 @@ const ProjectCard = ({
+ {simple && editMode && (
+
+ )}
@@ -172,7 +182,7 @@ const StyledCardContent = styled(CardContent)(({ theme }) => ({
const ProjectHeader = styled(Box)(() => ({
display: "flex",
- justifyContent: "flex-start",
+ justifyContent: "space-between",
alignItems: "center",
}));
diff --git a/src/shared/ui/SnackbarAlert.tsx b/src/shared/ui/SnackbarAlert.tsx
new file mode 100644
index 0000000..80d174b
--- /dev/null
+++ b/src/shared/ui/SnackbarAlert.tsx
@@ -0,0 +1,38 @@
+import {
+ Snackbar,
+ Alert,
+ type AlertColor,
+ type SnackbarOrigin,
+} from "@mui/material";
+import type { JSX } from "react";
+
+interface SnackbarAlertProps {
+ open: boolean;
+ onClose: () => void;
+ message: string;
+ severity?: AlertColor;
+ duration?: number;
+ anchorOrigin?: SnackbarOrigin;
+}
+
+const SnackbarAlert = ({
+ open,
+ onClose,
+ message,
+ severity = "success",
+ duration = 2500,
+ anchorOrigin = { vertical: "top", horizontal: "center" },
+}: SnackbarAlertProps): JSX.Element => (
+
+
+ {message}
+
+
+);
+
+export default SnackbarAlert;