diff --git a/src/app/styles/theme.ts b/src/app/styles/theme.ts index cfbf6cb..00d66be 100644 --- a/src/app/styles/theme.ts +++ b/src/app/styles/theme.ts @@ -163,6 +163,30 @@ const theme = createTheme({ fontSize: "1.4rem", }, }, + subtitle1: { + fontSize: "3.2rem", + fontWeight: 700, + lineHeight: 1.2, + letterSpacing: "-0.025em", + "@media (min-width:600px)": { + fontSize: "4rem", + }, + "@media (min-width:960px)": { + fontSize: "6rem", + }, + }, + subtitle2: { + fontSize: "3rem", + fontWeight: 700, + lineHeight: 1.2, + letterSpacing: "-0.025em", + "@media (min-width:600px)": { + fontSize: "3.2rem", + }, + "@media (min-width:960px)": { + fontSize: "3.6rem", + }, + }, }, shape: { @@ -360,15 +384,12 @@ const theme = createTheme({ MuiContainer: { styleOverrides: { root: { - paddingLeft: "1.6rem", - paddingRight: "1.6rem", + paddingInline: "1.6rem", "@media (min-width:600px)": { - paddingLeft: "2rem", - paddingRight: "2rem", + paddingInline: "2rem", }, "@media (min-width:960px)": { - paddingLeft: "2.4rem", - paddingRight: "2.4rem", + paddingInline: "2.4rem", }, }, }, diff --git a/src/entities/projects/types/projects.ts b/src/entities/projects/types/projects.ts index b2b10ac..d7e2584 100644 --- a/src/entities/projects/types/projects.ts +++ b/src/entities/projects/types/projects.ts @@ -1,7 +1,15 @@ +// 나중에 Project Owner 정보가 타입으로 들어가야할 것 같음 + expectedPeriod 타입 수정 or 포맷팅해서 DB 저장 + +import type { UserRole } from "@shared/user/types/user"; + +// 팀원 목록들도 타입으로 들어가야할 것 같음, 이미지도 타입으로 들어가야할 것 같음 +// 지원자들도 넣어야할 듯 export interface ProjectItemInsertReq { userId: string; // 작성자 id userName: string; // 작성사 이름 status: "모집중" | "모집완료"; + userRole: UserRole; + avatar: string; title: string; // 프로젝트 제목 oneLineInfo: string; // 프로젝트 한줄 소개 simpleInfo: string; // 프로젝트 간단 소개 @@ -13,6 +21,7 @@ export interface ProjectItemInsertReq { requirements: string[]; // 지원 요구사항 preferentialTreatment: string[]; // 우대사항 positions: Positions[]; // 모집 포지션 + applicants: string[]; // 지원자들 } interface Positions { @@ -20,7 +29,8 @@ interface Positions { count: number; experience: string; // 경력 } - +// 나중에 Project Owner 정보가 타입으로 들어가야할 것 같음 + expectedPeriod 타입 수정 or 포맷팅해서 DB 저장 +// 팀원 목록들도 타입으로 들어가야할 것 같음 export interface ProjectListRes extends ProjectItemInsertReq { id: string; // firebase 문서 id } diff --git a/src/entities/projects/ui/projects-card/ProjectCard.tsx b/src/entities/projects/ui/projects-card/ProjectCard.tsx new file mode 100644 index 0000000..dbb963b --- /dev/null +++ b/src/entities/projects/ui/projects-card/ProjectCard.tsx @@ -0,0 +1,315 @@ +import AccessTimeIcon from "@mui/icons-material/AccessTime"; +import LocationPinIcon from "@mui/icons-material/LocationPin"; +import PeopleAltIcon from "@mui/icons-material/PeopleAlt"; +import { + Button, + Card, + CardContent, + Chip, + Divider, + Stack, + styled, + Typography, + Box, + useMediaQuery, + useTheme, +} from "@mui/material"; +import type { JSX } from "react"; +import { Link } from "react-router-dom"; + +import type { ProjectListRes } from "@entities/projects/types/projects"; + +import DragScrollContainer from "@shared/ui/DragScrollContainer"; +import UserProfileAvatar from "@shared/user/ui/UserProfileAvatar"; +import UserProfileWithNamePosition from "@shared/user/ui/UserProfileWithNamePosition"; + +const ProjectCard = (): JSX.Element => { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.up("sm")); + + const mock: ProjectListRes = { + id: "1", + userId: "1", + userName: "John Doe", + status: "모집중", + title: "Project Title", + userRole: "frontend", + avatar: "https://via.placeholder.com/150", + oneLineInfo: "Project One Line Info", + simpleInfo: "Project Simple Info", + techStack: [ + "React", + "Node.js", + "MongoDB", + "TypeScript", + "JavaScript", + "Vue.js", + "Angular", + "Svelte", + "Next.js", + "Nuxt.js", + "Tailwind CSS", + "Bootstrap", + "Material UI", + "Chakra UI", + "Ant Design", + "Styled Components", + "Emotion", + "Tailwind CSS", + "Bootstrap", + "Material UI", + "Chakra UI", + "Ant Design", + "Styled Components", + "Emotion", + ], + teamSize: 4, + expectedPeriod: "1개월", + description: "Project Description", + workflow: "Project Workflow", + requirements: ["React", "Node.js", "MongoDB"], + preferentialTreatment: ["React", "Node.js", "MongoDB"], + positions: [ + { + position: "Frontend", + count: 2, + experience: "1년 이상", + }, + ], + applicants: ["asdfasdfsf2", "asdzxc1er", "bsdfgh12", "cbvscbatfg"], + }; + + return ( + + + + + + + + + {mock.title} + + + {mock.oneLineInfo} + + + {mock.simpleInfo} + + + + {isMobile ? ( + + ) : ( + + )} + + + {mock.techStack.map((stack, index) => ( + + ))} + + + + + + + {mock.teamSize}명 + + + + + + {mock.expectedPeriod} + + + + + + 온라인 + + + + + + + + + {mock.applicants.length}명 지원 + + + + 자세히 보기 + + + + + + ); +}; + +export default ProjectCard; + +const StyledCard = styled(Card)(({ theme }) => ({ + height: "100%", + maxWidth: "40rem", + maxHeight: "50rem", + width: "100%", + display: "flex", + flexDirection: "column", + transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)", + cursor: "pointer", + border: `1px solid ${theme.palette.divider}`, + + "&:hover": { + transform: "translateY(-0.4rem)", + boxShadow: + "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")]: { + maxWidth: "44rem", + maxHeight: "52rem", + "&: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), + + [theme.breakpoints.up("sm")]: { + gap: theme.spacing(2), + }, +})); + +const ProjectHeader = styled(Box)(() => ({ + display: "flex", + justifyContent: "flex-start", + alignItems: "center", +})); + +const StatusChip = styled(Chip)(({ theme }) => ({ + fontWeight: 600, + letterSpacing: "0.025em", + backgroundColor: theme.palette.primary.main, + color: theme.palette.primary.contrastText, + + "&:hover": { + backgroundColor: theme.palette.primary.dark, + }, +})); + +const ContentSection = styled(Box)(({ theme }) => ({ + display: "flex", + flexDirection: "column", + gap: theme.spacing(0.8), +})); + +const ProjectTitle = styled(Typography)(({ theme }) => ({ + lineHeight: 1.3, + letterSpacing: "-0.015em", + color: theme.palette.text.primary, +})); + +const OneLineInfo = styled(Typography)(() => ({ + lineHeight: 1.4, + fontWeight: 600, +})); + +const SimpleInfo = styled(Typography)(() => ({ + lineHeight: 1.5, + overflow: "hidden", + display: "-webkit-box", + WebkitLineClamp: 2, + WebkitBoxOrient: "vertical", +})); + +const TechChip = styled(Chip)(({ theme }) => ({ + backgroundColor: theme.palette.background.default, + border: `1px solid ${theme.palette.divider}`, + fontWeight: 500, + fontSize: "1.1rem", + flexShrink: 0, + whiteSpace: "nowrap", + + [theme.breakpoints.up("sm")]: { + fontSize: "1.2rem", + }, +})); + +const ProjectDetails = styled(Stack)(({ theme }) => ({ + flexDirection: "row", + flexWrap: "wrap", + gap: theme.spacing(1.6), + marginTop: theme.spacing(0.8), + + [theme.breakpoints.up("sm")]: { + gap: theme.spacing(2), + }, +})); + +const DetailItem = styled(Stack)(({ theme }) => ({ + flexDirection: "row", + alignItems: "center", + gap: theme.spacing(0.6), +})); + +const StyledDivider = styled(Divider)(({ theme }) => ({ + margin: `${theme.spacing(0.8)} 0`, + backgroundColor: theme.palette.divider, +})); + +const FooterSection = styled(Stack)(({ theme }) => ({ + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginTop: "auto", + gap: theme.spacing(1.2), +})); + +const StyledLink = styled(Link)(() => ({ + textDecoration: "none", + flexShrink: 0, +})); + +const ActionButton = styled(Button)(({ theme }) => ({ + fontWeight: 600, + letterSpacing: "0.025em", + borderRadius: theme.spacing(0.8), + boxShadow: "none", + transition: "all 0.2s cubic-bezier(0.4, 0, 0.2, 1)", + + "&:hover": { + transform: "translateY(-0.1rem)", + boxShadow: "0 4px 8px -2px rgba(37, 99, 235, 0.3)", + }, + + "&:active": { + transform: "translateY(0)", + }, +})); + +const TextHighlight = styled("span")(({ theme }) => ({ + color: theme.palette.primary.main, + fontWeight: 600, +})); diff --git a/src/entities/projects/ui/projects-stats/ProjectsStats.tsx b/src/entities/projects/ui/projects-stats/ProjectsStats.tsx new file mode 100644 index 0000000..7bbdca3 --- /dev/null +++ b/src/entities/projects/ui/projects-stats/ProjectsStats.tsx @@ -0,0 +1,91 @@ +import EmojiEventsIcon from "@mui/icons-material/EmojiEvents"; +import PeopleAltIcon from "@mui/icons-material/PeopleAlt"; +import RocketLaunchIcon from "@mui/icons-material/RocketLaunch"; +import { Card, CardContent, Stack, styled, Typography } from "@mui/material"; +import type { JSX } from "react"; + +const ProjectsStats = (): JSX.Element => { + const mock = [ + { + id: "a", + title: "진행중인 프로젝트", + value: 110, + icon: , + color: "#2563eb", + }, + { + id: "b", + title: "활성 사용자", + value: 120, + icon: , + color: "#16a34a", + }, + { + id: "c", + title: "완성된 프로젝트", + value: 130, + icon: , + color: "#eab308", + }, + ]; + + return ( + <> + {mock.map((stat) => { + return ( + + + + + {stat.icon} + + + {`${stat.value}+`} + + + {stat.title} + + + + + ); + })} + + ); +}; + +export default ProjectsStats; + +interface ProjectStatsIconProps { + color: string; +} + +const ProjectStatsCard = styled(Card)(() => ({ + flex: 1, +})); + +const ProjectStatsIcon = styled("span")(({ color }) => ({ + color: color, + display: "flex", + alignItems: "center", + justifyContent: "center", + borderRadius: "12px", + padding: "1.6rem", + backgroundColor: `${color}10`, +})); + +const ProjectStatsStack = styled(Stack)(() => ({ + display: "flex", + alignItems: "center", + flexDirection: "column", + gap: "0.8rem", +})); + +const ProjectStatsCount = styled(Typography)(() => ({ + fontSize: "2.4rem", + fontWeight: "bold", +})); + +const ProjectStatsTitle = styled(Typography)(() => ({ + fontSize: "1.6rem", +})); diff --git a/src/features/projects/hook/useProjectInsert.ts b/src/features/projects/hook/useProjectInsert.ts index 07521d7..ad6bb90 100644 --- a/src/features/projects/hook/useProjectInsert.ts +++ b/src/features/projects/hook/useProjectInsert.ts @@ -22,6 +22,9 @@ export default useProjectInsert; const TestData: ProjectItemInsertReq = { userId: "user1234", userName: "홍길동", + userRole: "frontend", + avatar: "https://via.placeholder.com/150", + applicants: [], status: "모집중", title: "AI 기반 음악 추천 서비스 개발", oneLineInfo: "AI로 사용자 취향을 분석하는 음악 추천 프로젝트입니다.", diff --git a/src/pages/home/ui/HomePage.tsx b/src/pages/home/ui/HomePage.tsx index 66f3e7e..05f07aa 100644 --- a/src/pages/home/ui/HomePage.tsx +++ b/src/pages/home/ui/HomePage.tsx @@ -1,6 +1,11 @@ +import { Box, Container, styled } from "@mui/material"; import type { JSX } from "react"; +import Hero from "@widgets/hero/ui/Hero"; + import useProjectList from "@entities/projects/queries/useProjectList"; +import ProjectCard from "@entities/projects/ui/projects-card/ProjectCard"; +import ProjectsStats from "@entities/projects/ui/projects-stats/ProjectsStats"; const HomePage = (): JSX.Element => { const { data } = useProjectList(); @@ -9,11 +14,65 @@ const HomePage = (): JSX.Element => { console.log("API_KEY: ", import.meta.env.VITE_API_KEY); return ( -
-

홈 페이지

-

환영합니다! 이곳은 홈 페이지입니다.

-
+ + + + + + + + + + + ); }; export default HomePage; + +const MainContainer = styled(Container)(({ theme }) => ({ + flexGrow: 1, + minHeight: "100vh", + backgroundColor: theme.palette.background.default, +})); + +const HeroContainer = styled(Box)(({ theme }) => ({ + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + backgroundColor: theme.palette.background.default, + padding: "2rem 0rem", + [theme.breakpoints.up("sm")]: { + padding: "4rem 2rem", + }, + [theme.breakpoints.up("md")]: { + padding: "6rem 2.4rem", + }, +})); + +const ProjectStatsContainer = styled(Box)(({ theme }) => ({ + display: "flex", + flexDirection: "column", + gap: "3.2rem", + justifyContent: "center", + padding: "2rem 0rem", + [theme.breakpoints.up("sm")]: { + flexDirection: "row", + padding: "4rem 2rem", + }, + [theme.breakpoints.up("md")]: { + padding: "6rem 2.4rem", + }, +})); + +const ProjectCardContainer = styled(Box)(({ theme }) => ({ + padding: "2rem 0rem", + [theme.breakpoints.up("sm")]: { + flexDirection: "row", + padding: "4rem 2rem", + }, + [theme.breakpoints.up("md")]: { + padding: "6rem 2.4rem", + }, +})); diff --git a/src/shared/hooks/useDraggable.tsx b/src/shared/hooks/useDraggable.tsx new file mode 100644 index 0000000..fd91e3a --- /dev/null +++ b/src/shared/hooks/useDraggable.tsx @@ -0,0 +1,67 @@ +import { useRef, useCallback } from "react"; + +interface ReturnTypes { + scrollRef: React.RefObject; + handleMouseDown: (e: React.MouseEvent) => void; +} + +const useDraggable = (): ReturnTypes => { + const scrollRef = useRef(null); + + const handleMouseDown = useCallback((e: React.MouseEvent): void => { + const slider = scrollRef.current; + if (!slider) return; + + e.preventDefault(); + + let isDown = true; + let startX = e.pageX; + let scrollLeft = slider.scrollLeft; + let hasMoved = false; + + slider.style.cursor = "grabbing"; + slider.style.userSelect = "none"; + + const handleMouseMove = (e: MouseEvent): void => { + if (!isDown) return; + e.preventDefault(); + + hasMoved = true; + const x = e.pageX; + const walk = (x - startX) * 1.5; + slider.scrollLeft = scrollLeft - walk; + }; + + const handleMouseUp = (): void => { + isDown = false; + + slider.style.cursor = "grab"; + slider.style.userSelect = ""; + + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + + if (hasMoved) { + const preventClick = (e: Event): void => { + e.stopPropagation(); + e.preventDefault(); + }; + + slider.addEventListener("click", preventClick, { once: true }); + setTimeout(() => { + slider.removeEventListener("click", preventClick); + }, 0); + } + }; + + document.addEventListener("mousemove", handleMouseMove, { passive: false }); + document.addEventListener("mouseup", handleMouseUp); + }, []); + + return { + scrollRef, + handleMouseDown, + }; +}; + +export default useDraggable; diff --git a/src/shared/react-query/queryClient.ts b/src/shared/react-query/queryClient.ts index db2b63d..85b0eae 100644 --- a/src/shared/react-query/queryClient.ts +++ b/src/shared/react-query/queryClient.ts @@ -1,5 +1,12 @@ import { QueryClient } from "@tanstack/react-query"; -const queryClient = new QueryClient(); +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: 1, + }, + }, +}); export default queryClient; diff --git a/src/shared/ui/DragScrollContainer.tsx b/src/shared/ui/DragScrollContainer.tsx new file mode 100644 index 0000000..d894864 --- /dev/null +++ b/src/shared/ui/DragScrollContainer.tsx @@ -0,0 +1,65 @@ +import { Box, Stack, styled } from "@mui/material"; +import type { JSX, ReactNode } from "react"; + +import useDraggable from "@shared/hooks/useDraggable"; + +const DragScrollContainer = ({ + children, +}: { + children: ReactNode; +}): JSX.Element => { + const { scrollRef, handleMouseDown } = useDraggable(); + + return ( + + + {children} + + + ); +}; + +export default DragScrollContainer; + +const Container = styled(Box)(({ theme }) => ({ + overflow: "hidden", + position: "relative", + marginTop: theme.spacing(0.4), + + "&::after": { + content: '""', + position: "absolute", + top: 0, + right: 0, + width: "2rem", + height: "100%", + background: `linear-gradient(to right, transparent, ${theme.palette.background.paper})`, + pointerEvents: "none", + zIndex: 1, + }, +})); + +const DragScrollSection = styled(Stack)(({ theme }) => ({ + flexDirection: "row", + gap: theme.spacing(0.8), + overflowX: "auto", + scrollbarWidth: "none", + msOverflowStyle: "none", + paddingBottom: theme.spacing(0.4), + paddingRight: theme.spacing(2), + cursor: "grab", + + "&::-webkit-scrollbar": { + display: "none", + }, + + "&:active": { + cursor: "grabbing", + }, + + scrollBehavior: "smooth", + WebkitOverflowScrolling: "touch", + + minHeight: "3.2rem", + alignItems: "center", +})); diff --git a/src/shared/user/types/user.ts b/src/shared/user/types/user.ts new file mode 100644 index 0000000..25501bc --- /dev/null +++ b/src/shared/user/types/user.ts @@ -0,0 +1,11 @@ +export interface User { + id: string; + name: string; + email: string; + avatar: string; + skills: string[]; + userRole: UserRole; + experience: string; +} + +export type UserRole = "frontend" | "backend" | "fullstack" | "designer" | "pm"; diff --git a/src/shared/user/ui/UserProfileAvatar.tsx b/src/shared/user/ui/UserProfileAvatar.tsx new file mode 100644 index 0000000..c94ad37 --- /dev/null +++ b/src/shared/user/ui/UserProfileAvatar.tsx @@ -0,0 +1,37 @@ +import { Avatar, Box, styled } from "@mui/material"; +import type { CSSProperties, JSX } from "react"; + +import type { User } from "@shared/user/types/user"; +import UserProfileWithNamePosition from "@shared/user/ui/UserProfileWithNamePosition"; + +interface UserProfileAvatarProps + extends Pick { + flexDirection?: CSSProperties["flexDirection"]; +} + +const UserProfileAvatar = ({ + name, + userRole, + avatar, + flexDirection = "row", +}: UserProfileAvatarProps): JSX.Element => { + return ( + + + + + ); +}; + +export default UserProfileAvatar; + +const UserProfileAvatarContainer = styled(Box)(() => ({ + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: "0.8rem", +})); diff --git a/src/shared/user/ui/UserProfileWithNamePosition.tsx b/src/shared/user/ui/UserProfileWithNamePosition.tsx new file mode 100644 index 0000000..6c76c7e --- /dev/null +++ b/src/shared/user/ui/UserProfileWithNamePosition.tsx @@ -0,0 +1,24 @@ +import { Stack, Typography } from "@mui/material"; +import type { CSSProperties, JSX } from "react"; + +import type { User } from "@shared/user/types/user"; + +interface UserProfileWithNamePositionProps + extends Pick { + flexDirection?: CSSProperties["flexDirection"]; +} + +const UserProfileWithNamePosition = ({ + name, + userRole, + flexDirection = "column", +}: UserProfileWithNamePositionProps): JSX.Element => { + return ( + + {name} + {userRole} + + ); +}; + +export default UserProfileWithNamePosition; diff --git a/src/widgets/hero/ui/Hero.tsx b/src/widgets/hero/ui/Hero.tsx new file mode 100644 index 0000000..fd88a1a --- /dev/null +++ b/src/widgets/hero/ui/Hero.tsx @@ -0,0 +1,82 @@ +import AddIcon from "@mui/icons-material/Add"; +import SearchIcon from "@mui/icons-material/Search"; +import { + Box, + Button, + styled, + Typography, + type ButtonProps, +} from "@mui/material"; +import type { JSX } from "react"; +import { Link } from "react-router-dom"; + +const Hero = (): JSX.Element => { + return ( + <> + + 함께 만들어가는{" "} + 사이드 프로젝트 🚀 + + + 아이디어는 있지만 팀이 없나요?
+ 프로젝트 잼에서 함께할 동료를 찾아보세요! +
+ + 혼자서는 힘들어도 함께라면 뭐든 할 수 있어요 ✨ + + + + + + 프로젝트 찾기 + + + + + + 프로젝트 등록 + + + + + ); +}; + +export default Hero; + +const HeroTitle = styled(Typography)(() => ({ + textAlign: "center", + marginBottom: "2.4rem", +})); + +const HeroTitleHighlight = styled(Typography)(({ theme }) => ({ + ...theme.typography.subtitle1, + color: theme.palette.primary.main, +})); + +const HeroDescription = styled(Typography)(() => ({ + textAlign: "center", + marginBottom: "1.6rem", +})); + +const HeroMessage = styled(Typography)(() => ({ + textAlign: "center", + marginBottom: "3.2rem", +})); + +const HeroButtonContainer = styled(Box)(() => ({ + display: "flex", + justifyContent: "center", + gap: "1.6rem", +})); + +const HeroButton = styled(Button)(() => ({ + display: "flex", + alignItems: "center", + gap: "0.8rem", +})); + +const HeroButtonLink = styled(Link)(() => ({ + textDecoration: "none", + color: "inherit", +}));