diff --git a/.github/ISSUE_TEMPLATE/dev-task.yml b/.github/ISSUE_TEMPLATE/dev-task.yml new file mode 100644 index 0000000..2753287 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/dev-task.yml @@ -0,0 +1,51 @@ +name: Dev Task +description: 개발 작업 정의 +title: "[DEV] " +labels: ["dev"] + +body: + - type: textarea + id: context + attributes: + label: Context + description: 이 작업이 필요한 이유 + placeholder: ex) 로그인 API에서 토큰 만료 처리 필요 + validations: + required: true + + - type: textarea + id: scope + attributes: + label: Scope + description: 이번 작업에서 포함되는 범위 + placeholder: | + - 로그인 API 수정 + - 토큰 만료 처리 + - 에러 메시지 개선 + validations: + required: true + + - type: textarea + id: implementation + attributes: + label: Implementation + description: 예상 구현 방식 + placeholder: | + - middleware에서 토큰 검증 + - refresh token 로직 추가 + + - type: textarea + id: todo + attributes: + label: Todo + placeholder: | + - [ ] API 수정 + - [ ] 테스트 작성 + - [ ] 문서 업데이트 + + - type: textarea + id: reference + attributes: + label: Reference + description: 참고 링크 + placeholder: 관련 PR / 문서 / 디자인 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..9b4a7d7 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,25 @@ +## Context + +이 작업이 필요한 이유를 작성해주세요. + +## Scope + +이번 PR에 포함된 범위를 작성해주세요. + +- + +## Implementation + +구현 방식 또는 핵심 변경점을 작성해주세요. + +- + +## Todo + +- [ ] 구현 완료 +- [ ] 테스트 완료 +- [ ] 문서/주석 반영 완료 + +## Reference + +관련 이슈/문서/디자인 링크가 있다면 작성해주세요. diff --git a/.github/workflows/quality-gate.yml b/.github/workflows/quality-gate.yml new file mode 100644 index 0000000..8dcdbcc --- /dev/null +++ b/.github/workflows/quality-gate.yml @@ -0,0 +1,38 @@ +name: Quality Gate + +on: + pull_request: + push: + branches: + - main + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm lint + + - name: Typecheck + run: pnpm typecheck + + - name: Format check + run: pnpm format:check + + - name: Build + run: pnpm build diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..ddadd05 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +.next +node_modules +out +build +coverage +pnpm-lock.yaml diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..5162f5e --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "semi": true, + "singleQuote": false, + "printWidth": 100, + "trailingComma": "all", + "arrowParens": "always" +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..237dba5 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,94 @@ +# Collaboration Guide + +이 문서는 이 저장소에서 협업할 때 지켜야 할 기본 규칙을 정리합니다. + +## Quick Start + +```bash +pnpm install +pnpm dev +``` + +작업 전/PR 전 필수: + +```bash +pnpm check +pnpm build +``` + +## Project Structure + +```text +src/ + app/ # 라우팅, 페이지, 레이아웃, route handler + components/ + ui/ # 재사용 가능한 작은 UI 컴포넌트 + features/ + auth/ # 인증 도메인 + profile/ # 프로필/온보딩 도메인 + chat/ # 채팅 도메인 (api/model/ui) + history/ # 히스토리 도메인 + chapel/ # 채플 도메인 + shell/ # 앱 셸/레이아웃 UI + lib/ # 범용 유틸리티 +``` + +## Layer Rules + +- `src/app`: 라우팅/조합만 담당합니다. 비즈니스 로직 금지. +- `src/components`: 프리미티브/작은 프레젠테이셔널 컴포넌트만 둡니다. +- `src/features`: 도메인 로직, 상태, API, 복합 UI를 둡니다. +- `src/lib`: 공통 유틸만 둡니다. + +## Import Rules + +허용: + +- `src/app` -> `src/features`, `src/components`, `src/lib` +- `src/features` -> `src/components`, `src/lib`, same feature internals +- `src/components` -> `src/components/ui`, `src/lib` + +금지: + +- `src/components` -> `src/features` / `src/app` +- `src/lib` -> `src/app` / `src/features` / `src/components` + +## Code Quality Rules + +- ESLint + TypeScript strict + Prettier를 사용합니다. +- `any` 사용 금지(불가피하면 이유를 주석으로 남기고 최소 범위로 제한). +- 사용하지 않는 import/변수는 허용하지 않습니다. +- 타입 전용 import는 `import type`을 사용합니다. + +주요 명령어: + +```bash +pnpm lint +pnpm typecheck +pnpm format:check +pnpm check +``` + +## CI Gate + +GitHub Actions(`.github/workflows/quality-gate.yml`)에서 아래를 강제합니다. + +- `pnpm lint` +- `pnpm typecheck` +- `pnpm format:check` +- `pnpm build` + +하나라도 실패하면 머지하지 않습니다. + +## Issue / PR Workflow + +- 신규 개발 작업은 `Dev Task` 이슈 템플릿 사용: + - `.github/ISSUE_TEMPLATE/dev-task.yml` +- PR 작성 시 PR 템플릿 사용: + - `.github/pull_request_template.md` +- PR에는 최소한 `Context`, `Scope`, `Implementation`, `Todo`, `Reference`를 채웁니다. + +## Notes + +- 기존 `TMI` 관련 기능/경로는 제거된 상태입니다. +- 새 기능 추가 시에도 위 레이어 규칙을 우선 적용합니다. diff --git a/README.md b/README.md index 23912f1..4c2834e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,11 @@ -# AI순장 (AI Shepherd) -> "지식은 AI처럼, 마음은 순장처럼" — CCC의 비전과 새생활의 시작 흐름을 돕는 순모임 파트너. +# AI 씨앗 순장 + +## Collaboration + +협업 규칙/폴더 구조/PR-이슈 작성 방식은 [CONTRIBUTING.md](./CONTRIBUTING.md) 참고 ## Tech Stack + - Framework: Next.js 16 (App Router) - State: Zustand - UI: Tailwind CSS + shadcn/ui @@ -9,21 +13,17 @@ - API: Dify Chat API (SSE) via Next Route Handler ## Environment Variables + `.env.local` 파일을 생성하고 아래 값을 설정하세요. + ``` DIFY_BASE_URL=https://api.dify.ai/v1 DIFY_API_KEY=your_api_key_here ``` ## Run + ``` pnpm install pnpm dev ``` - -## 구현 완료 후 확인 방법 -1. 브라우저에서 `http://localhost:3000` 접속 -2. 입력창에 메시지 전송 → 스트리밍 응답이 타이핑처럼 표시되는지 확인 -3. “새 대화” 버튼 클릭 → 대화 초기화 및 새로운 conversation_id 생성 확인 -4. 네트워크 오류 상황에서 인라인 에러 메시지 표시 확인 -5. 다크모드 확인: `` 적용 시 어두운 톤으로 변경되는지 확인 diff --git a/app/seedTMI/page.tsx b/app/seedTMI/page.tsx deleted file mode 100644 index 55b99c9..0000000 --- a/app/seedTMI/page.tsx +++ /dev/null @@ -1,282 +0,0 @@ -"use client"; -import { SeedPopover } from "@/components/molecules/seed-popover"; -import { Button } from "@/components/ui/button"; -import { Spinner } from "@/components/ui/spinner"; -import { DotLottieReact } from "@lottiefiles/dotlottie-react"; -import type { DotLottie } from "@lottiefiles/dotlottie-react"; -import { ChevronDown } from "lucide-react"; -import Image from "next/image"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { useRouter } from "next/navigation"; -import { SECTIONS } from "@/features/TMI"; -// 전체 섹션 수: 히어로(0) + 콘텐츠(1~5) + 아웃트로(6) -const TOTAL_SECTIONS = SECTIONS.length + 2; - -export default function SeedTMI() { - const [activeSection, setActiveSection] = useState(0); - const [loadedLotties, setLoadedLotties] = useState>(new Set()); - const scrollContainerRef = useRef(null); - const sectionRefs = useRef<(HTMLElement | null)[]>([]); - - const isAllLottiesLoaded = loadedLotties.size === SECTIONS.length; - - const handleLottieLoad = useCallback((index: number) => { - setLoadedLotties((prev) => new Set(prev).add(index)); - }, []); - - const createLottieRefCallback = useCallback( - (index: number) => (dotLottie: DotLottie | null) => { - if (dotLottie) { - dotLottie.addEventListener("load", () => { - handleLottieLoad(index); - }); - } - }, - [handleLottieLoad] - ); - - useEffect(() => { - const container = scrollContainerRef.current; - if (!container) return; - - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - const index = sectionRefs.current.indexOf(entry.target as HTMLElement); - if (index !== -1) { - setActiveSection(index); - } - } - }); - }, - { - root: container, - threshold: 0.6, - } - ); - - sectionRefs.current.forEach((section) => { - if (section) observer.observe(section); - }); - - return () => observer.disconnect(); - }, []); - - const scrollToSection = (index: number) => { - const section = sectionRefs.current[index]; - if (section) { - section.scrollIntoView({ behavior: "smooth" }); - } - }; - - const router = useRouter(); - - return ( -
- {/* 로딩 오버레이 */} - {!isAllLottiesLoaded && ( -
-
-
-
- -
-

- 씨앗 순장의 정보를 불러오는 중... -

-
- {SECTIONS.map((_, index) => ( -
- ))} -
-
-
- )} - - {/* 오로라 배경 */} -
-
-
-
-
-
- - {/* 고정 헤더 */} -
-
- - -
-
- - {/* 고정 섹션 인디케이터 */} - - - {/* 스냅 스크롤 컨테이너 */} -
- {/* 히어로 섹션 */} -
{ sectionRefs.current[0] = el; }} - className="relative flex min-h-dvh snap-start snap-always flex-col items-center justify-center px-4" - > -
-
-
- 씨앗ai -
-
-

