svelt kit test
Some checks failed
Deploy to Production / Deploy (push) Failing after 4s

This commit is contained in:
Lectom C Han
2025-07-25 14:43:32 +09:00
commit dd89e5fc90
31 changed files with 6008 additions and 0 deletions

View 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
View 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
View 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 인증 흐름이 올바르게 동작하는지 확인합니다.

0
README.md Normal file
View File

13
docker-compose.yml Normal file
View 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

View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

59
frontend/src/app.css Normal file
View 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
View 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
View 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>

View 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);
};

View 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>

View 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,
};

View 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
}
});
};

View 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));
}

View 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
};
};

View 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>

View 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>

View 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());
};

View 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, '/');
};

View 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
View 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;

View 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
View 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
View 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
View 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 에 요약해서 업데이트 해줘.