diff --git a/.env b/.env deleted file mode 100644 index 09d8b5f..0000000 --- a/.env +++ /dev/null @@ -1,6 +0,0 @@ -VITE_FIREBASE_API_KEY=AIzaSyABxF3nO6fVtgk-RQEwE5eYK5GKEt8diEI -VITE_FIREBASE_AUTH_DOMAIN=ajou-project-cafd9.firebaseapp.com -VITE_FIREBASE_PROJECT_ID=ajou-project-cafd9 -VITE_FIREBASE_STORAGE_BUCKET=ajou-project-cafd9.firebasestorage.app -VITE_FIREBASE_MESSAGING_SENDER_ID=926871086070 -VITE_FIREBASE_APP_ID=1:926871086070:web:9a01bad8170cb172f15dd7 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..42fd851 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Firebase Client SDK +# These values are exposed to the browser by Next.js. +NEXT_PUBLIC_FIREBASE_API_KEY= +NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN= +NEXT_PUBLIC_FIREBASE_PROJECT_ID= +NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET= +NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID= +NEXT_PUBLIC_FIREBASE_APP_ID= + +# Spring Backend +# Leave empty in Firebase Hosting builds so /api/** uses firebase.json rewrites. +# Set this only for local direct-to-Cloud-Run testing. +NEXT_PUBLIC_BACKEND_API_BASE_URL= diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..2ca15e5 --- /dev/null +++ b/.env.production @@ -0,0 +1,11 @@ +# Firebase Client SDK +# These values are exposed to the browser by Next.js. +NEXT_PUBLIC_FIREBASE_API_KEY=AIzaSyABxF3nO6fVtgk-RQEwE5eYK5GKEt8diEI +NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=ajou-project-cafd9.firebaseapp.com +NEXT_PUBLIC_FIREBASE_PROJECT_ID=ajou-project-cafd9 +NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=ajou-project-cafd9.firebasestorage.app +NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=926871086070 +NEXT_PUBLIC_FIREBASE_APP_ID=1:926871086070:web:9a01bad8170cb172f15dd7 + +# Keep empty in Firebase Hosting builds so /api/** uses firebase.json rewrites. +NEXT_PUBLIC_BACKEND_API_BASE_URL= diff --git a/.gitignore b/.gitignore index e5218f3..1ef639e 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,10 @@ out .vercel dist dist-ssr +.env +.env.*.local *.local +*.tsbuildinfo # Editor directories and files .vscode/* diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 533677b..7740c38 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -51,7 +51,7 @@ pnpm lint - 앱 개발 기준은 `Next.js App Router`다. - Firebase Hosting은 유지하되, 배포는 `out/` 정적 산출물을 기준으로 한다. - 공개 페이지는 `SSG`, 로그인 이후 내부 페이지는 `CSR`을 기본 전략으로 삼는다. -- `src/app`는 라우팅과 레이아웃을 담당하고, 실제 화면 구현은 `src/views`에 둔다. +- `src/app`는 라우팅과 레이아웃을 담당하고, 실제 화면 구현은 `src/screens`에 둔다. - Vite 앱 엔트리(`src/main.tsx`, `index.html`)와 레거시 App 엔트리(`src/app/index.tsx`)는 더 이상 사용하지 않는다. 새로 합류한 팀원은 "Next.js로 실행하지만 모든 페이지를 SSR로 만드는 프로젝트는 아니다"라고 이해하면 가장 덜 헷갈린다. @@ -90,16 +90,13 @@ aim-frontend/ │ │ ├─ page.tsx # 기본 라우트 │ │ └─ globals.css # 전역 스타일 진입점 │ │ - │ ├─ views/ # 화면 레이어 (기존 pages 레이어 대체) - │ ├─ widgets/ # 위젯 레이어 (큰 UI 블록) - │ ├─ features/ # 기능 레이어 - │ ├─ entities/ # 엔티티 레이어 + │ ├─ api/ # 백엔드/외부 API 클라이언트 + │ ├─ lib/ # 앱 유스케이스와 공통 로직 + │ ├─ screens/ # 화면 구현 (기존 pages 레이어 대체) │ └─ shared/ # 공유 레이어 │ ├─ ui/ # 재사용 가능한 UI 컴포넌트 │ ├─ assets/ # 전역 에셋 (images, icons, fonts) │ ├─ config/ # 설정 (firebase.ts) - │ ├─ api/ # API 클라이언트 - │ ├─ lib/ # 유틸리티 함수 │ └─ types/ # 공통 타입 정의 │ ├─ docs/ # 문서 @@ -138,9 +135,11 @@ aim-frontend/ - 라우트 공통 레이아웃/metadata: - `src/app/**/layout.tsx` - 실제 화면 구현: - - `src/views/**` -- 큰 화면 블록 조합: - - `src/widgets/**` + - `src/screens/**` +- 백엔드/외부 API 호출: + - `src/api/**` +- Firebase 로그인 흐름, 세션 생성, 화면과 API 사이의 유스케이스: + - `src/lib/**` - 재사용 UI: - `src/shared/ui/**` - 전역 스타일 진입: @@ -153,31 +152,36 @@ aim-frontend/ - `/login` 라우트 파일: - `src/app/(auth)/login/page.tsx` - 로그인 화면 구현: - - `src/views/login/index.tsx` + - `src/screens/login/index.tsx` --- ## 아키텍처 -### FSD (Feature-Sliced Design) +### 레이어 기준 -레이어 의존 방향은 **단방향**입니다. 하위 레이어는 상위 레이어를 import할 수 없습니다. +현재 구조는 FSD를 엄격히 적용하기보다, Next.js App Router에 맞춘 컴팩트한 레이어 구조를 사용합니다. 의존 방향은 **단방향**입니다. ``` -app → views → widgets → features → entities → shared +app → screens → lib → api → shared ``` - `@/` alias를 사용해 절대 경로로 import -- Next.js App Router와 충돌하지 않도록 화면 레이어는 `src/views`를 사용 -- 각 슬라이스는 `index.ts`(public API)를 통해서만 export +- `src/app`은 URL, layout, metadata 같은 라우팅 책임만 갖는다. +- `src/screens`는 라우트가 렌더링하는 화면 조합을 담당한다. +- `src/lib`는 로그인, 세션 생성처럼 화면과 API 사이의 앱 로직을 담당한다. +- `src/api`는 fetch, 요청/응답 타입, 외부 API 호출만 담당한다. +- `src/shared`는 UI, config, asset처럼 도메인과 무관한 공통 자원만 둔다. ```ts // 올바른 import import { Button } from '@/shared/ui/button' -import { AuthFeature } from '@/features/auth' +import { signInWithGoogle } from '@/lib/auth' +import { loginWithBackend } from '@/api/auth' -// 잘못된 import (하위에서 상위 참조 금지) -// shared에서 features import 불가 +// 잘못된 import +// api에서 screens/lib import 금지 +// shared에서 api/lib/screens import 금지 ``` ### 인프라 구조 @@ -224,7 +228,7 @@ git checkout -b fix/header-layout Next.js 전환 작업이나 구조 변경이 포함되면 아래 항목을 함께 확인합니다. - 새 라우트가 `src/app` 기준으로 추가되었는가 -- 라우트 파일이 화면 구현까지 전부 떠안지 않고, 필요한 경우 `src/views`를 조합하는가 +- 라우트 파일이 화면 구현까지 전부 떠안지 않고, 필요한 경우 `src/screens`를 조합하는가 - SEO 대상 페이지인지, CSR 유지 페이지인지 분류했는가 - 공개 SEO 페이지라면 `SSG` 가능한 구조인지 먼저 검토했는가 - `use client`가 꼭 필요한 곳에만 선언되었는가 @@ -236,7 +240,7 @@ Next.js 전환 작업이나 구조 변경이 포함되면 아래 항목을 함 - `CSR 페이지`라고 해서 Vite를 다시 쓰는 것은 아니다. - Next.js 안에서도 Client Component 기반으로 충분히 구현할 수 있다. -- `src/app/page.tsx`는 라우트 파일이고, `src/views/**`는 화면 구현 파일이다. +- `src/app/page.tsx`는 라우트 파일이고, `src/screens/**`는 화면 구현 파일이다. - 라우트 정의와 화면 구현을 분리하는 것이 현재 기준이다. - `pnpm build` 결과물은 `.next`만 보는 것이 아니라 최종적으로 `out/` 배포를 염두에 둔다. - `/`는 지금 정적 export 환경을 고려해 클라이언트에서 `/login`으로 이동한다. @@ -296,10 +300,10 @@ enum ButtonVariant { Primary = "primary" } // 1. 외부 라이브러리 import { useState } from 'react' -// 2. 내부 alias (FSD 레이어 순서) +// 2. 내부 alias (레이어 순서) import { auth } from '@/shared/config/firebase' -import { UserEntity } from '@/entities/user' -import { AuthFeature } from '@/features/auth' +import { loginWithBackend } from '@/api/auth' +import { signInWithGoogle } from '@/lib/auth' // 3. 상대 경로 import { getButtonClasses } from './button.utils' @@ -375,11 +379,11 @@ export const Loading: Story = { ### 화면 개발 패턴 -현재 프로젝트에서는 "라우트 파일은 얇게, 화면 구현은 views로" 가져가는 것을 권장합니다. +현재 프로젝트에서는 "라우트 파일은 얇게, 화면 구현은 screens로" 가져가는 것을 권장합니다. ```tsx // src/app/(auth)/login/page.tsx -import { LoginPage } from "@/views" +import { LoginPage } from "@/screens" export default function LoginRoute() { return @@ -387,7 +391,7 @@ export default function LoginRoute() { ``` ```tsx -// src/views/login/index.tsx +// src/screens/login/index.tsx "use client" export function LoginPage() { @@ -451,7 +455,7 @@ function LikeButton() { ## API 호출 가이드 -현재 저장소에는 공통 API 클라이언트 레이어가 아직 없다. API 연동이 시작되면 `src/shared/api` 또는 `src/shared/lib/api` 하위에 공통 fetch/HTTP 래퍼를 두고 일관되게 사용하는 것을 기본 정책으로 한다. +API 클라이언트는 `src/api` 하위에 둔다. 백엔드 API, Google People API처럼 네트워크 요청 자체와 요청/응답 타입은 이 레이어에 모으고, 화면 흐름과 세션 생성 로직은 `src/lib`에서 조합한다. 권장 원칙: @@ -459,6 +463,7 @@ function LikeButton() { - 인증 헤더/토큰 주입 로직 중앙화 - 에러 처리와 응답 파싱 로직 공통화 - 브라우저 전용 상호작용만 Client Component에서 처리 +- `src/api`는 `src/screens`나 `src/lib`를 import하지 않는다. ### Next.js 기준 권장 방식 diff --git a/firebase.json b/firebase.json index 5081109..52469e6 100644 --- a/firebase.json +++ b/firebase.json @@ -7,7 +7,16 @@ }, "hosting": { "public": "out", - "ignore": ["firebase.json", "**/.*", "**/node_modules/**"] + "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], + "rewrites": [ + { + "source": "/api/**", + "run": { + "serviceId": "aim-be-prod", + "region": "us-central1" + } + } + ] }, "storage": { "rules": "storage.rules" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3adf359..840da8e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,9 +69,6 @@ importers: autoprefixer: specifier: ^10.4.24 version: 10.4.24(postcss@8.5.6) - baseline-browser-mapping: - specifier: ^2.10.0 - version: 2.10.0 eslint: specifier: ^9.39.2 version: 9.39.2(jiti@2.6.1) @@ -1222,89 +1219,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1383,24 +1396,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@16.2.1': resolution: {integrity: sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@16.2.1': resolution: {integrity: sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@16.2.1': resolution: {integrity: sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@16.2.1': resolution: {integrity: sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA==} @@ -1523,56 +1540,67 @@ packages: resolution: {integrity: sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.53.2': resolution: {integrity: sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.53.2': resolution: {integrity: sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.53.2': resolution: {integrity: sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.53.2': resolution: {integrity: sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.53.2': resolution: {integrity: sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.53.2': resolution: {integrity: sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.53.2': resolution: {integrity: sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.53.2': resolution: {integrity: sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.53.2': resolution: {integrity: sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.53.2': resolution: {integrity: sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openharmony-arm64@4.53.2': resolution: {integrity: sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A==} @@ -1744,24 +1772,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -2169,41 +2201,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -3757,24 +3797,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..d898792 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/vite.svg b/public/vite.svg deleted file mode 100644 index af8d599..0000000 --- a/public/vite.svg +++ /dev/null @@ -1,15 +0,0 @@ - diff --git a/src/App.css b/src/App.css deleted file mode 100644 index c3e4122..0000000 --- a/src/App.css +++ /dev/null @@ -1,2 +0,0 @@ -/* All styles have been consolidated in src/index.css to avoid duplication */ -/* This file is kept for App-component-specific styles if needed */ diff --git a/src/api/auth/auth-api.ts b/src/api/auth/auth-api.ts new file mode 100644 index 0000000..95ab225 --- /dev/null +++ b/src/api/auth/auth-api.ts @@ -0,0 +1,63 @@ +export type AuthRole = "STUDENT" | "PROFESSOR" | "COMPANY"; + +export type BackendLoginRequest = { + role: AuthRole; + name: string; + department: string; +}; + +export type BackendUser = { + userId: number; + role: AuthRole; + status: "ACTIVE" | "BLOCKED" | "SUSPENDED" | "PENDING"; + adminRole: "NONE" | "ADMIN" | "SUPER_ADMIN"; + name: string; + department: string; +}; + +export class BackendLoginError extends Error { + status?: number; + + constructor(message: string, status?: number) { + super(message); + this.name = "BackendLoginError"; + this.status = status; + } +} + +const getBackendApiBaseUrl = (): string => { + const backendApiBaseUrl = process.env.NEXT_PUBLIC_BACKEND_API_BASE_URL ?? ""; + + return backendApiBaseUrl.replace(/\/$/, ""); +}; + +export const loginWithBackend = async ( + idToken: string, + payload: BackendLoginRequest, +): Promise => { + let response: Response; + + try { + response = await fetch(`${getBackendApiBaseUrl()}/api/auth/login`, { + method: "POST", + headers: { + Authorization: `Bearer ${idToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + cache: "no-store", + }); + } catch { + throw new BackendLoginError("Backend login network failed."); + } + + if (!response.ok) { + const message = await response.text().catch(() => ""); + throw new BackendLoginError( + message || `Backend login failed. (${response.status})`, + response.status, + ); + } + + return response.json() as Promise; +}; diff --git a/src/api/auth/google-profile-api.ts b/src/api/auth/google-profile-api.ts new file mode 100644 index 0000000..215213e --- /dev/null +++ b/src/api/auth/google-profile-api.ts @@ -0,0 +1,90 @@ +import type { AuthRole, BackendLoginRequest } from "./auth-api"; + +export const GOOGLE_PROFILE_SCOPES = [ + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/user.organization.read", +] as const; + +type PeopleApiName = { + displayName?: string; + metadata?: { + primary?: boolean; + }; +}; + +type PeopleApiOrganization = { + name?: string; + department?: string; + title?: string; + metadata?: { + primary?: boolean; + }; +}; + +export class GoogleProfileRoleError extends Error { + constructor() { + super("Google profile role could not be resolved."); + this.name = "GoogleProfileRoleError"; + } +} + +type PeopleApiProfile = { + names?: PeopleApiName[]; + organizations?: PeopleApiOrganization[]; +}; + +const getPrimaryItem = ( + items?: T[], +): T | undefined => items?.find((item) => item.metadata?.primary) ?? items?.[0]; + +const inferAuthRole = (organization?: PeopleApiOrganization): AuthRole => { + const profileText = [organization?.title, organization?.department, organization?.name] + .filter(Boolean) + .join(" ") + .toLowerCase(); + + if (/(student|undergraduate|graduate|[1-6]\s*학년|학부생|대학원생|학생)/i.test(profileText)) { + return "STUDENT"; + } + + if (/(professor|faculty|교수|교원)/i.test(profileText)) { + return "PROFESSOR"; + } + + if (/(staff|employee|worker|직원|행정)/i.test(profileText)) { + throw new GoogleProfileRoleError(); + } + + throw new GoogleProfileRoleError(); +}; + +export const fetchGoogleProfile = async (accessToken: string): Promise => { + const params = new URLSearchParams({ + personFields: "names,organizations", + }); + + const response = await fetch(`https://people.googleapis.com/v1/people/me?${params.toString()}`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + throw new Error("Google People API profile request failed."); + } + + const profile = (await response.json()) as PeopleApiProfile; + const primaryName = getPrimaryItem(profile.names); + const primaryOrganization = getPrimaryItem(profile.organizations); + + return { + role: inferAuthRole(primaryOrganization), + name: primaryName?.displayName?.trim() || "이름 미입력", + department: + primaryOrganization?.department?.trim() || + primaryOrganization?.name?.trim() || + primaryOrganization?.title?.trim() || + "", + }; +}; diff --git a/src/api/auth/index.ts b/src/api/auth/index.ts new file mode 100644 index 0000000..dc2bdda --- /dev/null +++ b/src/api/auth/index.ts @@ -0,0 +1,12 @@ +export { + BackendLoginError, + loginWithBackend, + type AuthRole, + type BackendLoginRequest, + type BackendUser, +} from "./auth-api"; +export { + fetchGoogleProfile, + GOOGLE_PROFILE_SCOPES, + GoogleProfileRoleError, +} from "./google-profile-api"; diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 9925cdd..b3cd8ac 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -1,4 +1,4 @@ -import { LoginPage } from "@/views/login"; +import { LoginPage } from "@/screens/login"; export default function LoginRoute(): React.ReactElement { return ; diff --git a/src/app/home/page.tsx b/src/app/home/page.tsx new file mode 100644 index 0000000..352c65f --- /dev/null +++ b/src/app/home/page.tsx @@ -0,0 +1,5 @@ +import { HomePage } from "@/screens/home"; + +export default function HomeRoute(): React.ReactElement { + return ; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ef6b1de..5fd226a 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,14 @@ import "./globals.css"; export const metadata: Metadata = { title: "AIM AJOU", description: "AIM AJOU frontend", + icons: { + icon: [ + { url: "/favicon.ico", sizes: "any" }, + { url: "/assets/ajou-logo.svg", type: "image/svg+xml" }, + ], + shortcut: "/favicon.ico", + apple: "/assets/ajou-logo.svg", + }, }; type RootLayoutProps = Readonly<{ diff --git a/src/assets/react.svg b/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/entities/index.ts b/src/entities/index.ts deleted file mode 100644 index 7e9c59b..0000000 --- a/src/entities/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Entities barrel export -export * from "./user"; diff --git a/src/entities/user/index.ts b/src/entities/user/index.ts deleted file mode 100644 index 6d6bbbf..0000000 --- a/src/entities/user/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// User entity exports -export {}; diff --git a/src/features/auth/index.ts b/src/features/auth/index.ts deleted file mode 100644 index dc4c495..0000000 --- a/src/features/auth/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Auth feature exports -export {}; diff --git a/src/features/index.ts b/src/features/index.ts deleted file mode 100644 index cae4c3d..0000000 --- a/src/features/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Features barrel export -export * from "./auth"; diff --git a/src/lib/auth/auth-error-message.ts b/src/lib/auth/auth-error-message.ts new file mode 100644 index 0000000..413af79 --- /dev/null +++ b/src/lib/auth/auth-error-message.ts @@ -0,0 +1,44 @@ +import { BackendLoginError, GoogleProfileRoleError } from "@/api/auth"; + +export const getAuthErrorMessage = (error: unknown): string => { + if (typeof error === "object" && error !== null && "code" in error) { + const code = String(error.code); + + switch (code) { + case "auth/invalid-email": + return "이메일 형식이 올바르지 않습니다."; + case "auth/user-not-found": + case "auth/wrong-password": + case "auth/invalid-credential": + return "이메일 또는 비밀번호를 확인해주세요."; + case "auth/popup-closed-by-user": + return "로그인 창이 닫혔습니다."; + case "auth/network-request-failed": + return "네트워크 연결을 확인해주세요."; + default: + break; + } + } + + if (error instanceof BackendLoginError) { + if (!error.status) { + return "백엔드 서버에 연결할 수 없습니다. 서버 실행 상태와 CORS 설정을 확인해주세요."; + } + + if (error.status === 401 || error.status === 403) { + return "백엔드에서 로그인 권한을 확인하지 못했습니다."; + } + + return `백엔드 로그인 처리 중 문제가 발생했습니다. (${error.status})`; + } + + if (error instanceof GoogleProfileRoleError) { + return "Google 계정의 구성원 구분을 확인할 수 없습니다. 관리자에게 문의해주세요."; + } + + if (error instanceof TypeError) { + return "네트워크 요청 중 문제가 발생했습니다."; + } + + return "로그인 중 문제가 발생했습니다."; +}; diff --git a/src/lib/auth/auth-service.ts b/src/lib/auth/auth-service.ts new file mode 100644 index 0000000..083af00 --- /dev/null +++ b/src/lib/auth/auth-service.ts @@ -0,0 +1,86 @@ +"use client"; + +import { + GoogleAuthProvider, + signInWithEmailAndPassword, + signInWithPopup, + signOut as firebaseSignOut, +} from "firebase/auth"; +import { + fetchGoogleProfile, + GOOGLE_PROFILE_SCOPES, + loginWithBackend, + type BackendLoginRequest, + type BackendUser, +} from "@/api/auth"; +import { auth } from "@/shared/config/firebase"; + +type SessionResponse = { + uid: string; + email: string | null; + backendUser: BackendUser; +}; + +const googleProvider = new GoogleAuthProvider(); +GOOGLE_PROFILE_SCOPES.forEach((scope) => googleProvider.addScope(scope)); + +const createSession = async ( + idToken: string, + profile: BackendLoginRequest, +): Promise => { + let backendUser: BackendUser; + + try { + backendUser = await loginWithBackend(idToken, profile); + } catch (error) { + await firebaseSignOut(auth).catch(() => undefined); + throw error; + } + + return { + uid: auth.currentUser?.uid ?? "", + email: auth.currentUser?.email ?? null, + backendUser, + }; +}; + +export const signInWithGoogle = async (): Promise => { + const credential = await signInWithPopup(auth, googleProvider); + + try { + const googleCredential = GoogleAuthProvider.credentialFromResult(credential); + const accessToken = googleCredential?.accessToken; + + if (!accessToken) { + throw new Error("Google access token is missing."); + } + + const [idToken, profile] = await Promise.all([ + credential.user.getIdToken(), + fetchGoogleProfile(accessToken), + ]); + + return await createSession(idToken, profile); + } catch (error) { + await firebaseSignOut(auth).catch(() => undefined); + throw error; + } +}; + +export const signInWithEmail = async ( + email: string, + password: string, +): Promise => { + const credential = await signInWithEmailAndPassword(auth, email, password); + const idToken = await credential.user.getIdToken(); + + return createSession(idToken, { + role: "COMPANY", + name: credential.user.displayName ?? credential.user.email ?? email, + department: "", + }); +}; + +export const signOut = async (): Promise => { + await firebaseSignOut(auth); +}; diff --git a/src/lib/auth/index.ts b/src/lib/auth/index.ts new file mode 100644 index 0000000..9c07f14 --- /dev/null +++ b/src/lib/auth/index.ts @@ -0,0 +1,3 @@ +export { signInWithEmail, signInWithGoogle, signOut } from "./auth-service"; +export { getAuthErrorMessage } from "./auth-error-message"; +export type { AuthRole, BackendLoginRequest, BackendUser } from "@/api/auth"; diff --git a/src/screens/home/home.stories.tsx b/src/screens/home/home.stories.tsx new file mode 100644 index 0000000..296e808 --- /dev/null +++ b/src/screens/home/home.stories.tsx @@ -0,0 +1,17 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; + +import { HomePage } from "./home"; + +const meta = { + title: "Pages/Home", + component: HomePage, + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout + layout: "fullscreen", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const LoggedOut: Story = {}; diff --git a/src/screens/home/home.tsx b/src/screens/home/home.tsx new file mode 100644 index 0000000..3b0663b --- /dev/null +++ b/src/screens/home/home.tsx @@ -0,0 +1,13 @@ +export const HomePage: React.FC = () => { + return ( +
+
+

