diff --git a/DESIGN.md b/DESIGN.md index 942fcdb..e810b1d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -22,6 +22,10 @@ Repo-V2 is a dark, quiet school-work surface: compact, direct, and built for rep ### Rules +- 모든 글자 크기, 굵기, 행간은 `--repo-font-*` 토큰 또는 해당 토큰을 사용하는 텍스트 유틸리티만 사용합니다. +- 모든 색상은 이 문서의 color token 또는 `src/shared/styles/colors.css`에 선언된 `--repo-*` 토큰만 사용합니다. +- 기능 구현 중 필요한 글자/색상 값이 토큰에 없으면 CSS에 임의 값을 쓰지 말고 먼저 디자인 토큰을 추가하고 이 문서에 용도를 기록합니다. +- 예외적으로 SVG/이미지 내부 표현, QR/픽셀 패턴, 외부 에셋 원본 색처럼 디자인 토큰으로 제어할 수 없는 값만 raw value를 허용합니다. - Dark primitives use tonal-shift depth before shadows. - Green is reserved for submitted/success status. - Error red appears only with validation feedback. @@ -49,6 +53,7 @@ Repo-V2 is a dark, quiet school-work surface: compact, direct, and built for rep - Letter spacing remains `0`. - Body text stays at or above 14px. +- 새 UI에서 `font-size`, `font-weight`, `line-height`를 직접 숫자로 지정하는 것은 금지합니다. 반드시 typography token을 참조합니다. ## 4. Spacing & Layout diff --git a/docs/GIT_WORKFLOW.md b/docs/GIT_WORKFLOW.md index 7cdfaaa..3731dfb 100644 --- a/docs/GIT_WORKFLOW.md +++ b/docs/GIT_WORKFLOW.md @@ -14,6 +14,15 @@ type (#이슈번호) :: 커밋 메세지 일반 작업은 이슈 생성 후 진행하므로 이슈 번호를 포함합니다. 이슈가 없는 예외 작업은 사용자가 명시적으로 허용한 경우에만 `type :: 커밋 메세지` 형식을 사용합니다. +### 강제 규칙 + +- 모든 작업 커밋은 `type (#이슈번호) :: 커밋 메세지` 형식을 반드시 사용합니다. +- 제목을 명사형 또는 결과 중심 구문으로 쓰더라도 `type`, 이슈 번호, `::` 구분자는 생략하지 않습니다. +- 이슈 기반 브랜치에서 작업 중이면 브랜치명의 이슈 번호를 커밋 제목에 그대로 사용합니다. +- 커밋 전 `git log -1 --pretty=format:%s` 또는 staged commit message 확인으로 형식을 검증합니다. +- 컨벤션을 벗어난 커밋은 PR 생성 전 `git commit --amend` 또는 interactive rebase로 메시지를 고친 뒤 올립니다. +- 사용자가 별도 문구를 요청해도 형식 prefix는 유지하고, 요청받은 문구는 `커밋 메세지` 영역에만 반영합니다. + ### 예시 ```txt @@ -45,6 +54,7 @@ chore (#22) :: ESLint ignore 범위 정리 - 제목은 가능한 한 짧고 명확하게 쓴다. - 커밋 내용은 명사형 또는 작업 결과가 바로 드러나는 짧은 구문으로 쓴다. - `~한다`, `~했다`, `~하기` 같은 서술형 제목은 피한다. +- 명사형 제목 규칙은 `type (#이슈번호) ::` 형식보다 우선하지 않는다. - 마침표로 끝내지 않는다. - 한국어를 우선 사용하되, 타입/파일명/API/컴포넌트명 등 영어가 자연스러운 부분은 영어를 사용한다. @@ -115,6 +125,7 @@ Repo-V2의 일반 작업은 아래 순서를 기본으로 합니다. - 브랜치명은 `type/#이슈번호-브랜치네임` 형식을 사용한다. - 커밋 제목 형식은 `type (#이슈번호) :: 커밋 메세지`를 사용한다. +- 커밋 제목 형식은 필수이며, 명사형 요약 요청이 있어도 `type (#이슈번호) ::` prefix를 생략하지 않는다. - type은 `feat`, `fix`, `build`, `chore`, `ci`, `docs`, `style`, `refactor`, `test`, `release`로 한정한다. - 제목은 명사형 또는 작업 결과 중심의 짧은 구문으로 작성한다. - 본문은 선택이며, 필요할 때만 짧게 작성한다. diff --git a/docs/UI_UX_DIRECTION.md b/docs/UI_UX_DIRECTION.md index 96b82d2..c92e298 100644 --- a/docs/UI_UX_DIRECTION.md +++ b/docs/UI_UX_DIRECTION.md @@ -4,6 +4,13 @@ Repo-V2의 UI/UX 핵심은 학생이 실제 취업에도 쓰고 싶은 포트폴리오 작성 경험을 제공하는 것입니다. +## 디자인 토큰 강제 기준 + +- 모든 신규/수정 UI의 글자 크기, 굵기, 행간은 `DESIGN.md`의 typography token만 사용합니다. +- 모든 신규/수정 UI의 색상은 `DESIGN.md`와 `src/shared/styles/colors.css`에 선언된 color token만 사용합니다. +- Figma에 있는 값이라도 토큰에 없으면 CSS에 바로 쓰지 않고, 먼저 토큰과 문서 용도를 추가한 뒤 사용합니다. +- raw `px` typography, raw hex/rgb/hsl 색상은 리뷰에서 수정 대상으로 봅니다. + ## 참고 자료 - UI 참고 영상: https://www.youtube.com/watch?v=fR8tsJ2r7Eg @@ -46,6 +53,15 @@ Repo-V2의 UI/UX 핵심은 학생이 실제 취업에도 쓰고 싶은 포트폴 - 전공/반 필터 - 이름 검색 +레주메북 상세 필터 기준: + +- 필터 버튼을 누르면 현재 문서 뷰어 위에 오른쪽 사이드 패널을 띄웁니다. +- 필터 패널이 열린 동안 문서 뷰어와 검색 영역은 어둡게 흐려지고, 패널만 명확하게 보여야 합니다. +- 필터 패널의 액션은 기존 공용 `Button`, 선택지는 기존 공용 `CheckboxOption`, 적용된 조건은 기존 공용 `Tag`, 검색은 기존 공용 `SearchField`를 사용합니다. +- 전공/반 섹션은 접힘/펼침이 가능해야 하며, 선택 변경은 `적용하기` 전까지 임시 상태로 유지합니다. +- `초기화`는 선택 조건을 모두 비우고, `적용하기`는 패널을 닫은 뒤 선택된 조건을 검색창 아래 태그로 노출합니다. +- 적용된 태그의 닫기 버튼을 누르면 해당 조건만 바로 제거됩니다. + ## 이번 단계에서 하지 않을 것 - Figma 화면별 세부 퍼블리싱 계획 확정 @@ -59,6 +75,7 @@ Repo-V2의 UI/UX 핵심은 학생이 실제 취업에도 쓰고 싶은 포트폴 - 기준 범위는 `1440 × 900`부터 `1920 × 1080`까지입니다. - `1440 × 900`에서 핵심 레이아웃이 가장 안정적으로 보이는 것을 우선합니다. - `1920 × 1080`에서는 콘텐츠 max-width와 여백 설계로 과도한 확장을 방지합니다. +- 도서관 레주메북 상세처럼 한 화면 안에서 열람하는 화면은 화면 높이가 커질 때 아래 여백만 늘리지 않고, 툴바·문서·페이지 표시 묶음 전체를 세로 기준으로 균형 있게 배치합니다. ## TODO diff --git a/src/app/(public)/library/page.module.css b/src/app/(public)/library/page.module.css new file mode 100644 index 0000000..f72cc9a --- /dev/null +++ b/src/app/(public)/library/page.module.css @@ -0,0 +1,108 @@ +.page { + position: relative; + min-height: 100vh; + color: var(--repo-gray-50); + background: var(--repo-bg-main); +} + +.content { + width: min(100%, 1008px); + margin: 0 auto; + padding: 77px 29px 36px; +} + +.hero { + display: grid; + justify-items: center; + gap: 14px; + text-align: center; +} + +.title { + margin: 0; + color: var(--repo-gray-50); + font-size: 36px; + font-weight: 700; + line-height: 1.2; + letter-spacing: 0; +} + +.description { + margin: 0; + color: var(--repo-gray-400); + font-size: 20px; + font-weight: 500; + line-height: 1.45; + letter-spacing: 0; +} + +.books { + display: grid; + grid-template-columns: repeat(4, 166px); + justify-content: center; + gap: 52px 48px; + margin-top: 49px; +} + +.toastLayer { + position: absolute; + z-index: 10; + top: 119px; + left: 50%; + width: min(calc(100% - 48px), 600px); + display: flex; + justify-content: center; + transform: translateX(-50%); +} + +@media (max-width: 980px) { + .content { + padding-top: 72px; + } + + .books { + grid-template-columns: repeat(3, 166px); + gap: 47px 50px; + } +} + +@media (max-width: 720px) { + .content { + padding: 56px 24px 80px; + } + + .title { + font-size: 32px; + } + + .description { + font-size: 18px; + } + + .books { + grid-template-columns: repeat(2, 166px); + gap: 40px 32px; + margin-top: 47px; + } + + .toastLayer { + top: 148px; + } +} + +@media (max-width: 520px) { + .content { + padding-inline: 18px; + } + + .books { + grid-template-columns: 166px; + } +} + +@media (min-width: 981px) { + .page { + height: 100dvh; + overflow: hidden; + } +} diff --git a/src/app/(public)/library/page.tsx b/src/app/(public)/library/page.tsx new file mode 100644 index 0000000..99c2dec --- /dev/null +++ b/src/app/(public)/library/page.tsx @@ -0,0 +1,53 @@ +import type { AppHeaderItem, LibraryBookCardProps } from '@/shared/ui' +import { AppHeader, LibraryBookCard, Toast } from '@/shared/ui' + +import styles from './page.module.css' + +const navigationItems = [ + { href: '/majors', label: '전공 관리', value: 'majors' }, + { href: '/students', label: '학생 관리', value: 'students' }, + { href: '/library', label: '도서관', value: 'library' }, +] satisfies readonly AppHeaderItem[] + +const libraryBooks = Array.from({ length: 8 }, (_, index) => ({ + ariaLabel: `2022 9기 2학년 ${index + 1}번째 포트폴리오 열람`, + batchLabel: '9기', + gradeLabel: '2학년', + href: `/resume-books/${index + 1}` as const, + title: '2022', +})) satisfies readonly LibraryBookCardProps[] + +type LibraryPageProps = { + readonly searchParams?: Promise<{ + readonly error?: string + }> +} + +export default async function LibraryPage({ searchParams }: LibraryPageProps) { + const params = await searchParams + const showsLoadError = params?.error === 'portfolio' + + return ( +
+ + {showsLoadError ? ( +
+ 포트폴리오 문서를 불러오는데 실패하였습니다. 잠시 후 다시 시도해 주세요. +
+ ) : null} +
+
+

