diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6bbf838..6f42725 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -32,6 +32,9 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
+ - name: Install Playwright browsers
+ run: pnpm exec playwright install --with-deps chromium
+
- name: Lint
run: pnpm lint
diff --git a/.gitignore b/.gitignore
index ef313fc..ca6c905 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@ pnpm-debug.log*
lerna-debug.log*
node_modules
+.pnpm-store/
dist
dist-ssr
.next
diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md
index 27ab729..67ed411 100644
--- a/docs/DESIGN_SYSTEM.md
+++ b/docs/DESIGN_SYSTEM.md
@@ -92,7 +92,7 @@
- `LibraryBookCard`는 도서관/레주메북 진입용 160×220 책 커버 카드입니다. 현재 커버는 `public/assets/library-book-cover.svg` 고정 asset을 사용하며, 이 파일명/경로는 issue #14 초기 카드 계약입니다. 카드 종류가 늘어나면 `cover` 또는 `variant` 계약을 별도 결정합니다.
- `PortfolioResumeSheet`는 학생 포트폴리오 첫 자기소개/이력서 시트입니다. 사용자 지정 기준에 따라 이름은 `Title Small`, 전공 상태는 `Body Tiny`, 학번/학과/email 메타는 `Body very Tiny`를 사용합니다. `resume small` / `resume major` token은 PDF/고밀도 출력 기준이 확정될 때 별도 적용 여부를 결정합니다.
-- 공개 route와 `ComponentPreview`에서 쓰는 예시 데이터는 실제 API mock이 아니라 화면 확인용 fixture이므로 `src/shared/fixtures/examples/publicExamples.ts`에 둡니다.
+- 공개 포트폴리오 route에서 쓰는 예시 데이터는 실제 API mock이 아니라 화면 확인용 fixture이므로 `src/shared/fixtures/examples/publicExamples.ts`에 둡니다.
### Spacing
diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx
new file mode 100644
index 0000000..b928c23
--- /dev/null
+++ b/src/app/(auth)/login/page.tsx
@@ -0,0 +1,11 @@
+import type { Metadata } from 'next'
+
+import { AuthLoginPage } from '@/features/auth/ui'
+
+export const metadata: Metadata = {
+ title: '로그인',
+}
+
+export default function LoginPage() {
+ return
+}
diff --git a/src/app/(public)/page.tsx b/src/app/(public)/page.tsx
index 6ae6edb..eca846e 100644
--- a/src/app/(public)/page.tsx
+++ b/src/app/(public)/page.tsx
@@ -1,49 +1,5 @@
-import Link from 'next/link'
-
-import { samplePortfolioHref, sampleResumeBookHref } from '@/shared/fixtures/examples/publicExamples'
-import { LibraryBookCard } from '@/shared/ui'
-
-import styles from './publicRoute.module.css'
+import { redirect } from 'next/navigation'
export default function HomePage() {
- return (
-
-
-
- Repo
-
- 대덕소프트마이스터고 학생을 위한 포트폴리오 플랫폼
-
-
- Repo는 학생의 이력서와 포트폴리오를 정리하고, 공개 URL을 통해 성장 기록을 보여주기 위한 서비스입니다.
-
-
-
- 공개 포트폴리오 예시
-
-
- 레주메북 예시
-
-
-
-
-
-
-
Examples
-
- 바로 확인하는 포트폴리오와 레주메북 예시
-
-
- 실제 데이터 연동 전에도 공개 포트폴리오와 레주메북의 진입 카드를 같은 컴포넌트 패턴으로 확인합니다.
-
-
-
-
-
-
-
-
-
-
- )
+ redirect('/login')
}
diff --git a/src/app/(public)/publicRoute.module.css b/src/app/(public)/publicRoute.module.css
index 59e4c35..060d88e 100644
--- a/src/app/(public)/publicRoute.module.css
+++ b/src/app/(public)/publicRoute.module.css
@@ -42,100 +42,13 @@
line-height: var(--repo-font-body-large-line-height);
}
-.actionRow {
- display: flex;
- flex-wrap: wrap;
- gap: 12px;
-}
-
-.actionLink {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- min-height: 48px;
- padding: 0 18px;
- border: 1px solid var(--border);
- border-radius: 14px;
- background: var(--surface-muted);
- 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);
-}
-
-.primaryActionLink {
- color: var(--repo-text-black);
- border-color: var(--brand);
- background: var(--brand);
-}
-
-.exampleSection {
- display: grid;
- grid-template-columns: minmax(0, 1fr) max-content;
- gap: 48px;
- align-items: center;
- margin-top: 24px;
- padding: 40px;
- border: 1px solid var(--border);
- border-radius: 32px;
- background: var(--surface);
-}
-
-.exampleSectionHeader {
- display: grid;
- gap: 16px;
- max-width: 560px;
-}
-
-.exampleTitle {
- margin: 0;
- font-size: var(--repo-font-title-medium-size);
- font-weight: var(--repo-font-title-medium-weight);
- line-height: var(--repo-font-title-medium-line-height);
- letter-spacing: -0.04em;
-}
-
-.exampleDescription {
- margin: 0;
- color: var(--text-secondary);
- 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);
-}
-
-.libraryBookGrid {
- display: grid;
- grid-template-columns: repeat(2, max-content);
- gap: 20px;
- padding: 24px;
- border-radius: 28px;
- background: var(--repo-gray-100);
-}
-
-@media (max-width: 820px) {
- .exampleSection {
- grid-template-columns: 1fr;
- }
-
- .libraryBookGrid {
- justify-content: start;
- width: fit-content;
- }
-}
-
-@media (max-width: 560px) {
- .libraryBookGrid {
- grid-template-columns: 1fr;
- }
-}
-
@media (max-width: 480px) {
.pageShell {
width: min(100% - 32px, 1120px);
padding: 48px 0;
}
- .heroCard,
- .exampleSection {
+ .heroCard {
padding: 28px;
border-radius: 24px;
}
diff --git a/src/app/(public)/resume-books/[bookId]/page.tsx b/src/app/(public)/resume-books/[bookId]/page.tsx
index a724695..b11b3da 100644
--- a/src/app/(public)/resume-books/[bookId]/page.tsx
+++ b/src/app/(public)/resume-books/[bookId]/page.tsx
@@ -18,7 +18,7 @@ export default async function ResumeBookPage({ params }: ResumeBookPageProps) {
{bookId} 레주메북
- 학년/전공/주제별 학생 포트폴리오를 모아보는 레주메북 화면의 Next.js route placeholder입니다.
+ 학년/전공/주제별 학생 포트폴리오를 모아보는 레주메북 화면입니다.
diff --git a/src/app/dev/component-preview/page.tsx b/src/app/dev/component-preview/page.tsx
deleted file mode 100644
index 8dc2452..0000000
--- a/src/app/dev/component-preview/page.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-import { notFound } from 'next/navigation'
-
-import { ComponentPreview } from '@/dev/ComponentPreview'
-
-export default function ComponentPreviewPage() {
- if (process.env.NODE_ENV !== 'development') {
- notFound()
- }
-
- return
-}
diff --git a/src/dev/ComponentPreview.module.css b/src/dev/ComponentPreview.module.css
deleted file mode 100644
index 45216d0..0000000
--- a/src/dev/ComponentPreview.module.css
+++ /dev/null
@@ -1,236 +0,0 @@
-.preview {
- display: grid;
- gap: 72px;
- min-height: 100vh;
- padding: 32px 36px 80px;
- color: var(--repo-text-black);
- background: var(--repo-gray-600);
-}
-
-.section {
- display: grid;
- gap: 28px;
- width: fit-content;
-}
-
-.sectionTitle {
- margin: 0;
- color: var(--repo-text-black);
- 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);
-}
-
-.buttonFrameWrap {
- display: grid;
- grid-template-columns: max-content 1fr;
- gap: 28px;
- align-items: center;
-}
-
-.stateLabels {
- display: grid;
- gap: 56px;
- padding-top: 75px;
-}
-
-.stateLabel {
- 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);
-}
-
-.buttonFrame,
-.inputFrame {
- display: grid;
- width: fit-content;
- border: 2px dashed #9747FF;
- border-radius: 8px;
-}
-
-.buttonFrame {
- gap: 28px;
- padding: 32px 38px 28px;
-}
-
-.buttonGrid {
- display: grid;
- grid-template-columns: repeat(4, max-content);
- gap: 38px 52px;
- align-items: center;
-}
-
-.column {
- display: grid;
- gap: 28px;
- justify-items: center;
-}
-
-.columnTitle {
- margin: 0;
- color: var(--repo-text-black);
- 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);
-}
-
-.buttonStack {
- display: grid;
- gap: 28px;
-}
-
-.buttonPreviewHover.buttonPreviewHover {
- background: var(--repo-bg-main-lighter);
-}
-
-.groupLabels {
- display: grid;
- grid-template-columns: 1fr 1fr;
- margin-top: -4px;
- color: var(--repo-text-black);
- 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);
- text-align: center;
-}
-
-.inputFrame {
- gap: 38px;
- width: 353px;
- padding: 52px 56px;
-}
-
-.logoFrame {
- display: grid;
- gap: 48px;
- width: fit-content;
- padding: 64px 96px;
- border: 2px dashed #9747FF;
- border-radius: 8px;
-}
-
-.checkboxFrame {
- display: grid;
- grid-template-columns: repeat(2, max-content);
- gap: 56px;
- width: fit-content;
- padding: 48px 56px;
- border: 2px dashed #9747FF;
- border-radius: 8px;
-}
-
-.checkboxGroup {
- display: grid;
- gap: 20px;
- align-content: start;
-}
-
-.previewLabel {
- margin: 0;
- color: var(--repo-text-black);
- 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);
-}
-
-.inlineControl {
- display: inline-flex;
- align-items: center;
- gap: 10px;
- color: 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);
-}
-
-.switchFrame {
- display: grid;
- gap: 48px;
- justify-items: center;
- width: 320px;
- padding: 56px 0;
- border: 2px dashed #9747FF;
- border-radius: 32px;
-}
-
-.switchInteractiveRow {
- display: inline-flex;
- align-items: center;
- gap: 16px;
-}
-
-.switchStateText {
- min-width: 24px;
- color: var(--repo-text-black);
- 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);
-}
-
-.feedbackFrame {
- display: grid;
- gap: 32px;
- width: fit-content;
- padding: 40px 48px;
- border: 2px dashed #9747FF;
- border-radius: 8px;
-}
-
-.feedbackBalloonFrame {
- width: 560px;
- min-height: 156px;
- padding: 46px 96px;
- border: 2px dashed #9747FF;
- border-radius: 20px;
-}
-
-.toastFrame {
- display: grid;
- gap: 56px;
- width: fit-content;
- padding: 56px 84px;
- border: 2px dashed #9747FF;
- border-radius: 20px;
-}
-
-.dropdownFrame {
- display: grid;
- gap: 42px;
- justify-items: center;
- width: fit-content;
- padding: 56px 76px;
- border: 2px dashed #9747FF;
- border-radius: 20px;
-}
-
-.tagFrame {
- display: grid;
- gap: 32px;
- justify-items: center;
- width: fit-content;
- padding: 42px 64px;
- border: 2px dashed #9747FF;
- border-radius: 20px;
-}
-
-.libraryBookFrame {
- display: grid;
- grid-template-columns: repeat(2, max-content);
- gap: 32px;
- width: fit-content;
- padding: 48px 56px;
- border: 2px dashed #9747FF;
- border-radius: 20px;
- background: var(--repo-gray-600);
-}
-
-
-.portfolioResumeSheetFrame {
- width: fit-content;
- padding: 32px;
- border: 2px dashed #9747FF;
- border-radius: 20px;
- background: var(--repo-gray-600);
-}
diff --git a/src/dev/ComponentPreview.tsx b/src/dev/ComponentPreview.tsx
deleted file mode 100644
index 7930c1b..0000000
--- a/src/dev/ComponentPreview.tsx
+++ /dev/null
@@ -1,333 +0,0 @@
-'use client'
-
-import type { SVGProps, ReactNode } from 'react'
-import { useState } from 'react'
-
-import { samplePortfolioResume, samplePortfolioHref, sampleResumeBookHref } from '@/shared/fixtures/examples/publicExamples'
-
-import {
- Button,
- Checkbox,
- CheckboxOption,
- Dropdown,
- Feedback,
- FeedbackBalloon,
- Input,
- Logo,
- LibraryBookCard,
- PortfolioResumeSheet,
- Switch,
- Tag,
- Toast,
-} from '@/shared/ui'
-
-import styles from './ComponentPreview.module.css'
-
-export function ComponentPreview() {
- const [interactiveCheckboxChecked, setInteractiveCheckboxChecked] = useState(false)
- const [interactiveOptionChecked, setInteractiveOptionChecked] = useState(false)
- const [interactiveSwitchChecked, setInteractiveSwitchChecked] = useState(false)
- const [feedbackExpanded, setFeedbackExpanded] = useState(false)
- const [selectedGrade, setSelectedGrade] = useState('all')
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 아이콘 off
- 아이콘 on
-
-
-
-
-
-
-
- Checkbox
-
-
-
-
-
Primitive
-
undefined} aria-label="unchecked checkbox" />
- undefined} aria-label="checked checkbox" />
-
- undefined} aria-label="disabled unchecked checkbox" />
- undefined} aria-label="disabled checked checkbox" />
-
-
-
-
Checkbox Option
-
undefined}>
- 선택지
-
-
undefined}>
- 선택지
-
-
- 클릭 가능한 선택지
-
-
undefined}>
- 비활성 선택지
-
-
undefined}>
- 비활성 선택지
-
-
-
-
-
-
-
- Switch
-
-
-
-
undefined} aria-label="off 상태 스위치" />
- undefined} aria-label="on 상태 스위치" />
-
-
- {interactiveSwitchChecked ? 'on' : 'off'}
-
- undefined} aria-label="disabled off 상태 스위치" />
- undefined} aria-label="disabled on 상태 스위치" />
-
-
-
-
-
- Feedback
-
-
-
- undefined}
- title="피드백 제목"
- />
- undefined}
- title="피드백 제목"
- />
-
-
-
-
-
-
- Feedback Balloon
-
-
-
-
-
-
-
-
-
-
- Portfolio Resume Sheet
-
-
-
-
-
-
-
- Library Book Card
-
-
-
-
-
-
-
-
-
-
- Tag
-
-
-
- undefined}>태그
- undefined} tone="light">
- 태그
-
- 태그
- 태그
-
-
-
-
-
- Dropdown
-
-
-
-
-
-
-
-
-
-
- Toast
-
-
-
- 오류 메시지
- 확인 메시지
-
-
-
-
-
-
-
- )
-}
-
-const gradeOptions = [
- { label: '전체', value: 'all' },
- { label: '1학년', value: 'grade-1' },
- { label: '2학년', value: 'grade-2' },
- { label: '3학년', value: 'grade-3' },
-]
-
-type PreviewColumnProps = {
- children: ReactNode
- title: string
-}
-
-function PreviewColumn({ children, title }: PreviewColumnProps) {
- return (
-
- )
-}
-
-function EyeIcon(props: SVGProps) {
- return (
-
- )
-}
diff --git a/src/features/auth/ui/AuthLoginPage.module.css b/src/features/auth/ui/AuthLoginPage.module.css
new file mode 100644
index 0000000..98ed2de
--- /dev/null
+++ b/src/features/auth/ui/AuthLoginPage.module.css
@@ -0,0 +1,331 @@
+.page {
+ position: relative;
+ display: grid;
+ min-height: 100vh;
+ place-items: center;
+ 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;
+}
+
+.header {
+ position: absolute;
+ top: 18px;
+ left: 68px;
+ right: 68px;
+ z-index: 1;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.logoLink {
+ display: inline-flex;
+ border-radius: 8px;
+}
+
+.loginPill {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ min-width: 106px;
+ min-height: 36px;
+ padding: 8px 18px;
+ border-radius: 999px;
+ color: var(--repo-gray-50);
+ background: rgb(17 17 17 / 28%);
+ font-size: var(--repo-font-body-tiny-size);
+ font-weight: 600;
+ line-height: var(--repo-font-body-tiny-line-height);
+ letter-spacing: 0;
+}
+
+.loginIcon {
+ width: 16px;
+ height: 16px;
+ color: currentColor;
+}
+
+.authCard {
+ position: relative;
+ z-index: 1;
+ display: grid;
+ grid-template-columns: minmax(0, 324px) minmax(0, 450px);
+ width: min(774px, 100%);
+ min-height: 448px;
+ border-radius: 11px;
+ overflow: hidden;
+ box-shadow: 0 18px 34px rgb(0 0 0 / 34%);
+ transition:
+ grid-template-columns 220ms ease,
+ width 220ms ease;
+}
+
+.teacherLayout {
+ grid-template-columns: minmax(0, 432px) minmax(0, 310px);
+ width: min(742px, 100%);
+}
+
+.brandPanel {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ grid-column: 1;
+ grid-row: 1;
+ min-height: 448px;
+ padding: 33px 27px 42px;
+ background: var(--repo-bg-main-lighter);
+ transition:
+ opacity 180ms ease,
+ transform 220ms ease;
+}
+
+.brandIntro {
+ display: grid;
+ align-content: start;
+ justify-items: start;
+}
+
+.divider {
+ width: 41px;
+ height: 1px;
+ margin: 18px 0 22px;
+ background: var(--repo-bg-main-point);
+}
+
+.welcome {
+ margin: 0;
+ color: var(--repo-gray-50);
+ font-size: var(--repo-font-body-tiny-size);
+ font-weight: 600;
+ line-height: var(--repo-font-body-tiny-line-height);
+ letter-spacing: 0;
+}
+
+.description {
+ margin: 9px 0 0;
+ color: var(--repo-gray-400);
+ 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);
+ letter-spacing: 0;
+}
+
+.switchButton {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ min-height: 28px;
+ padding: 0;
+ border: 0;
+ color: var(--repo-gray-400);
+ background: transparent;
+ 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;
+ cursor: pointer;
+}
+
+.switchButton:hover {
+ color: var(--repo-gray-50);
+}
+
+.switchIcon {
+ width: 16px;
+ height: 16px;
+ color: currentColor;
+}
+
+.formPanel {
+ display: grid;
+ grid-column: 2;
+ grid-row: 1;
+ place-items: center;
+ min-height: 448px;
+ padding: 64px 64px 70px;
+ color: var(--repo-text-black);
+ background: var(--repo-gray-50);
+ transition:
+ opacity 180ms ease,
+ transform 220ms ease;
+}
+
+.teacherLayout .brandPanel {
+ grid-column: 2;
+ animation: panelFadeInFromRight 220ms ease both;
+}
+
+.teacherLayout .formPanel {
+ grid-column: 1;
+ animation: panelFadeInFromLeft 220ms ease both;
+}
+
+.studentLayout .brandPanel {
+ animation: panelFadeInFromLeft 220ms ease both;
+}
+
+.studentLayout .formPanel {
+ animation: panelFadeInFromRight 220ms ease both;
+}
+
+.form {
+ display: grid;
+ width: min(322px, 100%);
+ align-content: start;
+}
+
+.title {
+ margin: 0 0 31px;
+ text-align: center;
+ 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;
+}
+
+.fieldGroup {
+ display: grid;
+ gap: 8px;
+ width: 100%;
+ margin: 0 0 14px;
+}
+
+.label {
+ 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;
+}
+
+.passwordIcon {
+ width: 16px;
+ height: 16px;
+}
+
+.submitButton {
+ width: 100%;
+ margin-top: 58px;
+}
+
+@media (max-width: 820px) {
+ .page {
+ min-height: 100svh;
+ padding: 88px 20px 40px;
+ }
+
+ .header {
+ top: 20px;
+ left: 24px;
+ right: 24px;
+ }
+
+ .authCard,
+ .teacherLayout {
+ grid-template-columns: 1fr;
+ width: min(430px, 100%);
+ }
+
+ .brandPanel {
+ min-height: 252px;
+ padding: 28px 24px;
+ }
+
+ .formPanel {
+ min-height: 430px;
+ padding: 44px 28px 52px;
+ }
+
+ .studentLayout .brandPanel {
+ grid-column: 1;
+ order: 2;
+ }
+
+ .studentLayout .formPanel {
+ grid-column: 1;
+ order: 1;
+ }
+
+ .teacherLayout .brandPanel {
+ grid-column: 1;
+ order: 2;
+ }
+
+ .teacherLayout .formPanel {
+ grid-column: 1;
+ order: 1;
+ }
+}
+
+@media (max-width: 420px) {
+ .page {
+ padding-inline: 16px;
+ }
+
+ .loginPill {
+ min-width: auto;
+ padding-inline: 14px;
+ }
+
+ .formPanel {
+ padding-inline: 22px;
+ }
+}
+
+@keyframes panelFadeInFromLeft {
+ from {
+ opacity: 0.82;
+ transform: translateX(-12px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+@keyframes panelFadeInFromRight {
+ from {
+ opacity: 0.82;
+ transform: translateX(12px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .authCard,
+ .brandPanel,
+ .formPanel {
+ transition: none;
+ }
+
+ .brandPanel,
+ .formPanel,
+ .studentLayout .brandPanel,
+ .studentLayout .formPanel,
+ .teacherLayout .brandPanel,
+ .teacherLayout .formPanel {
+ animation: none;
+ }
+}
diff --git a/src/features/auth/ui/AuthLoginPage.tsx b/src/features/auth/ui/AuthLoginPage.tsx
new file mode 100644
index 0000000..92d7b4c
--- /dev/null
+++ b/src/features/auth/ui/AuthLoginPage.tsx
@@ -0,0 +1,116 @@
+'use client'
+
+import Link from 'next/link'
+import { useState } from 'react'
+
+import { Button, Icon, Input, Logo } from '@/shared/ui'
+
+import styles from './AuthLoginPage.module.css'
+
+type LoginRole = 'student' | 'teacher'
+
+type LoginContent = {
+ description: string
+ switchLabel: string
+ title: string
+}
+
+const LOGIN_CONTENT: Record = {
+ student: {
+ description: 'DSM학생들을 위한 이력서 관리 플랫폼, Repo',
+ switchLabel: '선생님으로 전환하기',
+ title: '학생 로그인',
+ },
+ teacher: {
+ description: 'DSM선생님을 위한 이력서 관리 플랫폼, Repo',
+ switchLabel: '학생으로 전환하기',
+ title: '선생님 로그인',
+ },
+}
+
+const oppositeRole: Record = {
+ student: 'teacher',
+ teacher: 'student',
+}
+
+export function AuthLoginPage() {
+ const [role, setRole] = useState('student')
+ const [showPassword, setShowPassword] = useState(false)
+ const content = LOGIN_CONTENT[role]
+ const nextRole = oppositeRole[role]
+ const layoutClassName = role === 'student' ? styles.studentLayout : styles.teacherLayout
+ const passwordType = showPassword ? 'text' : 'password'
+ const passwordToggleLabel = showPassword ? '비밀번호 숨기기' : '비밀번호 보이기'
+ const passwordIconName = showPassword ? 'eye' : 'eye-off'
+
+ const formPanel = (
+
+ )
+
+ const brandPanel = (
+
+ )
+
+ return (
+
+
+
+
+ {brandPanel}
+ {formPanel}
+
+
+ )
+}
diff --git a/src/features/auth/ui/index.ts b/src/features/auth/ui/index.ts
new file mode 100644
index 0000000..04e3eb0
--- /dev/null
+++ b/src/features/auth/ui/index.ts
@@ -0,0 +1 @@
+export { AuthLoginPage } from './AuthLoginPage'
diff --git a/src/shared/ui/Icon/Icon.tsx b/src/shared/ui/Icon/Icon.tsx
index 27a4654..7ef246b 100644
--- a/src/shared/ui/Icon/Icon.tsx
+++ b/src/shared/ui/Icon/Icon.tsx
@@ -1,21 +1,94 @@
-import type { SVGProps } from 'react';
+import type { SVGProps } from 'react'
-export type IconName = 'plus' | 'right-arrow';
+export type IconName = 'chevron-right' | 'eye' | 'eye-off' | 'login' | 'plus' | 'right-arrow'
type IconProps = Omit, 'children'> & {
- name: IconName;
-};
+ name: IconName
+}
export function Icon({ name, 'aria-hidden': ariaHidden = true, focusable = false, ...props }: IconProps) {
+ if (name === 'chevron-right') {
+ return
+ }
+
+ if (name === 'eye') {
+ return
+ }
+
+ if (name === 'eye-off') {
+ return
+ }
+
+ if (name === 'login') {
+ return
+ }
+
if (name === 'plus') {
- return ;
+ return
}
if (name === 'right-arrow') {
- return ;
+ return
}
- return null;
+ return null
+}
+
+function ChevronRightIcon(props: Omit, 'children'>) {
+ return (
+
+ )
+}
+
+function EyeIcon(props: Omit, 'children'>) {
+ return (
+
+ )
+}
+
+function EyeOffIcon(props: Omit, 'children'>) {
+ return (
+
+ )
+}
+
+function LoginIcon(props: Omit, 'children'>) {
+ return (
+
+ )
}
function PlusIcon(props: Omit, 'children'>) {
@@ -23,7 +96,7 @@ function PlusIcon(props: Omit, 'children'>) {
- );
+ )
}
function RightArrowIcon(props: Omit, 'children'>) {
@@ -31,5 +104,5 @@ function RightArrowIcon(props: Omit, 'children'>) {
- );
+ )
}
diff --git a/src/shared/ui/Input/Input.tsx b/src/shared/ui/Input/Input.tsx
index 0a1447f..1a83470 100644
--- a/src/shared/ui/Input/Input.tsx
+++ b/src/shared/ui/Input/Input.tsx
@@ -7,10 +7,19 @@ type InputProps = Omit, 'className'> & {
error?: boolean
errorMessage?: string
onRightIconClick?: () => void
+ rightIconAriaLabel?: string
rightIcon?: ReactNode
}
-export function Input({ error = false, errorMessage, id, onRightIconClick, rightIcon, ...props }: InputProps) {
+export function Input({
+ error = false,
+ errorMessage,
+ id,
+ onRightIconClick,
+ rightIcon,
+ rightIconAriaLabel = '입력창 오른쪽 아이콘',
+ ...props
+}: InputProps) {
const generatedId = useId()
const inputId = id ?? generatedId
const hasError = error || Boolean(errorMessage)
@@ -32,7 +41,7 @@ export function Input({ error = false, errorMessage, id, onRightIconClick, right
{rightIcon ? (
onRightIconClick ? (