This commit is contained in:
24
.gitea/workflows/deploy.yml
Normal file
24
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
# .gitea/workflows/deploy.yml
|
||||
name: Deploy to Production
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Deploy to server
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: 172.16.10.175
|
||||
username: user # 'user' 대신 실제 서버 접속 아이디를 입력하세요.
|
||||
key: ${{ secrets.SELF_PRIVATE_175_KEY }}
|
||||
script: |
|
||||
cd /home/user/services/conRec
|
||||
git pull
|
||||
docker compose restart
|
||||
150
.gitignore
vendored
Normal file
150
.gitignore
vendored
Normal file
@@ -0,0 +1,150 @@
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/node
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=node
|
||||
|
||||
### Node ###
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
### Node Patch ###
|
||||
# Serverless Webpack directories
|
||||
.webpack/
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
# SvelteKit build / generate output
|
||||
.svelte-kit
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/node
|
||||
backend/uploads/*.*
|
||||
|
||||
.idea
|
||||
*.m4a
|
||||
|
||||
.wrangler
|
||||
73
GEMINI.md
Normal file
73
GEMINI.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Gemini AI 실행 계획: SvelteKit 기반 Q&A 피드백 플랫폼 구축
|
||||
|
||||
## 1. 프로젝트 목표
|
||||
SvelteKit 프레임워크와 Cloudflare Pages를 사용하여 OIDC 인증 기반의 Q&A 피드백 수집 웹 애플리케이션을 구축합니다. 정적 콘텐츠는 Cloudflare의 글로벌 CDN을 통해 제공하고, 인증 및 API 요청과 같은 동적 로직은 Cloudflare Workers에서 실행하여 성능과 보안을 최적화합니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 기술 스택 선정
|
||||
* **프레임워크**: `SvelteKit`
|
||||
* **패키지 매니저**: `pnpm` - 빠르고 효율적인 의존성 관리를 위해 사용합니다.
|
||||
* **배포 대상**: `Cloudflare Pages`
|
||||
* **인증 (OIDC)**: `lucia-auth`
|
||||
* **UI/스타일링**: `tailwindcss`, `shadcn-svelte`, `lucide-svelte`
|
||||
* **폼 처리**: `sveltekit-superforms`, `zod`
|
||||
* **SvelteKit 어댑터**: `@sveltejs/adapter-cloudflare` - Cloudflare Pages 및 Workers 배포에 최적화된 어댑터
|
||||
|
||||
---
|
||||
|
||||
## 3. 개발 단계별 실행 계획
|
||||
|
||||
### 1단계: 프로젝트 초기 설정 (Scaffolding)
|
||||
1. **SvelteKit 프로젝트 생성**: `frontend` 디렉토리에 SvelteKit 프로젝트를 설정합니다.
|
||||
2. **`@sveltejs/adapter-cloudflare` 설치 및 설정**: Cloudflare 배포를 위해 `svelte.config.js`에 해당 어댑터를 적용합니다.
|
||||
3. **필수 패키지 설치**: `pnpm install`을 사용하여 `lucia`, `sveltekit-superforms` 등 필요한 라이브러리를 설치합니다.
|
||||
4. **`shadcn-svelte` 초기화**: `pnpm dlx shadcn-svelte@latest init`을 실행하여 UI 컴포넌트 라이브러리를 설정합니다.
|
||||
5. **디렉토리 구조 설정**: `src/routes`, `src/lib`, `src/components` 등 기본 구조를 확정합니다.
|
||||
|
||||
### 2단계: OIDC 인증 및 API 연동 구현 (Cloudflare Workers 활용)
|
||||
1. **환경 변수 설정**: OIDC 및 API 관련 시크릿 정보를 Cloudflare Pages의 환경 변수로 설정합니다.
|
||||
* `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_ISSUER_URL`
|
||||
* `API_BASE_URL`, `API_KEY`, `DEFAULT_PROJECT_ID`
|
||||
2. **서버 함수(Worker) 구현**: SvelteKit의 서버 라우트(`*.server.ts`)를 사용하여 Cloudflare Worker에서 실행될 로직을 작성합니다.
|
||||
* `/login`, `/login/callback`, `/logout` 라우트를 구현하여 OIDC 인증 흐름을 처리합니다. 이 로직은 서버리스 함수에서 안전하게 실행됩니다.
|
||||
* API 호출을 위한 프록시 엔드포인트를 생성합니다. 예를 들어, 프론트엔드가 `/api/feedbacks`를 호출하면, 서버 함수가 이를 받아 `x-api-key` 헤더를 추가하여 실제 백엔드 API로 요청을 전달합니다.
|
||||
3. **Lucia Auth 설정**: `lucia`를 SvelteKit의 서버 환경과 연동하여 세션을 관리합니다.
|
||||
|
||||
### 3단계: 피드백 기능 구현
|
||||
1. **피드백 조회/검색**: 프론트엔드 페이지에서 우리가 만든 API 프록시 엔드포인트를 호출하여 안전하게 데이터를 가져와 표시합니다.
|
||||
2. **피드백 작성**: `sveltekit-superforms`를 사용하여 폼을 구현하고, `form action`을 통해 API 프록시 엔드포인트로 데이터를 전송하여 피드백을 생성합니다.
|
||||
|
||||
### 4단계: 배포
|
||||
1. **빌드**: `pnpm run build`를 실행하여 Cloudflare Pages가 요구하는 형식의 정적 파일과 Worker 스크립트를 생성합니다.
|
||||
2. **배포**: 생성된 `build` 디렉토리를 Cloudflare Pages에 연결하여 배포합니다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 진행 상황 요약 (2025-07-25)
|
||||
* **프로젝트 구조 및 설정 완료**:
|
||||
* `frontend` 디렉토리에 SvelteKit 프로젝트의 기본 파일 구조와 설정을 완료했습니다.
|
||||
* `pnpm`을 패키지 매니저로 채택하고 모든 의존성 설치를 완료했습니다.
|
||||
* Cloudflare Pages 배포를 위해 `@sveltejs/adapter-cloudflare`를 설정했습니다.
|
||||
* **UI 라이브러리 초기화 완료**:
|
||||
* `shadcn-svelte`를 초기화하고 관련 설정(Tailwind CSS, `app.css` 등)을 업데이트하여 UI 컴포넌트를 사용할 준비를 마쳤습니다.
|
||||
* **리팩토링 및 검증 완료**:
|
||||
* 프로젝트 디렉토리명을 `qna-feedback-frontend`에서 `frontend`로 일괄 변경했습니다.
|
||||
* `.gitignore` 파일을 확인하고, `pnpm run build`를 통해 프로젝트가 성공적으로 빌드되는 것을 확인했습니다.
|
||||
* 사용자가 로컬 미리보기 서버를 통해 페이지의 정상 동작을 확인했습니다.
|
||||
* **Lucia-auth v3 마이그레이션 및 빌드 오류 해결**:
|
||||
* `lucia/middleware` 모듈 로드 실패 에러를 해결하고, `lucia` v3 API에 맞춰 `src/lib/server/auth.ts` 코드를 수정했습니다.
|
||||
* `@lucia-auth/adapter-sqlite`의 import 구문 오류를 바로잡았습니다.
|
||||
* `routes/login/+page.server.ts` 파일의 `import` 구문 오류를 수정하여 빌드에 성공하도록 했습니다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 다음 단계
|
||||
|
||||
* **OIDC 흐름 검증을 위한 SQLite 의존성 분리**:
|
||||
* **목표**: 실제 백엔드 시스템과 분리하여 프론트엔드의 OIDC 인증 흐름을 독립적으로 검증합니다.
|
||||
* **계획**:
|
||||
1. **SQLite 의존성 제거**: `pnpm`을 사용하여 `better-sqlite3`와 `@lucia-auth/adapter-sqlite` 패키지를 프로젝트에서 완전히 제거합니다.
|
||||
2. **인증 로직 수정**: `src/lib/server/auth.ts` 파일에서 데이터베이스 관련 코드를 모두 제거합니다. OIDC 흐름 테스트를 위해 Lucia가 데이터베이스 없이 임시적으로 동작하도록 코드를 수정합니다. (이는 최종 프로덕션 코드가 아니며, 순수 OIDC 연동 테스트 목적입니다.)
|
||||
3. **콜백 핸들러 수정**: `src/routes/login/callback/+page.server.ts`에서 OIDC 제공자로부터 받은 사용자 정보를 데이터베이스에 저장하는 대신, 임시 세션만 생성하여 OIDC 인증 흐름이 올바르게 동작하는지 확인합니다.
|
||||
|
||||
13
docker-compose.yml
Normal file
13
docker-compose.yml
Normal file
@@ -0,0 +1,13 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "4173:4173"
|
||||
env_file:
|
||||
- ./frontend/.env
|
||||
restart: unless-stopped
|
||||
container_name: qna-feedback-frontend
|
||||
72
docs/OIDC_trouble_shooting.md
Normal file
72
docs/OIDC_trouble_shooting.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# OIDC 인증 구현 트러블슈팅 (svelte-check)
|
||||
|
||||
`pnpm run check` 실행 시 발생한 타입 에러들에 대한 요약 및 해결 과정 기록. 모든 문제는 최종적으로 해결되었습니다.
|
||||
|
||||
---
|
||||
|
||||
## 1-6. `src/lib/server/auth.ts` 관련 타입 오류
|
||||
|
||||
- **파일**: `src/lib/server/auth.ts`
|
||||
- **오류 요약**:
|
||||
1. `unstorage` 및 관련 어댑터 import 오류
|
||||
2. Lucia v3 생성자에 `env` 속성 사용 오류
|
||||
3. `getUserAttributes` 콜백의 `data` 타입 추론 오류 (`{}`)
|
||||
4. 더미 어댑터의 타입이 `Adapter` 인터페이스와 불일치
|
||||
- **원인**: Lucia v3의 API와 타입 시스템에 대한 이해 부족. 데이터베이스 의존성을 제거하는 과정에서 `unstorage` 어댑터를 잘못 사용하려고 시도함.
|
||||
- **해결 상태**: **완료**
|
||||
- **해결책**:
|
||||
1. `unstorage` 및 관련 패키지 의존성을 제거했습니다.
|
||||
2. 데이터베이스 없이 OIDC 흐름을 테스트하기 위해, Lucia v3의 `Adapter` 인터페이스를 직접 구현하는 **더미 인메모리 어댑터**를 `auth.ts` 내에 작성했습니다.
|
||||
3. 이 더미 어댑터는 `Map` 객체를 사용하여 세션과 사용자 정보를 메모리에 임시 저장합니다.
|
||||
4. Lucia 생성자에서 v2 방식의 `env` 속성을 제거하고, `Adapter` 인터페이스에 맞게 `setUser`와 같은 불필요한 메서드를 제거했습니다.
|
||||
5. `declare module 'lucia'`를 사용하여 `DatabaseUserAttributes` 타입을 명시적으로 정의함으로써, `getUserAttributes`의 타입 추론 오류를 해결했습니다.
|
||||
|
||||
---
|
||||
|
||||
## 7. `hooks.server.ts`의 `handleRequest` 오류
|
||||
|
||||
- **파일**: `src/hooks.server.ts`
|
||||
- **오류 메시지**: `Property 'handleRequest' does not exist on type 'Lucia<...>'`
|
||||
- **원인**: `lucia.handleRequest()`는 Lucia v2에서 사용하던 방식입니다. v3에서는 세션 유효성 검사와 쿠키 관리를 직접 처리해야 합니다.
|
||||
- **해결 상태**: **완료**
|
||||
- **해결책**: Lucia v3의 공식 문서에 따라 `hooks.server.ts`의 `handle` 함수를 수정했습니다. 이제 모든 요청에서 `event.cookies`를 통해 세션 ID를 가져오고, `lucia.validateSession()`으로 유효성을 검증합니다. 검증 결과에 따라 `event.locals.user`와 `event.locals.session`을 설정하고, 필요한 경우 세션 쿠키를 새로 생성하거나 비우는 로직을 추가했습니다.
|
||||
|
||||
---
|
||||
|
||||
## 8. `arctic` 모듈 선언 오류
|
||||
|
||||
- **파일**: `src/routes/login/callback/+page.server.ts`
|
||||
- **오류 메시지**: `Cannot find module 'arctic' or its corresponding type declarations.`
|
||||
- **원인**: `arctic` 패키지가 설치되지 않았습니다.
|
||||
- **해결 상태**: **완료**
|
||||
- **해결책**: `pnpm install arctic` 명령을 실행하여 의존성을 추가했습니다.
|
||||
|
||||
---
|
||||
|
||||
## 9. `logout` 라우트의 `redirect` 함수 오류
|
||||
|
||||
- **파일**: `src/routes/logout/+page.server.ts`
|
||||
- **오류 메시지**: `Expected 2 arguments, but got 3.`
|
||||
- **원인**: SvelteKit의 `redirect` 함수 API가 변경되었습니다. 더 이상 세 번째 인자로 헤더(쿠키 포함)를 전달할 수 없습니다.
|
||||
- **해결 상태**: **완료**
|
||||
- **해결책**: `redirect` 함수에서 헤더 인자를 제거하고, 대신 `event.cookies.set()`을 사용하여 로그아웃 시 세션 쿠키를 삭제하도록 로직을 변경했습니다.
|
||||
|
||||
---
|
||||
|
||||
## 추가 해결: `event.locals` 타입 오류
|
||||
|
||||
- **파일**: `src/app.d.ts`, `src/routes/**/+*.server.ts`
|
||||
- **오류 메시지**: `Property 'user' does not exist on type 'Locals'.`
|
||||
- **원인**: `hooks.server.ts`에서 `event.locals.user`와 `event.locals.session`을 할당했지만, SvelteKit의 전역 타입 정의 파일(`app.d.ts`)에 해당 속성이 선언되지 않았습니다.
|
||||
- **해결 상태**: **완료**
|
||||
- **해결책**: `src/app.d.ts` 파일의 `App.Locals` 인터페이스에 `user: import('lucia').User | null;`와 `session: import('lucia').Session | null;`를 추가하여 전역 타입을 확장했습니다.
|
||||
|
||||
---
|
||||
|
||||
## 추가 해결: 프론트엔드 컴포넌트 타입 오류
|
||||
|
||||
- **파일**: `src/routes/+layout.svelte`, `src/routes/+page.svelte`
|
||||
- **오류 메시지**: `Property 'session' does not exist on type '{ user: User | null; }'.`
|
||||
- **원인**: 서버 사이드(`+layout.server.ts`)에서 `data`로 전달하는 객체의 구조를 `{ session }`에서 `{ user }`로 변경했으나, 프론트엔드 컴포넌트에서는 여전히 `data.session`을 참조하고 있었습니다.
|
||||
- **해결 상태**: **완료**
|
||||
- **해결책**: `+layout.svelte`와 `+page.svelte` 파일에서 `data.session`을 참조하는 모든 코드를 `data.user`를 참조하도록 수정했습니다.
|
||||
65
docs/QNA_FEEDBACK_PRD.md
Normal file
65
docs/QNA_FEEDBACK_PRD.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# 제품 요구사항 문서 (PRD): Q&A 피드백 플랫폼
|
||||
|
||||
- **문서 버전**: 1.2
|
||||
- **작성일**: 2025년 7월 25일
|
||||
- **프로젝트명**: SvelteKit 기반 Q&A 피드백 플랫폼
|
||||
|
||||
---
|
||||
|
||||
## 1. 개요
|
||||
|
||||
### 1.1. 프로젝트 목표
|
||||
사용자로부터 질문, 답변, 버그 리포트, 기능 제안 등 다양한 형태의 피드백을 체계적으로 수집하고 관리하기 위한 웹 애플리케이션을 구축한다. 사용자는 별도의 회원가입 절차 없이 기존에 사용하던 OIDC 계정을 통해 로그인하여 피드백을 손쉽게 제출할 수 있다.
|
||||
|
||||
### 1.2. 대상 사용자
|
||||
- **피드백 제공자 (End-User)**: 서비스나 제품을 사용하는 모든 사용자. OIDC 계정을 통해 인증 후 피드백을 작성하고 제출할 수 있다.
|
||||
|
||||
### 1.3. 기대 효과
|
||||
- 피드백 수집 프로세스 간소화 및 사용자 참여 증대
|
||||
- 다양한 종류의 피드백을 '채널'별로 분류하여 체계적인 관리 가능
|
||||
- 파일 첨부 기능을 통해 문제 상황이나 제안에 대한 상세 정보 확보
|
||||
|
||||
---
|
||||
|
||||
## 2. 기능 요구사항
|
||||
|
||||
### 2.1. 사용자 인증 (FR-1)
|
||||
- **FR-1.1**: 사용자는 OIDC 제공자를 통해서만 인증(로그인)할 수 있다.
|
||||
- **FR-1.2**: 시스템 내 자체적인 회원가입 기능은 제공하지 않는다.
|
||||
- **FR-1.3**: OIDC 인증 성공 시, 사용자의 세션이 생성되어 로그인 상태가 유지되어야 한다.
|
||||
- **FR-1.4**: 사용자는 명시적인 '로그아웃' 버튼을 통해 언제든지 세션을 종료할 수 있다.
|
||||
- **FR-1.5**: OIDC 클라이언트 ID, 비밀키 등은 Cloudflare Pages의 환경 변수로 안전하게 관리되어야 한다.
|
||||
|
||||
### 2.2. Q&A 피드백 기능 (FR-2)
|
||||
- **FR-2.1**: 인증된 사용자만 피드백 관련 기능(작성, 조회)을 사용할 수 있다.
|
||||
- **FR-2.2**: 피드백 작성 폼은 다음 필드를 포함해야 한다: 채널, 제목, 내용, 파일 첨부.
|
||||
- **FR-2.3**: '제출' 시, 로그인된 사용자 정보(ID, 이름 등)를 포함하여 백엔드 API로 전송해야 한다.
|
||||
- **FR-2.4**: 피드백 생성 API는 `POST /api/projects/{projectId}/channels/{channelId}/feedbacks` 엔드포인트를 사용한다.
|
||||
- **FR-2.5**: 피드백 목록 조회는 `GET /api/projects/{projectId}/channels/{channelId}/feedbacks` 엔드포인트를 사용한다.
|
||||
- **FR-2.6**: 피드백 검색은 `GET /api/v2/projects/{projectId}/channels/{channelId}/feedbacks/search` 엔드포인트를 사용한다.
|
||||
|
||||
### 2.3. 데이터 구조 및 확장성 (FR-3)
|
||||
- **FR-3.1**: 피드백 데이터는 백엔드 API 명세에 따라 유연한 JSON 구조로 전송된다.
|
||||
- **FR-3.2**: API 호출에 필요한 `projectId`, `channelId`는 URL 경로의 일부로 동적으로 구성된다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 비기능 요구사항
|
||||
|
||||
### 3.1. 보안 및 설정 (NFR-1)
|
||||
- 모든 민감한 정보(OIDC 시크릿, API 키)는 소스 코드에 포함되지 않고, Cloudflare 환경 변수를 통해 안전하게 관리되어야 한다.
|
||||
- SvelteKit의 서버 함수(Cloudflare Workers로 배포됨)가 API 키를 사용하여 백엔드 API를 호출하는 프록시 역할을 수행한다.
|
||||
|
||||
### 3.2. 기술 스택 (NFR-2)
|
||||
- **프론트엔드**: SvelteKit, TypeScript, Tailwind CSS
|
||||
- **배포**: Cloudflare Pages (정적 파일 + Cloudflare Workers)
|
||||
|
||||
### 3.3. 배포 (NFR-3)
|
||||
- 프론트엔드 애플리케이션은 백엔드와 완전히 분리된 인프라에 독립적으로 배포된다.
|
||||
- SvelteKit의 `@sveltejs/adapter-cloudflare`를 사용하여 Cloudflare Pages에 최적화된 형태로 빌드된다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 범위 외 (Out of Scope)
|
||||
- **관리자 대시보드**: 제출된 피드백을 관리하는 기능.
|
||||
- **백엔드 API 및 데이터베이스 구현**: 이 문서에서는 프론트엔드 개발을 전제로 하며, 백엔드는 이미 존재하거나 별도로 개발되는 것으로 간주한다.
|
||||
31
frontend/Dockerfile
Normal file
31
frontend/Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
||||
# 1. Base image
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
# 2. Install pnpm
|
||||
RUN npm install -g pnpm
|
||||
|
||||
# 3. Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# 4. Copy dependency files and install dependencies
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# 5. Copy all source code
|
||||
COPY . .
|
||||
|
||||
# 6. Build the application
|
||||
RUN pnpm run build
|
||||
|
||||
# 7. Set up the final image
|
||||
FROM base AS final
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=base /app/.svelte-kit ./.svelte-kit
|
||||
COPY --from=base /app/node_modules ./node_modules
|
||||
COPY --from=base /app/package.json ./package.json
|
||||
|
||||
# 8. Expose port and start the preview server
|
||||
EXPOSE 4173
|
||||
CMD ["pnpm", "run", "preview", "--host", "0.0.0.0", "--port", "4173"]
|
||||
13
frontend/components.json
Normal file
13
frontend/components.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||
"style": "default",
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/app.css",
|
||||
"baseColor": "slate"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils"
|
||||
}
|
||||
}
|
||||
55
frontend/package.json
Normal file
55
frontend/package.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "qna-feedback-frontend",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write . && eslint --fix ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/adapter-cloudflare": "^7.1.1",
|
||||
"@sveltejs/adapter-node": "^5.0.1",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/eslint": "^8.56.7",
|
||||
"@types/node": "^24.1.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-svelte": "^2.36.0",
|
||||
"globals": "^15.0.0",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier-plugin-svelte": "^3.1.2",
|
||||
"prettier-plugin-tailwindcss": "^0.5.14",
|
||||
"svelte": "^4.2.7",
|
||||
"svelte-check": "^3.6.0",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^7.5.0",
|
||||
"vite": "^5.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lucia-auth/adapter-session-unstorage": "^2.1.0",
|
||||
"@lucia-auth/oauth": "^3.5.3",
|
||||
"arctic": "^3.7.0",
|
||||
"bits-ui": "^0.21.16",
|
||||
"clsx": "^2.1.1",
|
||||
"lucia": "^3.2.0",
|
||||
"lucide-svelte": "^0.378.0",
|
||||
"sveltekit-superforms": "^2.12.6",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwind-variants": "^1.0.0",
|
||||
"unstorage": "^1.16.1",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
4807
frontend/pnpm-lock.yaml
generated
Normal file
4807
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
59
frontend/src/app.css
Normal file
59
frontend/src/app.css
Normal file
@@ -0,0 +1,59 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 5.9% 10%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
15
frontend/src/app.d.ts
vendored
Normal file
15
frontend/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
interface Locals {
|
||||
user: import('lucia').User | null;
|
||||
session: import('lucia').Session | null;
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
12
frontend/src/app.html
Normal file
12
frontend/src/app.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
32
frontend/src/hooks.server.ts
Normal file
32
frontend/src/hooks.server.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { lucia } from '$lib/server/auth';
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const sessionId = event.cookies.get(lucia.sessionCookieName);
|
||||
if (!sessionId) {
|
||||
event.locals.user = null;
|
||||
event.locals.session = null;
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
const { session, user } = await lucia.validateSession(sessionId);
|
||||
if (session && session.fresh) {
|
||||
const sessionCookie = lucia.createSessionCookie(session.id);
|
||||
// sveltekit types deviates from the de-facto standard
|
||||
// https://kit.svelte.dev/docs/types#public-types-cookies
|
||||
event.cookies.set(sessionCookie.name, sessionCookie.value, {
|
||||
path: '.',
|
||||
...sessionCookie.attributes
|
||||
});
|
||||
}
|
||||
if (!session) {
|
||||
const sessionCookie = lucia.createBlankSessionCookie();
|
||||
event.cookies.set(sessionCookie.name, sessionCookie.value, {
|
||||
path: '.',
|
||||
...sessionCookie.attributes
|
||||
});
|
||||
}
|
||||
event.locals.user = user;
|
||||
event.locals.session = session;
|
||||
return resolve(event);
|
||||
};
|
||||
25
frontend/src/lib/components/ui/button/button.svelte
Normal file
25
frontend/src/lib/components/ui/button/button.svelte
Normal file
@@ -0,0 +1,25 @@
|
||||
<!-- src/lib/components/ui/button/button.svelte -->
|
||||
<script lang="ts">
|
||||
import { Button as ButtonPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils";
|
||||
import { buttonVariants, type Props, type Events } from "./index.js";
|
||||
|
||||
type $$Props = Props;
|
||||
type $$Events = Events;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export let variant: $$Props["variant"] = "default";
|
||||
export let size: $$Props["size"] = "default";
|
||||
export let builders: $$Props["builders"] = [];
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<ButtonPrimitive.Root
|
||||
{builders}
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
{...$$restProps}
|
||||
on:click
|
||||
on:keydown
|
||||
>
|
||||
<slot />
|
||||
</ButtonPrimitive.Root>
|
||||
52
frontend/src/lib/components/ui/button/index.ts
Normal file
52
frontend/src/lib/components/ui/button/index.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// src/lib/components/ui/button/index.ts
|
||||
import Root from "./button.svelte";
|
||||
import { tv, type VariantProps } from "tailwind-variants";
|
||||
import type { Button as ButtonPrimitive } from "bits-ui";
|
||||
|
||||
const buttonVariants = tv({
|
||||
base: "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
});
|
||||
|
||||
type Variant = VariantProps<typeof buttonVariants>["variant"];
|
||||
type Size = VariantProps<typeof buttonVariants>["size"];
|
||||
|
||||
type Props = ButtonPrimitive.Props & {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
};
|
||||
|
||||
type Events = ButtonPrimitive.Events;
|
||||
|
||||
export {
|
||||
Root,
|
||||
type Props,
|
||||
type Events,
|
||||
//
|
||||
Root as Button,
|
||||
buttonVariants,
|
||||
type Props as ButtonProps,
|
||||
type Events as ButtonEvents,
|
||||
};
|
||||
101
frontend/src/lib/server/auth.ts
Normal file
101
frontend/src/lib/server/auth.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Lucia, type Adapter, type DatabaseUser, type DatabaseSession } from 'lucia';
|
||||
import { dev } from '$app/environment';
|
||||
|
||||
// This is a dummy adapter for testing the OIDC flow without a real database.
|
||||
// It simulates user and session storage in memory.
|
||||
// DO NOT USE IN PRODUCTION.
|
||||
|
||||
const dummyUsers = new Map<string, DatabaseUser>();
|
||||
const dummySessions = new Map<string, DatabaseSession>();
|
||||
|
||||
// We need to manually implement the Adapter interface for our dummy store.
|
||||
const adapter: Adapter = {
|
||||
getSessionAndUser: async (sessionId) => {
|
||||
const session = dummySessions.get(sessionId);
|
||||
if (!session) return [null, null];
|
||||
const user = dummyUsers.get(session.userId);
|
||||
if (!user) return [null, null];
|
||||
return [session, user];
|
||||
},
|
||||
getUserSessions: async (userId) => {
|
||||
const sessions: DatabaseSession[] = [];
|
||||
for (const session of dummySessions.values()) {
|
||||
if (session.userId === userId) {
|
||||
sessions.push(session);
|
||||
}
|
||||
}
|
||||
return sessions;
|
||||
},
|
||||
setSession: async (session) => {
|
||||
dummySessions.set(session.id, session);
|
||||
},
|
||||
updateSessionExpiration: async (sessionId, expiresAt) => {
|
||||
const session = dummySessions.get(sessionId);
|
||||
if (session) {
|
||||
session.expiresAt = expiresAt;
|
||||
dummySessions.set(sessionId, session);
|
||||
}
|
||||
},
|
||||
deleteSession: async (sessionId) => {
|
||||
dummySessions.delete(sessionId);
|
||||
},
|
||||
deleteUserSessions: async (userId) => {
|
||||
for (const [id, session] of dummySessions.entries()) {
|
||||
if (session.userId === userId) {
|
||||
dummySessions.delete(id);
|
||||
}
|
||||
}
|
||||
},
|
||||
deleteExpiredSessions: async () => {
|
||||
const now = new Date();
|
||||
for (const [id, session] of dummySessions.entries()) {
|
||||
if (new Date(session.expiresAt) < now) {
|
||||
dummySessions.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const lucia = new Lucia(adapter, {
|
||||
sessionCookie: {
|
||||
attributes: {
|
||||
// set to `true` when using HTTPS
|
||||
secure: !dev
|
||||
}
|
||||
},
|
||||
getUserAttributes: (attributes) => {
|
||||
return {
|
||||
sub: attributes.sub,
|
||||
name: attributes.name,
|
||||
email: attributes.email
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
declare module 'lucia' {
|
||||
interface Register {
|
||||
Lucia: typeof lucia;
|
||||
DatabaseUserAttributes: {
|
||||
sub: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// A helper function to manage our dummy user store from the callback
|
||||
export const setDummyUser = (user: {
|
||||
id: string;
|
||||
sub: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
}) => {
|
||||
dummyUsers.set(user.id, {
|
||||
id: user.id,
|
||||
attributes: {
|
||||
sub: user.sub,
|
||||
name: user.name,
|
||||
email: user.email
|
||||
}
|
||||
});
|
||||
};
|
||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
8
frontend/src/routes/+layout.server.ts
Normal file
8
frontend/src/routes/+layout.server.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// src/routes/+layout.server.ts
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
return {
|
||||
user: locals.user
|
||||
};
|
||||
};
|
||||
29
frontend/src/routes/+layout.svelte
Normal file
29
frontend/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
</script>
|
||||
|
||||
<header class="p-4 border-b">
|
||||
<div class="container mx-auto flex justify-between items-center">
|
||||
<a href="/" class="text-xl font-bold">Q&A Platform</a>
|
||||
<nav>
|
||||
{#if data.user}
|
||||
<span>Welcome, {data.user.name ?? 'User'}!</span>
|
||||
<a href="/logout">
|
||||
<Button variant="outline" class="ml-4">Logout</Button>
|
||||
</a>
|
||||
{:else}
|
||||
<a href="/login">
|
||||
<Button>Login with OIDC</Button>
|
||||
</a>
|
||||
{/if}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container mx-auto p-4">
|
||||
<slot />
|
||||
</main>
|
||||
96
frontend/src/routes/+page.svelte
Normal file
96
frontend/src/routes/+page.svelte
Normal file
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { MessageSquare, BrainCircuit, Users } from 'lucide-svelte';
|
||||
|
||||
export let data: PageData;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Home - Q&A Feedback Platform</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-col items-center justify-center min-h-[calc(100vh-15rem)] text-center">
|
||||
<!-- Hero Section -->
|
||||
<section class="w-full py-12 md:py-24 lg:py-32">
|
||||
<div class="container px-4 md:px-6">
|
||||
<div class="flex flex-col items-center space-y-4 text-center">
|
||||
<div class="space-y-2">
|
||||
<h1 class="text-4xl font-bold tracking-tighter sm:text-5xl md:text-6xl">
|
||||
궁금한 모든 것, 이곳에서 해결하세요
|
||||
</h1>
|
||||
<p class="mx-auto max-w-[700px] text-muted-foreground md:text-xl">
|
||||
일상적인 질문부터 깊이 있는 전문 지식까지, 다양한 주제에 대해 자유롭게 묻고 답하며
|
||||
최고의 해답을 찾아보세요.
|
||||
</p>
|
||||
</div>
|
||||
{#if !data.user}
|
||||
<div class="space-x-4">
|
||||
<a href="/login">
|
||||
<Button size="lg">지금 시작하기</Button>
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Features Section -->
|
||||
<section id="features" class="w-full py-12 md:py-24 lg:py-32 bg-muted">
|
||||
<div class="container px-4 md:px-6">
|
||||
<div
|
||||
class="grid items-center gap-6 lg:grid-cols-3 lg:gap-12 xl:grid-cols-3 xl:gap-12"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center space-y-4 text-center">
|
||||
<div class="mb-4 rounded-full bg-primary p-4 text-primary-foreground">
|
||||
<MessageSquare class="h-8 w-8" />
|
||||
</div>
|
||||
<h3 class="text-xl font-bold">다양한 주제의 Q&A</h3>
|
||||
<p class="text-muted-foreground">
|
||||
기술, 과학, 예술, 일상 등 경계 없는 주제로 질문하고 답변에 참여할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center space-y-4 text-center">
|
||||
<div class="mb-4 rounded-full bg-primary p-4 text-primary-foreground">
|
||||
<BrainCircuit class="h-8 w-8" />
|
||||
</div>
|
||||
<h3 class="text-xl font-bold">인공지능 기반 답변</h3>
|
||||
<p class="text-muted-foreground">
|
||||
Gemini AI가 제공하는 정확하고 깊이 있는 답변으로 궁금증을 해결해 보세요.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center space-y-4 text-center">
|
||||
<div class="mb-4 rounded-full bg-primary p-4 text-primary-foreground">
|
||||
<Users class="h-8 w-8" />
|
||||
</div>
|
||||
<h3 class="text-xl font-bold">사용자 피드백 시스템</h3>
|
||||
<p class="text-muted-foreground">
|
||||
답변에 대한 피드백을 통해 AI를 학습시키고 더 나은 결과물을 함께 만들어갑니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Logged In User Info Section -->
|
||||
{#if data.user}
|
||||
<section class="w-full py-12 md:py-24">
|
||||
<div class="container px-4 md:px-6">
|
||||
<h2 class="text-3xl font-bold text-center mb-8">환영합니다, {data.user.name}!</h2>
|
||||
<p class="text-center text-muted-foreground mb-8">
|
||||
이제 질문을 등록하고 피드백을 남길 수 있습니다.
|
||||
</p>
|
||||
<div
|
||||
class="mx-auto max-w-2xl rounded-lg border bg-card text-card-foreground shadow-sm"
|
||||
>
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-semibold mb-2">내 세션 정보</h3>
|
||||
<pre
|
||||
class="mt-2 p-4 bg-muted rounded-md text-left text-sm overflow-x-auto"
|
||||
><code>{JSON.stringify(data.user, null, 2)}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
29
frontend/src/routes/login/+page.server.ts
Normal file
29
frontend/src/routes/login/+page.server.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// src/routes/login/+page.server.ts
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import {
|
||||
OIDC_CLIENT_ID,
|
||||
OIDC_ISSUER_URL,
|
||||
OIDC_REDIRECT_URI
|
||||
} from '$env/static/private';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
if (locals.session) {
|
||||
// Already logged in, redirect to home
|
||||
throw redirect(302, '/');
|
||||
}
|
||||
|
||||
// If not logged in, construct the OIDC authorization URL
|
||||
const authUrl = new URL(OIDC_ISSUER_URL + '/protocol/openid-connect/auth');
|
||||
authUrl.searchParams.set('client_id', OIDC_CLIENT_ID);
|
||||
authUrl.searchParams.set('redirect_uri', OIDC_REDIRECT_URI);
|
||||
authUrl.searchParams.set('response_type', 'code');
|
||||
authUrl.searchParams.set('scope', 'openid profile email');
|
||||
|
||||
// Generate and store state and code verifier for PKCE
|
||||
// NOTE: In a real app, you should generate and store these securely.
|
||||
// For simplicity, we are omitting state and PKCE for now, but they are
|
||||
// highly recommended for production to prevent CSRF and auth code injection attacks.
|
||||
|
||||
throw redirect(302, authUrl.toString());
|
||||
};
|
||||
88
frontend/src/routes/login/callback/+page.server.ts
Normal file
88
frontend/src/routes/login/callback/+page.server.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
// src/routes/login/callback/+page.server.ts
|
||||
import { lucia, setDummyUser } from '$lib/server/auth';
|
||||
import { OAuth2RequestError } from 'arctic';
|
||||
import { generateId } from 'lucia';
|
||||
import {
|
||||
OIDC_CLIENT_ID,
|
||||
OIDC_CLIENT_SECRET,
|
||||
OIDC_ISSUER_URL,
|
||||
OIDC_REDIRECT_URI
|
||||
} from '$env/static/private';
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
interface OIDCUser {
|
||||
sub: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ url, cookies, locals }) => {
|
||||
if (locals.session) {
|
||||
throw redirect(302, '/');
|
||||
}
|
||||
|
||||
const code = url.searchParams.get('code');
|
||||
if (!code) {
|
||||
throw error(400, 'No code provided');
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenUrl = new URL(OIDC_ISSUER_URL + '/protocol/openid-connect/token');
|
||||
const tokenResponse = await fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: code,
|
||||
redirect_uri: OIDC_REDIRECT_URI,
|
||||
client_id: OIDC_CLIENT_ID,
|
||||
client_secret: OIDC_CLIENT_SECRET
|
||||
})
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorBody = await tokenResponse.text();
|
||||
console.error('Failed to exchange token:', errorBody);
|
||||
throw new Error('Failed to exchange token');
|
||||
}
|
||||
|
||||
const tokens = await tokenResponse.json();
|
||||
const userInfoUrl = new URL(OIDC_ISSUER_URL + '/protocol/openid-connect/userinfo');
|
||||
const userResponse = await fetch(userInfoUrl, {
|
||||
headers: { Authorization: `Bearer ${tokens.access_token}` }
|
||||
});
|
||||
|
||||
if (!userResponse.ok) {
|
||||
const errorBody = await userResponse.text();
|
||||
console.error('Failed to fetch user info:', errorBody);
|
||||
throw new Error('Failed to fetch user info');
|
||||
}
|
||||
|
||||
const user: OIDCUser = await userResponse.json();
|
||||
const userId = generateId(15);
|
||||
|
||||
// Use the dummy user function
|
||||
setDummyUser({
|
||||
id: userId,
|
||||
sub: user.sub,
|
||||
name: user.name,
|
||||
email: user.email
|
||||
});
|
||||
|
||||
const session = await lucia.createSession(userId, {});
|
||||
const sessionCookie = lucia.createSessionCookie(session.id);
|
||||
cookies.set(sessionCookie.name, sessionCookie.value, {
|
||||
path: '.',
|
||||
...sessionCookie.attributes
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (e instanceof OAuth2RequestError) {
|
||||
throw error(400, 'Authentication failed');
|
||||
}
|
||||
throw error(500, 'An unknown error occurred');
|
||||
}
|
||||
|
||||
throw redirect(302, '/');
|
||||
};
|
||||
20
frontend/src/routes/logout/+page.server.ts
Normal file
20
frontend/src/routes/logout/+page.server.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// src/routes/logout/+page.server.ts
|
||||
import { lucia } from '$lib/server/auth';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
if (!event.locals.session) {
|
||||
throw redirect(302, '/');
|
||||
}
|
||||
|
||||
await lucia.invalidateSession(event.locals.session.id);
|
||||
const sessionCookie = lucia.createBlankSessionCookie();
|
||||
event.cookies.set(sessionCookie.name, sessionCookie.value, {
|
||||
path: '.',
|
||||
...sessionCookie.attributes
|
||||
});
|
||||
|
||||
// Redirect to the login page
|
||||
throw redirect(302, '/login');
|
||||
};
|
||||
19
frontend/svelte.config.js
Normal file
19
frontend/svelte.config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
import adapter from '@sveltejs/adapter-cloudflare';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
// See https://kit.svelte.dev/docs/adapter-cloudflare
|
||||
// for more information about creating a Workers site
|
||||
routes: {
|
||||
include: ['/*'],
|
||||
exclude: ['<all>']
|
||||
}
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
63
frontend/tailwind.config.js
Normal file
63
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,63 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
const config = {
|
||||
darkMode: ["class"],
|
||||
content: ["./src/**/*.{html,js,svelte,ts}"],
|
||||
safelist: ["dark"],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px"
|
||||
}
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border) / <alpha-value>)",
|
||||
input: "hsl(var(--input) / <alpha-value>)",
|
||||
ring: "hsl(var(--ring) / <alpha-value>)",
|
||||
background: "hsl(var(--background) / <alpha-value>)",
|
||||
foreground: "hsl(var(--foreground) / <alpha-value>)",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary) / <alpha-value>)",
|
||||
foreground: "hsl(var(--primary-foreground) / <alpha-value>)"
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary) / <alpha-value>)",
|
||||
foreground: "hsl(var(--secondary-foreground) / <alpha-value>)"
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive) / <alpha-value>)",
|
||||
foreground: "hsl(var(--destructive-foreground) / <alpha-value>)"
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted) / <alpha-value>)",
|
||||
foreground: "hsl(var(--muted-foreground) / <alpha-value>)"
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent) / <alpha-value>)",
|
||||
foreground: "hsl(var(--accent-foreground) / <alpha-value>)"
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover) / <alpha-value>)",
|
||||
foreground: "hsl(var(--popover-foreground) / <alpha-value>)"
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card) / <alpha-value>)",
|
||||
foreground: "hsl(var(--card-foreground) / <alpha-value>)"
|
||||
}
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)"
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["Inter", "sans-serif"]
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
};
|
||||
|
||||
export default config;
|
||||
13
frontend/tsconfig.json
Normal file
13
frontend/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
12
frontend/vite.config.ts
Normal file
12
frontend/vite.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { sveltekit } from "@sveltejs/kit/vite";
|
||||
import { defineConfig } from "vite";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
resolve: {
|
||||
alias: {
|
||||
$lib: path.resolve("./src/lib")
|
||||
}
|
||||
}
|
||||
});
|
||||
20
gemini_excute_log
Normal file
20
gemini_excute_log
Normal file
@@ -0,0 +1,20 @@
|
||||
이 프로젝트는 사용자의 Q&A 피드백을 수집하기 위한 사이트 프로젝트야. 이번 시도에서는 SvelteKit를 이용해 보고 싶어. 사용가능한 npm package들을 잘 │
|
||||
│ 선별해 주면 좋겠어. 백엔드와 프론트엔드를 별도 인프라에 따로 배포할거고, 백엔드에 해당하는 데이터베이스는 API 연동을 전제로 구성해줘. 직접 가입 │
|
||||
│ 없이, OIDC 사용자 인증만 제공할거야. 인증된 사용자는 Q&A를 작성할 수 있게 되는데, 작성시 피드백 생성 API를 post 방식으로 호출해. 스키마가 다양할 수 │
|
||||
│ 있기 때문에, 별도 첨부 파일을 만들고 다양한 채널을 추가해 확장할 수 있는 구조로 만들어 줘. 기본적인 인증은 OIDC 스펙에 맞추어 제공되면 돼. 빌드 전 │
|
||||
│ 설정으로 OIDC 클라이언트용 정보인 ID, 비밀키, 이슈 URL, authorization url 등을 설정할 수 있게 해줘. Q&A 작성시 Descope Login ID 기반으로 다시 해당 │
|
||||
│ 사용자의 정보를 호출해서 첨부하는 기능도 있어야해. 위 내용들 바탕으로 일단 실행 계획을 @GEMINI.md 에 작성하고, dosc 디렉토리를 만들어 전반적인 │
|
||||
│ 프로젝트의 PRD 문서를 생성해줘. 읽어보고 다시 지시할게.
|
||||
|
||||
|
||||
백엔드의 경우 Feedback DB를 이용하려면 /api/projects/{projectId}/channels/{channelId}/feedbacks 를 호출해 feedback 생성, │
|
||||
│ /api/projects/{projectId}/channels/{channelId}/feedbacks 를 호출해 피드백 리스트를 조회하고 │
|
||||
│ /api/v2/projects/{projectId}/channels/{channelId}/feedbacks/search 를 이용해 검색도 가능하게 할거야. svelte 의 백엔드와 별개로 전재하는 시스템이고, │
|
||||
│ header에 x-api-key 을 넣고, 인증해야 하니 이런 부분도 변수 처리해줘. 문서 업데이트 하고 일단 코드로 만들어보자.
|
||||
|
||||
배포는 Cloudflare에 할테니 스펙이 맞는지 체크해줘. 또 가능한 모든 npm 관련 진행은 pnpm으로 할 수 있도록 하자. 이 내용도 문서로 남겨줘.
|
||||
그리고 shadcn 적용한다고 한건 진행 되었는지 체크하고.
|
||||
|
||||
다음 작업 진행하기 전에 빌드하고 로컬에서 잘 돌아가는지부터 확인해 보자.
|
||||
|
||||
테스트페이지는 내가 확인했어. 이제 OIDC 진행하자. 진행하기 전에 지금까지 한 일들을 @GEMINI.md 에 요약해서 업데이트 해줘.
|
||||
Reference in New Issue
Block a user