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
543 changes: 316 additions & 227 deletions features/project/components/create-project-modal.tsx

Large diffs are not rendered by default.

41 changes: 26 additions & 15 deletions features/project/components/project-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,17 +73,20 @@ export const ProjectCard = ({ project }: ProjectCardProps) => {
href={ROUTES.PROJECT_DETAIL(project.projectId)}
className="focus-visible:ring-border-secondary block rounded-2xl focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
>
<article className="border-border-gray-light bg-surface-white flex flex-row gap-6 rounded-2xl border p-6">
<ProjectCardImage projectName={project.projectName} projectImage={project.projectImage} />

<div className="flex min-w-0 flex-1 flex-col gap-4">
<article className="border-border-gray-light bg-surface-white grid grid-cols-[80px_1fr] gap-x-3 gap-y-4 rounded-2xl border p-4 shadow-[0px_4px_4px_rgba(96,96,96,0.15)] lg:grid-cols-[286px_1fr] lg:gap-6 lg:p-6 lg:shadow-none">
<div className="col-span-2 lg:col-start-2 lg:row-start-1">
<ProjectCardHeader
isLeader={isLeader}
projectName={project.projectName}
projectStatus={project.projectStatus}
startDate={project.startDate}
onDelete={isLeader ? () => setDeleteProjectOpen(true) : undefined}
/>
</div>

<ProjectCardImage projectName={project.projectName} projectImage={project.projectImage} />

<div className="flex min-w-0 flex-col gap-2 lg:col-start-2 lg:row-start-2 lg:gap-4">
<ProjectCardTitle
projectName={project.projectName}
host={project.host}
Expand Down Expand Up @@ -126,12 +129,12 @@ export const ProjectCard = ({ project }: ProjectCardProps) => {

const ProjectCardImage = ({ projectName, projectImage }: ProjectCardImageProps) => {
return (
<div className="relative h-[214px] w-[286px] shrink-0 overflow-hidden rounded-2xl">
<div className="relative size-20 shrink-0 self-start overflow-hidden rounded-xl lg:row-span-3 lg:row-start-1 lg:h-[214px] lg:w-[286px] lg:rounded-2xl">
<Image
src={projectImage || DEFAULT_PROJECT_IMAGE}
alt={`${projectName} 이미지`}
fill
sizes="286px"
sizes="(min-width: 1024px) 286px, 80px"
className="object-cover"
/>
</div>
Expand All @@ -147,8 +150,8 @@ const ProjectCardHeader = ({ isLeader, projectName, projectStatus, startDate, on
<ProjectStateBadge variant={isInProgress ? 'waiting' : 'success'} />
{isLeader ? <ProjectStateBadge variant="leader" /> : null}
</div>
<div className="flex items-center gap-4">
<time className="text-body-base text-text-subtle font-normal" dateTime={startDate}>
<div className="flex items-center gap-1 lg:gap-4">
<time className="text-detail-xs text-text-subtle lg:text-body-base font-normal" dateTime={startDate}>
{formatIsoDateToDots(startDate)}
</time>
{isLeader ? (
Expand Down Expand Up @@ -180,8 +183,14 @@ const ProjectCardHeader = ({ isLeader, projectName, projectStatus, startDate, on
const ProjectCardTitle = ({ host, projectName, startDate, endDate }: ProjectCardTitleProps) => {
return (
<div className="flex flex-col">
<h2 className="text-heading-sm text-text-subtle font-bold">{projectName}</h2>
<p className="text-body-base text-text-subtle font-normal">
<h2 className="text-detail-xs text-text-basic lg:text-heading-sm lg:text-text-subtle font-bold">{projectName}</h2>
<div className="lg:hidden">
<p className="text-detail-xs text-text-subtle font-normal">
{formatIsoDateToDots(startDate)} - {formatIsoDateToDots(endDate)}
</p>
<p className="text-detail-xs text-text-subtle font-normal">{host}</p>
</div>
<p className="text-body-base text-text-subtle hidden font-normal lg:block">
{formatIsoDateToDots(startDate)} - {formatIsoDateToDots(endDate)}
<span className="text-text-subtler"> ・ </span>
{host}
Expand All @@ -199,7 +208,8 @@ const ProjectCardNotice = ({ projectStatus }: { projectStatus: ProjectCardItem['
return (
<div
className={cn(
'text-body-sm flex h-[37px] items-center rounded-lg px-3 font-normal',
'flex items-center rounded-lg px-2 py-1 font-normal lg:h-[37px] lg:px-3',
'lg:text-body-sm text-[10px] leading-[1.3]',
'bg-surface-gray-subtle text-text-subtle',
)}
>
Expand All @@ -211,13 +221,14 @@ const ProjectCardNotice = ({ projectStatus }: { projectStatus: ProjectCardItem['
const ProjectMemberList = ({ members, reviewedCount, totalReviewTargetCount }: ProjectMemberListProps) => {
return (
<div className="flex min-w-0 flex-col gap-2">
<p className="text-body-base text-text-subtle font-normal">
{'내가 작성한 후기 '}
<span>
<p className="text-detail-xs text-text-subtle lg:text-body-base font-normal">
<span className="lg:hidden">후기 작성 </span>
<span className="hidden lg:inline">내가 작성한 후기 </span>
<span className="text-text-primary font-medium lg:font-normal lg:text-inherit">
{reviewedCount}/{totalReviewTargetCount}
</span>
</p>
<div className="relative min-w-0 overflow-hidden">
<div className="relative hidden min-w-0 overflow-hidden lg:block">
<ul className="flex flex-nowrap gap-2">
{members.map((member) => (
<li key={member.memberId}>
Expand Down
183 changes: 142 additions & 41 deletions features/project/components/project-list-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import type { ReactNode } from 'react';

import { Dialog } from 'radix-ui';
import { Fragment, useState } from 'react';

import { DownIcon } from '@/shared/assets/icons';
import { Dropdown } from '@/shared/components/dropdown';
import { cn } from '@/shared/lib/cn';
Expand All @@ -10,46 +13,151 @@ import type { ProjectListFilterStatus, ProjectSort } from '../project.types';

import { PROJECT_LIST_STATUS_OPTIONS, PROJECT_SORT_OPTIONS } from '../project.constants';

const filterTriggerClassName =
'text-body-base text-text-subtle flex items-center gap-1 rounded-md px-4 font-medium outline-none';
const filterItemClassName = 'text-body-base text-text-basic h-12 min-w-[160px] justify-start px-4 font-normal';
const statusTriggerClassName =
'text-detail-xs text-text-subtle flex cursor-pointer items-center gap-1 rounded-xs px-1 font-medium outline-none focus-visible:ring-2 focus-visible:ring-border-primary focus-visible:ring-offset-2';

const statusMenuItemClassName = cn(
'text-body-sm w-full px-5 py-4 text-left font-normal transition-colors outline-none',
'text-text-basic hover:bg-action-secondary-hover hover:text-text-primary',
'focus-visible:bg-action-secondary-hover focus-visible:text-text-primary',
);

interface FilterDropdownProps<T extends string> {
value: T;
options: readonly { value: T; label: string }[];
onValueChange: (value: T) => void;
ariaLabel: string;
interface StatusFilterProps {
value: ProjectListFilterStatus;
onValueChange: (value: ProjectListFilterStatus) => void;
}

const FilterDropdown = <T extends string>({ value, options, onValueChange, ariaLabel }: FilterDropdownProps<T>) => {
const selectedLabel = options.find((option) => option.value === value)?.label;
const StatusFilterBottomSheet = ({
open,
onOpenChange,
value,
onValueChange,
}: StatusFilterProps & { open: boolean; onOpenChange: (open: boolean) => void }) => (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="bg-black-75 fixed inset-0 z-50" />
<Dialog.Content className="fixed inset-x-0 bottom-0 z-50 outline-none">
<div className="bg-surface-white flex w-full flex-col rounded-t-2xl">
<Dialog.Title className="text-body-base text-text-basic px-5 py-4 font-bold">전체</Dialog.Title>
<Dialog.Description className="sr-only">프로젝트 상태 필터를 선택하세요</Dialog.Description>
<ul className="flex flex-col pb-5">
{PROJECT_LIST_STATUS_OPTIONS.map((option) => {
const isSelected = option.value === value;

return (
<li key={option.value}>
<button
type="button"
className={cn(statusMenuItemClassName, isSelected && 'bg-action-secondary-hover text-text-primary')}
onClick={() => {
onValueChange(option.value);
onOpenChange(false);
}}
>
{option.menuLabel}
</button>
</li>
);
})}
</ul>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);

const StatusFilter = ({ value, onValueChange }: StatusFilterProps) => {
const [sheetOpen, setSheetOpen] = useState(false);
const selectedLabel = PROJECT_LIST_STATUS_OPTIONS.find((option) => option.value === value)?.label ?? '전체';

return (
<Dropdown.Root>
<Dropdown.Trigger type="button" className={cn(filterTriggerClassName)} aria-label={ariaLabel}>
{selectedLabel}
<DownIcon className="size-4 shrink-0" aria-hidden />
</Dropdown.Trigger>
<Dropdown.Content
align="end"
sideOffset={8}
collisionPadding={16}
className="border-border-gray-light w-auto min-w-[160px] gap-2 p-2 shadow-[0px_8px_24px_rgba(0,0,0,0.1)]"
<>
<button
type="button"
className={cn(statusTriggerClassName, 'lg:hidden')}
aria-label="상태 필터"
aria-haspopup="dialog"
aria-expanded={sheetOpen}
onClick={() => setSheetOpen(true)}
>
{options.map((option) => (
<Dropdown.Item
key={option.value}
className={filterItemClassName}
onSelect={() => onValueChange(option.value)}
{selectedLabel}
<DownIcon className="size-2.5 shrink-0" aria-hidden />
</button>

<div className="hidden lg:block">
<Dropdown.Root>
<Dropdown.Trigger type="button" className={statusTriggerClassName} aria-label="상태 필터">
{selectedLabel}
<DownIcon className="size-2.5 shrink-0" aria-hidden />
</Dropdown.Trigger>
<Dropdown.Content
align="start"
sideOffset={8}
collisionPadding={16}
className="border-border-gray-light w-auto min-w-[160px] gap-0 p-0 shadow-[0px_8px_24px_rgba(0,0,0,0.1)]"
>
{option.label}
</Dropdown.Item>
))}
</Dropdown.Content>
</Dropdown.Root>
{PROJECT_LIST_STATUS_OPTIONS.map((option) => {
const isSelected = option.value === value;

return (
<Dropdown.Item
key={option.value}
className={cn(
'text-body-sm text-text-basic h-auto justify-start rounded-none px-5 py-4 font-normal',
'hover:bg-action-secondary-hover hover:text-text-primary',
'data-[highlighted]:bg-action-secondary-hover data-[highlighted]:text-text-primary',
isSelected && 'bg-action-secondary-hover text-text-primary',
)}
onSelect={() => onValueChange(option.value)}
>
{option.menuLabel}
</Dropdown.Item>
);
})}
</Dropdown.Content>
</Dropdown.Root>
</div>

<StatusFilterBottomSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
value={value}
onValueChange={onValueChange}
/>
</>
);
};

interface SortFilterProps {
value: ProjectSort;
onValueChange: (value: ProjectSort) => void;
}

const SortFilter = ({ value, onValueChange }: SortFilterProps) => (
<div role="group" aria-label="프로젝트 정렬" className="text-detail-xs flex items-center gap-2 font-medium">
{PROJECT_SORT_OPTIONS.map((option, index) => (
<Fragment key={option.value}>
{index > 0 ? (
<span aria-hidden className="text-text-disabled">
·
</span>
) : null}
<button
type="button"
aria-pressed={value === option.value}
onClick={() => onValueChange(option.value)}
className={cn(
'focus-visible:ring-border-primary cursor-pointer rounded-xs transition-colors outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
value === option.value ? 'text-text-subtle' : 'text-text-disabled',
)}
>
{option.label}
</button>
</Fragment>
))}
</div>
);

interface Props {
status: ProjectListFilterStatus;
sort: ProjectSort;
Expand All @@ -60,17 +168,10 @@ interface Props {

export const ProjectListSection = ({ status, sort, onStatusChange, onSortChange, children }: Props) => (
<section className="border-border-gray mt-[30px] mb-20 overflow-hidden rounded-2xl border">
<div className="bg-surface-gray-subtle border-border-gray flex h-16 items-center justify-end border-b px-8">
<div className="flex items-center gap-2">
<FilterDropdown
value={status}
options={PROJECT_LIST_STATUS_OPTIONS}
onValueChange={onStatusChange}
ariaLabel="상태 필터"
/>
<FilterDropdown value={sort} options={PROJECT_SORT_OPTIONS} onValueChange={onSortChange} ariaLabel="정렬" />
</div>
<div className="bg-surface-gray-subtle border-border-gray flex h-[65px] items-center justify-between border-b px-4 lg:px-8">
<StatusFilter value={status} onValueChange={onStatusChange} />
<SortFilter value={sort} onValueChange={onSortChange} />
</div>
<div className="flex flex-col gap-6 p-8">{children}</div>
<div className="flex flex-col gap-4 p-4 lg:gap-6 lg:p-8">{children}</div>
</section>
);
7 changes: 4 additions & 3 deletions features/project/components/projects-page-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,19 @@ export const ProjectsPageContent = () => {

return (
<PageContainer>
<div className="border-border-gray-light mt-15.5 flex flex-row items-start justify-between border-b-[3px] pb-8">
<div className="border-border-gray-light mt-8 flex flex-col gap-4 border-b-[3px] pb-8 lg:mt-15.5 lg:flex-row lg:items-start lg:justify-between">
<div>
<h1 className="text-heading-lg text-text-basic font-bold">프로젝트 관리</h1>
<p className="text-heading-xs text-text-subtle mt-2 font-normal">
<p className="text-body-sm text-text-subtle lg:text-heading-xs mt-2 font-normal">
프로젝트를 생성하고 팀원들과 후기를 주고받으세요.
</p>
</div>
<Button
type="button"
variant="primary"
size="medium"
leftIcon={<SettingIcon aria-hidden className="size-6" />}
leftIcon={<SettingIcon aria-hidden className="size-4 lg:size-6" />}
className="h-11 w-full justify-center lg:h-auto lg:w-auto"
onClick={() => setIsCreateModalOpen(true)}
>
새 프로젝트 생성
Expand Down
25 changes: 16 additions & 9 deletions features/project/project.constants.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import type { ComponentType, SVGProps } from 'react';

import { BackendIcon, FrontendIcon, ProductDesignerIcon, ProductManagerIcon } from '@/shared/assets/icons';

import type { FetchMyProjectsParams, ProjectPositionValue } from './project.types';

export const PROJECT_FIELD_MAX_LENGTH = 20;
Expand All @@ -15,13 +19,15 @@ export const PROJECT_POSITIONS = Object.keys(PROJECT_POSITION_LABELS) as Project
/** zod enum·옵션 파생용 직군 값 튜플 (단일 출처) */
export const PROJECT_POSITION_VALUES = ['FE', 'BE', 'PM', 'PD'] as const satisfies readonly ProjectPositionValue[];

/** 프로젝트 생성 시 직군 선택 옵션. 회원가입(POSITION_OPTIONS)과 동일한 라벨 */
type ProjectPositionIcon = ComponentType<SVGProps<SVGSVGElement>>;

/** 프로젝트 생성 시 직군 선택 옵션. 회원가입(POSITION_OPTIONS)과 동일한 라벨·아이콘 */
export const PROJECT_POSITION_OPTIONS = [
{ value: 'FE', label: '프론트엔드' },
{ value: 'BE', label: '백엔드' },
{ value: 'PM', label: '프로덕트 매니저' },
{ value: 'PD', label: '프로덕트 디자이너' },
] as const satisfies readonly { value: ProjectPositionValue; label: string }[];
{ value: 'FE', label: '프론트엔드', Icon: FrontendIcon },
{ value: 'BE', label: '백엔드', Icon: BackendIcon },
{ value: 'PM', label: '프로덕트 매니저', Icon: ProductManagerIcon },
{ value: 'PD', label: '프로덕트 디자이너', Icon: ProductDesignerIcon },
] as const satisfies readonly { value: ProjectPositionValue; label: string; Icon: ProjectPositionIcon }[];

/** 프로젝트 생성 시 선택 가능한 최대 직군 수 */
export const PROJECT_POSITIONS_MAX_SELECT = 2;
Expand All @@ -38,10 +44,11 @@ export const PROJECT_SORT_OPTIONS = [
{ value: 'OLDEST', label: '오래된 순' },
] as const;

/** 트리거(label)와 메뉴(menuLabel) 문구. 메뉴의 ALL은 Figma '전체 보기'를 쓴다. */
export const PROJECT_LIST_STATUS_OPTIONS = [
{ value: 'ALL', label: '전체' },
{ value: 'IN_PROGRESS', label: '진행중' },
{ value: 'COMPLETED', label: '완료' },
{ value: 'ALL', label: '전체', menuLabel: '전체 보기' },
{ value: 'IN_PROGRESS', label: '팀 확정 전', menuLabel: '팀 확정 전' },
{ value: 'COMPLETED', label: '팀 확정', menuLabel: '팀 확정' },
] as const;

/** 백엔드 GET /api/v1/projects의 sort/status 기본값과 일치해야 SSR prefetch와 클라이언트 쿼리 키가 어긋나지 않는다 */
Expand Down
Loading
Loading