diff --git a/.gitignore b/.gitignore index ca6c905..21860ee 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ out *.njsproj *.sln *.sw? +.omo/ .omx/ .codex/* !.codex/agents/ diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..942fcdb --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,158 @@ +# Repo-V2 Design System + +## 1. Atmosphere & Identity + +Repo-V2 is a dark, quiet school-work surface: compact, direct, and built for repeated student and teacher workflows. The signature is layered charcoal UI with bright submission status accents, where rows and cards feel tappable without decorative noise. + +## 2. Color + +### Palette + +| Role | Token | Value | Usage | +| --- | --- | --- | --- | +| Surface/page | `--repo-gray-100` | `#F8F8F8` | App background | +| Surface/dark | `--repo-bg-main` | `#181819` | Header, footer, primary rows | +| Surface/dark-muted | `--repo-bg-main-lighter` | `#212124` | Active dark rows, selected nav | +| Surface/dark-raised | `--repo-bg-main-point` | `#43434B` | Secondary rows, filled controls | +| Text/inverse | `--repo-gray-50` | `#FFFFFF` | Text on dark surfaces | +| Text/muted | `--repo-gray-500` | `#919191` | Secondary dark-surface labels | +| Text/disabled | `--repo-gray-400` | `#ADADAD` | Placeholder and inactive labels | +| Accent/success | `--repo-main` | `#37E517` | Submitted state | +| Status/error | `--repo-state-error` | `#EB5757` | Error text | + +### Rules + +- Dark primitives use tonal-shift depth before shadows. +- Green is reserved for submitted/success status. +- Error red appears only with validation feedback. + +## 3. Typography + +### Scale + +| Level | Token | Usage | +| --- | --- | --- | +| Title/large | `--repo-font-title-large-*` | Page-level emphasis only | +| Title/medium | `--repo-font-title-medium-*` | Large modal emphasis and rare showcase headings | +| Title/small | `--repo-font-title-small-*` | Primary row labels, card titles, modal headings | +| Title/tiny | `--repo-font-title-tiny-*` | Navigation, buttons | +| Body/large | `--repo-font-body-large-*` | Prominent row metadata | +| Body/medium | `--repo-font-body-medium-*` | Input text and row actions | +| Body/small | `--repo-font-body-small-*` | Default form text | +| Body/tiny | `--repo-font-body-tiny-*` | Footer/legal text | + +### Font Stack + +- Primary: Pretendard, system UI fallback. + +### Rules + +- Letter spacing remains `0`. +- Body text stays at or above 14px. + +## 4. Spacing & Layout + +### Base Unit + +All spacing derives from 4px. + +| Token | Value | Usage | +| --- | --- | --- | +| `--space-2` | 8px | Tight icon and text clusters | +| `--space-3` | 12px | Compact row gaps | +| `--space-4` | 16px | Form and row internal spacing | +| `--space-5` | 20px | Default inset | +| `--space-6` | 24px | Card inset | +| `--space-8` | 32px | Component groups | +| `--space-10` | 40px | Large cards and modal rhythm | + +### Grid + +- Desktop publishing target: 1440px to 1920px wide. +- Components are fluid by default and receive width from their parent. +- Reusable controls target app-scale CSS pixels, not exported screenshot pixels: rows 56px, headers 64px, inputs 56px by default. +- Mobile support is basic reflow protection, not final polish for this milestone. + +## 5. Components + +### AppHeader +- **Structure**: `header > Logo + nav + action` +- **Variants**: student, teacher, public +- **States**: nav default, selected, focus +- **Accessibility**: landmark header, current page conveyed through `aria-current` +- **Motion**: 120ms background and color transition + +### LinkRow +- **Structure**: row button/link with primary text, optional status/date, optional trailing text, chevron +- **Variants**: submitted, missing, neutral +- **States**: default, active, focus +- **Accessibility**: interactive element supplies an accessible label through visible text + +### ClassCard +- **Structure**: large rounded button with class name, count, chevron +- **Variants**: default, selected +- **States**: default, hover, focus + +### TextSlot +- **Structure**: label, display text, optional editable input-like slot, optional error +- **Variants**: compact, wide +- **States**: read, edit, error + +### MajorInputGroup +- **Structure**: stacked input rows with validation text +- **States**: default, error + +### SearchField +- **Structure**: rounded input with search icon button +- **States**: default, focus + +### MajorList +- **Structure**: section label plus full-width major rows +- **States**: row default, selected, focus + +### AppFooter +- **Structure**: footer with product name, address, phone lines, legal links +- **Accessibility**: footer landmark + +### LibraryBook +- **Structure**: fixed-ratio book cover link using `/assets/library-book-cover.svg` as the cover source, with year, generation, grade, and action text layered above it +- **States**: default, hover, focus + +### PortfolioUrlModal +- **Structure**: dialog panel with title, description, input and actions +- **States**: default, error, pending through disabled actions +- **Accessibility**: dialog role and labelled title + +## 6. Motion & Interaction + +| Type | Duration | Easing | Usage | +| --- | --- | --- | --- | +| Micro | 120ms | ease-out | Button and row feedback | +| Standard | 200ms | ease-in-out | Modal and selected state shifts | + +Only `transform`, `opacity`, background, border, and color transitions are used in this component pass. + +## 7. Depth & Surface + +### Strategy + +Mixed tonal-shift with restrained shadows for raised cards and modal surfaces. + +| Level | Value | Usage | +| --- | --- | --- | +| Book | `0 14px 32px rgba(17, 17, 17, 0.28)` | Library book card | +| Modal | `0 20px 60px rgba(17, 17, 17, 0.18)` | URL modal | + +## 8. Accessibility Constraints & Accepted Debt + +### Constraints + +- Target WCAG 2.2 AA. +- Every interactive primitive exposes visible focus. +- Components must tolerate long Korean labels without overlap. + +### Accepted Debt + +| Item | Location | Why accepted | Owner / Exit | +| --- | --- | --- | --- | +| Pixel-perfect reference matching | Shared component showcase | User requested final visual drift to be handled in later QA | Final publishing QA | diff --git a/public/assets/auth-bg.svg b/public/assets/auth-bg.svg new file mode 100644 index 0000000..f089faf --- /dev/null +++ b/public/assets/auth-bg.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/app/component-showcase/page.module.css b/src/app/component-showcase/page.module.css new file mode 100644 index 0000000..804e064 --- /dev/null +++ b/src/app/component-showcase/page.module.css @@ -0,0 +1,55 @@ +.page { + display: grid; + gap: 32px; + min-height: 100vh; + padding: 20px; + background: var(--repo-gray-700); +} + +.section { + display: grid; + gap: var(--space-5); + width: 100%; +} + +.sectionTitle { + margin: 0; + color: var(--repo-gray-50); + font-size: var(--repo-font-body-large-size); + font-weight: var(--repo-font-body-large-weight); + line-height: var(--repo-font-body-large-line-height); + letter-spacing: 0; +} + +.stack { + display: grid; + gap: var(--space-5); +} + +.grid, +.gridWide, +.libraryArea { + display: grid; + gap: var(--space-6); + align-items: start; +} + +.grid { + grid-template-columns: repeat(2, minmax(240px, 1fr)); +} + +.gridWide { + grid-template-columns: minmax(220px, 300px) 1fr; +} + +.libraryArea { + grid-template-columns: 240px 240px 1fr; +} + +@media (max-width: 980px) { + .grid, + .gridWide, + .libraryArea { + grid-template-columns: 1fr; + } +} diff --git a/src/app/component-showcase/page.tsx b/src/app/component-showcase/page.tsx new file mode 100644 index 0000000..201825f --- /dev/null +++ b/src/app/component-showcase/page.tsx @@ -0,0 +1,107 @@ +import { + AppFooter, + AppHeader, + ClassCard, + LibraryBook, + LinkRow, + MajorInputGroup, + MajorList, + OptionList, + PortfolioUrlModal, + SearchField, + TextSlot, +} from '@/shared/ui' + +import styles from './page.module.css' + +const studentNavItems = [ + { href: '/', label: '홈', value: 'home' }, + { href: '/student/resume', label: '이력서 관리', value: 'resume' }, + { href: '/library', label: '도서관', value: 'library' }, +] as const + +const teacherNavItems = [ + { href: '/teacher/majors', label: '전공 관리', value: 'major' }, + { href: '/teacher/students', label: '학생 관리', value: 'students' }, + { href: '/library', label: '도서관', value: 'library' }, +] as const + +const majors = [ + { id: 'frontend', name: 'Frontend Developer' }, + { id: 'backend', name: 'Backend Developer' }, +] as const + +const options = [ + { id: 'frontend-1', label: 'Frontend Developer' }, + { id: 'backend-1', label: 'Backend Developer' }, + { id: 'frontend-2', label: 'Frontend Developer' }, + { id: 'backend-2', label: 'Backend Developer' }, + { id: 'frontend-3', label: 'Frontend Developer' }, + { id: 'backend-3', label: 'Backend Developer' }, +] as const + +export default function ComponentShowcasePage() { + return ( +
+
+

Rows

+
+ + + + +
+
+ +
+

Cards

+
+ + +
+
+ +
+

Text Slots

+
+ + +
+
+ +
+

Major Controls

+ + + +
+ +
+

Header

+ + + + + + +
+ +
+

Library And Modal

+
+ + + +
+
+ + +
+ ) +} diff --git a/src/app/globals.css b/src/app/globals.css index 4e3a1a3..633fc35 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -8,6 +8,29 @@ --border: var(--repo-gray-200); --brand: var(--repo-main); --brand-soft: var(--repo-bg-main-point); + --repo-radius-control: 10px; + --repo-radius-panel: 16px; + --repo-radius-card: 14px; + --repo-radius-pill: 999px; + --repo-size-row-min-height: 56px; + --repo-size-header-min-height: 64px; + --repo-size-input-min-height: 56px; + --repo-shadow-book: 0 14px 32px rgba(17, 17, 17, 0.28); + --repo-shadow-modal: 0 20px 60px rgba(17, 17, 17, 0.18); + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-7: 28px; + --space-8: 32px; + --space-9: 36px; + --space-10: 40px; + --space-12: 48px; + --space-14: 56px; + --space-16: 64px; + --space-18: 72px; + --space-20: 80px; color: var(--text-primary); background: var(--background); diff --git a/src/features/auth/ui/AuthLoginPage.module.css b/src/features/auth/ui/AuthLoginPage.module.css index 98ed2de..de07c31 100644 --- a/src/features/auth/ui/AuthLoginPage.module.css +++ b/src/features/auth/ui/AuthLoginPage.module.css @@ -6,19 +6,9 @@ overflow: hidden; padding: 96px 32px 64px; color: var(--repo-gray-50); - background: linear-gradient(180deg, var(--repo-bg-main-lighter) 0%, var(--repo-gray-950) 100%); -} - -.page::before { - position: absolute; - inset: 0; - content: ''; background: - radial-gradient(circle at 50% 42%, rgb(255 255 255 / 18%) 0 1px, transparent 1px 7px), - radial-gradient(ellipse at 50% 42%, rgb(255 255 255 / 16%) 0, transparent 35%); - background-size: 7px 7px, 100% 100%; - mask-image: radial-gradient(ellipse 44% 35% at 50% 42%, black 0 52%, transparent 76%); - pointer-events: none; + url('/assets/auth-bg.svg') center / cover no-repeat, + var(--repo-gray-950); } .header { diff --git a/src/shared/ui/AppFooter/AppFooter.module.css b/src/shared/ui/AppFooter/AppFooter.module.css new file mode 100644 index 0000000..dda5cd0 --- /dev/null +++ b/src/shared/ui/AppFooter/AppFooter.module.css @@ -0,0 +1,48 @@ +.footer { + width: 100%; + min-height: 240px; + padding: var(--space-10) var(--space-18); + color: var(--repo-gray-50); + background: var(--repo-bg-main); +} + +.inner { + display: grid; + gap: var(--space-3); +} + +.brand { + 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; +} + +.copy, +.address, +.links { + margin: 0; + color: var(--repo-gray-400); + font-size: var(--repo-font-body-tiny-size); + font-style: normal; + font-weight: var(--repo-font-body-tiny-weight); + line-height: 1.5; + letter-spacing: 0; +} + +.links { + display: flex; + gap: var(--space-3); + margin-top: var(--space-7); +} + +.links a:focus-visible { + outline: 2px solid var(--repo-main); + outline-offset: 2px; +} + +@media (max-width: 720px) { + .footer { + padding: var(--space-8) var(--space-6); + } +} diff --git a/src/shared/ui/AppFooter/AppFooter.tsx b/src/shared/ui/AppFooter/AppFooter.tsx new file mode 100644 index 0000000..2387b30 --- /dev/null +++ b/src/shared/ui/AppFooter/AppFooter.tsx @@ -0,0 +1,24 @@ +import Link from 'next/link' + +import styles from './AppFooter.module.css' + +export function AppFooter() { + return ( + + ) +} diff --git a/src/shared/ui/AppFooter/index.ts b/src/shared/ui/AppFooter/index.ts new file mode 100644 index 0000000..7ab34d4 --- /dev/null +++ b/src/shared/ui/AppFooter/index.ts @@ -0,0 +1 @@ +export { AppFooter } from './AppFooter' diff --git a/src/shared/ui/AppHeader/AppHeader.module.css b/src/shared/ui/AppHeader/AppHeader.module.css new file mode 100644 index 0000000..b23f52d --- /dev/null +++ b/src/shared/ui/AppHeader/AppHeader.module.css @@ -0,0 +1,94 @@ +.header { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: var(--space-10); + width: 100%; + min-height: var(--repo-size-header-min-height); + padding: var(--space-3) 64px; + color: var(--repo-gray-50); + background: var(--repo-bg-main); +} + +.nav { + display: flex; + min-width: 0; + align-items: center; + gap: var(--space-8); +} + +.navItem, +.loginButton { + display: inline-flex; + min-height: 36px; + align-items: center; + justify-content: center; + padding: 8px 20px; + border-radius: var(--repo-radius-pill); + color: var(--repo-gray-50); + 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); + letter-spacing: 0; + transition: + background 120ms ease-out, + color 120ms ease-out; +} + +.navItem { + min-width: 72px; +} + +.navItem:hover, +.active { + background: var(--repo-bg-main-lighter); +} + +.navItem:focus-visible, +.iconButton:focus-visible { + outline: 2px solid var(--repo-main); + outline-offset: 2px; +} + +.action { + display: flex; + justify-content: end; +} + +.iconButton { + display: inline-flex; + width: 36px; + height: 36px; + align-items: center; + justify-content: center; + border-radius: var(--repo-radius-pill); + color: var(--repo-gray-50); + background: var(--repo-bg-main-lighter); +} + +.loginButton { + gap: var(--space-2); + min-width: 112px; + border: 1px solid var(--repo-gray-500); + color: var(--repo-gray-50); + background: var(--repo-bg-main); +} + +.loginButton:hover { + background: var(--repo-bg-main-lighter); +} + +@media (max-width: 860px) { + .header { + grid-template-columns: 1fr auto; + gap: var(--space-5); + padding: var(--space-4) var(--space-6); + } + + .nav { + grid-column: 1 / -1; + order: 3; + overflow-x: auto; + gap: var(--space-4); + } +} diff --git a/src/shared/ui/AppHeader/AppHeader.tsx b/src/shared/ui/AppHeader/AppHeader.tsx new file mode 100644 index 0000000..ae37a82 --- /dev/null +++ b/src/shared/ui/AppHeader/AppHeader.tsx @@ -0,0 +1,49 @@ +import Link from 'next/link' + +import { Icon, Logo } from '@/shared/ui' + +import styles from './AppHeader.module.css' + +export type AppHeaderItem = { + readonly href: string + readonly label: string + readonly value: string +} + +export type AppHeaderProps = { + readonly activeItem?: string + readonly items: readonly AppHeaderItem[] + readonly loginHref?: string + readonly showLogin?: boolean +} + +export function AppHeader({ activeItem, items, loginHref = '/login', showLogin = false }: AppHeaderProps) { + return ( +
+ + +
+ {showLogin ? ( + + + 로그인 + + ) : ( + + + + )} +
+
+ ) +} diff --git a/src/shared/ui/AppHeader/index.ts b/src/shared/ui/AppHeader/index.ts new file mode 100644 index 0000000..344b575 --- /dev/null +++ b/src/shared/ui/AppHeader/index.ts @@ -0,0 +1,2 @@ +export { AppHeader } from './AppHeader' +export type { AppHeaderItem, AppHeaderProps } from './AppHeader' diff --git a/src/shared/ui/ClassCard/ClassCard.module.css b/src/shared/ui/ClassCard/ClassCard.module.css new file mode 100644 index 0000000..3249252 --- /dev/null +++ b/src/shared/ui/ClassCard/ClassCard.module.css @@ -0,0 +1,77 @@ +.card { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + min-height: 104px; + padding: var(--space-7) var(--space-10); + border: 0; + border-radius: var(--repo-radius-card); + color: var(--repo-gray-50); + background: var(--repo-bg-main-point); + font-family: inherit; + cursor: pointer; + transition: + background 120ms ease-out, + transform 120ms ease-out; +} + +.card:hover { + transform: translateY(-1px); +} + +.card:focus-visible { + outline: 2px solid var(--repo-main); + outline-offset: 3px; +} + +.selected { + box-shadow: inset 0 0 0 3px var(--repo-bg-main-point); + background: var(--repo-bg-main-lighter); +} + +.content { + display: inline-flex; + min-width: 0; + align-items: baseline; + gap: var(--space-3); +} + +.title { + font-size: var(--repo-font-title-s-medium-size); + font-weight: var(--repo-font-title-s-medium-weight); + line-height: var(--repo-font-title-s-medium-line-height); + letter-spacing: 0; + white-space: nowrap; +} + +.count { + color: var(--repo-gray-500); + 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; + white-space: nowrap; +} + +.icon { + width: 28px; + height: 28px; + flex: 0 0 auto; + color: var(--repo-gray-500); +} + +@media (max-width: 720px) { + .card { + min-height: 88px; + padding: var(--space-5); + } + + .title { + font-size: var(--repo-font-title-small-size); + } + + .count { + font-size: var(--repo-font-body-large-size); + } +} diff --git a/src/shared/ui/ClassCard/ClassCard.tsx b/src/shared/ui/ClassCard/ClassCard.tsx new file mode 100644 index 0000000..1d2c111 --- /dev/null +++ b/src/shared/ui/ClassCard/ClassCard.tsx @@ -0,0 +1,26 @@ +import type { ButtonHTMLAttributes } from 'react' + +import { Icon } from '@/shared/ui/Icon' + +import styles from './ClassCard.module.css' + +export type ClassCardProps = Omit, 'children' | 'className'> & { + readonly className?: string + readonly count: number + readonly selected?: boolean + readonly title: string +} + +export function ClassCard({ className, count, selected = false, title, type = 'button', ...props }: ClassCardProps) { + const cardClassName = [styles.card, selected ? styles.selected : '', className].filter(Boolean).join(' ') + + return ( + + ) +} diff --git a/src/shared/ui/ClassCard/index.ts b/src/shared/ui/ClassCard/index.ts new file mode 100644 index 0000000..3b3d904 --- /dev/null +++ b/src/shared/ui/ClassCard/index.ts @@ -0,0 +1,2 @@ +export { ClassCard } from './ClassCard' +export type { ClassCardProps } from './ClassCard' diff --git a/src/shared/ui/Icon/Icon.tsx b/src/shared/ui/Icon/Icon.tsx index 7ef246b..f2dda6b 100644 --- a/src/shared/ui/Icon/Icon.tsx +++ b/src/shared/ui/Icon/Icon.tsx @@ -1,12 +1,16 @@ import type { SVGProps } from 'react' -export type IconName = 'chevron-right' | 'eye' | 'eye-off' | 'login' | 'plus' | 'right-arrow' +export type IconName = 'bell' | 'chevron-right' | 'eye' | 'eye-off' | 'login' | 'plus' | 'right-arrow' | 'search' type IconProps = Omit, 'children'> & { name: IconName } export function Icon({ name, 'aria-hidden': ariaHidden = true, focusable = false, ...props }: IconProps) { + if (name === 'bell') { + return + } + if (name === 'chevron-right') { return } @@ -31,9 +35,26 @@ export function Icon({ name, 'aria-hidden': ariaHidden = true, focusable = false return } + if (name === 'search') { + return + } + return null } +function BellIcon(props: Omit, 'children'>) { + return ( + + + + ) +} + function ChevronRightIcon(props: Omit, 'children'>) { return ( @@ -106,3 +127,17 @@ function RightArrowIcon(props: Omit, 'children'>) { ) } + +function SearchIcon(props: Omit, 'children'>) { + return ( + + + + ) +} diff --git a/src/shared/ui/LibraryBook/LibraryBook.module.css b/src/shared/ui/LibraryBook/LibraryBook.module.css new file mode 100644 index 0000000..f57c8ad --- /dev/null +++ b/src/shared/ui/LibraryBook/LibraryBook.module.css @@ -0,0 +1,73 @@ +.book { + position: relative; + display: grid; + align-content: space-between; + width: min(160px, 100%); + aspect-ratio: 8 / 11; + padding: var(--space-5) 15px var(--space-4); + overflow: hidden; + border-radius: var(--repo-radius-control); + color: var(--repo-gray-50); + background: url('/assets/library-book-cover.svg') center / cover no-repeat; + transition: + transform 120ms ease-out, + filter 120ms ease-out; +} + +.book:hover { + filter: brightness(1.08); + transform: translateY(-2px); +} + +.book:focus-visible { + outline: 2px solid var(--repo-main); + outline-offset: 3px; +} + +.year { + position: relative; + display: flex; + align-items: baseline; + gap: 4px; + font-size: var(--repo-font-title-small-size); + font-weight: 700; + line-height: 1; + letter-spacing: 0; +} + +.year span { + color: var(--repo-gray-500); + font-size: var(--repo-font-body-small-size); + font-weight: 600; +} + +.grade { + position: absolute; + top: 50px; + left: 15px; + font-size: var(--repo-font-body-small-size); + font-weight: 500; + line-height: 1; + letter-spacing: 0; +} + +.action { + position: absolute; + right: 15px; + bottom: 15px; + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: var(--space-2); + color: var(--repo-gray-400); + font-size: var(--repo-font-body-tiny-size); + font-weight: 500; + line-height: 1; + letter-spacing: 0; + white-space: nowrap; +} + +.icon { + width: 16px; + height: 16px; +} diff --git a/src/shared/ui/LibraryBook/LibraryBook.tsx b/src/shared/ui/LibraryBook/LibraryBook.tsx new file mode 100644 index 0000000..be3c5a1 --- /dev/null +++ b/src/shared/ui/LibraryBook/LibraryBook.tsx @@ -0,0 +1,25 @@ +import { Icon } from '@/shared/ui/Icon' + +import styles from './LibraryBook.module.css' + +export type LibraryBookProps = { + readonly generation: string + readonly grade: string + readonly href: string + readonly year: string +} + +export function LibraryBook({ generation, grade, href, year }: LibraryBookProps) { + return ( + + + {year} {generation} + + {grade} + + 포트폴리오 열람 + + + + ) +} diff --git a/src/shared/ui/LibraryBook/index.ts b/src/shared/ui/LibraryBook/index.ts new file mode 100644 index 0000000..09adb95 --- /dev/null +++ b/src/shared/ui/LibraryBook/index.ts @@ -0,0 +1,2 @@ +export { LibraryBook } from './LibraryBook' +export type { LibraryBookProps } from './LibraryBook' diff --git a/src/shared/ui/LinkRow/LinkRow.module.css b/src/shared/ui/LinkRow/LinkRow.module.css new file mode 100644 index 0000000..fe9508b --- /dev/null +++ b/src/shared/ui/LinkRow/LinkRow.module.css @@ -0,0 +1,106 @@ +.row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + width: 100%; + min-height: var(--repo-size-row-min-height); + padding: var(--space-4) var(--space-6); + border: 0; + color: var(--repo-gray-50); + font-family: inherit; + text-align: left; + cursor: pointer; + transition: + background 120ms ease-out, + transform 120ms ease-out; +} + +.row:hover { + transform: translateY(-1px); +} + +.row:focus-visible { + outline: 2px solid var(--repo-main); + outline-offset: 2px; +} + +.defaultSurface { + background: var(--repo-bg-main-lighter); +} + +.mutedSurface { + background: var(--repo-bg-main-point); +} + +.leading, +.trailing { + display: inline-flex; + min-width: 0; + align-items: center; + gap: var(--space-4); +} + +.trailing { + margin-left: auto; + color: var(--repo-gray-500); +} + +.title { + overflow: hidden; + 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; + text-overflow: ellipsis; + white-space: nowrap; +} + +.status, +.action, +.meta { + flex: 0 0 auto; + 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); + letter-spacing: 0; +} + +.submitted .status { + color: var(--repo-main); +} + +.missing .status, +.neutral .status, +.action, +.meta { + color: var(--repo-gray-500); +} + +.chevron { + width: 20px; + height: 20px; + flex: 0 0 auto; +} + +@media (max-width: 720px) { + .row { + min-height: 52px; + padding: 14px var(--space-4); + } + + .leading, + .trailing { + gap: var(--space-3); + } + + .title { + font-size: var(--repo-font-body-large-size); + } + + .status, + .action, + .meta { + font-size: var(--repo-font-body-small-size); + } +} diff --git a/src/shared/ui/LinkRow/LinkRow.tsx b/src/shared/ui/LinkRow/LinkRow.tsx new file mode 100644 index 0000000..24b9077 --- /dev/null +++ b/src/shared/ui/LinkRow/LinkRow.tsx @@ -0,0 +1,73 @@ +import type { MouseEventHandler, ReactNode } from 'react' + +import { Icon } from '@/shared/ui/Icon' + +import styles from './LinkRow.module.css' + +export type LinkRowTone = 'submitted' | 'missing' | 'neutral' +export type LinkRowSurface = 'default' | 'muted' + +type LinkRowBaseProps = { + readonly actionLabel?: string + readonly className?: string + readonly meta?: ReactNode + readonly status?: ReactNode + readonly surface?: LinkRowSurface + readonly title: ReactNode + readonly tone?: LinkRowTone +} + +type LinkRowAnchorProps = LinkRowBaseProps & { + readonly href: string +} + +type LinkRowButtonProps = LinkRowBaseProps & { + readonly disabled?: boolean + readonly href?: never + readonly onClick?: MouseEventHandler +} + +export type LinkRowProps = LinkRowAnchorProps | LinkRowButtonProps + +const toneClassName: Record = { + missing: styles.missing, + neutral: styles.neutral, + submitted: styles.submitted, +} + +const surfaceClassName: Record = { + default: styles.defaultSurface, + muted: styles.mutedSurface, +} + +export function LinkRow(props: LinkRowProps) { + const { actionLabel, className, meta, status, surface = 'default', title, tone = 'neutral' } = props + const rowClassName = [styles.row, surfaceClassName[surface], toneClassName[tone], className].filter(Boolean).join(' ') + const content = ( + <> + + {title} + {status ? {status} : null} + + + {actionLabel ? {actionLabel} : null} + {meta ? {meta} : null} + + + + ) + + if ('href' in props) { + return ( + + {content} + + ) + } + + return ( + + ) +} diff --git a/src/shared/ui/LinkRow/index.ts b/src/shared/ui/LinkRow/index.ts new file mode 100644 index 0000000..779c3ec --- /dev/null +++ b/src/shared/ui/LinkRow/index.ts @@ -0,0 +1,2 @@ +export { LinkRow } from './LinkRow' +export type { LinkRowProps, LinkRowSurface, LinkRowTone } from './LinkRow' diff --git a/src/shared/ui/MajorInputGroup/MajorInputGroup.module.css b/src/shared/ui/MajorInputGroup/MajorInputGroup.module.css new file mode 100644 index 0000000..ab3c1d0 --- /dev/null +++ b/src/shared/ui/MajorInputGroup/MajorInputGroup.module.css @@ -0,0 +1,43 @@ +.group { + display: grid; + gap: var(--space-5); + width: 100%; +} + +.input { + width: 100%; + min-height: var(--repo-size-input-min-height); + padding: var(--space-4) var(--space-6); + border: 0; + border-radius: var(--repo-radius-control); + outline: 0; + color: var(--repo-gray-50); + background: var(--repo-bg-main-point); + font-size: var(--repo-font-body-large-size); + font-weight: var(--repo-font-body-medium-weight); + line-height: var(--repo-font-body-large-line-height); + letter-spacing: 0; +} + +.input::placeholder { + color: var(--repo-gray-400); + opacity: 1; +} + +.input:focus { + outline: 2px solid var(--repo-main); + outline-offset: 2px; +} + +.inputError { + outline: 1px solid var(--repo-state-error); +} + +.errorMessage { + margin: calc(var(--space-3) * -1) 0 0; + color: var(--repo-state-error); + 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); + letter-spacing: 0; +} diff --git a/src/shared/ui/MajorInputGroup/MajorInputGroup.tsx b/src/shared/ui/MajorInputGroup/MajorInputGroup.tsx new file mode 100644 index 0000000..3a40795 --- /dev/null +++ b/src/shared/ui/MajorInputGroup/MajorInputGroup.tsx @@ -0,0 +1,55 @@ +'use client' + +import type { InputHTMLAttributes } from 'react' +import { useId } from 'react' + +import styles from './MajorInputGroup.module.css' + +export type MajorInputValue = { + readonly id: string + readonly placeholder?: string + readonly value?: string +} + +export type MajorInputGroupProps = { + readonly ariaLabelPrefix?: string + readonly errorMessage?: string + readonly inputs: readonly MajorInputValue[] + readonly onChange?: (id: string, value: string) => void +} + +type MajorInputProps = Omit, 'className'> & { + readonly hasError?: boolean +} + +function MajorInput({ hasError = false, ...props }: MajorInputProps) { + return +} + +export function MajorInputGroup({ ariaLabelPrefix = '추가할 전공', errorMessage, inputs, onChange }: MajorInputGroupProps) { + const generatedId = useId() + const hasError = Boolean(errorMessage) + const errorMessageId = hasError ? `${generatedId}-error` : undefined + + return ( +
+ {inputs.map((input) => ( + onChange?.(input.id, event.target.value)} + placeholder={input.placeholder ?? '추가할 전공을 입력해주세요.'} + value={input.value} + /> + ))} + {errorMessage ? ( +

+ {errorMessage} +

+ ) : null} +
+ ) +} diff --git a/src/shared/ui/MajorInputGroup/index.ts b/src/shared/ui/MajorInputGroup/index.ts new file mode 100644 index 0000000..fa8ac5b --- /dev/null +++ b/src/shared/ui/MajorInputGroup/index.ts @@ -0,0 +1,2 @@ +export { MajorInputGroup } from './MajorInputGroup' +export type { MajorInputGroupProps, MajorInputValue } from './MajorInputGroup' diff --git a/src/shared/ui/MajorList/MajorList.module.css b/src/shared/ui/MajorList/MajorList.module.css new file mode 100644 index 0000000..0f45852 --- /dev/null +++ b/src/shared/ui/MajorList/MajorList.module.css @@ -0,0 +1,20 @@ +.section { + display: grid; + gap: var(--space-5); + width: 100%; +} + +.label { + margin: 0; + color: var(--repo-gray-50); + font-size: var(--repo-font-body-large-size); + font-weight: var(--repo-font-body-large-weight); + line-height: var(--repo-font-body-large-line-height); + letter-spacing: 0; +} + +.list { + display: grid; + gap: var(--space-5); + width: 100%; +} diff --git a/src/shared/ui/MajorList/MajorList.tsx b/src/shared/ui/MajorList/MajorList.tsx new file mode 100644 index 0000000..4b5102d --- /dev/null +++ b/src/shared/ui/MajorList/MajorList.tsx @@ -0,0 +1,32 @@ +'use client' + +import { LinkRow } from '@/shared/ui/LinkRow' + +import styles from './MajorList.module.css' + +export type MajorListItem = { + readonly id: string + readonly name: string +} + +export type MajorListProps = { + readonly items: readonly MajorListItem[] + readonly label?: string + readonly onSelect?: (id: string) => void + readonly selectedId?: string +} + +export function MajorList({ items, label = '전공관리 전공', onSelect, selectedId }: MajorListProps) { + return ( +
+

+ {label} +

+
+ {items.map((item) => ( + onSelect?.(item.id)} surface={item.id === selectedId ? 'default' : 'muted'} title={item.name} /> + ))} +
+
+ ) +} diff --git a/src/shared/ui/MajorList/index.ts b/src/shared/ui/MajorList/index.ts new file mode 100644 index 0000000..89b187d --- /dev/null +++ b/src/shared/ui/MajorList/index.ts @@ -0,0 +1,2 @@ +export { MajorList } from './MajorList' +export type { MajorListItem, MajorListProps } from './MajorList' diff --git a/src/shared/ui/OptionList/OptionList.module.css b/src/shared/ui/OptionList/OptionList.module.css new file mode 100644 index 0000000..86edb25 --- /dev/null +++ b/src/shared/ui/OptionList/OptionList.module.css @@ -0,0 +1,39 @@ +.list { + display: grid; + width: min(280px, 100%); + overflow: hidden; + border-radius: var(--space-2); + background: var(--repo-gray-50); + box-shadow: 0 var(--space-2) 22px rgba(17, 17, 17, 0.16); +} + +.item { + min-height: 40px; + padding: var(--space-2) var(--space-5); + border: 0; + border-bottom: 1px solid var(--repo-gray-200); + color: var(--repo-gray-500); + background: var(--repo-gray-50); + 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); + letter-spacing: 0; + text-align: center; + cursor: pointer; +} + +.item:last-child { + border-bottom: 0; +} + +.item:hover, +.selected { + color: var(--repo-bg-main); + background: var(--repo-gray-100); +} + +.item:focus-visible { + position: relative; + outline: 2px solid var(--repo-main); + outline-offset: -2px; +} diff --git a/src/shared/ui/OptionList/OptionList.tsx b/src/shared/ui/OptionList/OptionList.tsx new file mode 100644 index 0000000..f3e4487 --- /dev/null +++ b/src/shared/ui/OptionList/OptionList.tsx @@ -0,0 +1,32 @@ +'use client' + +import styles from './OptionList.module.css' + +export type OptionListItem = { + readonly id: string + readonly label: string +} + +export type OptionListProps = { + readonly items: readonly OptionListItem[] + readonly onSelect?: (id: string) => void + readonly selectedId?: string +} + +export function OptionList({ items, onSelect, selectedId }: OptionListProps) { + return ( +
+ {items.map((item) => ( + + ))} +
+ ) +} diff --git a/src/shared/ui/OptionList/index.ts b/src/shared/ui/OptionList/index.ts new file mode 100644 index 0000000..28f982a --- /dev/null +++ b/src/shared/ui/OptionList/index.ts @@ -0,0 +1,2 @@ +export { OptionList } from './OptionList' +export type { OptionListItem, OptionListProps } from './OptionList' diff --git a/src/shared/ui/PortfolioUrlModal/PortfolioUrlModal.module.css b/src/shared/ui/PortfolioUrlModal/PortfolioUrlModal.module.css new file mode 100644 index 0000000..79cc58e --- /dev/null +++ b/src/shared/ui/PortfolioUrlModal/PortfolioUrlModal.module.css @@ -0,0 +1,60 @@ +.modal { + display: grid; + justify-items: center; + gap: var(--space-10); + width: min(680px, 100%); + min-height: 360px; + padding: var(--space-14) var(--space-16) var(--space-12); + border-radius: var(--repo-radius-panel); + color: var(--repo-gray-50); + background: var(--repo-bg-main-lighter); + box-shadow: var(--repo-shadow-modal); +} + +.copy { + display: grid; + gap: var(--space-5); + text-align: center; +} + +.title, +.description { + margin: 0; + letter-spacing: 0; +} + +.title { + 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); +} + +.description { + color: var(--repo-gray-400); + font-size: var(--repo-font-body-large-size); + font-weight: var(--repo-font-body-medium-weight); + line-height: var(--repo-font-body-large-line-height); +} + +.actions { + display: flex; + gap: var(--space-5); + justify-content: center; +} + +@media (max-width: 720px) { + .modal { + gap: 40px; + min-height: auto; + padding: 48px 24px; + } + + .title { + font-size: var(--repo-font-title-small-size); + } + + .description { + font-size: var(--repo-font-body-medium-size); + } +} diff --git a/src/shared/ui/PortfolioUrlModal/PortfolioUrlModal.tsx b/src/shared/ui/PortfolioUrlModal/PortfolioUrlModal.tsx new file mode 100644 index 0000000..5e32a9b --- /dev/null +++ b/src/shared/ui/PortfolioUrlModal/PortfolioUrlModal.tsx @@ -0,0 +1,38 @@ +'use client' + +import type { ChangeEventHandler } from 'react' +import { useState } from 'react' + +import { Button, Input } from '@/shared/ui' + +import styles from './PortfolioUrlModal.module.css' + +export type PortfolioUrlModalProps = { + readonly defaultValue?: string + readonly errorMessage?: string + readonly onCancel?: () => void + readonly onConfirm?: (value: string) => void +} + +export function PortfolioUrlModal({ defaultValue, errorMessage, onCancel, onConfirm }: PortfolioUrlModalProps) { + const [value, setValue] = useState(defaultValue ?? '') + const handleChange: ChangeEventHandler = (event) => setValue(event.target.value) + + return ( +
+
+

+ 자신의 메인 url을 입력해주세요. +

+

자신을 보여줄 수 있는 사이트 링크를 입력해주세요.

+
+ +
+ + +
+
+ ) +} diff --git a/src/shared/ui/PortfolioUrlModal/index.ts b/src/shared/ui/PortfolioUrlModal/index.ts new file mode 100644 index 0000000..ec12a77 --- /dev/null +++ b/src/shared/ui/PortfolioUrlModal/index.ts @@ -0,0 +1,2 @@ +export { PortfolioUrlModal } from './PortfolioUrlModal' +export type { PortfolioUrlModalProps } from './PortfolioUrlModal' diff --git a/src/shared/ui/SearchField/SearchField.module.css b/src/shared/ui/SearchField/SearchField.module.css new file mode 100644 index 0000000..82438d7 --- /dev/null +++ b/src/shared/ui/SearchField/SearchField.module.css @@ -0,0 +1,51 @@ +.field { + display: flex; + align-items: center; + gap: var(--space-4); + width: min(640px, 100%); + min-height: 56px; + padding: var(--space-3) var(--space-6); + border-radius: var(--repo-radius-pill); + color: var(--repo-gray-50); + background: var(--repo-bg-main-point); +} + +.field:focus-within { + outline: 2px solid var(--repo-main); + outline-offset: 2px; +} + +.input { + width: 100%; + min-width: 0; + border: 0; + outline: 0; + color: var(--repo-gray-50); + background: transparent; + font-size: var(--repo-font-body-large-size); + font-weight: var(--repo-font-body-medium-weight); + line-height: var(--repo-font-body-large-line-height); + letter-spacing: 0; +} + +.input::placeholder { + color: var(--repo-gray-400); + opacity: 1; +} + +.icon { + width: 24px; + height: 24px; + flex: 0 0 auto; +} + +@media (max-width: 720px) { + .field { + min-height: 52px; + padding: var(--space-3) var(--space-5); + } + + .input { + font-size: var(--repo-font-body-large-size); + } +} diff --git a/src/shared/ui/SearchField/SearchField.tsx b/src/shared/ui/SearchField/SearchField.tsx new file mode 100644 index 0000000..0831e6a --- /dev/null +++ b/src/shared/ui/SearchField/SearchField.tsx @@ -0,0 +1,19 @@ +import type { InputHTMLAttributes } from 'react' + +import { Icon } from '@/shared/ui/Icon' + +import styles from './SearchField.module.css' + +export type SearchFieldProps = Omit, 'className' | 'type'> & { + readonly 'aria-label'?: string + readonly className?: string +} + +export function SearchField({ 'aria-label': ariaLabel = '학생 이름 검색', className, placeholder = '이름으로 학생을 찾아보세요.', ...props }: SearchFieldProps) { + return ( + + ) +} diff --git a/src/shared/ui/SearchField/index.ts b/src/shared/ui/SearchField/index.ts new file mode 100644 index 0000000..0e72999 --- /dev/null +++ b/src/shared/ui/SearchField/index.ts @@ -0,0 +1,2 @@ +export { SearchField } from './SearchField' +export type { SearchFieldProps } from './SearchField' diff --git a/src/shared/ui/TextSlot/TextSlot.module.css b/src/shared/ui/TextSlot/TextSlot.module.css new file mode 100644 index 0000000..80fedfb --- /dev/null +++ b/src/shared/ui/TextSlot/TextSlot.module.css @@ -0,0 +1,79 @@ +.slot { + display: grid; + justify-items: center; + align-content: center; + gap: var(--space-10); + min-height: 240px; + padding: var(--space-10); + color: var(--repo-gray-50); +} + +.compact { + max-width: 360px; +} + +.wide { + max-width: 880px; +} + +.label, +.value { + margin: 0; + color: var(--repo-gray-50); + font-size: var(--repo-font-title-s-medium-size); + font-weight: var(--repo-font-title-s-medium-weight); + line-height: var(--repo-font-title-s-medium-line-height); + letter-spacing: 0; + text-align: center; +} + +.value { + color: var(--repo-gray-400); +} + +.line { + width: min(180px, 100%); + height: 3px; + background: var(--repo-gray-50); +} + +.wide .line { + width: min(620px, 100%); +} + +.editable { + width: min(620px, 100%); + min-height: 72px; + padding: var(--space-5) var(--space-7); + color: var(--repo-gray-500); + background: 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); + letter-spacing: 0; + text-align: center; +} + +.errorMessage { + margin: 0; + justify-self: start; + color: var(--repo-state-error); + 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); + letter-spacing: 0; +} + +@media (max-width: 720px) { + .slot { + gap: var(--space-6); + min-height: 200px; + padding: var(--space-7) var(--space-4); + } + + .label, + .value, + .editable { + font-size: var(--repo-font-title-small-size); + } +} diff --git a/src/shared/ui/TextSlot/TextSlot.tsx b/src/shared/ui/TextSlot/TextSlot.tsx new file mode 100644 index 0000000..e3c3d97 --- /dev/null +++ b/src/shared/ui/TextSlot/TextSlot.tsx @@ -0,0 +1,57 @@ +'use client' + +import type { ChangeEventHandler, ReactNode } from 'react' + +import styles from './TextSlot.module.css' + +export type TextSlotVariant = 'compact' | 'wide' + +export type TextSlotProps = { + readonly className?: string + readonly editableAriaLabel?: string + readonly editablePlaceholder?: string + readonly editableValue?: string + readonly errorMessage?: string + readonly label: ReactNode + readonly onEditableValueChange?: (value: string) => void + readonly value?: ReactNode + readonly variant?: TextSlotVariant +} + +const variantClassName: Record = { + compact: styles.compact, + wide: styles.wide, +} + +export function TextSlot({ + className, + editableAriaLabel = '텍스트 입력', + editablePlaceholder, + editableValue, + errorMessage, + label, + onEditableValueChange, + value, + variant = 'compact', +}: TextSlotProps) { + const slotClassName = [styles.slot, variantClassName[variant], errorMessage ? styles.error : '', className].filter(Boolean).join(' ') + const handleEditableValueChange: ChangeEventHandler = (event) => onEditableValueChange?.(event.target.value) + + return ( +
+

{label}

+
+ ) +} diff --git a/src/shared/ui/TextSlot/index.ts b/src/shared/ui/TextSlot/index.ts new file mode 100644 index 0000000..94325ab --- /dev/null +++ b/src/shared/ui/TextSlot/index.ts @@ -0,0 +1,2 @@ +export { TextSlot } from './TextSlot' +export type { TextSlotProps, TextSlotVariant } from './TextSlot' diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 036da73..2ee5c93 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -1,24 +1,45 @@ +export { AppFooter } from './AppFooter'; +export { AppHeader } from './AppHeader'; export { Button } from './Button'; export { Checkbox, CheckboxOption } from './Checkbox'; +export { ClassCard } from './ClassCard'; export { Dropdown } from './Dropdown'; export { Feedback } from './Feedback'; export { FeedbackBalloon } from './FeedbackBalloon'; export { Icon } from './Icon'; export { Input } from './Input'; +export { LibraryBook } from './LibraryBook'; export { LibraryBookCard } from './LibraryBookCard'; export { Logo } from './Logo'; +export { LinkRow } from './LinkRow'; +export { MajorInputGroup } from './MajorInputGroup'; +export { MajorList } from './MajorList'; +export { OptionList } from './OptionList'; +export { PortfolioUrlModal } from './PortfolioUrlModal'; export { PortfolioResumeSheet } from './PortfolioResumeSheet'; +export { SearchField } from './SearchField'; export { Switch } from './Switch'; export { Tag } from './Tag'; +export { TextSlot } from './TextSlot'; export { Toast } from './Toast'; +export type { AppHeaderItem, AppHeaderProps } from './AppHeader'; export type { CheckboxProps } from './Checkbox'; +export type { ClassCardProps } from './ClassCard'; export type { DropdownOption, DropdownProps } from './Dropdown'; export type { FeedbackProps } from './Feedback'; export type { FeedbackBalloonProps } from './FeedbackBalloon'; export type { IconName } from './Icon'; +export type { LibraryBookProps } from './LibraryBook'; export type { LibraryBookCardProps } from './LibraryBookCard'; +export type { LinkRowProps, LinkRowSurface, LinkRowTone } from './LinkRow'; export type { LogoTone } from './Logo'; +export type { MajorInputGroupProps, MajorInputValue } from './MajorInputGroup'; +export type { MajorListItem, MajorListProps } from './MajorList'; +export type { OptionListItem, OptionListProps } from './OptionList'; +export type { PortfolioUrlModalProps } from './PortfolioUrlModal'; export type { PortfolioResumeSheetProps } from './PortfolioResumeSheet'; +export type { SearchFieldProps } from './SearchField'; export type { SwitchProps } from './Switch'; export type { TagProps, TagTone } from './Tag'; +export type { TextSlotProps, TextSlotVariant } from './TextSlot'; export type { ToastProps, ToastVariant } from './Toast';