로그인 완료

+

AIM AJOU Home

+

+ Firebase 로그인과 백엔드 로그인 API 호출이 완료되었습니다. +

+
+
+ ); +}; diff --git a/src/views/home/index.ts b/src/screens/home/index.ts similarity index 100% rename from src/views/home/index.ts rename to src/screens/home/index.ts diff --git a/src/views/index.ts b/src/screens/index.ts similarity index 75% rename from src/views/index.ts rename to src/screens/index.ts index 35758b4..3d3499f 100644 --- a/src/views/index.ts +++ b/src/screens/index.ts @@ -1,3 +1,3 @@ -// Pages barrel export +// Screen barrel export export { HomePage } from "./home"; export { LoginPage } from "./login"; diff --git a/src/screens/login/company-login-form.tsx b/src/screens/login/company-login-form.tsx new file mode 100644 index 0000000..a8df256 --- /dev/null +++ b/src/screens/login/company-login-form.tsx @@ -0,0 +1,74 @@ +import { Button } from "@/shared/ui/button/button"; +import { Input } from "@/shared/ui/input"; +import { LockIcon, MailIcon } from "@/shared/ui/icons"; + +type CompanyLoginFormProps = { + email: string; + password: string; + isSubmitting: boolean; + onEmailChange: (email: string) => void; + onPasswordChange: (password: string) => void; + onSubmit: (event: React.FormEvent) => void; +}; + +export const CompanyLoginForm = ({ + email, + password, + isSubmitting, + onEmailChange, + onPasswordChange, + onSubmit, +}: CompanyLoginFormProps): React.ReactElement => ( +
+ + onEmailChange(event.target.value)} + disabled={isSubmitting} + leftIcon={} + iconClassName="!text-[#000000] hover:!text-[#000000]" + size="large" + isFullWidth + className="rounded-[4px] text-[14px] shadow-none" + /> + + + onPasswordChange(event.target.value)} + disabled={isSubmitting} + leftIcon={} + iconClassName="!text-[#000000] hover:!text-[#000000]" + size="large" + isFullWidth + className="rounded-[4px] text-[14px] shadow-none" + /> + + + + 기업 계정이 없으신가요? 회원가입으로 이동하세요. + + +); diff --git a/src/screens/login/index.tsx b/src/screens/login/index.tsx new file mode 100644 index 0000000..7383406 --- /dev/null +++ b/src/screens/login/index.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { getAuthErrorMessage, signInWithEmail, signInWithGoogle } from "@/lib/auth"; +import { Navigation, Tabs, type NavItem, type TabItem } from "@/shared/ui"; +import { CompanyLoginForm } from "./company-login-form"; +import { StudentLoginPanel } from "./student-login-panel"; +import styles from "./login.module.css"; + +const navItems: NavItem[] = [ + { label: "포트폴리오", href: "#portfolio" }, + { label: "소개", href: "#about" }, + { label: "공지사항", href: "#notice" }, +]; + +const loginTabs: TabItem[] = [ + { id: "student", label: "학생/교수" }, + { id: "company", label: "기업" }, +]; + +export const LoginPage = () => { + const router = useRouter(); + const [activeTab, setActiveTab] = useState("student"); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + const isStudent = activeTab === "student"; + + const handleGoogleLoginClick = async () => { + setErrorMessage(""); + setIsSubmitting(true); + + try { + await signInWithGoogle(); + router.replace("/home"); + } catch (error) { + console.error("[auth] Google login failed", error); + setErrorMessage(getAuthErrorMessage(error)); + } finally { + setIsSubmitting(false); + } + }; + + const handleCompanyLoginSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + const trimmedEmail = email.trim(); + + if (!trimmedEmail || !password) { + setErrorMessage("이메일과 비밀번호를 입력해주세요."); + return; + } + + setErrorMessage(""); + setIsSubmitting(true); + + try { + await signInWithEmail(trimmedEmail, password); + router.replace("/home"); + } catch (error) { + console.error("[auth] Company login failed", error); + setErrorMessage(getAuthErrorMessage(error)); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+ undefined} + onSignup={() => undefined} + className="login-page__nav" + /> + +
+
+
+
+

+ AIM AJOU +

+ +
+
+ { + setActiveTab(nextTab); + setErrorMessage(""); + }} + variant="horizontal" + isAnimated + className="[&>button]:flex-1 [&>button]:text-[13px] [&>button]:md:text-[14px]" + /> +
+ +
+
+ {isStudent ? ( + + ) : ( + + )} + {errorMessage && ( +

+ {errorMessage} +

+ )} +
+
+
+
+
+
+
+
+ ); +}; diff --git a/src/views/login/login.module.css b/src/screens/login/login.module.css similarity index 100% rename from src/views/login/login.module.css rename to src/screens/login/login.module.css diff --git a/src/screens/login/student-login-panel.tsx b/src/screens/login/student-login-panel.tsx new file mode 100644 index 0000000..5ac398a --- /dev/null +++ b/src/screens/login/student-login-panel.tsx @@ -0,0 +1,32 @@ +import { Spinner } from "@/shared/ui/spinner/spinner"; + +type StudentLoginPanelProps = { + isSubmitting: boolean; + onGoogleLoginClick: () => void; +}; + +export const StudentLoginPanel = ({ + isSubmitting, + onGoogleLoginClick, +}: StudentLoginPanelProps): React.ReactElement => ( + <> +

+ 아주대학교 구성원(학생/교수)은 Google 계정으로 로그인하세요 +

+ + +); diff --git a/src/shared/ui/avatars/avatars.stories.tsx b/src/shared/ui/avatars/avatars.stories.tsx index b72e850..b39b2c0 100644 --- a/src/shared/ui/avatars/avatars.stories.tsx +++ b/src/shared/ui/avatars/avatars.stories.tsx @@ -3,12 +3,13 @@ import { Avatar, AvatarGroup, type AvatarProps } from "./avatars"; const meta = { title: "Shared/UI/Avatars", + component: Avatar, parameters: { layout: "padded", componentSubtitle: "유저 프로필 및 다중 유저를 표시하는 아바타 컴포넌트", }, tags: ["autodocs"], -} satisfies Meta; +} satisfies Meta; export default meta; diff --git a/src/shared/ui/checkbox/checkbox.stories.tsx b/src/shared/ui/checkbox/checkbox.stories.tsx index c497263..9faa501 100644 --- a/src/shared/ui/checkbox/checkbox.stories.tsx +++ b/src/shared/ui/checkbox/checkbox.stories.tsx @@ -1,5 +1,5 @@ +import { useState } from "react"; import type { Meta, StoryObj } from "@storybook/nextjs"; -import { useArgs } from "storybook/preview-api"; import { Checkbox, type CheckboxProps } from "./checkbox"; const meta = { @@ -16,15 +16,9 @@ type Story = StoryObj; // 인터랙션이 가능한 래퍼 컴포넌트 const InteractiveCheckbox = (args: CheckboxProps) => { - const [{ checked }, updateArgs] = useArgs(); + const [checked, setChecked] = useState(Boolean(args.checked)); - return ( - updateArgs({ checked: e.target.checked })} - /> - ); + return setChecked(e.target.checked)} />; }; export const Default: Story = { diff --git a/src/shared/ui/empty_states/empty-states.stories.tsx b/src/shared/ui/empty-states/empty-states.stories.tsx similarity index 100% rename from src/shared/ui/empty_states/empty-states.stories.tsx rename to src/shared/ui/empty-states/empty-states.stories.tsx diff --git a/src/shared/ui/empty_states/empty-states.tsx b/src/shared/ui/empty-states/empty-states.tsx similarity index 100% rename from src/shared/ui/empty_states/empty-states.tsx rename to src/shared/ui/empty-states/empty-states.tsx diff --git a/src/shared/ui/file-uploader/file-uploader.stories.tsx b/src/shared/ui/file-uploader/file-uploader.stories.tsx index d55bbd7..b88a565 100644 --- a/src/shared/ui/file-uploader/file-uploader.stories.tsx +++ b/src/shared/ui/file-uploader/file-uploader.stories.tsx @@ -4,11 +4,12 @@ import { ThumbnailUploader, FileListItem, FileUploader } from "./file-uploader"; const meta = { title: "Shared/UI/FileUploader", + component: FileUploader, parameters: { layout: "centered", }, tags: ["autodocs"], -} satisfies Meta; +} satisfies Meta; export default meta; diff --git a/src/shared/ui/form/form.stories.tsx b/src/shared/ui/form/form.stories.tsx index 16dd59a..56b5201 100644 --- a/src/shared/ui/form/form.stories.tsx +++ b/src/shared/ui/form/form.stories.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import type { Meta, StoryObj } from "@storybook/nextjs"; import { FormField, FormLabel, FormHelperText, FormErrorMessage } from "./form"; -import { Input, Textarea } from "@/shared/ui/inputBox/inputBox"; +import { Input, Textarea } from "@/shared/ui/input"; import { Checkbox } from "@/shared/ui/checkbox/checkbox"; import { ThumbnailUploader, diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 99d9206..d0b5431 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -1,5 +1,6 @@ export { Button } from "./button"; -export { Input } from "./input"; +export { Input, Select, Textarea } from "./input"; +export type { InputProps, InputSize, SelectProps, TextareaProps } from "./input"; export { DropdownMenu, SelectDropdown } from "./dropdown"; export { Pagination } from "./pagination/pagination"; export type { PaginationProps } from "./pagination/pagination"; diff --git a/src/shared/ui/input/index.ts b/src/shared/ui/input/index.ts new file mode 100644 index 0000000..b59f0b1 --- /dev/null +++ b/src/shared/ui/input/index.ts @@ -0,0 +1,2 @@ +export { Input, Select, Textarea } from "./input"; +export type { InputProps, InputSize, SelectProps, TextareaProps } from "./input"; diff --git a/src/shared/ui/input/index.tsx b/src/shared/ui/input/index.tsx deleted file mode 100644 index 93e096a..0000000 --- a/src/shared/ui/input/index.tsx +++ /dev/null @@ -1,4 +0,0 @@ -// Input component - to be implemented -export const Input = () => { - return ; -}; diff --git a/src/shared/ui/inputBox/inputBox.stories.tsx b/src/shared/ui/input/input.stories.tsx similarity index 98% rename from src/shared/ui/inputBox/inputBox.stories.tsx rename to src/shared/ui/input/input.stories.tsx index 2d56e17..f093af3 100644 --- a/src/shared/ui/inputBox/inputBox.stories.tsx +++ b/src/shared/ui/input/input.stories.tsx @@ -1,10 +1,10 @@ import type { Meta, StoryObj } from "@storybook/nextjs"; -import { Input, Textarea, Select } from "./inputBox"; +import { Input, Textarea, Select } from "."; // 아이콘 임포트 import { UserIcon, MailIcon, LockIcon, XCircleIcon } from "@/shared/ui/icons"; const meta = { - title: "Shared/UI/InputBox", + title: "Shared/UI/Input", component: Input, parameters: { layout: "padded", diff --git a/src/shared/ui/inputBox/inputBox.tsx b/src/shared/ui/input/input.tsx similarity index 100% rename from src/shared/ui/inputBox/inputBox.tsx rename to src/shared/ui/input/input.tsx diff --git a/src/shared/ui/lists/lists.stories.tsx b/src/shared/ui/lists/lists.stories.tsx index 8400579..5878298 100644 --- a/src/shared/ui/lists/lists.stories.tsx +++ b/src/shared/ui/lists/lists.stories.tsx @@ -4,13 +4,13 @@ import { ListItem, Table, type TableColumn, type TableRowData } from "./lists"; const meta = { title: "Shared/UI/Lists & Tables", - // Storybook Meta는 하나의 메인 컴포넌트를 필요로 하지만, 두 개를 문서화하기 위해 Wrapper 활용 + component: ListItem, parameters: { layout: "padded", componentSubtitle: "디자인 시스템에 정의된 List Item 및 Table 컴포넌트", }, tags: ["autodocs"], -} satisfies Meta; +} satisfies Meta; export default meta; diff --git a/src/shared/ui/modal/modal.stories.tsx b/src/shared/ui/modal/modal.stories.tsx index aa6e8cf..715da3b 100644 --- a/src/shared/ui/modal/modal.stories.tsx +++ b/src/shared/ui/modal/modal.stories.tsx @@ -4,7 +4,7 @@ import { Modal, ModalHeader, ModalContent, ModalFooter } from "./modal"; import { Button } from "@/shared/ui/button/button"; import { Checkbox } from "@/shared/ui/checkbox/checkbox"; import { FormField, FormLabel } from "@/shared/ui/form"; -import { Input, Textarea, Select } from "@/shared/ui/inputBox/inputBox"; +import { Input, Textarea, Select } from "@/shared/ui/input"; const meta = { title: "Shared/UI/Modal", diff --git a/src/shared/ui/modal/modal.tsx b/src/shared/ui/modal/modal.tsx index a66f649..470e62b 100644 --- a/src/shared/ui/modal/modal.tsx +++ b/src/shared/ui/modal/modal.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useId, useState } from "react"; +import React, { useEffect, useId } from "react"; import { createPortal } from "react-dom"; import { XIcon } from "@/shared/ui/icons"; @@ -56,15 +56,8 @@ const getClasses = (...classes: (string | undefined)[]) => classes.filter(Boolea export const Modal = ({ isOpen, onClose, children, className }: ModalProps) => { const generatedId = useId(); const titleId = `modal-title-${generatedId}`; - const [isMounted, setIsMounted] = useState(false); - - useEffect(() => { - setIsMounted(true); - return () => setIsMounted(false); - }, []); - useEffect(() => { - if (!isMounted || !isOpen) return; + if (!isOpen || typeof document === "undefined") return; // ESC 키 대응 const handleEsc = (e: KeyboardEvent) => { @@ -87,9 +80,9 @@ export const Modal = ({ isOpen, onClose, children, className }: ModalProps) => { } window.removeEventListener("keydown", handleEsc); }; - }, [isMounted, isOpen, onClose]); + }, [isOpen, onClose]); - if (!isMounted || !isOpen) return null; + if (!isOpen || typeof document === "undefined") return null; // 자식 요소들에 titleId를 주입하기 위해 React.Children.map 사용 (ModalHeader 탐색) const childrenWithA11y = React.Children.map(children, (child) => { diff --git a/src/views/home/home.stories.tsx b/src/views/home/home.stories.tsx deleted file mode 100644 index 0f9ef78..0000000 --- a/src/views/home/home.stories.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/nextjs"; - -import { expect, userEvent, within } from "storybook/test"; - -import { HomePage } from "./home"; - -const meta = { - title: "Pages/Home", - component: HomePage, - parameters: { - // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout - layout: "fullscreen", - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const LoggedOut: Story = {}; - -// More on component testing: https://storybook.js.org/docs/writing-tests/interaction-testing -export const LoggedIn: Story = { - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const loginButton = canvas.getByRole("button", { name: /Log in/i }); - await expect(loginButton).toBeInTheDocument(); - await userEvent.click(loginButton); - await expect(loginButton).not.toBeInTheDocument(); - - const logoutButton = canvas.getByRole("button", { name: /Log out/i }); - await expect(logoutButton).toBeInTheDocument(); - }, -}; diff --git a/src/views/home/home.tsx b/src/views/home/home.tsx deleted file mode 100644 index ee7d7f1..0000000 --- a/src/views/home/home.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import React from "react"; - -export const HomePage: React.FC = () => { - return ( -
-
-

- Pages in Storybook -

-

- We recommend building UIs with a{" "} - - component-driven - {" "} - process starting with atomic components and ending with pages. -

-

- Render pages with mock data. This makes it easy to build and review page states without - needing to navigate to them in your app. Here are some handy patterns for managing page - data in Storybook: -

-
    -
  • - Use a higher-level connected component. Storybook helps you compose such data from the - "args" of child component stories -
  • -
  • - Assemble data in the page component from your services. You can mock these services out - using Storybook. -
  • -
-

- Get a guided tutorial on component-driven development at{" "} - - Storybook tutorials - - . Read more in the{" "} - - docs - - . -

-
- Tip -
- Adjust the width of the canvas with the - - - - - - Viewports addon in the toolbar -
-
- ); -}; diff --git a/src/views/login/index.tsx b/src/views/login/index.tsx deleted file mode 100644 index 3112095..0000000 --- a/src/views/login/index.tsx +++ /dev/null @@ -1,156 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { Button } from "@/shared/ui/button/button"; -import { Input } from "@/shared/ui/inputBox/inputBox"; -import { Navigation, Tabs, type NavItem, type TabItem } from "@/shared/ui"; -import { LockIcon, MailIcon } from "@/shared/ui/icons"; -import styles from "./login.module.css"; - -const navItems: NavItem[] = [ - { label: "포트폴리오", href: "#portfolio" }, - { label: "소개", href: "#about" }, - { label: "공지사항", href: "#notice" }, -]; - -const loginTabs: TabItem[] = [ - { id: "student", label: "학생/교수" }, - { id: "company", label: "기업" }, -]; - -export const LoginPage = () => { - const [activeTab, setActiveTab] = useState("student"); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - - const isStudent = activeTab === "student"; - const signupCopy = "기업 계정이 없으신가요? 회원가입으로 이동하세요."; - - const handleCompanyLoginSubmit = (event: React.FormEvent) => { - event.preventDefault(); - }; - - return ( -
- undefined} - onSignup={() => undefined} - className="login-page__nav" - /> - -
-
-
-
-

- AIM AJOU -

- -
-
- -
- -
-
- {isStudent ? ( - <> -

- 아주대학교 구성원(학생/교수)은 Google 계정으로 로그인하세요 -

- - - ) : ( -
- - setEmail(event.target.value)} - leftIcon={} - iconClassName="!text-[#000000] hover:!text-[#000000]" - size="large" - isFullWidth - className="rounded-[4px] text-[14px] shadow-none" - /> - - - setPassword(event.target.value)} - leftIcon={} - iconClassName="!text-[#000000] hover:!text-[#000000]" - size="large" - isFullWidth - className="rounded-[4px] text-[14px] shadow-none" - /> - - - - {signupCopy} - - - )} -
-
-
-
-
-
-
-
- ); -}; diff --git a/src/widgets/index.ts b/src/widgets/index.ts deleted file mode 100644 index b59c177..0000000 --- a/src/widgets/index.ts +++ /dev/null @@ -1 +0,0 @@ -// Widgets barrel export diff --git a/tsconfig.app.json b/tsconfig.app.json index 6b07958..aed9f68 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -28,10 +28,9 @@ "paths": { "@/*": ["*"], "@app/*": ["app/*"], - "@views/*": ["views/*"], - "@widgets/*": ["widgets/*"], - "@features/*": ["features/*"], - "@entities/*": ["entities/*"], + "@screens/*": ["screens/*"], + "@api/*": ["api/*"], + "@lib/*": ["lib/*"], "@shared/*": ["shared/*"] } }, diff --git a/tsconfig.json b/tsconfig.json index a27bd29..c58e3b1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -31,17 +31,14 @@ "@app/*": [ "src/app/*" ], - "@views/*": [ - "src/views/*" + "@screens/*": [ + "src/screens/*" ], - "@widgets/*": [ - "src/widgets/*" + "@api/*": [ + "src/api/*" ], - "@features/*": [ - "src/features/*" - ], - "@entities/*": [ - "src/entities/*" + "@lib/*": [ + "src/lib/*" ], "@shared/*": [ "src/shared/*"