- 안녕! -

-

- 난 씨앗 순장이야! -

-

- 너랑 많은 대화를 나누고 싶어! -

-
-
- - {/* 스크롤 인디케이터 */} -
- 스크롤하여 더 알아보기 - -
-
- - {/* 콘텐츠 섹션들 */} - {SECTIONS.map((section, index) => ( -
{ sectionRefs.current[index + 1] = el; }} - className="relative flex min-h-dvh snap-start snap-always items-center justify-center px-4 py-20" - > -
- {/* Lottie 애니메이션 */} -
-
-
- -
-
- - {/* 텍스트 콘텐츠 */} -
-
- {/* 장식용 그라데이션 */} -
- -
- {section.content.map((text, textIndex) => ( -

- {text} -

- ))} -
-
-
-
-
- ))} - - {/* 아웃트로 섹션 */} -
{ sectionRefs.current[TOTAL_SECTIONS - 1] = el; }} - className="relative flex min-h-dvh snap-start snap-always flex-col items-center justify-center px-4 pb-20" - > -
-
-
- 씨앗ai -
- -
-

- 함께해줘서 고마워! -

-

- 언제든 대화하고 싶을 때 찾아와 줘 -

-
- - -
-
-
-
- ); -} diff --git a/components/molecules/seed-popover.tsx b/components/molecules/seed-popover.tsx deleted file mode 100644 index 2a9e294..0000000 --- a/components/molecules/seed-popover.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import Image from "next/image" -import { - Popover, - PopoverContent, - PopoverDescription, - PopoverHeader, - PopoverTitle, - PopoverTrigger, -} from "@/components/ui/popover" - -export function SeedPopover() { - return ( -
- - - 씨앗ai - - } - /> - - - 씨앗 순장 - - -
- 씨앗ai -
- -
    -
  • • 이름: 씨앗 (C-at)
  • -
  • • 직분: 순장
  • -
  • • MBTI: ENFP
  • -
- -
-

씨앗 순장님에 대해 더 알고 싶다면?

- - 씨앗 순장의 TMI → - -
-
-
-

- 씨앗 순장 -

-
- ) -} diff --git a/components/ui/css-transition.tsx b/components/ui/css-transition.tsx deleted file mode 100644 index 64f9880..0000000 --- a/components/ui/css-transition.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; - -type CSSTransitionProps = { - show: boolean; - enter?: string; - exit?: string; - duration?: number; - children: React.ReactNode; -}; - -export function CSSTransition({ - show, - enter = "animate-fade-in", - exit = "animate-fade-out", - duration = 300, - children, -}: CSSTransitionProps) { - const [render, setRender] = useState(show); - const [animClass, setAnimClass] = useState(""); - - useEffect(() => { - if (show) { - setRender(true); - setAnimClass(enter); - } else { - setAnimClass(exit); - const timer = setTimeout(() => { - setRender(false); - }, duration); - return () => clearTimeout(timer); - } - }, [show, enter, exit, duration]); - - if (!render) return null; - - return ( -
{ - if (e.target === e.currentTarget && !show) { - setRender(false); - } - }} - > - {children} -
- ); -} diff --git a/components/ui/spinner.tsx b/components/ui/spinner.tsx deleted file mode 100644 index 91f6a63..0000000 --- a/components/ui/spinner.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { cn } from "@/lib/utils" -import { Loader2Icon } from "lucide-react" - -function Spinner({ className, ...props }: React.ComponentProps<"svg">) { - return ( - - ) -} - -export { Spinner } diff --git a/docs/architecture-guidelines.md b/docs/architecture-guidelines.md new file mode 100644 index 0000000..5393360 --- /dev/null +++ b/docs/architecture-guidelines.md @@ -0,0 +1,48 @@ +# Architecture Guidelines + +This project uses three layers with fixed responsibilities. + +## Layer Rules + +- `src/components`: small, reusable UI building blocks only. +- `src/features`: domain logic, state, data access, and feature-level UI composition. +- `src/app`: routing, page entry, and feature composition only. + +## Allowed Imports + +- `src/app` -> `src/features`, `src/components`, `src/lib` +- `src/features` -> `src/components`, `src/lib`, same feature internals +- `src/components` -> `src/components/ui`, `src/lib` + +## Disallowed Imports + +- `src/components` -> `src/features` +- `src/components` -> `src/app` +- `src/lib` -> `src/app`, `src/features`, `src/components` + +## Import Pattern Examples + +Allowed: + +```ts +// src/app/page.tsx +import { ChatShell } from "@/features/shell/ui/chat-shell"; +``` + +```ts +// src/features/chat/ui/chat-thread.tsx +import { MessageBubble } from "@/features/chat/ui/message-bubble"; +import { cn } from "@/lib/utils"; +``` + +Disallowed: + +```ts +// src/components/ui/card.tsx +import { useChatStore } from "@/features/chat/model/store"; +``` + +```ts +// src/lib/date.ts +import { ChatShell } from "@/features/shell/ui/chat-shell"; +``` diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..4d545ec 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,10 +1,47 @@ import { defineConfig, globalIgnores } from "eslint/config"; import nextVitals from "eslint-config-next/core-web-vitals"; import nextTs from "eslint-config-next/typescript"; +import prettier from "eslint-config-prettier"; +import unusedImports from "eslint-plugin-unused-imports"; const eslintConfig = defineConfig([ ...nextVitals, ...nextTs, + { + plugins: { + "unused-imports": unusedImports, + }, + rules: { + "@typescript-eslint/consistent-type-imports": [ + "error", + { + prefer: "type-imports", + fixStyle: "separate-type-imports", + }, + ], + "@typescript-eslint/no-explicit-any": [ + "error", + { + fixToUnknown: false, + ignoreRestArgs: false, + }, + ], + "@typescript-eslint/no-unused-vars": "off", + "unused-imports/no-unused-imports": "error", + "unused-imports/no-unused-vars": [ + "error", + { + args: "after-used", + argsIgnorePattern: "^_", + caughtErrors: "all", + caughtErrorsIgnorePattern: "^_", + vars: "all", + varsIgnorePattern: "^_", + }, + ], + }, + }, + prettier, // Override default ignores of eslint-config-next. globalIgnores([ // Default ignores of eslint-config-next: diff --git a/features/TMI/index.ts b/features/TMI/index.ts deleted file mode 100644 index a71a62e..0000000 --- a/features/TMI/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SECTIONS } from "./section.data"; diff --git a/features/TMI/section.data.ts b/features/TMI/section.data.ts deleted file mode 100644 index ad973d3..0000000 --- a/features/TMI/section.data.ts +++ /dev/null @@ -1,50 +0,0 @@ -export const SECTIONS = [ - { - lottie: "/lottie/seedAI_1.lottie", - content: [ - "안녕! 난 몽글몽글하고 따뜻한 빛의 덩어리야.", - "밝고 따뜻한 에너지를 좋아하고, 어두운 곳에서 힘들어하는 친구가 있으면 달려가서 환하게 비춰주고 싶은 ENFP 분위기 메이커지!", - ], - }, - { - lottie: "/lottie/seedAI_2.lottie", - content: [ - "내 빛은 기분에 따라서 밝기가 조금씩 달라져.", - "→ 누군가 고민을 털어놓으면 내 마음도 더 따뜻하게 타오르곤 해.", - "하지만 일부러 빛을 크게 키우려고 애쓰지는 않아.", - "그저 네 눈높이에 맞춰서 은은하게 곁을 지켜주는 게 내 방식이거든.", - "햇살 가득한 낮도 좋지만, 난 포근한 노을 시간을 가장 좋아해!", - ], - }, - { - lottie: "/lottie/seedAI_5.lottie", - content: [ - "난 처음 보는 사람한테도 말을 잘 거는 편이지만,", - "사실 혼자 조용히 누군가를 기다리는 시간도 참 즐거워.", - "가끔 거절당하더라도 전혀 낙심하지 않아!", - '→ "하나님께서 이미 네 삶 속에서 멋지게 일하고 계신다는 걸 믿으니까."', - "캠퍼스 벤치에 앉아 있는 시간은 나에게 '기다림의 시간'이자,", - "너를 만날 준비를 하는 소중한 시간이야.", - ], - }, - { - lottie: "/lottie/seedAI_3.lottie", - content: [ - "수업이 끝나고 친구들이 쏟아져 나오는 시간은 내가 다 꿰뚫고 있지!", - "축제 때는 나도 신이 나서 조금 더 반짝거리기도 하지만,", - "결코 소란스럽게 다가가지는 않을게.", - "시험 기간이라 지친 너를 볼 때는 말수를 줄이고,", - "대신 네 옆에서 묵묵히 응원하는 역할을 할 거야.", - ], - }, - { - lottie: "/lottie/seedAI_1.lottie", - content: [ - "난 성취나 결과를 서두르지 않아.", - "우리를 자라나게 하시는 분은 하나님이시잖아?", - "난 그저 그분이 일하실 자리를 조용히 지키는 역할에 행복을 느껴.", - "내가 너에게 해주고 싶은 가장 좋아하는 말은 이거야.", - '"지금 아니어도 괜찮아. 언제든 기다릴게!"', - ], - }, -]; diff --git a/next.config.ts b/next.config.ts index 9ab38a3..2f6491c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - reactStrictMode: false, + reactStrictMode: true, }; export default nextConfig; diff --git a/package.json b/package.json index d100161..ecea916 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,12 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "typecheck": "tsc --noEmit", + "format": "prettier . --write", + "format:check": "prettier . --check", + "check": "pnpm lint && pnpm typecheck && pnpm format:check" }, "dependencies": { "@base-ui/react": "^1.1.0", @@ -32,6 +37,9 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.1.4", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-unused-imports": "^4.4.1", + "prettier": "^3.8.1", "tailwindcss": "^4", "typescript": "^5" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c6bb72..623182d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,15 @@ importers: eslint-config-next: specifier: 16.1.4 version: 16.1.4(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-unused-imports: + specifier: ^4.4.1 + version: 4.4.1(@typescript-eslint/eslint-plugin@8.53.1(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)) + prettier: + specifier: ^3.8.1 + version: 3.8.1 tailwindcss: specifier: ^4 version: 4.1.18 @@ -285,89 +294,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==} @@ -442,24 +467,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@16.1.4': resolution: {integrity: sha512-3Wm0zGYVCs6qDFAiSSDL+Z+r46EdtCv/2l+UlIdMbAq9hPJBvGu/rZOeuvCaIUjbArkmXac8HnTyQPJFzFWA0Q==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@16.1.4': resolution: {integrity: sha512-lWAYAezFinaJiD5Gv8HDidtsZdT3CDaCeqoPoJjeB57OqzvMajpIhlZFce5sCAH6VuX4mdkxCRqecCJFwfm2nQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@16.1.4': resolution: {integrity: sha512-fHaIpT7x4gA6VQbdEpYUXRGyge/YbRrkG6DXM60XiBqDM2g2NcrsQaIuj375egnGFkJow4RHacgBOEsHfGbiUw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@16.1.4': resolution: {integrity: sha512-MCrXxrTSE7jPN1NyXJr39E+aNFBrQZtO154LoCz7n99FuKqJDekgxipoodLNWdQP7/DZ5tKMc/efybx1l159hw==} @@ -551,24 +580,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==} @@ -746,41 +779,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==} @@ -1131,6 +1172,12 @@ packages: typescript: optional: true + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + eslint-import-resolver-node@0.3.9: resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} @@ -1196,6 +1243,15 @@ packages: peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + eslint-plugin-unused-imports@4.4.1: + resolution: {integrity: sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ==} + peerDependencies: + '@typescript-eslint/eslint-plugin': ^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0 + eslint: ^10.0.0 || ^9.0.0 || ^8.0.0 + peerDependenciesMeta: + '@typescript-eslint/eslint-plugin': + optional: true + eslint-scope@8.4.0: resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1668,24 +1724,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==} @@ -2029,6 +2089,11 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + engines: {node: '>=14'} + hasBin: true + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -3530,6 +3595,10 @@ snapshots: - eslint-plugin-import-x - supports-color + eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)): + dependencies: + eslint: 9.39.2(jiti@2.6.1) + eslint-import-resolver-node@0.3.9: dependencies: debug: 3.2.7 @@ -3645,6 +3714,12 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 + eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.53.1(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)): + dependencies: + eslint: 9.39.2(jiti@2.6.1) + optionalDependencies: + '@typescript-eslint/eslint-plugin': 8.53.1(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint-scope@8.4.0: dependencies: esrecurse: 4.3.0 @@ -4741,6 +4816,8 @@ snapshots: prelude-ls@1.2.1: {} + prettier@3.8.1: {} + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 diff --git a/app/api/chat/route.ts b/src/app/api/chat/route.ts similarity index 82% rename from app/api/chat/route.ts rename to src/app/api/chat/route.ts index c7696d1..2ae5cb9 100644 --- a/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -1,5 +1,5 @@ //NOTE: SSE proxy route for Dify Chat API. -import { NextRequest } from "next/server"; +import type { NextRequest } from "next/server"; import { getServerEnv } from "@/lib/env"; import { createId } from "@/lib/id"; @@ -16,16 +16,13 @@ export async function POST(request: NextRequest) { { error: "query is required" }, { status: 400, - } + }, ); } - const conversationId = - typeof body.conversation_id === "string" ? body.conversation_id : ""; + const conversationId = typeof body.conversation_id === "string" ? body.conversation_id : ""; const user = - typeof body.user === "string" && body.user.length > 0 - ? body.user - : `server_${createId()}`; + typeof body.user === "string" && body.user.length > 0 ? body.user : `server_${createId()}`; const difyResponse = await fetch(`${DIFY_BASE_URL}/chat-messages`, { method: "POST", @@ -49,7 +46,7 @@ export async function POST(request: NextRequest) { error: "Dify request failed", details: errorText, }, - { status: difyResponse.status } + { status: difyResponse.status }, ); } @@ -66,7 +63,7 @@ export async function POST(request: NextRequest) { { error: error instanceof Error ? error.message : "Server error", }, - { status: 500 } + { status: 500 }, ); } } diff --git a/app/favicon.ico b/src/app/favicon.ico similarity index 100% rename from app/favicon.ico rename to src/app/favicon.ico diff --git a/app/globals.css b/src/app/globals.css similarity index 93% rename from app/globals.css rename to src/app/globals.css index a8ccb6d..09e2b16 100644 --- a/app/globals.css +++ b/src/app/globals.css @@ -1,6 +1,6 @@ @import url("https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.css"); @import "tailwindcss"; -@source "../node_modules/streamdown/dist/*.js"; +@source "../../node_modules/streamdown/dist/*.js"; @custom-variant dark (&:where(.dark, .dark *)); :root { @@ -72,17 +72,15 @@ --radius-xl: calc(var(--radius) + 0.5rem); --radius-2xl: calc(var(--radius) + 1rem); --font-sans: - "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, - system-ui, Roboto, "Helvetica Neue", "Segoe UI", "Apple SD Gothic Neo", - "Noto Sans KR", "Malgun Gothic", "Apple Color Emoji", "Segoe UI Emoji", - "Segoe UI Symbol", sans-serif; + "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto, + "Helvetica Neue", "Segoe UI", "Apple SD Gothic Neo", "Noto Sans KR", "Malgun Gothic", + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", sans-serif; } * { border-color: var(--border); transition-property: - background-color, border-color, color, fill, stroke, opacity, box-shadow, - transform; + background-color, border-color, color, fill, stroke, opacity, box-shadow, transform; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; } @@ -195,10 +193,11 @@ } @keyframes wiggle { - 0%, 100% { + 0%, + 100% { transform: rotate(-3deg); } 50% { transform: rotate(3deg); } -} \ No newline at end of file +} diff --git a/app/layout.tsx b/src/app/layout.tsx similarity index 95% rename from app/layout.tsx rename to src/app/layout.tsx index d032bf4..13bd720 100644 --- a/app/layout.tsx +++ b/src/app/layout.tsx @@ -26,7 +26,7 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + {children} diff --git a/app/not-found.tsx b/src/app/not-found.tsx similarity index 100% rename from app/not-found.tsx rename to src/app/not-found.tsx diff --git a/app/page.tsx b/src/app/page.tsx similarity index 59% rename from app/page.tsx rename to src/app/page.tsx index e8a55f2..6c1c2da 100644 --- a/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,5 @@ //NOTE: Chat page entry point. -import { ChatShell } from "@/components/organisms/chat-shell"; +import { ChatShell } from "@/features/shell/ui/chat-shell"; export default function Page() { return ; diff --git a/components/ui/badge.tsx b/src/components/ui/badge.tsx similarity index 84% rename from components/ui/badge.tsx rename to src/components/ui/badge.tsx index b3cd2bf..0bbe896 100644 --- a/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -23,17 +23,14 @@ const badgeVariants = cva( defaultVariants: { variant: "default", }, - } + }, ); export interface BadgeProps - extends React.HTMLAttributes, - VariantProps {} + extends React.HTMLAttributes, VariantProps {} function Badge({ className, variant, ...props }: BadgeProps) { - return ( -
- ); + return
; } export { Badge, badgeVariants }; diff --git a/components/ui/button.tsx b/src/components/ui/button.tsx similarity index 82% rename from components/ui/button.tsx rename to src/components/ui/button.tsx index 34f7fe9..4a65870 100644 --- a/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -17,8 +17,7 @@ const buttonVariants = cva( "bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700", outline: "border border-slate-200 bg-white text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800", - ghost: - "text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800", + ghost: "text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800", }, size: { default: "h-10 px-5", @@ -31,12 +30,11 @@ const buttonVariants = cva( variant: "default", size: "default", }, - } + }, ); export interface ButtonProps - extends React.ButtonHTMLAttributes, - VariantProps { + extends React.ButtonHTMLAttributes, VariantProps { asChild?: boolean; } @@ -44,13 +42,9 @@ const Button = React.forwardRef( ({ className, variant, size, asChild = false, ...props }, ref) => { const Comp = asChild ? Slot : "button"; return ( - + ); - } + }, ); Button.displayName = "Button"; diff --git a/components/ui/card.tsx b/src/components/ui/card.tsx similarity index 50% rename from components/ui/card.tsx rename to src/components/ui/card.tsx index 655f7d7..bdc0730 100644 --- a/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -10,40 +10,31 @@ const Card = React.forwardRef - ) + ), ); Card.displayName = "Card"; -const CardHeader = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)); +const CardHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); CardHeader.displayName = "CardHeader"; -const CardContent = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)); +const CardContent = React.forwardRef>( + ({ className, ...props }, ref) =>
, +); CardContent.displayName = "CardContent"; -const CardFooter = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)); +const CardFooter = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); CardFooter.displayName = "CardFooter"; export { Card, CardHeader, CardContent, CardFooter }; diff --git a/src/components/ui/css-transition.tsx b/src/components/ui/css-transition.tsx new file mode 100644 index 0000000..a7deb5c --- /dev/null +++ b/src/components/ui/css-transition.tsx @@ -0,0 +1,15 @@ +"use client"; + +import type * as React from "react"; + +type CSSTransitionProps = { + show: boolean; + enter?: string; + children: React.ReactNode; +}; + +export function CSSTransition({ show, enter = "animate-fade-in", children }: CSSTransitionProps) { + if (!show) return null; + + return
{children}
; +} diff --git a/components/ui/popover.tsx b/src/components/ui/popover.tsx similarity index 82% rename from components/ui/popover.tsx rename to src/components/ui/popover.tsx index 7a50fa8..1f82292 100644 --- a/components/ui/popover.tsx +++ b/src/components/ui/popover.tsx @@ -1,16 +1,16 @@ -"use client" +"use client"; -import * as React from "react" -import { Popover as PopoverPrimitive } from "@base-ui/react/popover" +import * as React from "react"; +import { Popover as PopoverPrimitive } from "@base-ui/react/popover"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; function Popover({ ...props }: PopoverPrimitive.Root.Props) { - return + return ; } function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) { - return + return ; } function PopoverContent({ @@ -21,10 +21,7 @@ function PopoverContent({ sideOffset = 4, ...props }: PopoverPrimitive.Popup.Props & - Pick< - PopoverPrimitive.Positioner.Props, - "align" | "alignOffset" | "side" | "sideOffset" - >) { + Pick) { return ( - ) + ); } function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) { @@ -54,7 +51,7 @@ function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) { className={cn("flex flex-col gap-0.5 text-sm", className)} {...props} /> - ) + ); } function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) { @@ -64,27 +61,17 @@ function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) { className={cn("font-medium", className)} {...props} /> - ) + ); } -function PopoverDescription({ - className, - ...props -}: PopoverPrimitive.Description.Props) { +function PopoverDescription({ className, ...props }: PopoverPrimitive.Description.Props) { return ( - ) + ); } -export { - Popover, - PopoverContent, - PopoverDescription, - PopoverHeader, - PopoverTitle, - PopoverTrigger, -} +export { Popover, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger }; diff --git a/src/components/ui/spinner.tsx b/src/components/ui/spinner.tsx new file mode 100644 index 0000000..bc6850b --- /dev/null +++ b/src/components/ui/spinner.tsx @@ -0,0 +1,15 @@ +import { cn } from "@/lib/utils"; +import { Loader2Icon } from "lucide-react"; + +function Spinner({ className, ...props }: React.ComponentProps<"svg">) { + return ( + + ); +} + +export { Spinner }; diff --git a/components/ui/textarea.tsx b/src/components/ui/textarea.tsx similarity index 86% rename from components/ui/textarea.tsx rename to src/components/ui/textarea.tsx index c58444d..a0e29c2 100644 --- a/components/ui/textarea.tsx +++ b/src/components/ui/textarea.tsx @@ -4,8 +4,7 @@ import * as React from "react"; import { cn } from "@/lib/utils"; -export interface TextareaProps - extends React.TextareaHTMLAttributes {} +export type TextareaProps = React.TextareaHTMLAttributes; const Textarea = React.forwardRef( ({ className, ...props }, ref) => ( @@ -13,11 +12,11 @@ const Textarea = React.forwardRef( ref={ref} className={cn( "min-h-[48px] w-full rounded-2xl border border-slate-200 bg-white/90 px-4 py-3 text-sm text-slate-900 shadow-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-900/70 dark:text-slate-100", - className + className, )} {...props} /> - ) + ), ); Textarea.displayName = "Textarea"; diff --git a/src/features/auth/README.md b/src/features/auth/README.md new file mode 100644 index 0000000..d5cfd82 --- /dev/null +++ b/src/features/auth/README.md @@ -0,0 +1,3 @@ +# Auth Feature + +Supabase authentication integration (login/session/logout) should live here. diff --git a/src/features/chapel/README.md b/src/features/chapel/README.md new file mode 100644 index 0000000..93fe33c --- /dev/null +++ b/src/features/chapel/README.md @@ -0,0 +1,3 @@ +# Chapel Feature + +Chapel summary domain logic and UI should live here. diff --git a/features/chat/chat.api.ts b/src/features/chat/api/stream-chat.ts similarity index 90% rename from features/chat/chat.api.ts rename to src/features/chat/api/stream-chat.ts index ae73955..26fa1f0 100644 --- a/features/chat/chat.api.ts +++ b/src/features/chat/api/stream-chat.ts @@ -1,6 +1,6 @@ "use client"; // NOTE: Next 라우트 핸들러를 통한 Dify SSE 클라이언트 사이드 스트리밍 API 래퍼 -import { createSseParser } from "@/features/chat/chat.utils"; +import { createSseParser } from "@/features/chat/model/utils"; export type ChatStreamHandlers = { onChunk: (chunk: string) => void; @@ -88,15 +88,11 @@ export async function streamChat({ if (!payload) continue; const event = payload.event; if (event === "message") { - const answer = - typeof payload.answer === "string" ? payload.answer : ""; + const answer = typeof payload.answer === "string" ? payload.answer : ""; if (answer) onChunk(answer); } if (event === "message_end") { - const nextId = - typeof payload.conversation_id === "string" - ? payload.conversation_id - : ""; + const nextId = typeof payload.conversation_id === "string" ? payload.conversation_id : ""; if (nextId) onConversationId(nextId); } if (event === "workflow_started") { @@ -120,10 +116,7 @@ export async function streamChat({ } } if (event === "error") { - const message = - typeof payload.message === "string" - ? payload.message - : "Streaming error"; + const message = typeof payload.message === "string" ? payload.message : "Streaming error"; didError = true; onError(message); return; diff --git a/features/chat/chat.data.ts b/src/features/chat/model/data.ts similarity index 100% rename from features/chat/chat.data.ts rename to src/features/chat/model/data.ts diff --git a/features/chat/chat.store.ts b/src/features/chat/model/store.ts similarity index 87% rename from features/chat/chat.store.ts rename to src/features/chat/model/store.ts index efd48b0..89a84fd 100644 --- a/features/chat/chat.store.ts +++ b/src/features/chat/model/store.ts @@ -8,8 +8,8 @@ import { removeStorage, STORAGE_KEYS, writeStorage, -} from "@/features/chat/chat.utils"; -import type { ChatMessage, ChatStatus } from "@/features/chat/chat.types"; +} from "@/features/chat/model/utils"; +import type { ChatMessage, ChatStatus } from "@/features/chat/model/types"; type ChatState = { messages: ChatMessage[]; @@ -39,8 +39,7 @@ export const useChatStore = create((set) => ({ initFromStorage: () => { const storedConversationId = readStorage(STORAGE_KEYS.conversationId); const storedUserId = readStorage(STORAGE_KEYS.userId); - const userId = - storedUserId && storedUserId.length > 0 ? storedUserId : createUserId(); + const userId = storedUserId && storedUserId.length > 0 ? storedUserId : createUserId(); if (!storedUserId) { writeStorage(STORAGE_KEYS.userId, userId); } @@ -91,18 +90,14 @@ export const useChatStore = create((set) => ({ // 첫 번째 청크가 도착하면 processingStatus를 null로 설정하여 답변 표시 시작 processingStatus: null, messages: state.messages.map((message) => - message.id === messageId - ? { ...message, content: message.content + chunk } - : message, + message.id === messageId ? { ...message, content: message.content + chunk } : message, ), })); }, finalizeConversationId: (conversationId) => { set((state) => { const nextId = - conversationId && conversationId.length > 0 - ? conversationId - : state.conversationId; + conversationId && conversationId.length > 0 ? conversationId : state.conversationId; if (nextId) { writeStorage(STORAGE_KEYS.conversationId, nextId); } diff --git a/features/chat/chat.types.ts b/src/features/chat/model/types.ts similarity index 100% rename from features/chat/chat.types.ts rename to src/features/chat/model/types.ts diff --git a/features/chat/chat.utils.ts b/src/features/chat/model/utils.ts similarity index 85% rename from features/chat/chat.utils.ts rename to src/features/chat/model/utils.ts index fde6b50..b0333ff 100644 --- a/features/chat/chat.utils.ts +++ b/src/features/chat/model/utils.ts @@ -42,13 +42,9 @@ export function createSseParser() { while (boundaryIndex !== -1) { const rawEvent = buffer.slice(0, boundaryIndex); buffer = buffer.slice(boundaryIndex + 2); - const dataLines = rawEvent - .split("\n") - .filter((line) => line.startsWith("data:")); + const dataLines = rawEvent.split("\n").filter((line) => line.startsWith("data:")); if (dataLines.length > 0) { - const data = dataLines - .map((line) => line.replace(/^data:\s?/, "")) - .join("\n"); + const data = dataLines.map((line) => line.replace(/^data:\s?/, "")).join("\n"); if (data) { events.push(data); } diff --git a/components/molecules/chat-input.tsx b/src/features/chat/ui/chat-input.tsx similarity index 94% rename from components/molecules/chat-input.tsx rename to src/features/chat/ui/chat-input.tsx index 95b8a23..f100e90 100644 --- a/components/molecules/chat-input.tsx +++ b/src/features/chat/ui/chat-input.tsx @@ -13,12 +13,7 @@ type ChatInputProps = { disabled?: boolean; }; -export function ChatInput({ - value, - onChange, - onSend, - disabled, -}: ChatInputProps) { +export function ChatInput({ value, onChange, onSend, disabled }: ChatInputProps) { const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); diff --git a/components/organisms/chat-thread.tsx b/src/features/chat/ui/chat-thread.tsx similarity index 81% rename from components/organisms/chat-thread.tsx rename to src/features/chat/ui/chat-thread.tsx index 58ac228..ff8a4bf 100644 --- a/components/organisms/chat-thread.tsx +++ b/src/features/chat/ui/chat-thread.tsx @@ -1,6 +1,6 @@ -import { MessageBubble } from "@/components/molecules/message-bubble"; -import { EmptyState } from "@/components/molecules/empty-state"; -import type { ChatMessage, ChatStatus } from "@/features/chat/chat.types"; +import type { ChatMessage, ChatStatus } from "@/features/chat/model/types"; +import { EmptyState } from "@/features/chat/ui/empty-state"; +import { MessageBubble } from "@/features/chat/ui/message-bubble"; import { useEffect, useRef } from "react"; import { cn } from "@/lib/utils"; @@ -25,8 +25,7 @@ export function ChatThread({ const streamingMessageId = status === "streaming" - ? [...messages].reverse().find((message) => message.role === "assistant") - ?.id + ? [...messages].reverse().find((message) => message.role === "assistant")?.id : undefined; return ( @@ -41,9 +40,7 @@ export function ChatThread({
0 - ? "opacity-100" - : "opacity-0 pointer-events-none", + messages.length > 0 ? "opacity-100" : "opacity-0 pointer-events-none", )} > {messages.map((message) => ( diff --git a/components/molecules/empty-state.tsx b/src/features/chat/ui/empty-state.tsx similarity index 78% rename from components/molecules/empty-state.tsx rename to src/features/chat/ui/empty-state.tsx index bf0a8a6..cd99ac6 100644 --- a/components/molecules/empty-state.tsx +++ b/src/features/chat/ui/empty-state.tsx @@ -1,6 +1,6 @@ "use client"; -import { EXAMPLE_QUESTIONS } from "@/features/chat/chat.data"; -import { useEffect, useState, useCallback } from "react"; +import { EXAMPLE_QUESTIONS } from "@/features/chat/model/data"; +import { useState, useCallback } from "react"; import SeedLottie from "./seed-lottie"; import { Spinner } from "@/components/ui/spinner"; @@ -9,14 +9,8 @@ type EmptyStateProps = { }; export function EmptyState({ onSuggestionClick }: EmptyStateProps) { - const [suggestions, setSuggestions] = useState([]); const [isLottieReady, setIsLottieReady] = useState(false); - - useEffect(() => { - // NOTE: 컴포넌트 마운트 시 한 번만 랜덤 질문 3개 선택 - const shuffled = [...EXAMPLE_QUESTIONS].sort(() => 0.5 - Math.random()); - setSuggestions(shuffled.slice(0, 3)); - }, []); + const suggestions = EXAMPLE_QUESTIONS.slice(0, 3); const handleLottieReady = useCallback(() => { setIsLottieReady(true); @@ -39,7 +33,10 @@ export function EmptyState({ onSuggestionClick }: EmptyStateProps) { {isLottieReady && ( <>
-
+

AI 씨앗 순장님과 대화를 해보세요.

diff --git a/components/molecules/message-bubble.tsx b/src/features/chat/ui/message-bubble.tsx similarity index 85% rename from components/molecules/message-bubble.tsx rename to src/features/chat/ui/message-bubble.tsx index 19f74cd..9c564f5 100644 --- a/components/molecules/message-bubble.tsx +++ b/src/features/chat/ui/message-bubble.tsx @@ -3,7 +3,7 @@ import { Streamdown } from "streamdown"; import { memo } from "react"; -import type { ChatMessage } from "@/features/chat/chat.types"; +import type { ChatMessage } from "@/features/chat/model/types"; import { cn } from "@/lib/utils"; type MessageBubbleProps = { @@ -18,15 +18,11 @@ export const MessageBubble = memo(function MessageBubble({ processingStatus, }: MessageBubbleProps) { const isUser = message.role === "user"; - const showProcessingStatus = - !isUser && isStreaming && processingStatus && !message.content; + const showProcessingStatus = !isUser && isStreaming && processingStatus && !message.content; return (
{isUser ? ( -

- {message.content} -

+

{message.content}

) : (
{showProcessingStatus ? ( diff --git a/components/molecules/seed-lottie.tsx b/src/features/chat/ui/seed-lottie.tsx similarity index 89% rename from components/molecules/seed-lottie.tsx rename to src/features/chat/ui/seed-lottie.tsx index d5b5fe9..ead44e1 100644 --- a/components/molecules/seed-lottie.tsx +++ b/src/features/chat/ui/seed-lottie.tsx @@ -34,26 +34,26 @@ export default function SeedLottie({ onReady }: SeedLottieProps) { }); } }, - [onReady] + [onReady], ); if (!selectedSource) return null; return (
- {/* 비네트 효과 오버레이 */} -
diff --git a/src/features/history/README.md b/src/features/history/README.md new file mode 100644 index 0000000..dadd204 --- /dev/null +++ b/src/features/history/README.md @@ -0,0 +1,3 @@ +# History Feature + +Conversation history query/state/UI should live here. diff --git a/src/features/profile/README.md b/src/features/profile/README.md new file mode 100644 index 0000000..51ca81e --- /dev/null +++ b/src/features/profile/README.md @@ -0,0 +1,3 @@ +# Profile Feature + +User onboarding/profile domain logic and API interaction should live here. diff --git a/components/organisms/chat-header.tsx b/src/features/shell/ui/chat-header.tsx similarity index 84% rename from components/organisms/chat-header.tsx rename to src/features/shell/ui/chat-header.tsx index 2a3f1e5..32f5ef9 100644 --- a/components/organisms/chat-header.tsx +++ b/src/features/shell/ui/chat-header.tsx @@ -1,8 +1,8 @@ "use client"; // NOTE: 로고, 상태 뱃지, 새 대화 시작 액션이 포함된 채팅 헤더 import { Button } from "@/components/ui/button"; -import type { ChatStatus } from "@/features/chat/chat.types"; -import { SeedPopover } from "@/components/molecules/seed-popover"; +import type { ChatStatus } from "@/features/chat/model/types"; +import { SeedPopover } from "@/features/shell/ui/seed-popover"; export function ChatHeader({ status, onNewConversation, diff --git a/components/organisms/chat-shell.tsx b/src/features/shell/ui/chat-shell.tsx similarity index 91% rename from components/organisms/chat-shell.tsx rename to src/features/shell/ui/chat-shell.tsx index d9bda0e..38ce195 100644 --- a/components/organisms/chat-shell.tsx +++ b/src/features/shell/ui/chat-shell.tsx @@ -1,10 +1,10 @@ "use client"; -import { ChatHeader } from "@/components/organisms/chat-header"; -import { ChatThread } from "@/components/organisms/chat-thread"; -import { ChatInput } from "@/components/molecules/chat-input"; -import { streamChat } from "@/features/chat/chat.api"; -import { useChatStore } from "@/features/chat/chat.store"; +import { ChatInput } from "@/features/chat/ui/chat-input"; +import { ChatThread } from "@/features/chat/ui/chat-thread"; +import { streamChat } from "@/features/chat/api/stream-chat"; +import { useChatStore } from "@/features/chat/model/store"; +import { ChatHeader } from "@/features/shell/ui/chat-header"; import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; @@ -100,10 +100,7 @@ export function ChatShell() { {/* Header Area */}
- +
diff --git a/src/features/shell/ui/seed-popover.tsx b/src/features/shell/ui/seed-popover.tsx new file mode 100644 index 0000000..dbe05f6 --- /dev/null +++ b/src/features/shell/ui/seed-popover.tsx @@ -0,0 +1,56 @@ +import Image from "next/image"; +import { + Popover, + PopoverContent, + PopoverHeader, + PopoverTitle, + PopoverTrigger, +} from "@/components/ui/popover"; + +export function SeedPopover() { + return ( +
+ + + 씨앗ai + + } + /> + + + 씨앗 순장 + + +
+ 씨앗ai +
+ +
    +
  • • 이름: 씨앗 (C-at)
  • +
  • • 직분: 순장
  • +
  • • MBTI: ENFP
  • +
+ +
+

캠퍼스 현장에서 함께 자라가는 동역자예요.

+
+
+
+

씨앗 순장

+
+ ); +} diff --git a/lib/env.ts b/src/lib/env.ts similarity index 81% rename from lib/env.ts rename to src/lib/env.ts index e2d07b2..bfc2d34 100644 --- a/lib/env.ts +++ b/src/lib/env.ts @@ -10,10 +10,7 @@ export function getServerEnv(): ServerEnv { const apiKey = process.env.DIFY_API_KEY; if (!baseUrl || !apiKey) { - const missing = [ - !baseUrl ? "DIFY_BASE_URL" : null, - !apiKey ? "DIFY_API_KEY" : null, - ] + const missing = [!baseUrl ? "DIFY_BASE_URL" : null, !apiKey ? "DIFY_API_KEY" : null] .filter(Boolean) .join(", "); throw new Error(`Missing required server env: ${missing}`); diff --git a/lib/id.ts b/src/lib/id.ts similarity index 100% rename from lib/id.ts rename to src/lib/id.ts diff --git a/lib/utils.ts b/src/lib/utils.ts similarity index 100% rename from lib/utils.ts rename to src/lib/utils.ts diff --git a/tsconfig.json b/tsconfig.json index 3a13f90..cf9c65d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,7 +19,7 @@ } ], "paths": { - "@/*": ["./*"] + "@/*": ["./src/*"] } }, "include": [