+ 도서관 +

+

다양한 학생들의 포트폴리오를 둘러보세요.

+
+
+ {libraryBooks.map((book) => ( + + ))} +
+
+
+ ) +} diff --git a/src/app/(public)/resume-books/[bookId]/page.module.css b/src/app/(public)/resume-books/[bookId]/page.module.css new file mode 100644 index 0000000..d476ced --- /dev/null +++ b/src/app/(public)/resume-books/[bookId]/page.module.css @@ -0,0 +1,611 @@ +.page { + height: 100dvh; + overflow: hidden; + color: var(--repo-gray-50); + background: var(--repo-bg-main); +} + +.workspace { + position: relative; + display: flex; + flex-direction: column; + justify-content: center; + height: calc(100dvh - var(--repo-size-header-min-height)); + overflow: hidden; + padding: 16px 88px; +} + +.contentLayer { + position: relative; + display: flex; + width: 100%; + height: 100%; + flex-direction: column; + justify-content: center; + transition: + filter 180ms ease, + opacity 180ms ease; +} + +.page[data-filter-open='true'] .contentLayer { + filter: blur(6px); + opacity: 0.5; + pointer-events: none; + user-select: none; +} + +.toolbar { + position: absolute; + top: calc(50% - 395px); + right: 0; + left: 0; + z-index: 3; + display: flex; + align-items: center; + justify-content: center; + min-height: 43px; +} + +.searchGroup { + display: flex; + align-items: center; + justify-content: center; + gap: 14px; + min-width: 0; +} + +.filterButton { + display: inline-flex; + width: 43px; + height: 43px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + border: 0; + border-radius: var(--repo-radius-pill); + color: var(--repo-gray-300); + background: var(--repo-bg-main-point); + cursor: pointer; +} + +.filterButton:focus-visible, +.pageArrow:focus-visible { + outline: 2px solid var(--repo-main); + outline-offset: 3px; +} + +.filterButton:hover { + background: var(--repo-bg-main-lighter); +} + +.filterIcon { + width: 22px; + height: 22px; +} + +.searchField.searchField { + flex: 0 0 468px; + width: min(100vw - 760px, 468px); + max-width: calc(100vw - 760px); + min-height: 50px; + padding: 9px 20px; +} + +.searchField.searchField:focus-within { + outline: 0; +} + +.searchField.searchField input { + font-size: var(--repo-font-body-small-size); + line-height: var(--repo-font-body-small-line-height); +} + +.searchField.searchField input::-webkit-search-cancel-button { + display: none; + appearance: none; +} + +.searchField.searchField svg { + width: 20px; + height: 20px; +} + +.downloadButton { + position: absolute; + top: 50%; + right: 0; + min-width: 180px; + min-height: 43px; + transform: translateY(-50%); + border-radius: 12px; + background: var(--repo-bg-main-point); + font-size: var(--repo-font-body-small-size); + font-weight: var(--repo-font-body-small-weight); + line-height: var(--repo-font-body-small-line-height); +} + +.toastLayer { + position: absolute; + z-index: 4; + top: calc(50% - 402px); + left: 50%; + display: flex; + width: min(calc(100% - 48px), 600px); + justify-content: center; + transform: translateX(-50%); +} + +.activeFilters { + position: absolute; + top: calc(50% - 331px); + left: 50%; + display: flex; + width: min(100%, 552px); + flex-wrap: wrap; + gap: 8px; + align-self: center; + transform: translateX(-50%); +} + +.viewer { + position: relative; + display: grid; + align-items: center; + margin-top: clamp(85px, 8.6vh, 119px); +} + +.sheets { + display: grid; + grid-template-columns: repeat(2, 423px); + justify-content: center; + gap: 9px; +} + +.pageArrow { + position: absolute; + z-index: 1; + top: 48%; + border: 0; + color: var(--repo-gray-50); + background: transparent; + font-size: 41px; + font-weight: 300; + line-height: 1; + cursor: pointer; +} + +.previousArrow { + left: calc(50% - 504px); +} + +.nextArrow { + right: calc(50% - 504px); +} + +.pageIndicator { + margin: clamp(12px, 1.8vh, 28px) 0 0; + color: var(--repo-gray-500); + font-size: var(--repo-font-body-small-size); + font-weight: var(--repo-font-body-small-weight); + line-height: var(--repo-font-body-small-line-height); + text-align: center; +} + +.pageIndicator strong { + color: var(--repo-gray-500); + font-size: var(--repo-font-body-small-size); + font-weight: var(--repo-font-body-small-weight); + line-height: var(--repo-font-body-small-line-height); +} + +.emptyState { + display: grid; + justify-items: center; + gap: 56px; + margin-top: 0; + text-align: center; +} + +.emptyMessage { + margin: 0; + color: var(--repo-gray-50); + font-size: var(--repo-font-title-small-size); + font-weight: var(--repo-font-title-small-weight); + line-height: var(--repo-font-title-small-line-height); +} + +.returnButton { + min-width: 150px; + min-height: 44px; + border-radius: 9px; + font-size: var(--repo-font-title-tiny-size); + font-weight: var(--repo-font-title-tiny-weight); + line-height: var(--repo-font-title-tiny-line-height); +} + +.sheet { + width: 423px; + height: 599px; + overflow: hidden; + padding: 20px; + color: var(--repo-text-black); + background: var(--repo-gray-50); +} + +.sheetHeader { + display: grid; + grid-template-columns: 50px minmax(0, 1fr) 47px; + gap: 13px; + align-items: start; +} + +.profileImage { + width: 47px; + height: 47px; + border-radius: var(--repo-radius-pill); + background: + radial-gradient(circle at 58% 42%, #ffffff 0 8px, #d7d7d7 9px 10px, transparent 11px), + radial-gradient(circle at 37% 41%, #f7f7f7 0 12px, #d8d8d8 13px 15px, transparent 16px), + radial-gradient(circle at 40% 38%, #323232 0 2px, transparent 3px), + radial-gradient(circle at 61% 37%, #323232 0 2px, transparent 3px), + linear-gradient(135deg, #e8e4de, #c8c1b7 46%, #f1eee9); +} + +.identity { + display: grid; + gap: 5px; + min-width: 0; +} + +.nameRow { + display: flex; + align-items: baseline; + gap: 9px; + min-width: 0; +} + +.name { + margin: 0; + color: var(--repo-text-black); + font-size: var(--repo-font-title-small-size); + font-weight: var(--repo-font-title-small-weight); + line-height: var(--repo-font-title-small-line-height); + letter-spacing: 0; +} + +.major { + color: var(--repo-gray-500); + font-size: 9px; + font-weight: 500; + line-height: 1; + white-space: nowrap; +} + +.meta { + margin: 0; + color: var(--repo-text-black); + font-size: var(--repo-font-body-very-tiny-size); + font-weight: var(--repo-font-body-very-tiny-weight); + line-height: var(--repo-font-body-very-tiny-line-height); +} + +.qrCode { + width: 47px; + height: 47px; + margin-left: auto; + background: + linear-gradient(90deg, #000 50%, transparent 0) 0 0 / 9px 9px, + linear-gradient(#000 50%, transparent 0) 0 0 / 9px 9px, + linear-gradient(90deg, transparent 35%, #000 0 65%, transparent 0) 0 0 / 18px 18px, + var(--repo-gray-50); + image-rendering: pixelated; + outline: 4px solid var(--repo-gray-50); +} + +.introBox { + display: grid; + gap: 7px; + margin-top: 14px; + padding: 11px 13px; + border-radius: 8px; + background: var(--repo-gray-100); +} + +.introBox h2, +.section h2 { + margin: 0; + color: var(--repo-text-black); + font-size: var(--repo-font-body-tiny-size); + font-weight: var(--repo-font-body-tiny-weight); + line-height: var(--repo-font-body-tiny-line-height); + letter-spacing: 0; +} + +.introBox p { + margin: 0; + color: var(--repo-gray-800); + font-size: var(--repo-font-resume-small-size); + font-weight: var(--repo-font-resume-small-weight); + line-height: var(--repo-font-resume-small-line-height); +} + +.section { + display: grid; + gap: 9px; + margin-top: 14px; +} + +.skillList { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 0; + margin: 0; + list-style: none; +} + +.skillList li { + min-width: 52px; + padding: 5px 12px; + border-radius: var(--repo-radius-pill); + background: var(--repo-gray-100); + font-size: 8px; + line-height: 1; + text-align: center; +} + +.activityList { + display: grid; + gap: 10px; + padding: 11px 0 13px; + margin: 0; + border-top: 1px solid var(--repo-gray-300); + list-style: none; +} + +.activityList li { + display: grid; + gap: 3px; + color: var(--repo-text-black); + font-size: 11px; + line-height: 1.25; +} + +.activityList time, +.detailSection h3 { + color: var(--repo-gray-500); + font-size: 12px; + font-weight: 500; +} + +.detailSection { + padding-top: 5px; + border-top: 1px dashed var(--repo-gray-300); +} + +.detailSection + .detailSection { + margin-top: 9px; +} + +.detailSection h3 { + margin: 0 0 4px; +} + +.detailSection ul { + padding-left: 16px; + margin: 0; + color: var(--repo-text-black); + font-size: 11px; + line-height: 1.25; +} + +.filterPanel { + position: absolute; + top: 0; + right: 0; + z-index: 10; + display: grid; + width: min(354px, 100%); + height: 100%; + grid-template-rows: auto 1fr auto; + border-left: 1px solid var(--repo-bg-main-point); + color: var(--repo-gray-50); + background: var(--repo-bg-main-lighter); + box-shadow: -24px 0 60px rgb(0 0 0 / 18%); +} + +.filterHeader { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 58px; + padding: 0 24px; + border-bottom: 1px solid var(--repo-bg-main-point); +} + +.filterHeader h2 { + margin: 0; + font-size: var(--repo-font-body-medium-size); + font-weight: var(--repo-font-body-medium-weight); + line-height: var(--repo-font-body-medium-line-height); +} + +.closeButton { + display: inline-flex; + width: 24px; + height: 24px; + align-items: center; + justify-content: center; + border: 0; + color: var(--repo-gray-50); + background: transparent; + font-size: 24px; + font-weight: 300; + line-height: 1; + cursor: pointer; +} + +.closeButton:focus-visible, +.filterSectionTitle:focus-visible { + outline: 2px solid var(--repo-main); + outline-offset: 3px; +} + +.filterBody { + overflow-y: auto; + padding: 24px 24px 84px; +} + +.filterSection + .filterSection { + margin-top: 26px; +} + +.filterSectionTitle { + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + padding: 0; + border: 0; + color: var(--repo-gray-50); + background: transparent; + font-size: var(--repo-font-body-small-size); + font-weight: var(--repo-font-body-small-weight); + line-height: var(--repo-font-body-small-line-height); + text-align: left; + cursor: pointer; +} + +.filterSectionIcon { + display: inline-block; + color: var(--repo-gray-50); + font-size: 18px; + line-height: 1; + transform: rotate(180deg); +} + +.filterSectionTitle[aria-expanded='true'] .filterSectionIcon { + transform: rotate(0deg); +} + +.filterOptions { + display: grid; + gap: 8px; + margin-top: 12px; +} + +.filterOptions label { + min-width: 0; + padding: 9px 12px; + border-radius: 7px; +} + +.filterActions { + display: flex; + gap: 16px; + align-items: center; + justify-content: flex-end; + padding: 18px 24px; +} + +.resetButton, +.applyButton { + min-width: 102px; + min-height: 42px; + border-radius: 10px; + font-size: var(--repo-font-body-small-size); + font-weight: var(--repo-font-body-small-weight); + line-height: var(--repo-font-body-small-line-height); +} + +.applyButton { + background: var(--repo-bg-main-point); +} + +@media (max-width: 1700px) { + .workspace { + padding-inline: 48px; + } + + .previousArrow { + left: calc(50% - 540px); + } + + .nextArrow { + right: calc(50% - 540px); + } +} + +@media (max-width: 1080px) { + .workspace { + padding: 24px 28px; + } + + .toolbar { + grid-template-columns: 1fr; + } + + .searchField { + width: min(640px, calc(100vw - 128px)); + } + + .downloadButton { + justify-self: center; + } + + .sheets { + grid-template-columns: minmax(320px, 520px); + } + + .sheets .sheet:nth-child(2) { + display: none; + } + + .previousArrow { + left: 0; + } + + .nextArrow { + right: 0; + } +} + +@media (max-width: 640px) { + .workspace { + padding: 40px 18px; + } + + .searchGroup { + gap: 12px; + } + + .filterButton { + width: 48px; + height: 48px; + } + + .searchField { + width: calc(100vw - 96px); + } + + .downloadButton { + width: 100%; + } + + .sheet { + min-height: auto; + padding: 20px 18px 32px; + } + + .sheetHeader { + grid-template-columns: 52px minmax(0, 1fr); + } + + .qrCode { + display: none; + } + + .pageArrow { + display: none; + } +} diff --git a/src/app/(public)/resume-books/[bookId]/page.tsx b/src/app/(public)/resume-books/[bookId]/page.tsx index b11b3da..b9a217e 100644 --- a/src/app/(public)/resume-books/[bookId]/page.tsx +++ b/src/app/(public)/resume-books/[bookId]/page.tsx @@ -1,27 +1,409 @@ -import styles from '../../publicRoute.module.css' +'use client' -type ResumeBookPageProps = { - params: Promise<{ - bookId: string - }> +import { useEffect, useRef, useState, type ReactNode } from 'react' +import { useRouter } from 'next/navigation' + +import type { AppHeaderItem } from '@/shared/ui' +import { AppHeader, Button, CheckboxOption, SearchField, Tag, Toast } from '@/shared/ui' + +import styles from './page.module.css' + +const navigationItems = [ + { href: '/majors', label: '전공 관리', value: 'majors' }, + { href: '/students', label: '학생 관리', value: 'students' }, + { href: '/library', label: '도서관', value: 'library' }, +] satisfies readonly AppHeaderItem[] + +const skills = ['Figma', 'illustrator', 'photoshop'] + +const activities = [ + { date: '2025.12.25', title: '제 1회 SCSC 온라인 해커톤 2위' }, + { date: '2025.07.18', title: '2025 교내 해커톤 우수상' }, +] + +const contests = ['제4회 2026 블레이버스 MVP 개발 해커톤', '제 1회 SCSC온라인 해커톤', '2025 교내 해커톤'] +const projects = ['TEENS', '스플', 'D-ask', 'DSG', 'Studiz', 'hear', '마음씨', 'Repo'] + +const majorFilters = [ + { chipLabel: 'Frontend', label: 'Frontend Developer', value: 'frontend' }, + { chipLabel: 'Backend', label: 'Backend Developer', value: 'backend' }, + { chipLabel: 'UI/UX', label: 'UI/UX Engineer', value: 'ui-ux' }, + { chipLabel: 'AI', label: 'AI Developer', value: 'ai' }, + { chipLabel: 'Blockchain', label: 'Blockchain Developer', value: 'blockchain' }, + { chipLabel: 'Embedded', label: 'Embedded Developer', value: 'embedded' }, + { chipLabel: 'Android', label: 'Android Developer', value: 'android' }, + { chipLabel: 'iOS', label: 'iOS Developer', value: 'ios' }, + { chipLabel: 'QA', label: 'QA Master', value: 'qa' }, +] as const + +const classFilters = ['1반', '2반', '3반', '4반'] as const + +type MajorFilterValue = (typeof majorFilters)[number]['value'] +type ClassFilterValue = (typeof classFilters)[number] + +type FilterState = { + readonly classes: readonly ClassFilterValue[] + readonly majors: readonly MajorFilterValue[] +} + +type FilterSectionKey = 'classes' | 'majors' + +const defaultFilters: FilterState = { + classes: ['4반'], + majors: ['frontend', 'ui-ux'], +} + +const defaultExpandedFilterSections: Record = { + classes: false, + majors: false, } -export default async function ResumeBookPage({ params }: ResumeBookPageProps) { - const { bookId } = await params +export default function ResumeBookPage() { + const router = useRouter() + const [isFilterOpen, setIsFilterOpen] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + const [showsDownloadToast, setShowsDownloadToast] = useState(false) + const [appliedFilters, setAppliedFilters] = useState(defaultFilters) + const [draftFilters, setDraftFilters] = useState(defaultFilters) + const [expandedFilterSections, setExpandedFilterSections] = useState(defaultExpandedFilterSections) + const downloadToastTimerId = useRef | null>(null) + + useEffect(() => { + return () => { + if (downloadToastTimerId.current) { + clearTimeout(downloadToastTimerId.current) + } + } + }, []) + + const openFilter = () => { + setDraftFilters(appliedFilters) + setExpandedFilterSections(defaultExpandedFilterSections) + setIsFilterOpen(true) + } + + const closeFilter = () => { + setDraftFilters(appliedFilters) + setIsFilterOpen(false) + } + + const applyFilters = () => { + setAppliedFilters(draftFilters) + setIsFilterOpen(false) + } + + const resetFilters = () => { + const emptyFilters: FilterState = { classes: [], majors: [] } + + setDraftFilters(emptyFilters) + setAppliedFilters(emptyFilters) + } + + const toggleFilterSection = (sectionKey: FilterSectionKey) => { + setExpandedFilterSections((currentSections) => ({ + ...currentSections, + [sectionKey]: !currentSections[sectionKey], + })) + } + + const removeMajorFilter = (value: MajorFilterValue) => { + setAppliedFilters((currentFilters) => ({ + ...currentFilters, + majors: currentFilters.majors.filter((major) => major !== value), + })) + } + + const removeClassFilter = (value: ClassFilterValue) => { + setAppliedFilters((currentFilters) => ({ + ...currentFilters, + classes: currentFilters.classes.filter((className) => className !== value), + })) + } + + const toggleMajorFilter = (value: MajorFilterValue, checked: boolean) => { + setDraftFilters((currentFilters) => ({ + ...currentFilters, + majors: checked + ? [...currentFilters.majors, value] + : currentFilters.majors.filter((major) => major !== value), + })) + } + + const toggleClassFilter = (value: ClassFilterValue, checked: boolean) => { + setDraftFilters((currentFilters) => ({ + ...currentFilters, + classes: checked + ? [...currentFilters.classes, value] + : currentFilters.classes.filter((className) => className !== value), + })) + } + + const showDownloadToast = () => { + if (downloadToastTimerId.current) { + clearTimeout(downloadToastTimerId.current) + } + + setShowsDownloadToast(true) + downloadToastTimerId.current = setTimeout(() => { + setShowsDownloadToast(false) + downloadToastTimerId.current = null + }, 2500) + } + + const hasAppliedFilters = appliedFilters.majors.length > 0 || appliedFilters.classes.length > 0 + const normalizedSearchQuery = searchQuery.trim() + const showsEmptySearchResult = normalizedSearchQuery.length > 0 return ( -
-
-
- Resume Book -

- {bookId} 레주메북 -

-

- 학년/전공/주제별 학생 포트폴리오를 모아보는 레주메북 화면입니다. -

-
-
+
+ +
+
+
+
+ + setSearchQuery(event.target.value)} + /> +
+ +
+ + {showsDownloadToast ? ( +
+ PDF 다운로드에 성공했습니다. +
+ ) : null} + + {hasAppliedFilters && !showsEmptySearchResult ? ( +
+ {appliedFilters.classes.map((className) => ( + removeClassFilter(className)}> + {className} + + ))} + {appliedFilters.majors.map((major) => { + const option = majorFilters.find((filter) => filter.value === major) + + return option ? ( + removeMajorFilter(major)}> + {option.chipLabel} + + ) : null + })} +
+ ) : null} + + {showsEmptySearchResult ? ( + router.push('/library')} /> + ) : ( + <> +
+ +
+ + +
+ +
+ +

+ 4 / 126 +

+ + )} +
+ + {isFilterOpen ? ( + + ) : null} +
) } + +function SearchEmptyState({ + onReturn, + searchQuery, +}: { + readonly onReturn: () => void + readonly searchQuery: string +}) { + return ( +
+

+ 입력하신 '{searchQuery}'와(과) 일치하는 학생이 없습니다. +
+ 이름을 다시 확인해주세요. +

+ +
+ ) +} + +function FilterSection({ + children, + expanded, + onToggle, + title, +}: { + readonly children: ReactNode + readonly expanded: boolean + readonly onToggle: () => void + readonly title: string +}) { + return ( +
+ + {expanded ?
{children}
: null} +
+ ) +} + +function ResumeSheet() { + return ( +
+
+
+
+
+

최하은

+ Frontend Developer +
+

2415 인공지능소프트웨어과 | mare2mare6@gmail.com

+
+
+
+ +
+

안녕하세요 저는 디자이너가 되고 싶은 인간입니다

+

+ 새벽자습너무 졸립니다. 뭘 적지.. 한줄소개는 이런식으로 쭉쭉 들어갑니다. + 줄넘김 가능합니다. 자기소개자기소개자기소개자기소개자기소개자기소개.. 최대 4줄이면 충분하겠지만.. +

+
+ +
+

기술스택

+
    + {skills.map((skill) => ( +
  • {skill}
  • + ))} +
+
+ +
+

활동

+
    + {activities.map((activity) => ( +
  1. + + {activity.title} +
  2. + ))} +
+
+ +
+

대회

+
    + {contests.map((contest) => ( +
  • {contest}
  • + ))} +
+
+ +
+

Project

+
    + {projects.map((project) => ( +
  • {project}
  • + ))} +
+
+
+ ) +} + +function FilterIcon() { + return ( + + ) +} diff --git a/src/shared/ui/LibraryBookCard/LibraryBookCard.module.css b/src/shared/ui/LibraryBookCard/LibraryBookCard.module.css index ba71781..98a01ce 100644 --- a/src/shared/ui/LibraryBookCard/LibraryBookCard.module.css +++ b/src/shared/ui/LibraryBookCard/LibraryBookCard.module.css @@ -2,8 +2,8 @@ position: relative; display: block; overflow: hidden; - width: 160px; - height: 220px; + width: 166px; + height: 228px; padding: 20px 15px 16px; border: 0; border-radius: 12px; @@ -42,7 +42,7 @@ .title { color: var(--repo-gray-50); - font-size: 24px; + font-size: 25px; font-weight: 700; line-height: 1; letter-spacing: -0.04em; @@ -58,7 +58,7 @@ .gradeLabel { display: block; - margin-top: 6px; + margin-top: 7px; color: var(--repo-gray-50); font-size: 16px; font-weight: 500; @@ -69,7 +69,7 @@ .action { position: absolute; right: 15px; - bottom: 15px; + bottom: 16px; display: inline-flex; align-items: center; justify-content: flex-end; @@ -78,7 +78,7 @@ font-size: 14px; font-weight: 500; line-height: 1; - letter-spacing: -0.05em; + letter-spacing: 0; white-space: nowrap; } diff --git a/src/shared/ui/LibraryBookCard/LibraryBookCard.tsx b/src/shared/ui/LibraryBookCard/LibraryBookCard.tsx index b8701d1..ad7e08f 100644 --- a/src/shared/ui/LibraryBookCard/LibraryBookCard.tsx +++ b/src/shared/ui/LibraryBookCard/LibraryBookCard.tsx @@ -15,7 +15,7 @@ type LibraryBookCardBaseProps = { } /** - * Fixed 160×220 library-entry book cover card for the current Figma-derived library/resume-book entry pattern. + * Fixed 166×228 library-entry book cover card for the current Figma-derived library/resume-book entry pattern. * Keep this API intentionally narrow until multiple cover variants or sizes are approved. */ export type LibraryBookCardProps = LibraryBookCardBaseProps & Omit, keyof LibraryBookCardBaseProps | 'href'> diff --git a/src/shared/ui/Tag/Tag.module.css b/src/shared/ui/Tag/Tag.module.css index fa20857..2636186 100644 --- a/src/shared/ui/Tag/Tag.module.css +++ b/src/shared/ui/Tag/Tag.module.css @@ -2,12 +2,12 @@ display: inline-flex; align-items: center; justify-content: center; - gap: 6px; + gap: 4px; width: fit-content; - min-height: 24px; - padding: 2px 12px; + min-height: 26px; + padding: 2px 8px 2px 10px; border: 1px solid transparent; - border-radius: 50px; + border-radius: var(--repo-radius-pill); } .dark { @@ -24,9 +24,9 @@ .text { color: currentColor; - font-size: var(--repo-font-body-tiny-size); - font-weight: var(--repo-font-body-tiny-weight); - line-height: var(--repo-font-body-tiny-line-height); + font-size: var(--repo-font-body-small-size); + font-weight: var(--repo-font-body-small-weight); + line-height: var(--repo-font-body-small-line-height); white-space: nowrap; } @@ -34,8 +34,8 @@ display: inline-flex; align-items: center; justify-content: center; - width: 18px; - height: 18px; + width: 14px; + height: 14px; padding: 0; color: currentColor; background: transparent; @@ -53,6 +53,6 @@ } .removeIcon { - width: 18px; - height: 18px; + width: 14px; + height: 14px; }