Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
33 changes: 27 additions & 6 deletions src/app/styles/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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",
},
},
},
Expand Down
12 changes: 11 additions & 1 deletion src/entities/projects/types/projects.ts
Original file line number Diff line number Diff line change
@@ -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; // 프로젝트 간단 소개
Expand All @@ -13,14 +21,16 @@ export interface ProjectItemInsertReq {
requirements: string[]; // 지원 요구사항
preferentialTreatment: string[]; // 우대사항
positions: Positions[]; // 모집 포지션
applicants: string[]; // 지원자들
}

interface Positions {
position: string;
count: number;
experience: string; // 경력
}

// 나중에 Project Owner 정보가 타입으로 들어가야할 것 같음 + expectedPeriod 타입 수정 or 포맷팅해서 DB 저장
// 팀원 목록들도 타입으로 들어가야할 것 같음
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

제가 놓친 부분이 있었나보군요! 등록 api에 추가하도록 하겠습니다.

Copy link
Contributor Author

@tkyoun0421 tkyoun0421 Jun 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

스탠딩 미팅에서 같이 맞춰봐야 할 것 같아요!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋습니다~

export interface ProjectListRes extends ProjectItemInsertReq {
id: string; // firebase 문서 id
}
315 changes: 315 additions & 0 deletions src/entities/projects/ui/projects-card/ProjectCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<StyledCard>
<StyledCardContent>
<ProjectHeader>
<StatusChip label={mock.status} color="primary" size="small" />
</ProjectHeader>

<ContentSection>
<ProjectTitle variant="h5" fontWeight={700}>
{mock.title}
</ProjectTitle>
<OneLineInfo variant="body1" color="primary" fontWeight={600}>
{mock.oneLineInfo}
</OneLineInfo>
<SimpleInfo variant="body2" color="text.secondary">
{mock.simpleInfo}
</SimpleInfo>
</ContentSection>
<Stack flexDirection={"row"} gap={"0.8rem"} alignItems={"flex-start"}>
{isMobile ? (
<UserProfileAvatar
name={mock.userName}
userRole={mock.userRole}
avatar={mock.avatar}
flexDirection="row"
/>
) : (
<UserProfileWithNamePosition
name={mock.userName}
userRole={mock.userRole}
flexDirection="row"
/>
)}
</Stack>
<DragScrollContainer>
{mock.techStack.map((stack, index) => (
<TechChip key={index} label={stack} size="small" />
))}
</DragScrollContainer>

<ProjectDetails>
<DetailItem>
<PeopleAltIcon fontSize="small" color="action" />
<Typography variant="body2" color="text.secondary">
{mock.teamSize}명
</Typography>
</DetailItem>
<DetailItem>
<AccessTimeIcon fontSize="small" color="action" />
<Typography variant="body2" color="text.secondary">
{mock.expectedPeriod}
</Typography>
</DetailItem>
<DetailItem>
<LocationPinIcon fontSize="small" color="action" />
<Typography variant="body2" color="text.secondary">
온라인
</Typography>
</DetailItem>
</ProjectDetails>

<StyledDivider />

<FooterSection>
<Typography variant="body1" color="textPrimary">
<TextHighlight>{mock.applicants.length}명</TextHighlight> 지원
</Typography>
<StyledLink to={`/project/${mock.id}`}>
<ActionButton variant="contained" color="primary" size="medium">
자세히 보기
</ActionButton>
</StyledLink>
</FooterSection>
</StyledCardContent>
</StyledCard>
);
};

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,
}));
Loading
Loading