Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"Neue",
"Pretendard",
"Segoe",
"treemap",
"TTFB"
],
"css.lint.unknownAtRules": "ignore",
Expand Down
130 changes: 130 additions & 0 deletions src/entities/projects/api/getProjectLikeApi.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> => {
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<string[]> => {
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<string[]> => {
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<ProjectListRes[]> => {
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<ProjectListRes[]> => {
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("내가 좋아요한 프로젝트 정보를 가져오는데 실패했습니다.");
}
};
3 changes: 2 additions & 1 deletion src/entities/projects/hook/useGetProjects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ({
Expand All @@ -14,7 +15,7 @@ const useGetProjects = ({
lastVisible: QueryDocumentSnapshot<DocumentData> | null;
}> => {
return useQuery({
queryKey: ["projects"],
queryKey: [queryKeys.projects],
queryFn: () => getProjectList({ pageSize, lastDoc: null }),
});
};
Expand Down
64 changes: 64 additions & 0 deletions src/entities/projects/queries/useGetProjectLike.ts
Original file line number Diff line number Diff line change
@@ -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<boolean, Error> => {
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<string[], Error> => {
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<string[], Error> => {
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,
});
};
46 changes: 46 additions & 0 deletions src/entities/projects/ui/liked-projects/LikedProjectsEmpty.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<EmptyContainer>
<IconWrapper>
<FavoriteIcon sx={{ fontSize: 64, color: "text.disabled" }} />
</IconWrapper>
<EmptyTitle variant="h6" color="text.secondary">
아직 좋아요한 프로젝트가 없습니다
</EmptyTitle>
<EmptyDescription variant="body2" color="text.disabled">
마음에 드는 프로젝트에 좋아요를 눌러보세요!
</EmptyDescription>
</EmptyContainer>
);
};

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,
}));
97 changes: 97 additions & 0 deletions src/entities/projects/ui/liked-projects/LikedProjectsList.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Container>
<LoadingSpinner />
<Typography>좋아요한 프로젝트를 불러오는 중...</Typography>
</Container>
);
}

if (isEmpty) {
return <LikedProjectsEmpty />;
}

return (
<Container>
<ProjectGrid>
{paginatedProjects.map((project, index) => (
<ProjectCard
key={`${project.id}-${index}`}
project={project}
simple
editMode={editMode}
selected={selectedIds.includes(project.id)}
onSelect={() => onSelectProject && onSelectProject(project.id)}
/>
))}
</ProjectGrid>

{projects.length > itemsPerPage && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setPage}
/>
)}
</Container>
);
};

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)",
},
}));
5 changes: 4 additions & 1 deletion src/entities/projects/ui/post-info/ProjectPostInfo.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -16,6 +18,7 @@ const ProjectPostInfo = ({
}: {
values: PostInfoType | null;
}): JSX.Element | null => {
const { data: likedUsers } = useGetProjectLikedUsers();
if (!values) return null;

return (
Expand All @@ -34,7 +37,7 @@ const ProjectPostInfo = ({
/>
<InfoRow
title="관심 등록"
content={`${values.likedUsers.length}명`}
content={`${likedUsers?.length || 0}명`}
color="error"
/>
</>
Expand Down
Loading