init commit from ado4

master
jaehwang 2025-12-08 13:27:07 +09:00
commit d820394ccc
126 changed files with 51929 additions and 0 deletions

43
.gitignore vendored Normal file
View File

@ -0,0 +1,43 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tar.gz
server/downloads/*
!server/downloads/.gitkeep
server/database.sqlite
# Environment
.env
.env.*
!.env.example
# Python
__pycache__/
*.py[cod]
*$py.class
venv/
*.egg-info/
# Data files
data/

99
App.tsx Normal file
View File

@ -0,0 +1,99 @@
import React from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './src/contexts/AuthContext';
import { LanguageProvider } from './src/contexts/LanguageContext';
import { ThemeProvider } from './src/contexts/ThemeContext';
import Navbar from './components/Navbar';
import CastADApp from './src/pages/CastADApp';
import LoginPage from './src/pages/LoginPage';
import RegisterPage from './src/pages/RegisterPage';
import ForgotPasswordPage from './src/pages/ForgotPasswordPage';
import ResetPasswordPage from './src/pages/ResetPasswordPage';
import VerifyEmailPage from './src/pages/VerifyEmailPage';
import OAuthCallbackPage from './src/pages/OAuthCallbackPage';
import AdminDashboard from './src/pages/AdminDashboard';
import LandingPage from './src/pages/LandingPage';
import BrandPage from './src/pages/BrandPage';
import CreditsPage from './src/pages/CreditsPage';
import './src/styles/globals.css';
import './src/styles/text-effects.css';
import './src/styles/animations.css';
// 홈 라우트: 로그인 여부에 따라 랜딩 또는 SaaS 앱 표시
const HomeRoute: React.FC = () => {
const { user } = useAuth();
return user ? <CastADApp /> : <LandingPage />;
};
// SaaS 앱 보호 라우트: 로그인 필요
const ProtectedAppRoute: React.FC = () => {
const { user } = useAuth();
return user ? <CastADApp /> : <Navigate to="/login" />;
};
// 랜딩 페이지용 라우트 (Navbar 포함)
const PublicRoutes: React.FC = () => {
return (
<>
<Navbar />
<div className="pt-16 min-h-screen bg-background text-foreground font-sans">
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/verify-email" element={<VerifyEmailPage />} />
<Route path="/oauth/callback" element={<OAuthCallbackPage />} />
<Route path="/admin" element={<AdminDashboard />} />
<Route path="/brand" element={<BrandPage />} />
<Route path="/" element={<LandingPage />} />
<Route path="/app/*" element={<Navigate to="/login" />} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</div>
</>
);
};
const AppRoutes: React.FC = () => {
const { user } = useAuth();
// 로그인된 사용자는 SaaS 앱으로 (Navbar 없음, Sidebar 사용)
if (user) {
return (
<Routes>
<Route path="/login" element={<Navigate to="/app" />} />
<Route path="/register" element={<Navigate to="/app" />} />
<Route path="/forgot-password" element={<Navigate to="/app" />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/verify-email" element={<VerifyEmailPage />} />
<Route path="/oauth/callback" element={<OAuthCallbackPage />} />
<Route path="/admin" element={<AdminDashboard />} />
<Route path="/brand" element={<BrandPage />} />
<Route path="/credits" element={<CreditsPage />} />
<Route path="/app/*" element={<CastADApp />} />
<Route path="/" element={<Navigate to="/app" />} />
<Route path="*" element={<Navigate to="/app" />} />
</Routes>
);
}
// 로그인되지 않은 사용자는 공개 페이지로
return <PublicRoutes />;
};
const App: React.FC = () => {
return (
<AuthProvider>
<LanguageProvider>
<ThemeProvider>
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
</ThemeProvider>
</LanguageProvider>
</AuthProvider>
);
};
export default App;

75
CLAUDE.md Normal file
View File

@ -0,0 +1,75 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
CastAD (formerly ADo4) is an AI-powered marketing video generation platform for pension (accommodation) businesses. It uses Google Gemini for content generation/TTS and Suno AI for music creation, with Puppeteer for server-side video rendering.
## Development Commands
```bash
# Start both frontend and backend concurrently (recommended)
./start.sh
# Or run manually:
npm run dev # Run frontend + backend together
npm run build # Build frontend for production
cd server && node index.js # Run backend only
# Install dependencies
npm install # Frontend dependencies
cd server && npm install # Backend dependencies
npm run build:all # Install all + build frontend
```
## Architecture
### Three-Layer Structure
- **Frontend**: React 19 + TypeScript + Vite (port 3000/5173)
- **Backend**: Express.js + SQLite (port 3001)
- **External APIs**: Google Gemini, Suno AI, YouTube Data API
### Key Directories
- `components/` - React UI components (InputForm, Navbar, ResultPlayer, etc.)
- `src/pages/` - Page components (GeneratorPage, Dashboard, AdminDashboard, Login/Register)
- `src/contexts/` - Global state (AuthContext for JWT auth, LanguageContext for i18n)
- `src/locales.ts` - Multi-language translations (ko, en, ja, zh, th, vi)
- `services/` - Frontend API services (geminiService, sunoService, ffmpegService, naverService)
- `server/` - Express backend
- `index.js` - Main server with auth, rendering, and API routes
- `db.js` - SQLite schema (users, history tables)
- `geminiBackendService.js` - Server-side Gemini operations
- `youtubeService.js` - YouTube upload via OAuth
- `downloads/` - Generated video storage
- `temp/` - Temporary rendering files
### Data Flow
1. User inputs business info/photos → GeneratorPage.tsx
2. Frontend calls Gemini API for creative content → geminiService.ts
3. Music generation via Suno proxy → sunoService.ts
4. Render request sent to backend → server/index.js
5. Puppeteer captures video, FFmpeg merges audio → final MP4 in downloads/
### Authentication
- JWT tokens stored in localStorage
- Auth middleware in server/index.js (`authenticateToken`, `requireAdmin`)
- Roles: 'user' and 'admin'
- Default admin: `admin` / `admin123`
### Environment Variables (.env in root)
```
VITE_GEMINI_API_KEY= # Required: Google AI Studio API key
SUNO_API_KEY= # Required: Suno AI proxy key
JWT_SECRET= # Required: JWT signing secret
FRONTEND_URL= # Optional: For CORS (default: http://localhost:5173)
PORT= # Optional: Backend port (default: 3001)
```
## Tech Notes
- Vite proxies `/api`, `/render`, `/downloads`, `/temp` to backend (port 3001)
- Path alias `@/` maps to project root
- SQLite database at `server/database.sqlite` (not tracked in git)
- Video rendering uses Puppeteer headless Chrome + FFmpeg
- Multi-language support: UI language separate from content generation language

279
DEPLOY.md Normal file
View File

@ -0,0 +1,279 @@
# CaStAD 서버 배포 가이드
## 도메인 정보
- **Primary**: https://castad.ktenterprise.net
- **Secondary**: https://ado2.whitedonkey.kr
---
## 1. 서버 요구사항
| 항목 | 최소 | 권장 |
|------|------|------|
| **CPU** | 2 Core | 4 Core |
| **RAM** | 4GB | 8GB |
| **Storage** | 50GB | 100GB SSD |
| **OS** | Ubuntu 20.04+ | Ubuntu 22.04 LTS |
### 필수 소프트웨어
```bash
# Node.js 18+
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt-get install -y nodejs
# PM2 (프로세스 관리)
sudo npm install -g pm2
# Nginx
sudo apt-get install -y nginx
# FFmpeg (영상 렌더링)
sudo apt-get install -y ffmpeg
# Python 3 (Instagram 서비스)
sudo apt-get install -y python3 python3-pip
# Chromium (Puppeteer용)
sudo apt-get install -y chromium-browser
# Certbot (SSL 인증서)
sudo apt-get install -y certbot python3-certbot-nginx
```
---
## 2. 프로젝트 클론
```bash
# 디렉토리 생성
sudo mkdir -p /var/www/castad
sudo chown $USER:$USER /var/www/castad
# Git 클론
cd /var/www/castad
git clone https://github.com/waabaa/19-claude-saas-castad.git .
# 실행 권한 부여
chmod +x startserver.sh
```
---
## 3. 환경 변수 설정
```bash
# .env 파일 생성
cp .env.production.example .env
# 편집
nano .env
```
**필수 변경 항목:**
```bash
# JWT 시크릿 (반드시 변경!)
JWT_SECRET=your-unique-secret-key-here
# Gemini API 키
VITE_GEMINI_API_KEY=your-key
# Suno API 키
SUNO_API_KEY=your-key
# Instagram 암호화 키 (생성 명령)
python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
```
---
## 4. YouTube OAuth 설정
```bash
# Google Cloud Console에서 다운로드한 파일을 복사
cp /path/to/client_secret.json server/client_secret.json
```
---
## 5. Nginx 설정
```bash
# Nginx 설정 파일 복사
sudo cp nginx/castad.conf /etc/nginx/sites-available/castad.conf
# 심볼릭 링크 생성
sudo ln -s /etc/nginx/sites-available/castad.conf /etc/nginx/sites-enabled/
# 기본 사이트 비활성화 (선택)
sudo rm /etc/nginx/sites-enabled/default
# 문법 검사
sudo nginx -t
# Nginx 재시작
sudo systemctl restart nginx
```
---
## 6. SSL 인증서 발급
```bash
# Let's Encrypt SSL 인증서 발급
sudo certbot --nginx -d castad.ktenterprise.net -d ado2.whitedonkey.kr
# 자동 갱신 테스트
sudo certbot renew --dry-run
```
**Nginx 설정 업데이트 (자동 수정됨):**
인증서 경로가 다를 경우 `nginx/castad.conf` 수정:
```nginx
ssl_certificate /etc/letsencrypt/live/castad.ktenterprise.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/castad.ktenterprise.net/privkey.pem;
```
---
## 7. 서버 시작
```bash
# 서버 시작 (빌드 포함)
./startserver.sh start
# 상태 확인
./startserver.sh status
# 로그 보기
./startserver.sh logs
```
---
## 8. PM2 시작 시 자동 실행 설정
```bash
# 현재 상태 저장
pm2 save
# 시스템 시작 시 자동 실행
pm2 startup
# 표시되는 명령어 실행 (예시)
sudo env PATH=$PATH:/usr/bin pm2 startup systemd -u ubuntu --hp /home/ubuntu
```
---
## 9. 방화벽 설정 (선택)
```bash
# UFW 설정
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 22/tcp
sudo ufw enable
```
---
## 명령어 요약
| 명령어 | 설명 |
|--------|------|
| `./startserver.sh start` | 서버 시작 (빌드 포함) |
| `./startserver.sh stop` | 서버 중지 |
| `./startserver.sh restart` | 서버 재시작 |
| `./startserver.sh status` | 상태 확인 |
| `./startserver.sh logs` | 로그 보기 |
| `./startserver.sh update` | Git pull + 재빌드 + 재시작 |
---
## 업데이트 방법
```bash
cd /var/www/castad
# 간단한 방법
./startserver.sh update
# 또는 수동으로
git pull origin main
npm install --legacy-peer-deps
cd server && npm install --legacy-peer-deps && cd ..
npm run build
pm2 restart all
```
---
## 트러블슈팅
### 502 Bad Gateway
```bash
# 백엔드 상태 확인
pm2 status
pm2 logs castad-backend
# 포트 확인
sudo netstat -tlnp | grep 3001
```
### SSL 인증서 오류
```bash
# 인증서 갱신
sudo certbot renew
# Nginx 재시작
sudo systemctl restart nginx
```
### Puppeteer 오류
```bash
# Chromium 설치 확인
chromium-browser --version
# 또는 환경변수 설정
export PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
```
### Instagram 서비스 오류
```bash
# Python 의존성 재설치
pip3 install -r server/instagram/requirements.txt
# 서비스 재시작
pm2 restart castad-instagram
```
---
## 백업
```bash
# 데이터베이스 백업
cp server/database.sqlite backups/database_$(date +%Y%m%d).sqlite
# 업로드 파일 백업
tar -czvf backups/uploads_$(date +%Y%m%d).tar.gz server/downloads/
```
---
## 모니터링
```bash
# PM2 모니터링
pm2 monit
# 시스템 리소스
htop
# Nginx 접속 로그
tail -f /var/log/nginx/castad_access.log
# Nginx 에러 로그
tail -f /var/log/nginx/castad_error.log
```

85
GEMINI.md Normal file
View File

@ -0,0 +1,85 @@
# BizVibe - AI Music Video Generator
## Project Overview
**BizVibe** is a React-based web application designed to automatically generate marketing music videos for businesses. It leverages the power of Google's Gemini ecosystem and Suno AI to create a comprehensive multimedia package including:
* **Creative Text:** Ad copy and lyrics/scripts generated by **Gemini 3 Pro**.
* **Audio:**
* Custom songs composed by **Suno AI** (V5 model).
* Professional voiceovers generated by **Gemini TTS** (`gemini-2.5-flash-preview-tts`).
* **Visuals:**
* High-quality ad posters designed by **Gemini 3 Pro Image** (`gemini-3-pro-image-preview`).
* Cinematic video backgrounds created by **Veo** (`veo-3.1-fast-generate-preview`).
The application provides an end-to-end flow: collecting business details -> generating assets -> previewing the final music video.
## Architecture & Tech Stack
* **Frontend Framework:** React 19 with Vite.
* **Language:** TypeScript.
* **AI Integration:**
* `@google/genai` SDK for interacting with Gemini and Veo models.
* Custom REST API integration for Suno AI (via CORS proxy).
* **Media Processing:** FFmpeg (WASM) is included in dependencies (`@ffmpeg/ffmpeg`), likely for client-side media assembly.
* **Styling:** Tailwind CSS.
* **State Management:** React Context / Local State.
## Building and Running
### Prerequisites
* Node.js (v18+ recommended)
* A valid Google Cloud Project with Gemini API access enabled.
### Installation
Install the project dependencies:
```bash
npm install
```
### Development Server
Start the local development server:
```bash
npm run dev
```
Access the app at `http://localhost:5173` (default Vite port).
### Production Build
Create a production-ready build:
```bash
npm run build
```
Preview the build locally:
```bash
npm run preview
```
### Configuration
* **API Key:** The application requires a Google Gemini API Key.
* It checks for `process.env.API_KEY`.
* It also includes an `ApiKeySelector` component that allows users to select/input a key if running in a specific AI Studio environment.
* **Suno API:** The `sunoService.ts` currently contains a hardcoded API key and uses a CORS proxy. *Note: This should be externalized in a production environment.*
## Development Conventions
### File Structure
* `src/components/`: Reusable UI components (Input forms, Result players, Loading overlays).
* `src/services/`: specialized modules for API interactions.
* `geminiService.ts`: Handles Text, Image, Video (Veo), and TTS generation.
* `sunoService.ts`: Handles music generation via Suno API.
* `ffmpegService.ts`: Media processing utilities.
* `src/types.ts`: Centralized TypeScript definitions for domain models (`BusinessInfo`, `GeneratedAssets`, etc.).
* `App.tsx`: Main application controller handling the generation workflow state machine.
### Code Style
* **Components:** Functional components with Hooks.
* **Styling:** Utility-first CSS using Tailwind classes.
* **Async Handling:** `async/await` pattern used extensively in services with error handling for API failures.
* **Type Safety:** Strict TypeScript interfaces for all data models and API responses.
### Key Workflows
1. **Input:** User submits `BusinessInfo` (including images).
2. **Processing (Sequential/Parallel):**
* Text content is generated first.
* Audio (Song or TTS) is generated using the text.
* Poster image is generated using uploaded images.
* Video background is generated using the Poster image.
3. **Output:** `GeneratedAssets` are aggregated and displayed in the `ResultPlayer`.

305
PLAN.md Normal file
View File

@ -0,0 +1,305 @@
# CastAD SaaS UI/UX 전체 리디자인 계획
## 개요
POC 상태의 펜션 쇼츠 자동 생성 웹사이트를 프로페셔널한 SaaS 서비스로 전환
## 기술 스택 결정
| 항목 | 현재 | 변경 후 |
|------|------|---------|
| CSS Framework | Tailwind CDN | Tailwind npm + PostCSS |
| UI Components | 직접 구현 | shadcn/ui (Radix 기반) |
| Icons | lucide-react | lucide-react (유지) |
| Design System | 없음 | CSS Variables 기반 토큰 |
| Animations | index.html 인라인 | tailwind-animate + CSS 파일 분리 |
## 디자인 방향
### 컬러 팔레트 (다크 테마)
- **Background**: `#09090b` (zinc-950)
- **Card**: `#18181b` (zinc-900)
- **Border**: `#27272a` (zinc-800)
- **Primary**: `#a855f7` (purple-500) → `#9333ea` (purple-600)
- **Accent**: `#06b6d4` (cyan-500)
- **Success**: `#22c55e` (green-500)
- **Destructive**: `#ef4444` (red-500)
### 타이포그래피
- **Display**: Inter (600-700)
- **Body**: Inter (400)
- **Mono**: JetBrains Mono
### 레이아웃 원칙
- 최대 너비: 1280px (7xl)
- 섹션 패딩: 24-32px
- 카드 둥글기: 12px (rounded-xl)
- 일관된 8px 그리드 시스템
---
## 단계별 구현 계획
### Phase 1: 인프라 설정 (기반 작업)
#### 1.1 Tailwind CSS npm 전환
- [ ] `tailwindcss`, `postcss`, `autoprefixer` 설치
- [ ] `tailwind.config.js` 생성 (커스텀 컬러, 폰트, 애니메이션)
- [ ] `postcss.config.js` 생성
- [ ] `src/styles/globals.css` 생성 (Tailwind directives)
- [ ] index.html에서 CDN 제거
#### 1.2 shadcn/ui 초기화
- [ ] `npx shadcn@latest init` 실행
- [ ] components.json 설정 (경로, 스타일)
- [ ] `src/components/ui/` 디렉토리 구조 설정
#### 1.3 디자인 토큰 설정
- [ ] CSS variables 정의 (colors, radius, spacing)
- [ ] 다크 테마 기본값 설정
- [ ] 폰트 로딩 최적화 (Google Fonts → next/font 스타일)
---
### Phase 2: 기본 컴포넌트 구축
#### 2.1 shadcn/ui 컴포넌트 설치
```bash
npx shadcn@latest add button card input label select textarea
npx shadcn@latest add dialog sheet tabs toast sonner
npx shadcn@latest add dropdown-menu avatar badge separator
npx shadcn@latest add progress skeleton scroll-area
npx shadcn@latest add form (react-hook-form + zod)
```
#### 2.2 커스텀 컴포넌트 생성
- [ ] `PageHeader` - 페이지 제목 + 설명
- [ ] `PageContainer` - 일관된 레이아웃 래퍼
- [ ] `FeatureCard` - 기능 소개 카드
- [ ] `PricingCard` - 가격표 카드
- [ ] `StatCard` - 통계 카드
- [ ] `VideoPlayer` - 영상 재생기 (기존 ResultPlayer 리팩토링)
- [ ] `LoadingSpinner` - 로딩 인디케이터
- [ ] `EmptyState` - 빈 상태 표시
#### 2.3 레이아웃 컴포넌트
- [ ] `MainLayout` - 전체 앱 레이아웃
- [ ] `DashboardLayout` - 대시보드용 사이드바 레이아웃
- [ ] `AuthLayout` - 로그인/회원가입 전용 레이아웃
---
### Phase 3: Navbar 리디자인
#### 현재 문제점
- 모바일 메뉴 없음
- 언어 선택 UI 투박함
- 사용자 메뉴 단순함
#### 개선 사항
- [ ] 반응형 모바일 메뉴 (Sheet 컴포넌트 활용)
- [ ] 언어 선택 드롭다운 개선
- [ ] 사용자 아바타 + 드롭다운 메뉴
- [ ] 네비게이션 링크 호버 효과
- [ ] 스크롤 시 배경 blur 효과
---
### Phase 4: Landing Page 리디자인
#### 4.1 Hero 섹션
- [ ] 더 절제된 타이포그래피 (text-4xl → text-5xl)
- [ ] 애니메이션 최적화 (framer-motion 고려)
- [ ] CTA 버튼 개선 (그래디언트 보더)
- [ ] 신뢰도 지표 리디자인
#### 4.2 Features 섹션
- [ ] FeatureCard 컴포넌트로 통일
- [ ] 아이콘 + 제목 + 설명 구조
- [ ] 호버 시 미묘한 상승 효과
#### 4.3 How It Works
- [ ] 스텝 카드 (번호 + 아이콘 + 설명)
- [ ] 연결선 또는 화살표 그래픽
#### 4.4 Success Cases
- [ ] 캐러셀 개선 (자동 재생 + 수동 조작)
- [ ] 후기 카드 디자인 개선
#### 4.5 Pricing 섹션
- [ ] PricingCard 컴포넌트 활용
- [ ] 인기 플랜 하이라이트
- [ ] 기능 비교표 추가 고려
#### 4.6 Footer
- [ ] 링크 구조화
- [ ] 소셜 미디어 아이콘
- [ ] 저작권 정보
---
### Phase 5: Generator Page 리디자인
#### 5.1 InputForm 분할 (47KB → 6개 컴포넌트)
- [ ] `BusinessInfoSection` - 비즈니스 기본 정보
- [ ] `ImageUploadSection` - 이미지 업로드
- [ ] `AudioSettingsSection` - 오디오 설정
- [ ] `VisualSettingsSection` - 비주얼 설정
- [ ] `CategorySection` - 카테고리 선택
- [ ] `FormActions` - 제출 버튼
#### 5.2 폼 UI 개선
- [ ] shadcn Form + react-hook-form + zod 적용
- [ ] 실시간 유효성 검사
- [ ] 섹션별 접기/펼치기 (Collapsible)
- [ ] 진행 상태 인디케이터
#### 5.3 Loading 상태 개선
- [ ] 스켈레톤 UI
- [ ] 단계별 진행 표시 (Stepper)
- [ ] 취소 기능
---
### Phase 6: Result Player 리디자인
#### 6.1 ResultPlayer 분할 (38KB → 5개 컴포넌트)
- [ ] `VideoPreview` - 영상 미리보기
- [ ] `AudioControls` - 오디오 컨트롤
- [ ] `TextOverlayPreview` - 텍스트 오버레이
- [ ] `DownloadOptions` - 다운로드 옵션
- [ ] `SharePanel` - 공유 기능
#### 6.2 UI 개선
- [ ] 탭 UI 개선 (Tabs 컴포넌트)
- [ ] 진행률 표시 개선
- [ ] 토스트 알림 (Sonner)
---
### Phase 7: Dashboard 리디자인
#### 7.1 사용자 대시보드
- [ ] 사이드바 레이아웃 적용
- [ ] 통계 카드 (생성 횟수, 저장 공간 등)
- [ ] 최근 생성물 그리드
- [ ] 필터링 + 검색 기능
- [ ] 페이지네이션
#### 7.2 관리자 대시보드
- [ ] DataTable 컴포넌트 (정렬, 필터, 페이지네이션)
- [ ] 사용자 관리 UI 개선
- [ ] 통계 차트 (선택적)
---
### Phase 8: Auth Pages 리디자인
#### 8.1 Login/Register
- [ ] AuthLayout 적용
- [ ] 폼 유효성 검사 개선
- [ ] 소셜 로그인 버튼 (UI만, 추후 구현)
- [ ] 비밀번호 강도 표시
---
### Phase 9: 반응형 및 접근성
#### 9.1 반응형
- [ ] 모든 페이지 모바일 테스트
- [ ] 터치 타겟 크기 확인 (44x44px 최소)
- [ ] 가로 스크롤 제거
#### 9.2 접근성
- [ ] ARIA 라벨 추가
- [ ] 키보드 네비게이션 확인
- [ ] 색상 대비 검사 (WCAG AA)
- [ ] 포커스 상태 시각화
---
### Phase 10: 마무리
#### 10.1 성능 최적화
- [ ] 이미지 최적화 (lazy loading)
- [ ] 번들 사이즈 분석
- [ ] 불필요한 의존성 제거
#### 10.2 코드 정리
- [ ] 사용하지 않는 코드 제거
- [ ] TypeScript 타입 정리
- [ ] 컴포넌트 문서화 (주석)
---
## 파일 구조 변경
```
/
├── src/
│ ├── components/
│ │ ├── ui/ # shadcn/ui 컴포넌트
│ │ │ ├── button.tsx
│ │ │ ├── card.tsx
│ │ │ ├── input.tsx
│ │ │ └── ...
│ │ ├── layout/ # 레이아웃 컴포넌트
│ │ │ ├── MainLayout.tsx
│ │ │ ├── DashboardLayout.tsx
│ │ │ └── AuthLayout.tsx
│ │ ├── generator/ # Generator 관련 컴포넌트
│ │ │ ├── BusinessInfoSection.tsx
│ │ │ ├── ImageUploadSection.tsx
│ │ │ └── ...
│ │ ├── player/ # Player 관련 컴포넌트
│ │ │ ├── VideoPreview.tsx
│ │ │ └── ...
│ │ └── shared/ # 공유 컴포넌트
│ │ ├── PageHeader.tsx
│ │ ├── FeatureCard.tsx
│ │ └── ...
│ ├── styles/
│ │ ├── globals.css # Tailwind + CSS variables
│ │ └── animations.css # 텍스트 이펙트 (유지)
│ ├── lib/
│ │ └── utils.ts # cn() 등 유틸리티
│ └── pages/ # 페이지 컴포넌트
├── components/ # 기존 컴포넌트 (점진적 이전)
├── tailwind.config.js # 새로 생성
├── postcss.config.js # 새로 생성
└── components.json # shadcn/ui 설정
```
---
## 예상 작업량
| Phase | 작업 내용 | 파일 수 |
|-------|----------|--------|
| 1 | 인프라 설정 | 5-6 |
| 2 | 기본 컴포넌트 | 15-20 |
| 3 | Navbar | 2-3 |
| 4 | Landing Page | 5-6 |
| 5 | Generator Page | 8-10 |
| 6 | Result Player | 6-8 |
| 7 | Dashboard | 4-5 |
| 8 | Auth Pages | 2-3 |
| 9 | 반응형/접근성 | 전체 수정 |
| 10 | 마무리 | 전체 검토 |
---
## 주의사항
1. **점진적 마이그레이션**: 기존 기능이 깨지지 않도록 단계별 진행
2. **텍스트 이펙트 보존**: 11가지 텍스트 이펙트는 유지 (핵심 기능)
3. **다국어 지원 유지**: i18n 구조 그대로 유지
4. **백엔드 연동 유지**: API 호출 로직 변경 없음
---
## 승인 필요 항목
진행 전 확인:
1. 이 계획대로 진행해도 될까요?
2. 특별히 우선순위를 높이거나 제외할 부분이 있나요?
3. Phase 1부터 순차적으로 진행할까요?

2825
README.md Normal file

File diff suppressed because it is too large Load Diff

360
SALES_MANUAL.md Normal file
View File

@ -0,0 +1,360 @@
# CaStAD (카스타드) 영업 매뉴얼 v3.0
## 솔루션 한 줄 소개
> **"네이버 플레이스 URL 하나로 15초만에 인스타그램 릴스, 틱톡, 유튜브 쇼츠 영상을 자동 생성하는 AI 마케팅 플랫폼"**
---
## 목차
1. [솔루션 개요](#1-솔루션-개요)
2. [타겟 고객](#2-타겟-고객)
3. [핵심 가치 제안](#3-핵심-가치-제안)
4. [경쟁 우위](#4-경쟁-우위)
5. [요금제 안내](#5-요금제-안내)
6. [데모 시나리오](#6-데모-시나리오)
7. [예상 질문 및 답변 (FAQ)](#7-예상-질문-및-답변-faq)
8. [성공 사례](#8-성공-사례)
9. [계약 절차](#9-계약-절차)
10. [기술 지원 정책](#10-기술-지원-정책)
---
## 1. 솔루션 개요
### CaStAD란?
CaStAD(카스타드)는 펜션, 풀빌라, 리조트 등 숙박업 사업자를 위한 **AI 기반 마케팅 영상 자동 생성 SaaS 플랫폼**입니다.
### 핵심 기능
| 기능 | 설명 |
|------|------|
| **AI 영상 자동 생성** | 네이버 플레이스 URL만 입력하면 사진, 리뷰 등을 분석하여 자동으로 홍보 영상 생성 |
| **AI 음악 생성** | 펜션 분위기에 맞는 배경음악 자동 생성 (Suno AI 연동) |
| **AI 카피라이팅** | Google Gemini AI가 감성적인 광고 문구 자동 생성 |
| **다국어 지원** | 한국어, 영어, 일본어, 중국어, 태국어, 베트남어 6개국어 |
| **3채널 동시 업로드** | YouTube, Instagram Reels, TikTok 원클릭 업로드 |
| **통합 분석 대시보드** | 모든 채널의 조회수, 참여율 등을 한눈에 확인 |
### 주요 특징
- **시간 절약**: 기존 2시간 → 15초로 영상 제작 시간 99% 단축
- **비용 절감**: 영상 제작 외주비 월 50만원+ → 월 2.9만원부터
- **전문가 수준**: AI가 최신 트렌드를 반영한 전문적인 영상 제작
- **자동화**: 정기적인 업로드까지 완전 자동화 가능
---
## 2. 타겟 고객
### 주요 타겟
| 업종 | 예시 | 니즈 |
|------|------|------|
| **펜션** | 전국 2만+ 펜션 | 저렴하고 쉬운 마케팅 |
| **풀빌라** | 프리미엄 풀빌라 | 고급스러운 영상 콘텐츠 |
| **리조트/호텔** | 중소형 리조트 | 일관된 브랜딩 영상 |
| **글램핑** | 글램핑장 | 감성적인 분위기 전달 |
| **게스트하우스** | 외국인 대상 숙소 | 다국어 마케팅 |
### 이상적인 고객 프로필
- 네이버 플레이스에 등록된 사업자
- SNS 마케팅 필요성을 인지하지만 시간/역량 부족
- 마케팅 예산이 제한적인 개인 사업자
- 여러 펜션을 운영하는 법인 사업자
### 고객 Pain Point
1. "영상 만들 시간이 없어요"
2. "영상 편집할 줄 몰라요"
3. "외주 맡기기엔 너무 비싸요"
4. "SNS에 올리는 것도 귀찮아요"
5. "어떤 콘텐츠가 효과적인지 모르겠어요"
---
## 3. 핵심 가치 제안
### Value Proposition Canvas
**고객 문제 (Pains)**
- 영상 제작 시간 부족
- 편집 기술 부족
- 높은 외주 비용
- 일관성 없는 마케팅
**솔루션 제공 (Gains)**
- 15초 만에 영상 완성
- 기술 필요 없음
- 월 2.9만원부터
- AI가 일관된 품질 보장
### ROI 계산 예시
**현재 비용 (외주 의뢰 시)**
- 영상 제작: 30만원/건
- 월 2개 제작: 60만원
- 연간: 720만원
**CaStAD 사용 시 (Pro 플랜)**
- 월 요금: 9.9만원
- 연간: 118.8만원
- **절감 효과: 연 600만원 이상**
---
## 4. 경쟁 우위
### 경쟁사 비교표
| 항목 | CaStAD | A사 (영상 편집 앱) | B사 (마케팅 대행) |
|------|--------|------------------|------------------|
| 자동화 수준 | ★★★★★ | ★★☆☆☆ | ★☆☆☆☆ |
| 가격 | ★★★★★ | ★★★☆☆ | ★☆☆☆☆ |
| 숙박업 특화 | ★★★★★ | ★☆☆☆☆ | ★★★☆☆ |
| AI 음악 생성 | ★★★★★ | ☆☆☆☆☆ | ☆☆☆☆☆ |
| 3채널 통합 | ★★★★★ | ☆☆☆☆☆ | ★★★☆☆ |
| 다국어 지원 | ★★★★★ | ★☆☆☆☆ | ★★☆☆☆ |
### 우리만의 차별점
1. **숙박업 완전 특화**
- 네이버 플레이스 데이터 자동 추출
- 숙박업에 최적화된 AI 프롬프트
- 업종별 최적화된 템플릿
2. **완전 자동화**
- URL 입력 → 영상 생성 → SNS 업로드까지 원스톱
- 예약 업로드 기능으로 정기 마케팅 자동화
3. **AI 음악 생성**
- 로열티 프리 음악 걱정 없음
- 펜션 분위기에 맞춤 생성
4. **합리적인 가격**
- 영상 1개 비용 = 커피 한잔 가격
---
## 5. 요금제 안내
### 요금제 비교
| 항목 | Free | Basic | Pro | Business |
|------|------|-------|-----|----------|
| 월 요금 | 무료 | ₩29,000 | ₩99,000 | ₩299,000 |
| 월 크레딧 | 10회 | 15회 | 75회 | 무제한 |
| 관리 펜션 | 1개 | 1개 | 5개 | 무제한 |
| YouTube 업로드 | ✓ | ✓ | ✓ | ✓ |
| Instagram 업로드 | - | ✓ | ✓ | ✓ |
| TikTok 업로드 | - | ✓ | ✓ | ✓ |
| 다국어 콘텐츠 | - | - | ✓ | ✓ |
| 프리미엄 템플릿 | - | - | ✓ | ✓ |
| 전담 매니저 | - | - | - | ✓ |
| 분석 리포트 | - | - | 주간 | 일간 |
### 타겟별 추천 플랜
| 고객 유형 | 추천 플랜 | 이유 |
|----------|----------|------|
| 개인 펜션 운영자 | Basic | 1개 펜션 관리에 충분한 기능 |
| 2-5개 펜션 운영 법인 | Pro | 다중 펜션 관리 + 다국어 |
| 리조트/체인 | Business | 무제한 + 전담 지원 |
| 시작 테스트 | Free | 무료로 기능 체험 |
### 업셀링 포인트
**Free → Basic**
- "월 10회로 부족하시죠? 15회로 늘리면서 Instagram까지!"
**Basic → Pro**
- "펜션 추가하셨나요? Pro면 5개까지 관리 가능!"
- "외국인 손님 많으시죠? 다국어로 글로벌 마케팅!"
**Pro → Business**
- "월 75회 이상 필요하시면 무제한이 경제적!"
- "전담 매니저가 성과 분석까지 도와드립니다!"
---
## 6. 데모 시나리오
### 5분 데모 시나리오
**1단계: 문제 제기 (30초)**
> "사장님, 요즘 인스타 릴스나 틱톡 마케팅 하고 계신가요? 하시려면 영상을 만들어야 하는데 시간도 없고 어렵죠?"
**2단계: 솔루션 소개 (30초)**
> "저희 CaStAD는 네이버 플레이스 URL만 넣으면 자동으로 영상이 만들어집니다. 한번 보시겠어요?"
**3단계: 실시간 데모 (2분)**
1. 고객 펜션의 네이버 플레이스 URL 복사
2. CaStAD에 붙여넣기
3. 15초 후 영상 생성 완료
4. 영상 미리보기 및 다운로드
**4단계: 기능 설명 (1분)**
> "이 영상이 자동으로 만들어졌습니다. 음악도 AI가 만든 거예요.
> 여기서 바로 유튜브, 인스타, 틱톡에 업로드할 수 있어요."
**5단계: 클로징 (30초)**
> "지금 무료 체험 가입하시면 10회까지 무료로 사용 가능합니다.
> 한번 사용해보시고 효과 있으시면 Basic 플랜 추천드릴게요."
### 핵심 데모 포인트
1. **고객의 실제 펜션으로 데모** → 즉각적인 공감
2. **15초 생성 시간 강조** → "이게 15초 만에요?"
3. **음악도 AI 생성** → "음악도 따로 안 구해도 돼요?"
4. **원클릭 업로드** → "바로 올릴 수 있네요!"
---
## 7. 예상 질문 및 답변 (FAQ)
### 제품 관련
**Q: 영상 품질이 어느 정도예요?**
> A: 1080p Full HD로 제작되며, 인스타 릴스나 틱톡에 올리기에 전문가 수준입니다. 실제 데모 영상을 보여드릴까요?
**Q: 네이버 플레이스가 없으면 못 써요?**
> A: 직접 사진 업로드도 가능합니다. 다만 네이버 플레이스가 있으면 리뷰, 정보가 자동 추출되어 더 풍부한 영상이 만들어져요.
**Q: 음악 저작권 문제는 없나요?**
> A: 모든 음악이 Suno AI로 자동 생성되어 저작권 문제가 전혀 없습니다. 상업적 사용도 100% 가능해요.
**Q: 영상 수정할 수 있어요?**
> A: 마음에 안 드시면 다른 스타일로 다시 생성하실 수 있어요. 생성 횟수 내에서 무제한 재생성 가능합니다.
### 가격 관련
**Q: 왜 월 구독제예요?**
> A: AI 서버 운영 비용이 들기 때문에 구독제로 운영됩니다. 대신 영상 1개당 비용으로 계산하면 건당 2,000원도 안 돼요.
**Q: 계약 기간은요?**
> A: 월 단위 구독이라 언제든 해지 가능합니다. 연 결제 시 20% 할인 혜택도 있어요.
**Q: 크레딧이 남으면 이월되나요?**
> A: 죄송하지만 미사용 크레딧은 이월되지 않습니다. 대신 크레딧 추가 구매가 가능해요.
### 기술 관련
**Q: 설치해야 해요?**
> A: 웹 기반이라 설치 필요 없습니다. 크롬 브라우저만 있으면 PC, 모바일 어디서든 사용 가능해요.
**Q: 인스타 연동이 안전한가요?**
> A: 비밀번호는 암호화되어 저장되고, 원하시면 언제든 연결 해제 가능합니다. 인스타 공식 연동 방식도 지원해요.
**Q: 여러 명이 함께 쓸 수 있어요?**
> A: Business 플랜에서 팀 계정 기능을 지원합니다. 직원분들과 함께 사용 가능해요.
---
## 8. 성공 사례
### 사례 1: 제주 오션뷰 펜션
> "인스타 팔로워가 한 달 만에 3배 늘었어요.
> 매주 릴스 올리는데 10분도 안 걸려요."
>
> - 월 영상 제작: 15개
> - 예약률 증가: 25%
> - 팔로워 증가: 850명 → 2,400명
### 사례 2: 강원도 풀빌라 체인 (5개 지점)
> "각 지점 개별로 마케팅하는 게 힘들었는데,
> 이제 한 곳에서 5개 다 관리해요."
>
> - 관리 시간: 주 10시간 → 1시간
> - 월 콘텐츠: 5개 → 50개
> - 마케팅 비용: 200만원 → 10만원
### 사례 3: 경기도 글램핑장
> "틱톡에서 영상 하나가 10만뷰 나왔어요.
> 외국인 예약이 갑자기 늘어났어요."
>
> - 틱톡 조회수: 평균 1,000 → 15,000
> - 외국인 예약: 10% → 35%
> - 네이버 플레이스 순위: 8위 → 2위
---
## 9. 계약 절차
### 신규 계약 프로세스
```
1. 무료 체험
2. 데모 및 상담
3. 플랜 선택
4. 결제 및 계정 생성
5. 초기 설정 지원
6. 정기 영상 생성 시작
```
### 필요 서류
- 사업자등록증 (세금계산서 발행 시)
- 결제 카드 정보 (자동결제)
### 결제 방법
- 신용카드 (자동결제)
- 계좌이체 (연 결제 시)
- 세금계산서 발행 가능
---
## 10. 기술 지원 정책
### 지원 채널
| 플랜 | 이메일 | 채팅 | 전화 | 전담 매니저 |
|------|--------|------|------|------------|
| Free | ✓ (48h) | - | - | - |
| Basic | ✓ (24h) | ✓ | - | - |
| Pro | ✓ (12h) | ✓ | ✓ | - |
| Business | ✓ (4h) | ✓ | ✓ | ✓ |
### 지원 범위
- 계정 및 결제 문의
- 기능 사용 방법
- SNS 연동 문제
- 영상 생성 오류
- 비즈니스 플랜: 마케팅 전략 컨설팅 포함
### 응답 시간 보장
- Business 플랜: 4시간 내 응답 보장
- 긴급 이슈: 1시간 내 처리
---
## 영업 자료 체크리스트
### 필수 준비물
- [ ] 데모 계정 (관리자 권한)
- [ ] 노트북 + 인터넷 (데모용)
- [ ] 명함 및 회사 소개서
- [ ] 요금표 출력물
- [ ] 계약서 양식
### 현장 체크
- [ ] 고객 네이버 플레이스 URL 확인
- [ ] 현재 마케팅 방법 파악
- [ ] 예산 및 결정권자 확인
- [ ] 경쟁사 사용 여부 확인
- [ ] 다음 미팅 일정 잡기
---
## 연락처
- **영업 문의**: sales@castad.io
- **기술 지원**: support@castad.io
- **파트너십**: partners@castad.io
---
*이 매뉴얼은 영업팀 내부용입니다. 외부 유출을 금합니다.*
**마지막 업데이트: 2025년 12월**
**버전: 3.0.0**

21
SERVER_DEPLOY_GUIDE.md Normal file
View File

@ -0,0 +1,21 @@
#!/bin/bash
# 배포 가이드 (서버에서 실행하세요)
# 1. 압축 해제
# tar -xzvf bizvibe_deploy.tar.gz
# 2. 필수 패키지 설치 (Ubuntu 기준)
# sudo apt-get update && sudo apt-get install -y ffmpeg fonts-noto-cjk
# 3. 의존성 설치 및 빌드
# ./deploy.sh
# 4. 환경 변수 설정
# .env 파일을 열어 API 키들을 확인하고 채워주세요.
# 특히 SUNO_API_KEY, VITE_GEMINI_API_KEY 확인 필수!
# 5. 서버 실행
# ./start.sh
echo "배포 준비 완료! bizvibe_deploy.tar.gz 파일을 서버로 전송하세요."

80
USER_MANUAL.md Normal file
View File

@ -0,0 +1,80 @@
# ADo4 사용자 매뉴얼 (User Manual)
**ADo4**는 AI 기술을 활용하여 누구나 쉽게 전문가 수준의 마케팅 비디오를 만들 수 있는 도구입니다. 이 매뉴얼을 따라 단계별로 영상을 만들어보세요.
---
## 1. 시작하기 (메인 화면)
서비스에 접속하면 가장 먼저 보게 되는 메인 대시보드입니다. 왼쪽은 **데이터 입력 영역**, 오른쪽은 **설정 영역**으로 구성되어 있습니다.
### 1-1. 업체 정보 입력 (자동 검색 기능)
가장 쉬운 방법은 **[정보 자동 검색]** 기능을 활용하는 것입니다.
* **Google Maps 검색:** '지도 검색 (Global)' 입력창에 `지역명 + 상호명` (예: "강남 스타벅스", "군산 이성당")을 입력하고 **[검색]** 버튼을 누르세요.
* **Naver 플레이스:** 네이버 지도 URL이 있다면 붙여넣고 **[가져오기]**를 누르면 더욱 정확한 한국형 데이터를 가져올 수 있습니다.
> 💡 **Tip:** 자동 검색을 사용하면 업체명, 설명, 대표 사진(AI가 선별한 베스트 컷)이 자동으로 채워집니다.
---
## 2. 세부 설정 (Customization)
자동으로 입력된 정보를 확인하고, 원하는 스타일로 다듬어 보세요.
### 2-1. 프로젝트 설정
* **브랜드 이름 & 설명:** 자동 입력된 내용을 수정하거나 직접 입력합니다. 이곳에 적힌 내용(분위기, 특징)을 바탕으로 AI가 가사를 씁니다.
* **화면 비율:**
* `16:9`: 유튜브, TV, 모니터용 (가로형)
* `9:16`: 인스타그램 릴스, 틱톡, 유튜브 쇼츠용 (세로형)
* **비주얼 스타일:**
* `슬라이드`: 사진들이 부드럽게 전환되는 슬라이드쇼 영상 (추천)
* `AI 비디오`: 사진을 바탕으로 AI가 움직이는 영상을 생성 (생성 시간 김)
### 2-2. 오디오 및 자막 설정
* **오디오 모드:**
* `노래 (Song)`: 나만의 오리지널 CM송을 작곡합니다.
* `성우 (Narration)`: 차분하고 신뢰감 있는 성우 목소리로 읽어줍니다.
* **장르 선택:** 팝, 발라드, 재즈, 힙합 등 원하는 분위기를 고르세요.
* **자막 스타일:** 영상 위에 입혀질 자막의 디자인(네온, 타자기 등)을 선택하세요.
---
## 3. 영상 생성 시작
모든 설정이 끝났다면, 우측 하단의 **[영상 생성 시작]** 버튼을 클릭하세요.
> ⏳ **소요 시간:** 약 1~2분 정도 소요됩니다.
> AI가 시나리오 작성 -> 작곡 -> 이미지 검수 -> 영상 합성을 진행하는 동안 잠시만 기다려주세요.
---
## 4. 결과 확인 및 저장 (플레이어 화면)
영상이 완성되면 자동으로 **결과 플레이어** 화면이 나타납니다.
### 4-1. 영상 재생
* 가운데 **[재생 ▶]** 버튼을 눌러 완성된 영상을 감상해 보세요.
* 가사나 광고 문구가 음악에 맞춰 화면에 나타나는 것을 볼 수 있습니다.
### 4-2. 영상 저장 및 활용
* **[영상 저장 (텍스트 효과 포함)]:** 완성된 고화질 MP4 파일을 내 컴퓨터로 다운로드합니다. 동시에 서버에서 **YouTube 자동 업로드** 준비가 진행됩니다.
* **[YouTube 업로드]:** (영상 저장 후 활성화됨) 클릭 한 번으로 내 유튜브 채널에 영상을 업로드합니다. 업로드가 완료되면 **[보러가기]** 버튼이 나타납니다.
* **[빠른 저장 (효과X)]:** 자막 효과 없이 원본 영상과 음악만 빠르게 합쳐서 저장하고 싶을 때 사용합니다.
---
## ❓ 자주 묻는 질문 (FAQ)
**Q. 영상 생성이 실패했어요.**
A. 일시적인 네트워크 문제일 수 있습니다. 잠시 후 다시 시도해 주세요. 계속 실패한다면 입력한 이미지의 용량이 너무 크거나(개당 10MB 이상), 인터넷 연결이 불안정할 수 있습니다.
**Q. 유튜브 업로드가 안 돼요.**
A. 최초 1회 인증이 필요합니다. 관리자에게 문의하거나 서버 설정 가이드를 참고하여 구글 계정 인증을 완료해주세요.
**Q. 세로 영상(9:16)인데 사진이 잘려요.**
A. AI가 자동으로 최적의 구도를 찾지만, 가로로 긴 사진은 양옆이 잘릴 수밖에 없습니다. 되도록 세로로 찍은 사진을 업로드하거나, AI에게 맡겨주세요.
---
© 2025 ADo4. All Rights Reserved.

20
components.json Normal file
View File

@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/styles/globals.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/src/components",
"utils": "@/src/lib/utils",
"ui": "@/src/components/ui",
"lib": "@/src/lib",
"hooks": "@/src/hooks"
}
}

View File

@ -0,0 +1,150 @@
import React, { useState, useEffect } from 'react';
import { Key } from 'lucide-react';
/**
* ApiKeySelector props
* @interface ApiKeySelectorProps
* @property {(key?: string) => void} onKeySelected - API . ( )
* @property {string | null} [initialError] - ( )
*/
interface ApiKeySelectorProps {
onKeySelected: (key?: string) => void;
initialError?: string | null;
}
/**
* API
* Gemini Veo Google Cloud Project API ,
* AI Studio .
*/
const ApiKeySelector: React.FC<ApiKeySelectorProps> = ({ onKeySelected, initialError }) => {
const [loading, setLoading] = useState(false); // 로딩 상태 관리
const [error, setError] = useState<string | null>(initialError || null); // 에러 메시지 관리
const [manualKey, setManualKey] = useState(''); // 수동으로 입력된 API 키
const [isAiStudio, setIsAiStudio] = useState(false); // 현재 환경이 Google AI Studio인지 여부
/**
* API
* Google AI Studio , API .
*/
const checkKey = async () => {
try {
// window 객체에 aistudio 속성이 있는지 확인하여 AI Studio 환경을 감지합니다.
if ((window as any).aistudio) {
setIsAiStudio(true);
// AI Studio에서 이미 API 키가 선택되어 있는지 확인합니다.
const hasKey = await (window as any).aistudio.hasSelectedApiKey();
if (hasKey && !initialError) {
// 키가 이미 선택되어 있고 초기 에러가 없으면 onKeySelected 콜백을 호출하여 앱 시작
onKeySelected();
}
}
} catch (e) {
console.error("API 키 확인 중 오류 발생:", e);
// 오류가 발생해도 isAiStudio 상태는 유지
}
};
// 컴포넌트가 처음 렌더링될 때 한 번만 checkKey 함수 실행
useEffect(() => {
checkKey();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/**
* AI Studio API
* AI Studio `openSelectKey()` API UI .
*/
const handleSelectKey = async () => {
setLoading(true); // 로딩 상태 시작
setError(null); // 기존 에러 메시지 초기화
try {
if((window as any).aistudio) {
await (window as any).aistudio.openSelectKey(); // AI Studio 키 선택 대화 상자 열기
onKeySelected(); // 키 선택 성공 시 콜백 호출
}
} catch (e: any) {
console.error("API 키 선택 중 오류 발생:", e);
// 특정 에러 메시지에 따라 사용자에게 더 명확한 안내 제공
if (e.message && e.message.includes("Requested entity was not found")) {
setError("유효하지 않은 프로젝트/키가 선택되었습니다. 다시 시도해주세요.");
} else {
setError("API 키 선택에 실패했습니다. 다시 시도해주세요.");
}
} finally {
setLoading(false); // 로딩 상태 종료
}
};
/**
* API
* API .
*/
const handleManualSubmit = (e: React.FormEvent) => {
e.preventDefault(); // 폼 기본 제출 동작 방지
if(!manualKey.trim()) {
setError("유효한 API 키를 입력해주세요."); // 빈 값일 경우 에러 메시지
return;
}
onKeySelected(manualKey.trim()); // 유효한 키가 입력되면 콜백 호출
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-90 p-4">
<div className="glass-panel p-8 rounded-2xl max-w-md w-full text-center border border-purple-500/30 shadow-2xl shadow-purple-900/20">
<div className="mb-6 flex justify-center">
<div className="p-4 bg-purple-900/30 rounded-full">
<Key className="w-8 h-8 text-purple-400" />
</div>
</div>
<h2 className="text-2xl font-bold text-white mb-2">API </h2>
<p className="text-gray-400 mb-6">
Veo Gemini , Google Cloud API .
</p>
{error && (
<div className="mb-4 p-3 bg-red-900/30 border border-red-500/30 rounded text-red-200 text-sm">
{error}
</div>
)}
{isAiStudio ? (
// AI Studio 환경일 경우 API 키 선택 버튼 제공
<button
onClick={handleSelectKey}
disabled={loading}
className="w-full py-3 px-4 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white font-bold rounded-xl transition-all transform hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed shadow-lg"
>
{loading ? '연결 중...' : 'API 키 선택'}
</button>
) : (
// 일반 브라우저 환경일 경우 수동 API 키 입력 폼 제공
<form onSubmit={handleManualSubmit} className="flex flex-col gap-4">
<input
type="password" // 비밀번호 타입으로 입력 값 숨김
value={manualKey}
onChange={(e) => setManualKey(e.target.value)}
placeholder="Gemini API 키 입력"
className="w-full p-3 rounded-xl bg-white/10 border border-white/20 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
<button
type="submit"
className="w-full py-3 px-4 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white font-bold rounded-xl transition-all transform hover:scale-105 shadow-lg"
>
</button>
</form>
)}
<div className="mt-4 text-xs text-gray-500">
<a href="https://ai.google.dev/gemini-api/docs/billing" target="_blank" rel="noreferrer" className="underline hover:text-gray-300">
</a>
</div>
</div>
</div>
);
};
export default ApiKeySelector;

100
components/CaStADLogo.tsx Normal file
View File

@ -0,0 +1,100 @@
import React from 'react';
interface CaStADLogoProps {
size?: 'sm' | 'md' | 'lg' | 'xl';
showText?: boolean;
className?: string;
}
const sizeClasses = {
sm: { icon: 'w-8 h-8', text: 'text-lg' },
md: { icon: 'w-12 h-12', text: 'text-xl' },
lg: { icon: 'w-16 h-16', text: 'text-2xl' },
xl: { icon: 'w-24 h-24', text: 'text-4xl' },
};
// Main CaStAD Logo with custard pudding icon
export const CaStADLogo: React.FC<CaStADLogoProps> = ({
size = 'md',
showText = true,
className
}) => {
const { icon, text } = sizeClasses[size];
return (
<div className={`flex items-center gap-2 ${className || ''}`}>
{/* Custard Pudding SVG Icon */}
<div className={`${icon} relative`}>
<img
src="/images/castad-logo.svg"
alt="CaStAD"
className="w-full h-full object-contain"
/>
</div>
{showText && (
<div className="flex flex-col">
<span className={`${text} font-bold bg-gradient-to-r from-amber-600 via-amber-500 to-yellow-600 bg-clip-text text-transparent`}>
CaStAD
</span>
<span className="text-[10px] text-muted-foreground tracking-wide">
</span>
</div>
)}
</div>
);
};
// Inline version with custard cream colors
export const CaStADLogoInline: React.FC<CaStADLogoProps> = ({
size = 'md',
className
}) => {
const { icon, text } = sizeClasses[size];
return (
<div className={`flex items-center gap-2 ${className || ''}`}>
{/* Custard emoji as simple icon */}
<span className={`${icon} flex items-center justify-center`}>
<span className="text-2xl">🍮</span>
</span>
<span className={`${text} font-bold bg-gradient-to-r from-amber-600 via-amber-500 to-yellow-600 bg-clip-text text-transparent`}>
CaStAD
</span>
</div>
);
};
// Icon only version
export const CaStADIcon: React.FC<{ size?: 'sm' | 'md' | 'lg' | 'xl'; className?: string }> = ({
size = 'md',
className
}) => {
const { icon } = sizeClasses[size];
return (
<div className={`${icon} ${className || ''}`}>
<img
src="/images/castad-logo.svg"
alt="CaStAD"
className="w-full h-full object-contain"
/>
</div>
);
};
// Text only gradient version
export const CaStADText: React.FC<{ size?: 'sm' | 'md' | 'lg' | 'xl'; className?: string }> = ({
size = 'md',
className
}) => {
const { text } = sizeClasses[size];
return (
<span className={`${text} font-bold bg-gradient-to-r from-amber-600 via-amber-500 to-yellow-600 bg-clip-text text-transparent ${className || ''}`}>
CaStAD
</span>
);
};
export default CaStADLogo;

1169
components/InputForm.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,133 @@
import React from 'react';
import { useLanguage } from '../src/contexts/LanguageContext';
import { cn } from '../src/lib/utils';
import { Card, CardContent } from '../src/components/ui/card';
import { Progress } from '../src/components/ui/progress';
import { Loader2, Sparkles, Music, Image, Video, CheckCircle } from 'lucide-react';
interface LoadingOverlayProps {
status: string;
message: string;
progress?: number;
}
const STEP_ICONS: Record<string, React.ReactNode> = {
'crawling': <Sparkles className="w-6 h-6" />,
'generating_text': <Sparkles className="w-6 h-6" />,
'generating_audio': <Music className="w-6 h-6" />,
'generating_poster': <Image className="w-6 h-6" />,
'generating_video': <Video className="w-6 h-6" />,
'completed': <CheckCircle className="w-6 h-6 text-green-500" />,
};
const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ status, message, progress }) => {
const { t } = useLanguage();
let displayMessage = message;
switch (status) {
case 'crawling':
case 'generating_text':
displayMessage = t('loadingStep1');
break;
case 'generating_audio':
displayMessage = t('loadingStep2').replace('{style}', 'Auto');
break;
case 'generating_poster':
case 'generating_video':
displayMessage = t('loadingStep3');
break;
case 'completed':
displayMessage = t('loadingStep4');
break;
default:
break;
}
const currentProgress = progress || (
status === 'crawling' ? 10 :
status === 'generating_text' ? 25 :
status === 'generating_audio' ? 50 :
status === 'generating_poster' ? 70 :
status === 'generating_video' ? 85 :
status === 'completed' ? 100 : 10
);
const currentIcon = STEP_ICONS[status] || <Sparkles className="w-6 h-6" />;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/95 backdrop-blur-sm">
<Card className="w-full max-w-md mx-4 border-border/50 shadow-2xl">
<CardContent className="pt-8 pb-8 text-center">
{/* Animated Icon */}
<div className="relative w-24 h-24 mx-auto mb-8">
{/* Outer ring */}
<div className="absolute inset-0 rounded-full border-4 border-primary/20 animate-ping" style={{ animationDuration: '2s' }} />
{/* Spinning ring */}
<div className="absolute inset-0 rounded-full border-4 border-transparent border-t-primary border-r-accent animate-spin" style={{ animationDuration: '1.5s' }} />
{/* Center icon */}
<div className="absolute inset-3 rounded-full bg-gradient-to-br from-primary to-accent flex items-center justify-center shadow-lg">
<div className="text-primary-foreground animate-pulse">
{currentIcon}
</div>
</div>
</div>
<h2 className="text-2xl font-bold text-foreground mb-2">
{t('loadingTitle')}
</h2>
<p className="text-muted-foreground text-sm mb-6">
{t('loadingSubtitle')}
</p>
{/* Progress Bar */}
<div className="mb-6">
<Progress value={currentProgress} className="h-2" />
<div className="flex items-center justify-between mt-2 text-xs text-muted-foreground">
<span>{t('textStyle') === '자막 스타일' ? '진행률' : 'Progress'}</span>
<span className="font-medium">{currentProgress}%</span>
</div>
</div>
{/* Status Message */}
<div className="flex items-center justify-center gap-2 mb-4">
<Loader2 className="w-4 h-4 animate-spin text-primary" />
<p className="text-lg font-semibold gradient-text">
{displayMessage}
</p>
</div>
{/* Steps indicator */}
<div className="flex items-center justify-center gap-2 mb-6">
{['generating_text', 'generating_audio', 'generating_poster', 'completed'].map((step, idx) => {
const isActive = status === step;
const isPast = ['generating_text', 'generating_audio', 'generating_poster', 'generating_video', 'completed'].indexOf(status) >
['generating_text', 'generating_audio', 'generating_poster', 'completed'].indexOf(step);
return (
<div
key={step}
className={cn(
"w-2 h-2 rounded-full transition-all",
isActive && "w-6 bg-primary",
isPast && "bg-primary/60",
!isActive && !isPast && "bg-muted-foreground/30"
)}
/>
);
})}
</div>
<p className="text-xs text-muted-foreground">
{t('loadingWait')}
</p>
</CardContent>
</Card>
</div>
);
};
export default LoadingOverlay;

555
components/Navbar.tsx Normal file
View File

@ -0,0 +1,555 @@
import React, { useState, useEffect } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../src/contexts/AuthContext';
import { useLanguage } from '../src/contexts/LanguageContext';
import { useTheme, PALETTES, ColorPalette } from '../src/contexts/ThemeContext';
import {
LogOut,
User,
LayoutDashboard,
Menu,
Settings,
Globe,
ChevronDown,
Video,
Shield,
X,
Coins,
AlertCircle,
Sun,
Moon,
Palette,
Check
} from 'lucide-react';
import { CaStADLogo, CaStADLogoInline } from './CaStADLogo';
import { Language } from '../types';
import { cn } from '../src/lib/utils';
import { Button } from '../src/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '../src/components/ui/dropdown-menu';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
SheetClose,
} from '../src/components/ui/sheet';
import { Avatar, AvatarFallback } from '../src/components/ui/avatar';
import { Badge } from '../src/components/ui/badge';
import { Separator } from '../src/components/ui/separator';
const LANGUAGES = [
{ code: 'KO', label: '한국어', flag: 'kr' },
{ code: 'EN', label: 'English', flag: 'us' },
{ code: 'JP', label: '日本語', flag: 'jp' },
{ code: 'CN', label: '中文', flag: 'cn' },
{ code: 'TH', label: 'ไทย', flag: 'th' },
{ code: 'VN', label: 'Tiếng Việt', flag: 'vn' },
] as const;
const Navbar: React.FC = () => {
const { user, logout, token } = useAuth();
const { language, setLanguage, t } = useLanguage();
const { theme, palette, toggleTheme, setPalette } = useTheme();
const navigate = useNavigate();
const location = useLocation();
const [isScrolled, setIsScrolled] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const [credits, setCredits] = useState<number | null>(null);
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 10);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
// 크레딧 조회
useEffect(() => {
const fetchCredits = async () => {
if (!user || !token) {
setCredits(null);
return;
}
try {
const backendPort = import.meta.env.VITE_BACKEND_PORT || '3001';
const res = await fetch(`http://localhost:${backendPort}/api/credits`, {
headers: { Authorization: `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
setCredits(data.credits);
}
} catch (err) {
console.error('Failed to fetch credits:', err);
}
};
fetchCredits();
// 주기적으로 업데이트 (30초마다)
const interval = setInterval(fetchCredits, 30000);
return () => clearInterval(interval);
}, [user, token]);
const handleLogout = () => {
logout();
navigate('/login');
setMobileOpen(false);
};
// Hide navbar during server rendering (autoplay mode)
const searchParams = new URLSearchParams(location.search);
if (searchParams.get('autoplay') === 'true') {
return null;
}
const currentLanguage = LANGUAGES.find(l => l.code === language) || LANGUAGES[0];
const NavLink = ({ to, children, icon: Icon }: { to: string; children: React.ReactNode; icon?: React.ElementType }) => {
const isActive = location.pathname === to;
return (
<Link
to={to}
onClick={() => setMobileOpen(false)}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
)}
>
{Icon && <Icon className="w-4 h-4" />}
{children}
</Link>
);
};
return (
<nav
className={cn(
"fixed top-0 left-0 right-0 z-50 transition-all duration-300",
isScrolled
? "bg-background/80 backdrop-blur-xl border-b border-border shadow-sm"
: "bg-background/50 backdrop-blur-md"
)}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<Link to="/" className="flex items-center group hover:opacity-90 transition-opacity">
<CaStADLogo size="sm" />
</Link>
{/* Desktop Navigation */}
<div className="hidden md:flex items-center gap-1">
<NavLink to="/" icon={Video}>
{t('menuCreate')}
</NavLink>
{user && (
<NavLink to="/dashboard" icon={LayoutDashboard}>
{t('menuLibrary')}
</NavLink>
)}
{user?.role === 'admin' && (
<NavLink to="/admin" icon={Shield}>
{t('menuAdmin')}
</NavLink>
)}
</div>
{/* Right Actions */}
<div className="flex items-center gap-2">
{/* Language Switcher */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="gap-1.5 text-muted-foreground hover:text-foreground"
>
<span className={`fi fi-${currentLanguage.flag} rounded-sm`} />
<span className="hidden sm:inline text-xs font-medium">
{currentLanguage.code}
</span>
<ChevronDown className="w-3 h-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuLabel className="text-xs text-muted-foreground">
{t('settingLanguage') || 'Language'}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{LANGUAGES.map((lang) => (
<DropdownMenuItem
key={lang.code}
onClick={() => setLanguage(lang.code as Language)}
className={cn(
"gap-2 cursor-pointer",
language === lang.code && "bg-accent"
)}
>
<span className={`fi fi-${lang.flag} rounded-sm`} />
<span className="text-sm">{lang.label}</span>
{language === lang.code && (
<Badge variant="secondary" className="ml-auto text-[10px] px-1.5">
</Badge>
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Theme Toggle */}
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
className="w-9 h-9 text-muted-foreground hover:text-foreground"
>
{theme === 'dark' ? (
<Sun className="w-4 h-4" />
) : (
<Moon className="w-4 h-4" />
)}
<span className="sr-only"> </span>
</Button>
{/* Palette Selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="w-9 h-9 text-muted-foreground hover:text-foreground"
>
<Palette className="w-4 h-4" />
<span className="sr-only"> </span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuLabel className="text-xs text-muted-foreground">
</DropdownMenuLabel>
<DropdownMenuSeparator />
{(Object.keys(PALETTES) as ColorPalette[]).map((key) => (
<DropdownMenuItem
key={key}
onClick={() => setPalette(key)}
className="gap-2 cursor-pointer"
>
<div className={cn(
"w-5 h-5 rounded-full bg-gradient-to-r",
PALETTES[key].preview
)} />
<span className="text-sm">{PALETTES[key].name}</span>
{palette === key && (
<Check className="w-4 h-4 ml-auto text-primary" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* User Menu / Auth Buttons */}
{user ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="gap-2 pl-2">
<Avatar className="w-7 h-7">
<AvatarFallback className="bg-primary/10 text-primary text-xs font-semibold">
{user.name?.charAt(0).toUpperCase() || 'U'}
</AvatarFallback>
</Avatar>
<span className="hidden sm:inline text-sm font-medium max-w-[100px] truncate">
{user.name}
</span>
<ChevronDown className="w-3 h-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">{user.name}</p>
<p className="text-xs text-muted-foreground">@{user.username}</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{/* 크레딧 표시 */}
{user.role !== 'admin' && credits !== null && (
<>
<div className="px-2 py-2">
<div className={cn(
"flex items-center justify-between px-3 py-2 rounded-lg",
credits <= 0 ? "bg-destructive/10" : credits <= 3 ? "bg-yellow-500/10" : "bg-primary/10"
)}>
<div className="flex items-center gap-2">
<Coins className={cn(
"w-4 h-4",
credits <= 0 ? "text-destructive" : credits <= 3 ? "text-yellow-600" : "text-primary"
)} />
<span className="text-sm font-medium"></span>
</div>
<div className="flex items-center gap-1">
<span className={cn(
"text-lg font-bold",
credits <= 0 ? "text-destructive" : credits <= 3 ? "text-yellow-600" : "text-primary"
)}>
{credits}
</span>
<span className="text-xs text-muted-foreground"></span>
</div>
</div>
{credits <= 3 && (
<Link
to="/credits"
className="flex items-center gap-1.5 mt-2 text-xs text-muted-foreground hover:text-primary transition-colors"
>
<AlertCircle className="w-3 h-3" />
{credits <= 0 ? '크레딧이 부족합니다. 충전 요청하기' : '크레딧이 부족합니다'}
</Link>
)}
</div>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/dashboard" className="flex items-center gap-2">
<LayoutDashboard className="w-4 h-4" />
{t('menuLibrary')}
</Link>
</DropdownMenuItem>
{user.role === 'admin' && (
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/admin" className="flex items-center gap-2">
<Shield className="w-4 h-4" />
{t('menuAdmin')}
</Link>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleLogout}
className="cursor-pointer text-destructive focus:text-destructive"
>
<LogOut className="w-4 h-4 mr-2" />
{t('menuLogout')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<div className="hidden md:flex items-center gap-2">
<Button variant="ghost" size="sm" asChild>
<Link to="/login">{t('menuLogin')}</Link>
</Button>
<Button size="sm" className="shadow-lg shadow-primary/20" asChild>
<Link to="/register">{t('menuStart')}</Link>
</Button>
</div>
)}
{/* Mobile Menu Button */}
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetTrigger asChild className="md:hidden">
<Button variant="ghost" size="icon" className="shrink-0">
<Menu className="w-5 h-5" />
<span className="sr-only"> </span>
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-[300px] sm:w-[350px]">
<SheetHeader className="text-left">
<SheetTitle>
<CaStADLogo size="sm" />
</SheetTitle>
</SheetHeader>
<div className="mt-8 flex flex-col gap-2">
{/* Mobile Navigation Links */}
<NavLink to="/" icon={Video}>
{t('menuCreate')}
</NavLink>
{user && (
<NavLink to="/dashboard" icon={LayoutDashboard}>
{t('menuLibrary')}
</NavLink>
)}
{user?.role === 'admin' && (
<NavLink to="/admin" icon={Shield}>
{t('menuAdmin')}
</NavLink>
)}
<Separator className="my-4" />
{/* Mobile User Section */}
{user ? (
<div className="space-y-4">
<div className="flex items-center gap-3 px-3 py-2">
<Avatar className="w-10 h-10">
<AvatarFallback className="bg-primary/10 text-primary font-semibold">
{user.name?.charAt(0).toUpperCase() || 'U'}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{user.name}</p>
<p className="text-xs text-muted-foreground">@{user.username}</p>
</div>
{user.role === 'admin' && (
<Badge variant="secondary" className="text-[10px]">Admin</Badge>
)}
</div>
{/* 모바일 크레딧 표시 */}
{user.role !== 'admin' && credits !== null && (
<div className={cn(
"flex items-center justify-between px-3 py-3 rounded-lg mx-3",
credits <= 0 ? "bg-destructive/10" : credits <= 3 ? "bg-yellow-500/10" : "bg-primary/10"
)}>
<div className="flex items-center gap-2">
<Coins className={cn(
"w-5 h-5",
credits <= 0 ? "text-destructive" : credits <= 3 ? "text-yellow-600" : "text-primary"
)} />
<span className="font-medium"></span>
</div>
<div className="flex items-center gap-1">
<span className={cn(
"text-xl font-bold",
credits <= 0 ? "text-destructive" : credits <= 3 ? "text-yellow-600" : "text-primary"
)}>
{credits}
</span>
<span className="text-sm text-muted-foreground"></span>
</div>
</div>
)}
{user.role !== 'admin' && credits !== null && credits <= 3 && (
<Link
to="/credits"
onClick={() => setMobileOpen(false)}
className="flex items-center justify-center gap-2 mx-3 px-3 py-2 rounded-lg bg-primary/10 text-primary text-sm font-medium hover:bg-primary/20 transition-colors"
>
<Coins className="w-4 h-4" />
</Link>
)}
<Button
variant="outline"
className="w-full justify-start gap-2 text-destructive hover:text-destructive"
onClick={handleLogout}
>
<LogOut className="w-4 h-4" />
{t('menuLogout')}
</Button>
</div>
) : (
<div className="flex flex-col gap-2">
<Button variant="outline" className="w-full" asChild>
<Link to="/login" onClick={() => setMobileOpen(false)}>
{t('menuLogin')}
</Link>
</Button>
<Button className="w-full" asChild>
<Link to="/register" onClick={() => setMobileOpen(false)}>
{t('menuStart')}
</Link>
</Button>
</div>
)}
<Separator className="my-4" />
{/* Mobile Theme Settings */}
<div className="px-3 space-y-4">
{/* Theme Toggle */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{theme === 'dark' ? '다크 모드' : '라이트 모드'}
</span>
<Button
variant="outline"
size="sm"
onClick={toggleTheme}
className="gap-2"
>
{theme === 'dark' ? (
<>
<Sun className="w-4 h-4" />
<span></span>
</>
) : (
<>
<Moon className="w-4 h-4" />
<span></span>
</>
)}
</Button>
</div>
{/* Palette Selection */}
<div>
<p className="text-xs font-medium text-muted-foreground mb-2">
</p>
<div className="flex gap-2">
{(Object.keys(PALETTES) as ColorPalette[]).map((key) => (
<button
key={key}
onClick={() => setPalette(key)}
className={cn(
"w-8 h-8 rounded-full bg-gradient-to-r transition-all",
PALETTES[key].preview,
palette === key
? "ring-2 ring-offset-2 ring-offset-background ring-primary scale-110"
: "hover:scale-105"
)}
title={PALETTES[key].name}
/>
))}
</div>
</div>
</div>
<Separator className="my-4" />
{/* Mobile Language Selection */}
<div className="px-3">
<p className="text-xs font-medium text-muted-foreground mb-2">
{t('settingLanguage') || 'Language'}
</p>
<div className="grid grid-cols-2 gap-2">
{LANGUAGES.map((lang) => (
<button
key={lang.code}
onClick={() => setLanguage(lang.code as Language)}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors",
language === lang.code
? "bg-primary/10 text-primary border border-primary/20"
: "bg-accent/50 hover:bg-accent"
)}
>
<span className={`fi fi-${lang.flag} rounded-sm`} />
<span className="truncate">{lang.label}</span>
</button>
))}
</div>
</div>
</div>
</SheetContent>
</Sheet>
</div>
</div>
</div>
</nav>
);
};
export default Navbar;

View File

@ -0,0 +1,234 @@
import React, { useState, useEffect } from 'react';
import { X, ChevronRight, Check } from 'lucide-react';
interface TourStep {
target: string;
title: string;
description: string;
placement?: 'top' | 'bottom' | 'left' | 'right';
}
interface OnboardingTourProps {
steps: TourStep[];
onComplete: () => void;
onSkip: () => void;
}
const OnboardingTour: React.FC<OnboardingTourProps> = ({ steps, onComplete, onSkip }) => {
const [currentStep, setCurrentStep] = useState(0);
const [position, setPosition] = useState({ top: 0, left: 0 });
useEffect(() => {
// Remove previous highlights
document.querySelectorAll('.tour-highlight').forEach(el => {
el.classList.remove('tour-highlight');
});
const updatePosition = () => {
const target = document.querySelector(steps[currentStep].target);
if (!target) {
console.warn(`Tour target not found: ${steps[currentStep].target}`);
// Try again after a short delay
setTimeout(updatePosition, 100);
return;
}
// 팝업을 화면 기준으로 고정 배치 (스크롤에 영향받지 않음)
const viewportWidth = window.innerWidth;
// 모바일/데스크톱 구분
const isMobile = viewportWidth < 768;
let top: number;
let left: number;
if (isMobile) {
// 모바일: 화면 하단 고정
top = 20; // viewport 기준
left = 20;
} else {
// 데스크톱: 화면 우측 상단 고정
top = 20; // viewport 기준 (상단에서 20px)
left = viewportWidth - 340; // 우측에서 340px (카드 너비 320 + 여유 20)
}
setPosition({ top, left });
// Highlight target element
target.classList.add('tour-highlight');
// Scroll into view
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
};
// Wait for DOM to be ready
const timer = setTimeout(updatePosition, 150);
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition);
return () => {
clearTimeout(timer);
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition);
};
}, [currentStep, steps]);
const handleNext = () => {
if (currentStep < steps.length - 1) {
setCurrentStep(currentStep + 1);
} else {
onComplete();
}
};
const handlePrev = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1);
}
};
const currentStepData = steps[currentStep];
return (
<>
{/* Overlay - 매우 투명하게 */}
<div className="fixed inset-0 bg-black/5 z-40 animate-in fade-in" />
{/* Tour Card - viewport 기준 고정 위치 */}
<div
className="fixed z-50 w-80 bg-gray-900/98 backdrop-blur-md border-2 border-purple-500 rounded-2xl shadow-2xl animate-in fade-in zoom-in-95"
style={{
top: `${position.top}px`, // viewport 기준
right: '20px', // 우측 고정
maxHeight: 'calc(100vh - 40px)', // 화면 높이에서 여유 40px
overflowY: 'auto'
}}
>
{/* Header */}
<div className="p-4 border-b border-gray-700 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
{steps.map((_, idx) => (
<div
key={idx}
className={`h-2 rounded-full transition-all ${
idx === currentStep
? 'w-8 bg-purple-500'
: idx < currentStep
? 'w-2 bg-green-500'
: 'w-2 bg-gray-600'
}`}
/>
))}
</div>
<span className="text-xs text-gray-400 ml-2">
{currentStep + 1} / {steps.length}
</span>
</div>
<button
onClick={onSkip}
className="text-gray-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-5">
<h3 className="text-xl font-bold text-white mb-2 flex items-center gap-2">
<span className="w-8 h-8 rounded-full bg-purple-600 flex items-center justify-center text-sm">
{currentStep + 1}
</span>
{currentStepData.title}
</h3>
<p className="text-gray-300 text-sm leading-relaxed">
{currentStepData.description}
</p>
</div>
{/* Footer */}
<div className="p-4 border-t border-gray-700 flex items-center justify-between">
<button
onClick={onSkip}
className="text-sm text-gray-400 hover:text-white transition-colors"
>
</button>
<div className="flex gap-2">
{currentStep > 0 && (
<button
onClick={handlePrev}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg text-sm font-semibold transition-all"
>
</button>
)}
<button
onClick={handleNext}
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm font-semibold transition-all flex items-center gap-2"
>
{currentStep === steps.length - 1 ? (
<>
<Check className="w-4 h-4" />
</>
) : (
<>
<ChevronRight className="w-4 h-4" />
</>
)}
</button>
</div>
</div>
</div>
{/* CSS for highlight effect */}
<style>{`
.tour-highlight {
position: relative !important;
z-index: 9999 !important;
background-color: white !important;
box-shadow:
0 0 0 6px rgba(168, 85, 247, 1),
0 0 0 12px rgba(168, 85, 247, 0.5),
0 0 40px 0 rgba(168, 85, 247, 0.6),
0 0 80px 0 rgba(168, 85, 247, 0.3) !important;
border-radius: 16px !important;
animation: pulse-highlight 2s cubic-bezier(0.4, 0, 0.6, 1) infinite !important;
outline: none !important;
transform: scale(1.02) !important;
transition: all 0.3s ease !important;
}
.tour-highlight * {
position: relative !important;
z-index: 10000 !important;
}
@keyframes pulse-highlight {
0%, 100% {
box-shadow:
0 0 0 6px rgba(168, 85, 247, 1),
0 0 0 12px rgba(168, 85, 247, 0.5),
0 0 40px 0 rgba(168, 85, 247, 0.6),
0 0 80px 0 rgba(168, 85, 247, 0.3);
transform: scale(1.02);
}
50% {
box-shadow:
0 0 0 8px rgba(168, 85, 247, 1),
0 0 0 16px rgba(168, 85, 247, 0.6),
0 0 60px 0 rgba(168, 85, 247, 0.8),
0 0 100px 0 rgba(168, 85, 247, 0.4);
transform: scale(1.03);
}
}
`}</style>
</>
);
};
export default OnboardingTour;

157
components/ResultList.tsx Normal file
View File

@ -0,0 +1,157 @@
import React from 'react';
import { GeneratedAssets } from '../types';
import { Play, Clock, Music, Mic, Video, Image as ImageIcon, Download } from 'lucide-react';
/**
* ResultList Props
* @interface ResultListProps
* @property {GeneratedAssets[]} history - ()
* @property {(asset: GeneratedAssets) => void} onSelect -
* @property {string} [currentId] - ID ( , UI )
*/
interface ResultListProps {
history: GeneratedAssets[];
onSelect: (asset: GeneratedAssets) => void;
currentId?: string;
}
/**
*
* AI / , .
*/
const ResultList: React.FC<ResultListProps> = ({ history, onSelect, currentId }) => {
// 파일 강제 다운로드 핸들러
const handleDirectDownload = async (e: React.MouseEvent, url: string, filename: string) => {
e.stopPropagation();
e.preventDefault();
try {
const response = await fetch(url);
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(blobUrl);
a.remove();
} catch (err) {
console.error("다운로드 실패:", err);
window.open(url, '_blank');
}
};
if (history.length === 0) return null;
return (
<div className="w-full max-w-5xl mx-auto mt-12 mb-20 animate-in slide-in-from-bottom-10 duration-700">
<div className="flex items-center gap-3 mb-6 px-4">
<Clock className="w-5 h-5 text-purple-400" />
<h3 className="text-xl font-bold text-white"> (History)</h3>
<span className="px-2 py-0.5 bg-purple-500/20 text-purple-300 text-xs rounded-full font-mono">{history.length}</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 px-4">
{history.map((asset) => (
<div
key={asset.id}
onClick={() => onSelect(asset)}
className={`group relative bg-white/5 border rounded-2xl overflow-hidden cursor-pointer transition-all hover:scale-[1.02] hover:shadow-xl hover:shadow-purple-500/10
${currentId === asset.id ? 'border-purple-500 ring-1 ring-purple-500 bg-white/10' : 'border-white/10 hover:border-purple-500/50'}
`}
>
{/* 포스터 썸네일 */}
<div className="aspect-video bg-black relative overflow-hidden">
{asset.posterUrl ? (
<img
src={asset.posterUrl}
alt={asset.businessName}
className="w-full h-full object-cover opacity-80 group-hover:opacity-100 transition-opacity"
onError={(e) => {
e.currentTarget.style.display = 'none'; // 이미지 숨김
e.currentTarget.nextElementSibling?.classList.remove('hidden'); // 대체 div 표시 (구조상 별도 처리 필요하지만 간단히 숨김 처리)
// 부모 요소에 배경색이 있으므로 이미지가 숨겨지면 배경색이 보임.
// 더 완벽하게 하려면 state로 관리해야 하나, 여기선 간단히 처리.
}}
/>
) : (
<div className="w-full h-full bg-gray-800 flex items-center justify-center text-gray-500"><ImageIcon /></div>
)}
{/* 이미지 로드 실패 시 보여줄 폴백 (JS로 제어하기보다, 위 onError에서 이미지 숨기면 아래 배경이 보임) */}
<div className="absolute inset-0 flex items-center justify-center bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity backdrop-blur-[2px]">
<div className="p-3 rounded-full bg-white/20 backdrop-blur-sm border border-white/30">
<Play className="w-6 h-6 text-white fill-white" />
</div>
</div>
<div className="absolute top-2 left-2 flex gap-1 flex-wrap">
{asset.audioMode === 'Song' ? (
<div className="p-1 bg-purple-600/90 rounded text-white shadow-sm"><Music className="w-3 h-3" /></div>
) : (
<div className="p-1 bg-blue-600/90 rounded text-white shadow-sm"><Mic className="w-3 h-3" /></div>
)}
<div className="px-2 py-0.5 bg-black/60 backdrop-blur-sm rounded text-[10px] text-white font-bold border border-white/10 flex items-center">
{asset.textEffect}
</div>
</div>
</div>
{/* 정보 영역 */}
<div className="p-4">
<h4 className="text-white font-bold truncate mb-1 text-base">{asset.businessName}</h4>
{asset.sourceUrl && (
<a href={asset.sourceUrl} target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()} className="text-xs text-blue-400 hover:underline truncate block mb-2 opacity-70 hover:opacity-100">
{asset.sourceUrl}
</a>
)}
<div className="flex items-center justify-between text-xs text-gray-400">
{/* 생성 날짜 및 시간 */}
<span>
{new Date(asset.createdAt).toLocaleDateString()}
</span>
{/* 장르 또는 비디오 타입 표시 */}
<span className="flex items-center gap-1 bg-white/5 px-1.5 py-0.5 rounded">
{asset.audioMode === 'Song' ? (
<>{asset.musicGenre || 'Music'}</>
) : (
<><Mic className="w-3 h-3" /> Narration</>
)}
</span>
</div>
</div>
{/* 하단 버튼 영역 */}
<div className="px-4 pb-4 pt-0 flex gap-2">
{asset.finalVideoPath ? (
<button
onClick={(e) => handleDirectDownload(e, asset.finalVideoPath!, `CastAD_${asset.businessName}_Final.mp4`)}
className="flex-1 py-2 bg-green-600 hover:bg-green-500 rounded-lg text-sm font-bold text-white transition-colors flex items-center justify-center gap-2"
>
<Download className="w-4 h-4" />
</button>
) : null}
<button
onClick={(e) => {
e.stopPropagation();
onSelect(asset);
}}
className={`flex-1 py-2 bg-purple-600 hover:bg-purple-500 rounded-lg text-sm font-bold text-white transition-colors flex items-center justify-center gap-2 ${asset.finalVideoPath ? 'bg-gray-700 hover:bg-gray-600' : ''}`}
>
<Play className="w-4 h-4" /> {asset.finalVideoPath ? '수정/재생성' : '결과 보기 / 다운로드'}
</button>
</div>
</div>
))}
</div>
</div>
);
};
export default ResultList;

949
components/ResultPlayer.tsx Normal file
View File

@ -0,0 +1,949 @@
/// <reference lib="dom" />
import React, { useEffect, useRef, useState } from 'react';
import { GeneratedAssets } from '../types';
import { Play, Pause, RefreshCw, Download, Image as ImageIcon, Video as VideoIcon, Music, Mic, Loader2, Film, Share2, Youtube, ArrowLeft, Volume2, VolumeX } from 'lucide-react';
import { mergeVideoAndAudio } from '../services/ffmpegService';
import ShareModal from './ShareModal';
import SlideshowBackground from './SlideshowBackground';
import YouTubeSEOPreview from '../src/components/YouTubeSEOPreview';
// shadcn/ui components
import { cn } from '../src/lib/utils';
import { Button } from '../src/components/ui/button';
import { Card, CardContent } from '../src/components/ui/card';
import { Badge } from '../src/components/ui/badge';
import { Tabs, TabsList, TabsTrigger } from '../src/components/ui/tabs';
import { Separator } from '../src/components/ui/separator';
import { Progress } from '../src/components/ui/progress';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../src/components/ui/tooltip';
import { useLanguage } from '../src/contexts/LanguageContext';
interface ResultPlayerProps {
assets: GeneratedAssets;
onReset: () => void;
autoPlay?: boolean;
}
type TextPosition = 'center' | 'bottom-left' | 'top-right' | 'center-left';
const ResultPlayer: React.FC<ResultPlayerProps> = ({ assets, onReset, autoPlay = false }) => {
const { t } = useLanguage();
const videoRef = useRef<HTMLVideoElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTextIndex, setCurrentTextIndex] = useState(0);
const [showText, setShowText] = useState(false);
const [activeTab, setActiveTab] = useState<'video' | 'poster'>('video');
const [textPosition, setTextPosition] = useState<TextPosition>('center');
const [hasPlayed, setHasPlayed] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [isMerging, setIsMerging] = useState(false);
const [mergeProgress, setMergeProgress] = useState('');
const [isServerDownloading, setIsServerDownloading] = useState(false);
const [isRendering, setIsRendering] = useState(false);
const [downloadProgress, setDownloadProgress] = useState(0);
const [lastProjectFolder, setLastProjectFolder] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [uploadStatus, setUploadStatus] = useState('');
const [youtubeUrl, setYoutubeUrl] = useState<string | null>(null);
const [showShareModal, setShowShareModal] = useState(false);
const [showSEOPreview, setShowSEOPreview] = useState(false);
// AutoPlay 로직
useEffect(() => {
const video = videoRef.current;
const audio = audioRef.current;
if (autoPlay && audio) {
if (video) video.volume = 1.0;
audio.volume = 1.0;
let isStarted = false;
const stopPlay = () => {
if (video) video.pause();
audio.pause();
setIsPlaying(false);
setHasPlayed(true);
};
const handleEnded = () => {
stopPlay();
};
const startPlay = () => {
if (isStarted) return;
isStarted = true;
if (video) video.currentTime = 0;
audio.currentTime = 0;
audio.addEventListener('ended', handleEnded);
const playPromises = [
audio.play().catch(e => console.error("오디오 재생 실패", e))
];
if (video) {
playPromises.push(video.play().catch(e => console.error("비디오 재생 실패", e)));
}
Promise.all(playPromises).then(() => {
setActiveTab('video');
setIsPlaying(true);
setHasPlayed(true);
});
};
const checkReady = () => {
const videoReady = video ? video.readyState >= 3 : true;
const audioReady = audio.readyState >= 3;
if (videoReady && audioReady) {
startPlay();
}
};
checkReady();
if (video) video.addEventListener('canplay', checkReady);
audio.addEventListener('canplay', checkReady);
const timer = setTimeout(() => {
console.warn("AutoPlay 타임아웃 - 강제 재생 시도");
startPlay();
}, 10000);
return () => {
if (video) video.removeEventListener('canplay', checkReady);
audio.removeEventListener('canplay', checkReady);
audio.removeEventListener('ended', handleEnded);
clearTimeout(timer);
};
}
}, [autoPlay, assets.aspectRatio]);
const togglePlay = () => {
if (audioRef.current) {
if (isPlaying) {
videoRef.current?.pause();
audioRef.current.pause();
} else {
videoRef.current?.play();
audioRef.current.play();
setActiveTab('video');
setHasPlayed(true);
}
setIsPlaying(!isPlaying);
}
};
const toggleMute = () => {
if (audioRef.current) {
audioRef.current.muted = !isMuted;
setIsMuted(!isMuted);
}
};
useEffect(() => {
const video = videoRef.current;
const audio = audioRef.current;
if (audio) {
if (video) video.loop = true;
const handleEnded = () => {
setIsPlaying(false);
video?.pause();
setCurrentTextIndex(0);
setShowText(false);
};
audio.addEventListener('ended', handleEnded);
return () => {
audio.removeEventListener('ended', handleEnded);
};
}
}, []);
useEffect(() => {
const mediaRef = videoRef.current || audioRef.current;
if (autoPlay && !isPlaying && mediaRef && mediaRef.currentTime > 0) {
// 재생 종료 상태
}
}, [autoPlay, isPlaying]);
useEffect(() => {
if (!isPlaying) return;
const positions: TextPosition[] = ['center', 'bottom-left', 'center-left', 'top-right'];
const interval = setInterval(() => {
setShowText(false);
setTimeout(() => {
setCurrentTextIndex((prev) => (prev + 1) % assets.adCopy.length);
const randomPos = positions[Math.floor(Math.random() * positions.length)];
setTextPosition(randomPos);
setShowText(true);
}, 600);
}, 4500);
setShowText(true);
return () => clearInterval(interval);
}, [isPlaying, assets.adCopy.length]);
const handleServerDownload = async () => {
if (isServerDownloading) return;
setIsServerDownloading(true);
setDownloadProgress(10);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 300000);
try {
const urlToBase64 = async (url: string): Promise<string | undefined> => {
if (!url) return undefined;
try {
// blob URL이든 외부 URL이든 모두 처리
let response: Response;
if (url.startsWith('blob:')) {
response = await fetch(url);
} else if (url.startsWith('http')) {
// 외부 URL은 프록시를 통해 가져옴 (CORS 우회)
response = await fetch(`/api/proxy/audio?url=${encodeURIComponent(url)}`);
} else {
return undefined;
}
const blob = await response.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result as string;
resolve(result.split(',')[1]);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
} catch (e) {
console.error('Audio URL to Base64 failed:', e);
return undefined;
}
};
setDownloadProgress(20);
const posterBase64 = await urlToBase64(assets.posterUrl);
const audioBase64 = await urlToBase64(assets.audioUrl);
setDownloadProgress(30);
let imagesBase64: string[] = [];
if (assets.images && assets.images.length > 0) {
imagesBase64 = await Promise.all(
assets.images.map(async (img) => {
if (img.startsWith('blob:')) {
return (await urlToBase64(img)) || img;
}
if (img.startsWith('data:')) {
return img.split(',')[1];
}
return img;
})
);
}
setDownloadProgress(40);
const payload = {
...assets,
historyId: assets.id,
posterBase64,
audioBase64,
imagesBase64
};
setIsRendering(true);
setDownloadProgress(50);
const token = localStorage.getItem('token');
const response = await fetch('/render', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : ''
},
body: JSON.stringify(payload),
signal: controller.signal
});
setDownloadProgress(80);
if (!response.ok) {
const err = await response.text();
throw new Error(`서버 오류: ${err}`);
}
const folderNameHeader = response.headers.get('X-Project-Folder');
if (folderNameHeader) {
setLastProjectFolder(decodeURIComponent(folderNameHeader));
}
setDownloadProgress(90);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `CastAD_${assets.businessName}_Final.mp4`;
document.body.appendChild(a);
a.click();
a.remove();
setDownloadProgress(100);
} catch (e: any) {
console.error(e);
if (e.name === 'AbortError') {
alert("영상 생성 시간 초과 (5분). 서버 부하가 높거나 네트워크 문제일 수 있습니다.");
} else {
alert("영상 생성 실패: 서버가 실행 중인지 확인해주세요.");
}
} finally {
clearTimeout(timeoutId);
setIsServerDownloading(false);
setIsRendering(false);
setTimeout(() => setDownloadProgress(0), 2000);
}
};
const handleYoutubeClick = () => {
if (!lastProjectFolder) {
alert("먼저 영상을 생성(다운로드)해야 업로드할 수 있습니다.");
return;
}
setShowSEOPreview(true);
};
const handleYoutubeUpload = async (seoData?: any) => {
if (!lastProjectFolder) {
alert("먼저 영상을 생성(다운로드)해야 업로드할 수 있습니다.");
return;
}
setShowSEOPreview(false);
setIsUploading(true);
setUploadStatus("YouTube 연결 확인 중...");
setYoutubeUrl(null);
const token = localStorage.getItem('token');
try {
// 먼저 사용자의 YouTube 연결 상태 확인
const connRes = await fetch('/api/youtube/connection', {
headers: { 'Authorization': token ? `Bearer ${token}` : '' }
});
const connData = await connRes.json();
// SEO 데이터가 있으면 사용, 없으면 기본값
const title = seoData?.snippet?.title_ko || `${assets.businessName} - AI Marketing Video`;
const description = seoData?.snippet?.description_ko || assets.description;
const tags = seoData?.snippet?.tags_ko || [];
const categoryId = seoData?.snippet?.categoryId || '19'; // 19 = Travel & Events
const pinnedComment = seoData?.pinned_comment_ko || '';
// 비디오 경로 생성
const videoPath = `downloads/${lastProjectFolder}/final.mp4`;
let response;
if (connData.connected) {
// 사용자 채널에 업로드 (새 API)
setUploadStatus("내 채널에 업로드 중...");
response = await fetch('/api/youtube/my-upload', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : ''
},
body: JSON.stringify({
videoPath,
seoData: { title, description, tags, pinnedComment },
historyId: assets.id,
categoryId
})
});
} else {
// 레거시 업로드 (시스템 채널)
setUploadStatus("시스템 채널에 업로드 중...");
response = await fetch('/api/youtube/upload', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : ''
},
body: JSON.stringify({
videoPath,
seoData: { title, description, tags },
categoryId
})
});
}
if (!response.ok) {
const errData = await response.json();
throw new Error(errData.error || "업로드 실패");
}
const data = await response.json();
setYoutubeUrl(data.youtubeUrl);
setUploadStatus(connData.connected ? "내 채널에 업로드 완료!" : "업로드 완료!");
} catch (e: any) {
console.error(e);
setUploadStatus(e.message || "업로드 실패");
alert(`YouTube 업로드 실패: ${e.message}`);
} finally {
setIsUploading(false);
}
};
const handleMergeDownload = async () => {
if (isMerging) return;
setIsMerging(true);
setMergeProgress('초기화 중...');
try {
const mergedUrl = await mergeVideoAndAudio(assets.videoUrl, assets.audioUrl, (msg) => setMergeProgress(msg));
const a = document.createElement('a');
a.href = mergedUrl;
a.download = `CastAD_Campaign_Full.mp4`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setMergeProgress('완료!');
setTimeout(() => {
setIsMerging(false);
setMergeProgress('');
}, 2000);
} catch (e: any) {
alert(e.message || "다운로드 중 오류가 발생했습니다.");
setIsMerging(false);
setMergeProgress('');
}
};
const handleAudioDownload = async () => {
try {
if (assets.audioUrl.startsWith('blob:')) {
const a = document.createElement('a');
a.href = assets.audioUrl;
a.download = `CastAD_${assets.businessName}_Audio.mp3`;
document.body.appendChild(a);
a.click();
a.remove();
} else {
const response = await fetch(assets.audioUrl);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `CastAD_${assets.businessName}_Audio.mp3`;
document.body.appendChild(a);
a.click();
a.remove();
}
} catch (e) {
console.error("오디오 다운로드 실패:", e);
alert("오디오 다운로드 실패");
}
};
const getEffectClass = () => {
switch(assets.textEffect) {
case 'Neon': return 'effect-neon text-white font-black tracking-wider';
case 'Glitch': return 'effect-glitch font-bold tracking-tighter uppercase';
case 'Typewriter': return 'effect-typewriter font-mono bg-black/50 p-2';
case 'Cinematic': return 'effect-cinematic font-light tracking-[0.2em] uppercase text-gray-100';
case 'Bold': return 'effect-bold text-white font-black italic tracking-tighter';
case 'Motion': return 'effect-motion font-extrabold italic tracking-tight text-5xl md:text-7xl';
case 'LineReveal': return 'effect-line-reveal font-bold uppercase tracking-widest py-4';
case 'Boxed': return 'effect-boxed font-bold uppercase tracking-wider';
case 'Elegant': return 'effect-elegant font-light text-4xl md:text-6xl';
case 'BlockReveal': return 'effect-block-reveal font-black uppercase italic text-5xl md:text-7xl px-4 py-1';
case 'Custom': return 'effect-custom custom-effect';
default: return 'effect-cinematic';
}
};
const getRandomColor = () => {
const colors = ['#FF00DE', '#00FF94', '#00F0FF', '#FFFD00', '#FF5C00', '#9D00FF'];
return colors[Math.floor(Math.random() * colors.length)];
};
useEffect(() => {
if (assets.textEffect === 'Custom' && assets.customStyleCSS) {
let styleTag = document.getElementById('generated-custom-style');
if (!styleTag) {
styleTag = document.createElement('style');
styleTag.id = 'generated-custom-style';
document.head.appendChild(styleTag);
}
styleTag.textContent = assets.customStyleCSS;
}
}, [assets.textEffect, assets.customStyleCSS]);
const formatText = (text: string) => {
const commonClasses = "block drop-shadow-xl px-4 rounded-lg text-center mx-auto whitespace-pre-wrap";
if (assets.textEffect === 'Typewriter' || assets.textEffect === 'Glitch') {
return (
<span className={`inline-block ${commonClasses} ${getEffectClass()}`} data-text={text}>
{text}
</span>
);
}
const lines = text.split('\n');
const noBgEffects = ['Motion', 'LineReveal', 'Boxed', 'Elegant', 'BlockReveal'];
return lines.map((line, idx) => {
const style: React.CSSProperties = {};
if (assets.textEffect === 'Motion') {
style.animationDelay = `${idx * 0.2}s`;
} else if (assets.textEffect === 'BlockReveal') {
style.animationDelay = `${idx * 0.3}s`;
(style as any)['--block-color'] = getRandomColor();
}
return (
<span
key={idx}
className={`block mb-3 ${commonClasses} ${!noBgEffects.includes(assets.textEffect) ? 'bg-black/20 backdrop-blur-sm' : ''} ${getEffectClass()}`}
style={style}
>
{line.trim()}
</span>
);
});
};
const getPositionClasses = () => {
return 'inset-0 items-center justify-center text-center';
};
const cleanLyrics = assets.lyrics
.split('\n')
.filter(line => {
const trimmed = line.trim();
if (!trimmed) return false;
if (trimmed.startsWith('[') || trimmed.startsWith('(')) return false;
if (/^(Narrator|나레이터|성우|Speaker|Woman|Man).*?:/i.test(trimmed)) return false;
return true;
})
.map(line => line.replace(/\*\*/g, "").replace(/\*/g, "").replace(/[•*-]/g, "").trim())
.join(" • ");
const marqueeDuration = `${Math.max(20, cleanLyrics.length * 0.3)}s`;
const isVertical = assets.aspectRatio === '9:16';
const containerClass = isVertical ? "max-w-md aspect-[9/16]" : "max-w-5xl aspect-video";
const wrapperClass = isVertical ? "max-w-md" : "max-w-5xl";
// [오디오 전용 모드]
if (assets.creationMode === 'AudioOnly') {
return (
<div className="w-full max-w-lg mx-auto pb-20 flex flex-col items-center justify-center min-h-[60vh]">
<Card className="w-full border-border/50 shadow-2xl overflow-hidden">
<CardContent className="p-8 text-center relative">
{/* 배경 효과 */}
<div className="absolute inset-0 bg-gradient-to-b from-primary/10 to-transparent" />
<div className={cn(
"absolute inset-0 flex items-center justify-center transition-opacity duration-1000 pointer-events-none",
isPlaying ? 'opacity-40' : 'opacity-10'
)}>
<div className="w-48 h-48 bg-primary rounded-full blur-[80px] animate-pulse" />
</div>
<div className="relative z-10 flex flex-col items-center">
{/* 앨범 커버 */}
<div className={cn(
"w-32 h-32 rounded-full bg-gradient-to-br from-card to-background border-4 border-border flex items-center justify-center shadow-xl mb-6",
isPlaying && 'animate-spin-slow'
)}>
{assets.audioMode === 'Song'
? <Music className="w-12 h-12 text-primary" />
: <Mic className="w-12 h-12 text-accent" />
}
</div>
<div className="mb-8">
<h2 className="text-2xl font-bold text-foreground mb-2">{assets.businessName}</h2>
<Badge variant="secondary">
{assets.audioMode === 'Song' ? 'AI Generated Song' : 'AI Voice Narration'}
</Badge>
</div>
{/* 컨트롤 */}
<div className="flex gap-4 items-center mb-6">
<Button
size="icon"
variant="outline"
onClick={toggleMute}
className="w-12 h-12 rounded-full"
>
{isMuted ? <VolumeX className="w-5 h-5" /> : <Volume2 className="w-5 h-5" />}
</Button>
<Button
onClick={togglePlay}
className="w-16 h-16 rounded-full bg-foreground text-background hover:opacity-90"
>
{isPlaying ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6 ml-1" />}
</Button>
</div>
{/* 다운로드 */}
<Button
variant="outline"
onClick={handleAudioDownload}
className="w-full"
>
<Download className="w-4 h-4 mr-2" />
MP3 {t('textStyle') === '자막 스타일' ? '다운로드' : 'Download'}
</Button>
</div>
<audio ref={audioRef} src={assets.audioUrl} onEnded={() => setIsPlaying(false)} />
</CardContent>
</Card>
<Button variant="ghost" onClick={onReset} className="mt-6">
<ArrowLeft className="w-4 h-4 mr-2" />
{t('textStyle') === '자막 스타일' ? '처음으로' : 'Go Back'}
</Button>
</div>
);
}
// [비디오 모드]
return (
<div className={autoPlay ? "fixed inset-0 w-screen h-screen z-[9999] bg-black flex items-center justify-center overflow-hidden" : `w-full ${wrapperClass} mx-auto pb-10`}>
{/* 탭 */}
{!autoPlay && (
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'video' | 'poster')} className="w-full mb-6">
<TabsList className="grid w-full max-w-md mx-auto grid-cols-2">
<TabsTrigger value="video" className="flex items-center gap-2">
<VideoIcon className="w-4 h-4" />
{t('textStyle') === '자막 스타일' ? '광고 영상' : 'Video'}
</TabsTrigger>
<TabsTrigger value="poster" className="flex items-center gap-2">
<ImageIcon className="w-4 h-4" />
{t('textStyle') === '자막 스타일' ? '광고 포스터' : 'Poster'}
</TabsTrigger>
</TabsList>
</Tabs>
)}
<Card className={cn(
"overflow-hidden border-border/50 shadow-2xl",
autoPlay && "border-none rounded-none"
)}>
<CardContent className={cn("p-0", autoPlay && "h-full")}>
{/* 콘텐츠 영역 */}
<div className={cn(
"relative overflow-hidden mx-auto bg-black",
autoPlay ? "w-full h-full" : `${containerClass} rounded-lg`
)}>
{/* 비디오/슬라이드쇼 뷰 */}
<div className={cn(
"absolute inset-0 transition-opacity duration-700",
activeTab === 'video' ? 'opacity-100 z-10' : 'opacity-0 z-0'
)}>
{assets.visualStyle === 'Slideshow' && assets.images && assets.images.length > 0 ? (
<SlideshowBackground
images={assets.images}
durationPerImage={5000}
effect={assets.transitionEffect}
/>
) : (
<video
ref={videoRef}
src={assets.videoUrl}
className="w-full h-full object-cover"
playsInline
muted
loop
preload="auto"
/>
)}
{/* 텍스트 오버레이 */}
<div className={cn(
"absolute flex pointer-events-none z-20 p-8 transition-all duration-500",
getPositionClasses()
)}>
<div className={cn(
"transition-all duration-700 transform ease-out",
showText ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 translate-y-8 scale-95',
isVertical ? 'w-full px-4' : 'max-w-4xl'
)}>
<div
className={cn(
"text-white",
isVertical ? 'text-3xl leading-snug' : 'text-4xl md:text-6xl leading-tight'
)}
style={{
fontFamily: assets.textEffect === 'Typewriter' ? "'Fira Code', monospace" : "'Noto Sans KR', sans-serif",
letterSpacing: '-0.02em',
textShadow: '0 4px 30px rgba(0,0,0,0.5)'
}}
>
{formatText(assets.adCopy[currentTextIndex])}
</div>
</div>
</div>
{/* 재생 버튼 오버레이 */}
{!isPlaying && !autoPlay && (
<button
type="button"
className="absolute inset-0 flex items-center justify-center bg-black/60 z-50 cursor-pointer backdrop-blur-[2px] transition-all hover:bg-black/50"
onClick={togglePlay}
>
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-primary to-accent flex items-center justify-center shadow-2xl hover:scale-110 transition-transform">
<Play className="w-8 h-8 text-primary-foreground fill-current ml-1" />
</div>
</button>
)}
{/* 가사 Marquee */}
<div className={cn(
"absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black via-black/80 to-transparent z-20 flex items-end p-6",
isVertical ? 'h-40 pb-10' : 'h-32'
)}>
<div className="w-full flex items-end gap-4">
<div className="hidden md:block w-1.5 h-16 bg-gradient-to-b from-primary to-accent rounded-full" />
<div className="flex-1 overflow-hidden">
<div className="flex items-center gap-2 mb-2">
{assets.audioMode === 'Song' ? (
<>
<Music className="w-4 h-4 text-primary" />
<span className="text-primary text-xs font-bold uppercase tracking-widest">Original AI Sound</span>
</>
) : (
<>
<Mic className="w-4 h-4 text-accent" />
<span className="text-accent text-xs font-bold uppercase tracking-widest">AI Voice Narration</span>
</>
)}
</div>
<div className="relative h-12 overflow-hidden w-full mask-linear-fade">
<div
className="flex whitespace-nowrap"
style={{
animation: `marquee ${marqueeDuration} linear infinite`
}}
>
<span className="text-white/90 text-lg font-medium mr-12 inline-block">
{cleanLyrics}
</span>
<span className="text-white/90 text-lg font-medium mr-12 inline-block">
{cleanLyrics}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
{/* 포스터 뷰 */}
<div className={cn(
"absolute inset-0 bg-black flex items-center justify-center transition-opacity duration-700",
activeTab === 'poster' ? 'opacity-100 z-10' : 'opacity-0 z-0'
)}>
<img src={assets.posterUrl} alt="AI Generated Poster" className="w-full h-full object-contain" />
</div>
<audio ref={audioRef} src={assets.audioUrl} onEnded={() => setIsPlaying(false)} />
</div>
</CardContent>
</Card>
{/* 컨트롤 바 - 2줄 레이아웃 */}
{!autoPlay && (
<Card className="mt-4 border-border/50">
<CardContent className="p-4 space-y-4">
{/* 1줄: 재생 컨트롤 */}
<div className="flex items-center justify-center gap-4">
{/* 뒤로가기 버튼 */}
<Button
variant="ghost"
size="sm"
onClick={onReset}
className="text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="w-4 h-4 mr-1" />
{t('textStyle') === '자막 스타일' ? '처음으로' : 'Back'}
</Button>
<Separator orientation="vertical" className="h-8" />
{/* 재생/일시정지 버튼 - 중앙에 크게 */}
<Button
variant={isPlaying ? "default" : "outline"}
size="icon"
onClick={togglePlay}
className={cn(
"h-14 w-14 rounded-full transition-all duration-300",
isPlaying
? "bg-primary hover:bg-primary/90 shadow-lg shadow-primary/30 scale-110"
: "hover:bg-primary/10 hover:border-primary hover:scale-105"
)}
>
{isPlaying ? (
<Pause className="w-6 h-6" />
) : (
<Play className="w-6 h-6 ml-0.5" />
)}
</Button>
<div className="min-w-[100px]">
<p className="text-[10px] text-muted-foreground mb-0.5 text-center">
{isPlaying ? 'NOW PLAYING' : 'READY TO PLAY'}
</p>
<p className="text-xs font-medium text-foreground text-center">
{activeTab === 'video'
? (t('textStyle') === '자막 스타일' ? 'AI 광고 영상' : 'AI Ad Video')
: (t('textStyle') === '자막 스타일' ? 'AI 포스터' : 'AI Poster')
}
</p>
</div>
</div>
<Separator />
{/* 2줄: 저장 버튼들 */}
<div className="flex items-center justify-center gap-2 flex-wrap">
<TooltipProvider>
{/* 서버 다운로드 */}
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={handleServerDownload}
disabled={isServerDownloading}
className="gap-2"
>
{isServerDownloading ? (
<><Loader2 className="w-4 h-4 animate-spin" /> {t('textStyle') === '자막 스타일' ? '생성중...' : 'Rendering...'}</>
) : (
<><Download className="w-4 h-4" /> {t('textStyle') === '자막 스타일' ? '영상 저장' : 'Save Video'}</>
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('textStyle') === '자막 스타일' ? '텍스트 효과가 포함된 영상 저장' : 'Save with text effects'}</p>
</TooltipContent>
</Tooltip>
{/* YouTube 업로드 */}
{!youtubeUrl ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
onClick={handleYoutubeClick}
disabled={isUploading || !lastProjectFolder}
className={cn(
"gap-2",
lastProjectFolder && "bg-red-600 hover:bg-red-500 text-white border-red-600"
)}
>
{isUploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Youtube className="w-4 h-4" />}
YouTube
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{!lastProjectFolder
? (t('textStyle') === '자막 스타일' ? '먼저 영상을 저장하세요' : 'Save video first')
: (t('textStyle') === '자막 스타일' ? 'YouTube에 업로드' : 'Upload to YouTube')
}</p>
</TooltipContent>
</Tooltip>
) : (
<div className="flex items-center gap-2">
<Button
variant="default"
asChild
className="bg-red-600 hover:bg-red-500 gap-2"
>
<a href={youtubeUrl} target="_blank" rel="noopener noreferrer">
<Youtube className="w-4 h-4" />
{t('textStyle') === '자막 스타일' ? '보러가기' : 'Watch'}
</a>
</Button>
<Badge variant="outline" className="text-green-500 border-green-500/50">
{t('textStyle') === '자막 스타일' ? '업로드 완료' : 'Uploaded'}
</Badge>
</div>
)}
</TooltipProvider>
</div>
{/* 다운로드 진행률 */}
{downloadProgress > 0 && downloadProgress < 100 && (
<div className="w-full pt-2 border-t border-border/50">
<Progress value={downloadProgress} className="h-2" />
<p className="text-xs text-muted-foreground text-center mt-1">
{t('textStyle') === '자막 스타일' ? '렌더링 중...' : 'Rendering...'} {downloadProgress}%
</p>
</div>
)}
</CardContent>
</Card>
)}
{!autoPlay && showShareModal && (
<ShareModal
videoUrl={assets.videoUrl}
posterUrl={assets.posterUrl}
businessName={assets.businessName}
onClose={() => setShowShareModal(false)}
/>
)}
{/* YouTube SEO Preview Modal */}
<YouTubeSEOPreview
isOpen={showSEOPreview}
onClose={() => setShowSEOPreview(false)}
businessInfo={{
businessName: assets.businessName,
description: assets.description || assets.adCopy?.join(' ') || '',
categories: assets.pensionCategories || [],
address: assets.address || '',
language: assets.language || 'KO'
}}
videoPath={lastProjectFolder || undefined}
videoDuration={60}
onUpload={handleYoutubeUpload}
/>
{/* Puppeteer 녹화 시그널 */}
{autoPlay && hasPlayed && !isPlaying && (
<div id="playback-completed" className="hidden"></div>
)}
</div>
);
};
export default ResultPlayer;

189
components/ShareModal.tsx Normal file
View File

@ -0,0 +1,189 @@
import React, { useState, useEffect } from 'react';
import { X, Copy, Check, Share2, Smartphone, Link as LinkIcon } from 'lucide-react';
/**
* ShareModal Props
* @interface ShareModalProps
* @property {string} videoUrl - URL
* @property {string} posterUrl - ( , ) URL
* @property {string} businessName - ( )
* @property {() => void} onClose -
*/
interface ShareModalProps {
videoUrl: string;
posterUrl: string; // 현재 사용되지 않음
businessName: string;
onClose: () => void;
}
/**
*
* . ( , )
*/
const ShareModal: React.FC<ShareModalProps> = ({ videoUrl, businessName, onClose }) => {
const [isGenerating, setIsGenerating] = useState(true); // 공유 링크 생성 중 여부
const [shareLink, setShareLink] = useState(''); // 생성된 공유 링크
const [copied, setCopied] = useState(false); // 링크 복사 성공 여부
const [canShareFile, setCanShareFile] = useState(false); // Web Share API로 파일 공유 가능한지 여부
/**
* Web Share API .
*/
useEffect(() => {
// 고유 ID를 기반으로 목업 공유 링크를 생성합니다. (실제 서비스에서는 백엔드에서 생성)
const uniqueId = Math.random().toString(36).substring(2, 10);
const mockLink = `${window.location.origin}/share/${uniqueId}`; // 실제 앱에서는 호스팅된 비디오 URL이 됩니다.
// 링크 생성 시뮬레이션 (1.5초 후 완료)
const timer = setTimeout(() => {
setShareLink(mockLink);
setIsGenerating(false);
}, 1500);
// Web Share API (파일 공유) 지원 여부 확인
if (navigator.share && navigator.canShare) {
setCanShareFile(true);
}
return () => clearTimeout(timer); // 컴포넌트 언마운트 시 타이머 정리
}, []);
/**
*
*/
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(shareLink);
setCopied(true);
setTimeout(() => setCopied(false), 2000); // 2초 후 복사 상태 초기화
} catch (err) {
console.error('링크 복사 실패:', err);
alert("링크 복사에 실패했습니다. 수동으로 복사해주세요.");
}
};
/**
* API
* Instagram, KakaoTalk .
*/
const handleNativeShare = async () => {
try {
// 비디오 Blob URL을 File 객체로 변환하여 공유 데이터에 포함시킵니다.
const response = await fetch(videoUrl);
const blob = await response.blob();
// 파일 이름은 업체명과 광고로 구성
const file = new File([blob], `${businessName.replace(/\s+/g, '_')}_광고.mp4`, { type: 'video/mp4' });
const shareData = {
title: `${businessName} AI 광고 영상`,
text: 'BizVibe로 제작된 AI 음악 비디오 광고를 확인해보세요!',
files: [file] // 공유할 파일 배열
};
// 파일 공유가 가능한지 다시 확인 후 공유
if (navigator.canShare(shareData)) {
await navigator.share(shareData);
} else {
// 파일 공유가 지원되지 않을 경우, 텍스트와 링크만 공유하는 폴백
await navigator.share({
title: shareData.title,
text: shareData.text,
url: shareLink
});
}
} catch (err) {
console.error('공유 중 오류 발생:', err);
alert("공유 기능 사용 중 오류가 발생했습니다.");
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm animate-in fade-in duration-200"> {/* 모달 배경 및 페이드인 애니메이션 */}
<div className="relative w-full max-w-md mx-4 bg-[#1a1a1d] border border-purple-500/30 rounded-2xl shadow-2xl overflow-hidden">
{/* 모달 헤더 */}
<div className="p-6 border-b border-white/10 flex items-center justify-between bg-white/5">
<h3 className="text-xl font-bold text-white flex items-center gap-2">
<Share2 className="w-5 h-5 text-purple-400" />
</h3>
<button
onClick={onClose} // 모달 닫기 버튼
className="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 space-y-6">
{isGenerating ? (
// 링크 생성 중일 때 로딩 스피너 표시
<div className="text-center py-8 space-y-4">
<div className="relative w-16 h-16 mx-auto">
<div className="absolute inset-0 border-4 border-purple-500/30 rounded-full"></div>
<div className="absolute inset-0 border-4 border-t-purple-500 rounded-full animate-spin"></div>
</div>
<p className="text-gray-300 animate-pulse"> ...</p>
</div>
) : (
<>
{/* 고유 링크 섹션 */}
<div className="space-y-2">
<label className="text-xs font-bold text-gray-400 uppercase tracking-wider flex items-center gap-1">
<LinkIcon className="w-3 h-3" />
</label>
<div className="flex items-center gap-2 p-2 bg-black/50 rounded-xl border border-gray-700">
<input
type="text"
readOnly // 읽기 전용
value={shareLink}
className="flex-1 bg-transparent text-sm text-purple-300 outline-none font-mono"
/>
<button
onClick={handleCopy} // 복사 버튼
className={`p-2 rounded-lg transition-all ${
copied
? 'bg-green-500/20 text-green-400' // 복사 완료 시 초록색 강조
: 'bg-white/10 hover:bg-white/20 text-white'
}`}
>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />} {/* 복사 아이콘 변경 */}
</button>
</div>
<p className="text-[10px] text-gray-500">
* . .
</p>
</div>
{/* 네이티브 공유 섹션 */}
{canShareFile && (
<button
onClick={handleNativeShare} // 네이티브 공유 버튼
className="w-full py-4 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white font-bold rounded-xl shadow-lg shadow-purple-900/30 transform transition-all hover:scale-[1.02] flex items-center justify-center gap-3"
>
<Smartphone className="w-5 h-5" />
(Instagram, Kakao )
</button>
)}
{/* 소셜 아이콘 목업 (클릭 시 새 탭 열림) */}
<div className="grid grid-cols-3 gap-3 pt-2">
{['Twitter', 'Facebook', 'LinkedIn'].map((platform) => (
<button
key={platform}
className="py-3 rounded-xl bg-white/5 hover:bg-white/10 border border-white/5 text-gray-400 hover:text-white text-xs font-medium transition-all"
onClick={() => window.open(`https://twitter.com/intent/tweet?text=${encodeURIComponent('BizVibe로 만든 AI 음악 비디오를 확인해보세요! ' + shareLink)}`, '_blank')} // 소셜 공유 링크 생성
>
{platform}
</button>
))}
</div>
</>
)}
</div>
</div>
</div>
);
};
export default ShareModal;

View File

@ -0,0 +1,222 @@
import React, { useEffect, useState } from 'react';
import { TransitionEffect } from '../types';
interface SlideshowBackgroundProps {
images: string[];
durationPerImage?: number;
transitionDuration?: number;
effect?: TransitionEffect; // 선택된 효과
}
const EFFECTS_MAP: Record<TransitionEffect, string[]> = {
'Mix': ['fade', 'blur-fade'],
'Zoom': ['zoom-in', 'zoom-out', 'ken-burns-extreme'],
'Slide': ['slide-left', 'slide-right', 'slide-up', 'slide-down'],
'Wipe': ['pixelate-sim', 'flash', 'glitch'] // Wipe 대신 화려한 효과들을 매핑
};
const ALL_EFFECTS = [
'fade',
'slide-left', 'slide-right', 'slide-up', 'slide-down',
'zoom-in', 'zoom-out', 'zoom-spin',
'flip-x', 'flip-y',
'blur-fade',
'glitch',
'ken-burns-extreme',
'pixelate-sim',
'flash'
];
/**
*
*/
const SlideshowBackground: React.FC<SlideshowBackgroundProps> = ({
images,
durationPerImage = 6000,
transitionDuration = 1500,
effect = 'Mix' // 기본값 Mix
}) => {
const [activeIndex, setActiveIndex] = useState(0);
const [currentAnimName, setCurrentAnimName] = useState('fade');
// 효과 매핑 로직
const getNextEffect = () => {
const candidates = EFFECTS_MAP[effect] || EFFECTS_MAP['Mix'];
return candidates[Math.floor(Math.random() * candidates.length)];
};
useEffect(() => {
if (images.length <= 1) return;
// 초기 효과 설정
setCurrentAnimName(getNextEffect());
const interval = setInterval(() => {
const nextIndex = (activeIndex + 1) % images.length;
setCurrentAnimName(getNextEffect()); // 다음 효과 랜덤 선택 (카테고리 내에서)
setActiveIndex(nextIndex);
}, durationPerImage);
return () => clearInterval(interval);
}, [activeIndex, images.length, durationPerImage, effect]);
if (!images || images.length === 0) return <div className="w-full h-full bg-black" />;
return (
<div className="absolute inset-0 w-full h-full overflow-hidden bg-black perspective-1000">
{images.map((src, index) => {
const isActive = index === activeIndex;
const isPrev = index === (activeIndex - 1 + images.length) % images.length;
let effectClass = '';
if (isActive) {
effectClass = `animate-${currentAnimName}-in`;
} else if (isPrev) {
effectClass = `animate-fade-out`;
}
return (
<div
key={`${src}-${index}`}
className={`absolute inset-0 w-full h-full
${isActive ? 'z-20 opacity-100' : isPrev ? 'z-10 opacity-100' : 'z-0 opacity-0'}
${effectClass}
`}
style={{
animationDuration: `${transitionDuration}ms`,
animationFillMode: 'forwards',
transformOrigin: 'center center'
}}
>
<img
src={src}
alt={`Slide ${index}`}
className="w-full h-full object-cover"
style={{ objectPosition: 'center' }}
/>
{/* 텍스트 가독성을 위한 오버레이 */}
<div className="absolute inset-0 bg-black/30 pointer-events-none" />
</div>
);
})}
<style>{`
.perspective-1000 { perspective: 1000px; }
/* === Common Out Animation === */
@keyframes fade-out {
0% { opacity: 1; }
100% { opacity: 0; }
}
.animate-fade-out { animation-name: fade-out; }
/* === 1. Fade === */
@keyframes fade-in {
0% { opacity: 0; }
100% { opacity: 1; }
}
.animate-fade-in { animation-name: fade-in; }
/* === 2-5. Slides === */
@keyframes slide-left-in {
0% { transform: translateX(100%); opacity: 0; }
100% { transform: translateX(0); opacity: 1; }
}
.animate-slide-left-in { animation-name: slide-left-in; }
@keyframes slide-right-in {
0% { transform: translateX(-100%); opacity: 0; }
100% { transform: translateX(0); opacity: 1; }
}
.animate-slide-right-in { animation-name: slide-right-in; }
@keyframes slide-up-in {
0% { transform: translateY(100%); opacity: 0; }
100% { transform: translateY(0); opacity: 1; }
}
.animate-slide-up-in { animation-name: slide-up-in; }
@keyframes slide-down-in {
0% { transform: translateY(-100%); opacity: 0; }
100% { transform: translateY(0); opacity: 1; }
}
.animate-slide-down-in { animation-name: slide-down-in; }
/* === 6-8. Zooms === */
@keyframes zoom-in-in {
0% { transform: scale(0.5); opacity: 0; }
100% { transform: scale(1); opacity: 1; }
}
.animate-zoom-in-in { animation-name: zoom-in-in; }
@keyframes zoom-out-in {
0% { transform: scale(1.5); opacity: 0; }
100% { transform: scale(1); opacity: 1; }
}
.animate-zoom-out-in { animation-name: zoom-out-in; }
@keyframes zoom-spin-in {
0% { transform: scale(0) rotate(-180deg); opacity: 0; }
100% { transform: scale(1) rotate(0deg); opacity: 1; }
}
.animate-zoom-spin-in { animation-name: zoom-spin-in; }
/* === 9-10. Flips (3D) === */
@keyframes flip-x-in {
0% { transform: perspective(400px) rotateX(90deg); opacity: 0; }
100% { transform: perspective(400px) rotateX(0deg); opacity: 1; }
}
.animate-flip-x-in { animation-name: flip-x-in; }
@keyframes flip-y-in {
0% { transform: perspective(400px) rotateY(90deg); opacity: 0; }
100% { transform: perspective(400px) rotateY(0deg); opacity: 1; }
}
.animate-flip-y-in { animation-name: flip-y-in; }
/* === 11. Blur Fade === */
@keyframes blur-fade-in {
0% { filter: blur(20px); opacity: 0; transform: scale(1.1); }
100% { filter: blur(0px); opacity: 1; transform: scale(1); }
}
.animate-blur-fade-in { animation-name: blur-fade-in; }
/* === 12. Glitch === */
@keyframes glitch-in {
0% { transform: translate(0); opacity: 0; }
20% { transform: translate(-5px, 5px); opacity: 1; }
40% { transform: translate(-5px, -5px); }
60% { transform: translate(5px, 5px); }
80% { transform: translate(5px, -5px); }
100% { transform: translate(0); opacity: 1; }
}
.animate-glitch-in { animation-name: glitch-in; }
/* === 13. Ken Burns Extreme === */
@keyframes ken-burns-extreme-in {
0% { transform: scale(1.5) translate(10%, 10%); opacity: 0; }
100% { transform: scale(1) translate(0, 0); opacity: 1; }
}
.animate-ken-burns-extreme-in { animation-name: ken-burns-extreme-in; }
/* === 14. Pixelate Simulation === */
@keyframes pixelate-sim-in {
0% { filter: blur(10px) contrast(200%); opacity: 0; }
50% { filter: blur(5px) contrast(150%); opacity: 0.5; }
100% { filter: blur(0) contrast(100%); opacity: 1; }
}
.animate-pixelate-sim-in { animation-name: pixelate-sim-in; }
/* === 15. Flash (Lens Flare Vibe) === */
@keyframes flash-in {
0% { filter: brightness(300%); opacity: 0; }
50% { filter: brightness(200%); opacity: 1; }
100% { filter: brightness(100%); opacity: 1; }
}
.animate-flash-in { animation-name: flash-in; }
`}</style>
</div>
);
};
export default SlideshowBackground;

156
deploy.sh Normal file
View File

@ -0,0 +1,156 @@
#!/bin/bash
# 색상 정의
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
echo -e "${BLUE}=== BizVibe 배포 자동화 스크립트 ===${NC}"
# 1. 현재 경로 확인
PROJECT_ROOT=$(pwd)
DIST_PATH="$PROJECT_ROOT/dist"
echo -e "${GREEN}[1] 현재 프로젝트 경로:${NC} $PROJECT_ROOT"
# 2. 시스템 의존성 설치 (Puppeteer/Chromium 용)
echo -e "${GREEN}[1.5] Installing Puppeteer system dependencies...${NC}"
if [ -x "$(command -v apt-get)" ]; then
sudo apt-get update && sudo apt-get install -y \
ca-certificates \
fonts-liberation \
libasound2t64 \
libatk-bridge2.0-0 \
libatk1.0-0 \
libc6 \
libcairo2 \
libcups2 \
libdbus-1-3 \
libexpat1 \
libfontconfig1 \
libgbm1 \
libgcc1 \
libglib2.0-0 \
libgtk-3-0 \
libnspr4 \
libnss3 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libstdc++6 \
libx11-6 \
libx11-xcb1 \
libxcb1 \
libxcomposite1 \
libxcursor1 \
libxdamage1 \
libxext6 \
libxfixes3 \
libxi6 \
libxrandr2 \
libxrender1 \
libxss1 \
libxtst6 \
lsb-release \
wget \
xdg-utils \
fonts-noto-cjk
if [ $? -ne 0 ]; then
echo -e "${RED}❌ System dependency installation failed!${NC}"
# 의존성 설치 실패해도 일단 진행 (이미 설치된 경우 등 고려)
else
echo -e "${GREEN}✅ System dependencies installed successfully.${NC}"
fi
else
echo -e "${YELLOW}⚠️ 'apt-get' not found. Skipping system dependency installation. Ensure dependencies are installed manually.${NC}"
fi
# 3. 프로젝트 빌드
echo -e "${GREEN}[2] Building project...${NC}"
npm run build:all
if [ $? -ne 0 ]; then
echo -e "${RED}❌ 빌드 실패! 오류를 확인해주세요.${NC}"
exit 1
fi
echo -e "${GREEN}✅ 빌드 완료!${NC}"
# 3. Nginx 설정 파일 생성 (경로 자동 적용)
echo -e "${GREEN}[3] Nginx 설정 파일 생성 중...${NC}"
NGINX_CONF="nginx_bizvibe.conf"
DOMAIN_NAME="bizvibe.ktenterprise.net" # 기본 도메인 (필요 시 수정)
cat > $NGINX_CONF <<EOF
server {
listen 80;
server_name $DOMAIN_NAME;
# 1. 프론트엔드 (React 빌드 결과물)
location / {
root $DIST_PATH;
index index.html;
try_files \$uri \$uri/ /index.html;
}
# 2. 백엔드 API 프록시
location /api {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host \$host;
proxy_cache_bypass \$http_upgrade;
}
# 3. 영상 생성 및 다운로드 프록시
location /render {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host \$host;
}
location /downloads {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host \$host;
}
location /temp {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host \$host;
}
}
EOF
echo -e "${GREEN}✅ Nginx 설정 파일($NGINX_CONF) 생성 완료! (Root 경로: $DIST_PATH)${NC}"
# 4. PM2로 백엔드 시작
echo -e "${GREEN}[4] PM2로 백엔드 서버 시작...${NC}"
# PM2 설치 확인 및 설치
if ! command -v pm2 &> /dev/null
then
echo -e "${YELLOW}PM2가 설치되어 있지 않습니다. 설치를 시도합니다...${NC}"
npm install -g pm2
fi
# 기존 프로세스 재시작 또는 새로 시작
pm2 restart bizvibe-backend 2>/dev/null || pm2 start server/index.js --name "bizvibe-backend"
pm2 save
echo -e "${GREEN}✅ 백엔드 서버 구동 완료!${NC}"
# 5. 최종 안내 (sudo 필요 작업)
echo -e ""
echo -e "${BLUE}=== 🎉 배포 준비 완료! 남은 단계 ===${NC}"
echo -e "${YELLOW}다음 명령어를 복사하여 실행하면 Nginx 설정이 적용됩니다 (관리자 권한 필요):${NC}"
echo -e ""
echo -e "1. 설정 파일 이동:"
echo -e " ${GREEN}sudo cp $NGINX_CONF /etc/nginx/sites-available/bizvibe${NC}"
echo -e ""
echo -e "2. 사이트 활성화:"
echo -e " ${GREEN}sudo ln -s /etc/nginx/sites-available/bizvibe /etc/nginx/sites-enabled/${NC}"
echo -e ""
echo -e "3. Nginx 문법 검사 및 재시작:"
echo -e " ${GREEN}sudo nginx -t && sudo systemctl reload nginx${NC}"
echo -e ""

38
index.html Normal file
View File

@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="ko" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CaStAD - 카스타드 | AI 펜션 마케팅 영상 플랫폼</title>
<meta name="description" content="CaStAD(카스타드) - 펜션, 풀빌라, 오션뷰 숙소를 위한 AI 마케팅 영상 제작 SaaS 플랫폼. 15초만에 인스타그램 릴스, 틱톡, YouTube Shorts용 홍보 영상을 자동 생성합니다." />
<meta name="keywords" content="CaStAD, 카스타드, 펜션 마케팅, AI 영상 제작, 인스타그램 릴스, 틱톡 영상, 유튜브 쇼츠, 숙박업 마케팅, 풀빌라, 오션뷰, 펜션 홍보, SaaS" />
<meta property="og:title" content="CaStAD - 카스타드 | AI 펜션 마케팅 영상 플랫폼" />
<meta property="og:description" content="AI가 만드는 펜션 홍보 영상. 네이버 플레이스 URL만 입력하면 15초만에 인스타 릴스 영상 완성!" />
<meta property="og:image" content="/images/castad-logo.svg" />
<link rel="icon" type="image/svg+xml" href="/images/castad-logo.svg" />
<meta property="og:type" content="website" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/lipis/flag-icons@7.0.0/css/flag-icons.min.css"/>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Noto+Sans+KR:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script type="importmap">
{
"imports": {
"react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/",
"lucide-react": "https://aistudiocdn.com/lucide-react@^0.554.0",
"@google/genai": "https://aistudiocdn.com/@google/genai@^1.30.0",
"react/": "https://aistudiocdn.com/react@^19.2.0/",
"react": "https://aistudiocdn.com/react@^19.2.0",
"@ffmpeg/ffmpeg": "https://unpkg.com/@ffmpeg/ffmpeg@0.12.10/dist/esm/index.js",
"@ffmpeg/util": "https://unpkg.com/@ffmpeg/util@0.12.1/dist/esm/index.js",
"@ffmpeg/core": "https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm/ffmpeg-core.js",
"dom": "https://aistudiocdn.com/dom@^0.0.3"
}
}
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>

16
index.tsx Normal file
View File

@ -0,0 +1,16 @@
/// <reference lib="dom" />
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

5
metadata.json Normal file
View File

@ -0,0 +1,5 @@
{
"name": "BizVibe - AI Music Video Generator",
"description": "Transform your business photo and info into a stunning music video with AI-generated Korean ad copy and visuals.",
"requestFramePermissions": []
}

231
multi_domain_deploy.sh Normal file
View File

@ -0,0 +1,231 @@
#!/bin/bash
# ==============================================================================
# BizVibe Multi-Domain Deployment & Setup Script
# ==============================================================================
# 이 스크립트는 멀티 도메인 Nginx 환경에 BizVibe 서비스를 안전하게 배포합니다.
# 기존 서비스와의 포트 충돌을 방지하며 Nginx 설정을 자동으로 생성합니다.
# ==============================================================================
# 색상 정의
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# ------------------------------------------------------------------------------
# 1. 포트 자동 할당 (충돌 방지)
# ------------------------------------------------------------------------------
echo -e "${CYAN}[1] 사용 가능한 포트 확인 중...${NC}"
# 사용 중인 포트 목록 가져오기
USED_PORTS=$(netstat -tuln | grep LISTEN | awk '{print $4}' | awk -F: '{print $NF}' | sort -n | uniq)
# 추천 포트 범위 (3000번대 -> 4000번대 순으로 검색)
# nginx_analysis.txt에 따르면 3000번은 whitedonkey가 사용 중일 수 있으므로 3001부터 시작
START_PORT=3001
END_PORT=4000
TARGET_PORT=""
for port in $(seq $START_PORT $END_PORT); do
if ! echo "$USED_PORTS" | grep -q "^$port$"; then
TARGET_PORT=$port
break
fi
done
if [ -z "$TARGET_PORT" ]; then
echo -e "${RED}❌ 사용 가능한 포트를 찾을 수 없습니다 (3001-4000). 수동으로 설정해주세요.${NC}"
exit 1
fi
echo -e "${GREEN}✅ BizVibe 백엔드 포트로 ${TARGET_PORT}번을 할당했습니다.${NC}"
# ------------------------------------------------------------------------------
# 2. 필수 패키지 설치
# ------------------------------------------------------------------------------
echo -e "${CYAN}[2] 시스템 패키지 업데이트 및 필수 라이브러리 설치...${NC}"
# Node.js, FFmpeg, 한글 폰트 등 필수 패키지 설치
if [ -x "$(command -v apt-get)" ]; then
sudo apt-get update
sudo apt-get install -y ffmpeg fonts-noto-cjk nginx certbot python3-certbot-nginx \
ca-certificates fonts-liberation libasound2t64 libatk-bridge2.0-0 libatk1.0-0 \
libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgbm1 libgcc1 \
libglib2.0-0 libgtk-3-0 libnspr4 libnss3 libpango-1.0-0 libpangocairo-1.0-0 \
libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 \
libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 lsb-release \
wget xdg-utils
else
echo -e "${YELLOW}⚠️ apt-get을 찾을 수 없습니다. 패키지 설치를 건너뜁니다.${NC}"
fi
# ------------------------------------------------------------------------------
# 3. 프로젝트 빌드 및 설정
# ------------------------------------------------------------------------------
echo -e "${CYAN}[3] 프로젝트 의존성 설치 및 빌드...${NC}"
# 프론트엔드 의존성
npm install
# 백엔드 의존성
cd server
npm install --legacy-peer-deps
cd ..
# 포트 설정 적용 (vite.config.ts 및 server/index.js 수정 필요 시 환경 변수로 처리 권장)
# 여기서는 .env 파일 생성/수정으로 처리
if [ ! -f .env ]; then
echo "VITE_GEMINI_API_KEY=YOUR_GEMINI_KEY" > .env
echo "SUNO_API_KEY=YOUR_SUNO_KEY" >> .env
echo -e "${YELLOW}⚠️ .env 파일이 생성되었습니다. API 키를 입력해야 합니다.${NC}"
fi
# 프론트엔드 URL 설정 (배포 시 도메인 주소로 설정)
# 이 부분은 아래 도메인 입력 후에 업데이트하도록 이동하거나, 여기서 미리 placeholder로 설정
if ! grep -q "FRONTEND_URL=" .env; then
echo "FRONTEND_URL=http://localhost:3000" >> .env
fi
# 백엔드 포트 환경변수 추가
if grep -q "PORT=" .env; then
sed -i "s/^PORT=.*/PORT=$TARGET_PORT/" .env
else
echo "PORT=$TARGET_PORT" >> .env
fi
# 프론트엔드 빌드 (Vite가 .env의 설정을 참조함)
npm run build
# ------------------------------------------------------------------------------
# 4. PM2 프로세스 관리 (무중단 배포)
# ------------------------------------------------------------------------------
echo -e "${CYAN}[4] PM2로 서비스 실행...${NC}"
# PM2 설치 확인
if ! command -v pm2 &> /dev/null; then
npm install -g pm2
fi
# 기존 프로세스 정리 (이름 기준)
pm2 delete bizvibe-backend 2>/dev/null
# 백엔드 실행 (서버 사이드 렌더링 포함)
# 정적 파일(프론트엔드 dist)도 백엔드(Express)에서 서빙하도록 server/index.js가 수정되어야 함.
# 현재 server/index.js는 API만 제공하므로, Nginx가 프론트엔드를 서빙하고 API는 백엔드로 프록시하는 구조가 적합.
cd server
pm2 start index.js --name "bizvibe-backend" -- --port $TARGET_PORT
cd ..
pm2 save
echo -e "${GREEN}✅ PM2 서비스 시작 완료 (Port: $TARGET_PORT)${NC}"
# ------------------------------------------------------------------------------
# 5. Nginx 가상호스트 설정 (자동 생성)
# ------------------------------------------------------------------------------
echo -e "${CYAN}[5] Nginx 설정 파일 생성...${NC}"
# 사용자에게 도메인 입력 받기
read -p "배포할 도메인 주소를 입력하세요 (예: bizvibe.ktenterprise.net): " DOMAIN_NAME
if [ -z "$DOMAIN_NAME" ]; then
echo -e "${RED}❌ 도메인이 입력되지 않아 Nginx 설정을 건너뜁니다.${NC}"
else
# .env 파일에 FRONTEND_URL 업데이트 (https://도메인)
# 기존 값이 있으면 교체, 없으면 추가
if grep -q "FRONTEND_URL=" .env; then
sed -i "s|^FRONTEND_URL=.*|FRONTEND_URL=https://$DOMAIN_NAME|" .env
else
echo "FRONTEND_URL=https://$DOMAIN_NAME" >> .env
fi
echo -e "${GREEN}✅ .env 파일에 FRONTEND_URL=https://$DOMAIN_NAME 설정 완료${NC}"
# PM2 재시작하여 변경된 환경변수 적용
pm2 restart bizvibe-backend
NGINX_CONF="/etc/nginx/sites-available/$DOMAIN_NAME"
PROJECT_ROOT=$(pwd)
# Nginx 설정 파일 작성
sudo tee $NGINX_CONF > /dev/null <<EOF
server {
listen 80;
server_name $DOMAIN_NAME;
# 대용량 파일 업로드 허용 (영상 생성 요청 시 Base64 데이터 전송 때문)
client_max_body_size 500M;
# 프론트엔드 정적 파일 서빙
location / {
# 로컬(서버 내부) 및 공인 IP 접속은 인증 제외 (비활성화)
# satisfy any;
# allow 127.0.0.1;
# allow 116.125.140.86; # 서버 공인 IP 추가
# deny all;
# 기본 인증 설정 (외부 접속 시) - 비활성화
# auth_basic "Restricted Area";
# auth_basic_user_file /etc/nginx/.htpasswd;
root $PROJECT_ROOT/dist;
index index.html;
try_files \$uri \$uri/ /index.html;
}
# 백엔드 API 프록시
location /api {
proxy_pass http://127.0.0.1:$TARGET_PORT;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host \$host;
proxy_cache_bypass \$http_upgrade;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_read_timeout 300s; # 긴 API 응답 시간 허용 (Suno 생성 등)
}
# 렌더링/업로드 엔드포인트 프록시
location /render {
proxy_pass http://127.0.0.1:$TARGET_PORT;
proxy_set_header Host \$host;
proxy_read_timeout 300s; # 긴 렌더링 시간 허용
}
location /auth {
proxy_pass http://127.0.0.1:$TARGET_PORT;
proxy_set_header Host \$host;
}
location /oauth2callback {
proxy_pass http://127.0.0.1:$TARGET_PORT;
proxy_set_header Host \$host;
}
}
EOF
# 심볼릭 링크 생성
sudo ln -sfn $NGINX_CONF /etc/nginx/sites-enabled/
# 설정 검증 및 재시작
if sudo nginx -t; then
sudo systemctl reload nginx
echo -e "${GREEN}✅ Nginx 설정 완료! http://$DOMAIN_NAME 에서 접속 가능합니다.${NC}"
# SSL 인증서 발급 여부 확인
read -p "SSL 인증서(Let's Encrypt)를 발급하시겠습니까? (y/n): " INSTALL_SSL
if [[ "$INSTALL_SSL" == "y" || "$INSTALL_SSL" == "Y" ]]; then
sudo certbot --nginx -d $DOMAIN_NAME
fi
else
echo -e "${RED}❌ Nginx 설정 오류 발생. 설정 파일을 확인하세요: $NGINX_CONF${NC}"
fi
fi
echo -e "${CYAN}======================================================${NC}"
echo -e "${GREEN}🎉 배포 완료!${NC}"
echo -e "Backend Port: $TARGET_PORT"
echo -e "Domain: http://$DOMAIN_NAME"
echo -e "${CYAN}======================================================${NC}"

164
nginx/castad.conf Normal file
View File

@ -0,0 +1,164 @@
# ============================================
# CaStAD Nginx Configuration
# ============================================
# 도메인:
# - castad.ktenterprise.net
# - ado2.whitedonkey.kr
# ============================================
# Upstream 설정
upstream castad_backend {
server 127.0.0.1:3001;
keepalive 64;
}
upstream castad_instagram {
server 127.0.0.1:5001;
keepalive 32;
}
# HTTP → HTTPS 리다이렉트
server {
listen 80;
listen [::]:80;
server_name castad.ktenterprise.net ado2.whitedonkey.kr;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS 서버 설정
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name castad.ktenterprise.net ado2.whitedonkey.kr;
# SSL 인증서 (Let's Encrypt)
ssl_certificate /etc/letsencrypt/live/castad.ktenterprise.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/castad.ktenterprise.net/privkey.pem;
# SSL 설정
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
ssl_session_tickets off;
# HSTS
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# 기타 보안 헤더
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# 로그
access_log /var/log/nginx/castad_access.log;
error_log /var/log/nginx/castad_error.log;
# 파일 업로드 크기 제한 (영상 업로드용)
client_max_body_size 500M;
client_body_timeout 300s;
# 정적 파일 캐싱 (프론트엔드 빌드 파일)
root /var/www/castad/dist;
index index.html;
# Gzip 압축
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml;
gzip_comp_level 6;
# 정적 자원 캐싱
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# API 요청 → Backend
location /api/ {
proxy_pass http://castad_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
}
# 렌더링 요청 → Backend (긴 타임아웃)
location /render {
proxy_pass http://castad_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 600s;
proxy_connect_timeout 75s;
proxy_buffering off;
}
# 다운로드 파일
location /downloads/ {
proxy_pass http://castad_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 임시 파일
location /temp/ {
proxy_pass http://castad_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 업로드된 에셋
location /uploads/ {
proxy_pass http://castad_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Instagram 서비스 (내부 프록시)
location /instagram-service/ {
internal;
proxy_pass http://castad_instagram/;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
# SPA 라우팅 - 모든 요청을 index.html로
location / {
try_files $uri $uri/ /index.html;
}
# 에러 페이지
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

39
nginx_bizvibe.conf Normal file
View File

@ -0,0 +1,39 @@
server {
listen 80;
server_name YOUR_DOMAIN_OR_IP; # 예: bizvibe.ktenterprise.net
# 1. 프론트엔드 (React 빌드 결과물)
# 'npm run build'로 생성된 dist 폴더 경로를 지정하세요.
location / {
root /home/ubuntu/projects/bizvibe/dist; # [수정 필요] 실제 프로젝트 경로로 변경
index index.html;
try_files $uri $uri/ /index.html;
}
# 2. 백엔드 API 프록시
# Node.js 서버(포트 3001)로 요청을 전달합니다.
location /api {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
# 3. 영상 생성 및 다운로드 프록시
location /render {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
}
location /downloads {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
}
location /temp {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
}
}

6391
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

58
package.json Normal file
View File

@ -0,0 +1,58 @@
{
"name": "castad-ai-marketing-video-platform",
"private": true,
"version": "3.0.0",
"type": "module",
"scripts": {
"dev": "concurrently \"vite\" \"cd server && node index.js\"",
"build": "vite build",
"preview": "vite preview",
"start:server": "cd server && node index.js",
"build:all": "npm install && npm run build && cd server && npm install"
},
"dependencies": {
"@ffmpeg/core": "0.12.6",
"@ffmpeg/ffmpeg": "0.12.10",
"@ffmpeg/util": "0.12.1",
"@google/genai": "^1.30.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"dom": "^0.0.3",
"lottie-react": "^2.4.1",
"lucide-react": "^0.554.0",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.9.6",
"sonner": "^2.0.7"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.22",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"concurrently": "^9.2.1",
"postcss": "^8.5.6",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^3.4.18",
"tailwindcss-animate": "^1.0.7",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

6
postcss.config.js Normal file
View File

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

View File

@ -0,0 +1,69 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120">
<defs>
<!-- Custard cream gradient -->
<linearGradient id="custardGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#FFF5E1"/>
<stop offset="50%" style="stop-color:#FFE4B5"/>
<stop offset="100%" style="stop-color:#F5C97A"/>
</linearGradient>
<!-- Caramel top gradient -->
<linearGradient id="caramelGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#D4A056"/>
<stop offset="100%" style="stop-color:#B8860B"/>
</linearGradient>
<!-- Bowl/Cup gradient -->
<linearGradient id="bowlGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#8B7355"/>
<stop offset="50%" style="stop-color:#6B4423"/>
<stop offset="100%" style="stop-color:#4A2C17"/>
</linearGradient>
<!-- Shine effect -->
<linearGradient id="shineGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#FFFFFF;stop-opacity:0.6"/>
<stop offset="100%" style="stop-color:#FFFFFF;stop-opacity:0"/>
</linearGradient>
<!-- Drop shadow -->
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-color="#000000" flood-opacity="0.2"/>
</filter>
</defs>
<!-- Ramekin/Bowl base -->
<ellipse cx="60" cy="95" rx="40" ry="8" fill="#3D2314" opacity="0.3"/>
<!-- Bowl body -->
<path d="M20 55 L25 90 Q60 100 95 90 L100 55 Q100 50 95 48 L25 48 Q20 50 20 55" fill="url(#bowlGradient)" filter="url(#shadow)"/>
<!-- Bowl rim -->
<ellipse cx="60" cy="48" rx="38" ry="8" fill="#8B7355"/>
<ellipse cx="60" cy="48" rx="35" ry="6" fill="#6B4423"/>
<!-- Custard cream -->
<ellipse cx="60" cy="46" rx="32" ry="5" fill="url(#custardGradient)"/>
<!-- Caramel top layer -->
<ellipse cx="60" cy="44" rx="28" ry="4" fill="url(#caramelGradient)"/>
<!-- Caramelized sugar drizzle -->
<path d="M40 43 Q50 41 60 43 Q70 45 80 43" stroke="#8B4513" stroke-width="1.5" fill="none" stroke-linecap="round"/>
<!-- Shine on custard -->
<ellipse cx="48" cy="42" rx="8" ry="2" fill="url(#shineGradient)"/>
<!-- Steam wisps -->
<path d="M45 35 Q43 28 47 22" stroke="#D4A056" stroke-width="2" fill="none" stroke-linecap="round" opacity="0.5">
<animate attributeName="d" dur="2s" repeatCount="indefinite" values="M45 35 Q43 28 47 22;M45 35 Q47 28 43 22;M45 35 Q43 28 47 22"/>
<animate attributeName="opacity" dur="2s" repeatCount="indefinite" values="0.5;0.3;0.5"/>
</path>
<path d="M60 33 Q58 26 62 20" stroke="#D4A056" stroke-width="2" fill="none" stroke-linecap="round" opacity="0.6">
<animate attributeName="d" dur="2.5s" repeatCount="indefinite" values="M60 33 Q58 26 62 20;M60 33 Q62 26 58 20;M60 33 Q58 26 62 20"/>
<animate attributeName="opacity" dur="2.5s" repeatCount="indefinite" values="0.6;0.3;0.6"/>
</path>
<path d="M75 35 Q73 28 77 22" stroke="#D4A056" stroke-width="2" fill="none" stroke-linecap="round" opacity="0.4">
<animate attributeName="d" dur="3s" repeatCount="indefinite" values="M75 35 Q73 28 77 22;M75 35 Q77 28 73 22;M75 35 Q73 28 77 22"/>
<animate attributeName="opacity" dur="3s" repeatCount="indefinite" values="0.4;0.2;0.4"/>
</path>
<!-- "C" letter integrated into design (subtle) -->
<text x="60" y="75" font-family="Georgia, serif" font-size="20" font-weight="bold" fill="#FFF5E1" text-anchor="middle" opacity="0.9">C</text>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
public/videos/1.mp4 Normal file

Binary file not shown.

BIN
public/videos/3.mp4 Normal file

Binary file not shown.

BIN
public/videos/5.mp4 Normal file

Binary file not shown.

BIN
saas.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

View File

@ -0,0 +1 @@
{"web":{"client_id":"671727489621-v119rtes771fnrifpmu2pjepja63j4sn.apps.googleusercontent.com","project_id":"grand-solstice-477822-s9","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_secret":"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","redirect_uris":["http://localhost:3001/oauth2callback"]}}

Binary file not shown.

506
server/db.js Normal file
View File

@ -0,0 +1,506 @@
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const bcrypt = require('bcrypt');
const DB_PATH = path.join(__dirname, 'database.sqlite');
const db = new sqlite3.Database(DB_PATH, (err) => {
if (err) {
console.error('데이터베이스 연결 실패:', err.message);
} else {
console.log('SQLite 데이터베이스에 연결되었습니다.');
}
});
// 테이블 초기화
db.serialize(() => {
// Users 테이블
db.run(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE,
password TEXT NOT NULL,
name TEXT,
phone TEXT,
role TEXT DEFAULT 'user',
approved INTEGER DEFAULT 0,
email_verified INTEGER DEFAULT 0,
verification_token TEXT,
reset_token TEXT,
reset_token_expiry DATETIME,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
// 기존 테이블에 새 컬럼 추가 (이미 존재하면 무시)
db.run("ALTER TABLE users ADD COLUMN email TEXT UNIQUE", (err) => {});
db.run("ALTER TABLE users ADD COLUMN email_verified INTEGER DEFAULT 0", (err) => {});
db.run("ALTER TABLE users ADD COLUMN verification_token TEXT", (err) => {});
db.run("ALTER TABLE users ADD COLUMN reset_token TEXT", (err) => {});
db.run("ALTER TABLE users ADD COLUMN reset_token_expiry DATETIME", (err) => {});
// OAuth 컬럼 추가
db.run("ALTER TABLE users ADD COLUMN oauth_provider TEXT", (err) => {});
db.run("ALTER TABLE users ADD COLUMN oauth_provider_id TEXT", (err) => {});
db.run("ALTER TABLE users ADD COLUMN profile_image TEXT", (err) => {});
// 크레딧 컬럼 추가 (무료 플랜 기본값 10)
db.run("ALTER TABLE users ADD COLUMN credits INTEGER DEFAULT 10", (err) => {});
// 구독 플랜 컬럼 추가 (free, basic, pro, business)
db.run("ALTER TABLE users ADD COLUMN plan_type TEXT DEFAULT 'free'", (err) => {});
// 최대 펜션 수 (free/basic: 1, pro: 5, business: unlimited)
db.run("ALTER TABLE users ADD COLUMN max_pensions INTEGER DEFAULT 1", (err) => {});
// 월간 크레딧 한도 (플랜별 다름, 무료=10)
db.run("ALTER TABLE users ADD COLUMN monthly_credits INTEGER DEFAULT 10", (err) => {});
// 구독 시작일
db.run("ALTER TABLE users ADD COLUMN subscription_started_at DATETIME", (err) => {});
// 구독 만료일
db.run("ALTER TABLE users ADD COLUMN subscription_expires_at DATETIME", (err) => {});
// ============================================
// 펜션/브랜드 프로필 테이블 (다중 펜션 지원)
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS pension_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
is_default INTEGER DEFAULT 0,
brand_name TEXT,
brand_name_en TEXT,
region TEXT,
address TEXT,
pension_types TEXT,
target_customers TEXT,
key_features TEXT,
nearby_attractions TEXT,
booking_url TEXT,
homepage_url TEXT,
kakao_channel TEXT,
instagram_handle TEXT,
languages TEXT DEFAULT 'KO',
price_range TEXT,
description TEXT,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)`);
// 펜션별 is_default 컬럼 추가 (기존 테이블용)
db.run("ALTER TABLE pension_profiles ADD COLUMN is_default INTEGER DEFAULT 0", (err) => {
// 이미 존재하면 에러가 발생하므로 무시
});
// 펜션별 YouTube 플레이리스트 ID 직접 연결
db.run("ALTER TABLE pension_profiles ADD COLUMN youtube_playlist_id TEXT", (err) => {});
db.run("ALTER TABLE pension_profiles ADD COLUMN youtube_playlist_title TEXT", (err) => {});
// ============================================
// YouTube 분석 데이터 캐시 테이블 (펜션별)
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS youtube_analytics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pension_id INTEGER NOT NULL,
playlist_id TEXT NOT NULL,
date DATE NOT NULL,
views INTEGER DEFAULT 0,
playlist_starts INTEGER DEFAULT 0,
average_time_in_playlist REAL DEFAULT 0,
estimated_minutes_watched REAL DEFAULT 0,
subscribers_gained INTEGER DEFAULT 0,
likes INTEGER DEFAULT 0,
comments INTEGER DEFAULT 0,
shares INTEGER DEFAULT 0,
cached_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(pension_id) REFERENCES pension_profiles(id) ON DELETE CASCADE,
UNIQUE(pension_id, date)
)`);
// 펜션별 월간 요약 통계 테이블
db.run(`CREATE TABLE IF NOT EXISTS pension_monthly_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pension_id INTEGER NOT NULL,
year_month TEXT NOT NULL,
total_views INTEGER DEFAULT 0,
total_videos INTEGER DEFAULT 0,
total_watch_time REAL DEFAULT 0,
avg_view_duration REAL DEFAULT 0,
top_video_id TEXT,
growth_rate REAL DEFAULT 0,
cached_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(pension_id) REFERENCES pension_profiles(id) ON DELETE CASCADE,
UNIQUE(pension_id, year_month)
)`);
// ============================================
// YouTube OAuth 토큰 테이블
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS youtube_connections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER UNIQUE NOT NULL,
google_user_id TEXT,
google_email TEXT,
youtube_channel_id TEXT,
youtube_channel_title TEXT,
access_token TEXT,
refresh_token TEXT,
token_expiry DATETIME,
scopes TEXT,
connected_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)`);
// ============================================
// YouTube 업로드 설정 테이블
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS youtube_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER UNIQUE NOT NULL,
default_privacy TEXT DEFAULT 'private',
default_category_id TEXT DEFAULT '19',
default_tags TEXT,
default_hashtags TEXT,
auto_upload INTEGER DEFAULT 0,
upload_timing TEXT DEFAULT 'manual',
scheduled_day TEXT,
scheduled_time TEXT,
default_playlist_id TEXT,
notify_on_upload INTEGER DEFAULT 1,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)`);
// ============================================
// YouTube 플레이리스트 캐시 테이블 (펜션별 연결 지원)
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS youtube_playlists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
pension_id INTEGER,
playlist_id TEXT NOT NULL,
title TEXT,
item_count INTEGER DEFAULT 0,
cached_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(pension_id) REFERENCES pension_profiles(id) ON DELETE SET NULL,
UNIQUE(user_id, playlist_id)
)`);
// 플레이리스트에 pension_id 컬럼 추가 (기존 테이블용)
db.run("ALTER TABLE youtube_playlists ADD COLUMN pension_id INTEGER", (err) => {
// 이미 존재하면 에러가 발생하므로 무시
});
// ============================================
// 업로드 히스토리 테이블 (펜션별 연결 지원)
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS upload_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
pension_id INTEGER,
history_id INTEGER,
youtube_video_id TEXT,
youtube_url TEXT,
title TEXT,
privacy_status TEXT,
playlist_id TEXT,
uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
status TEXT DEFAULT 'completed',
error_message TEXT,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(pension_id) REFERENCES pension_profiles(id) ON DELETE SET NULL,
FOREIGN KEY(history_id) REFERENCES history(id) ON DELETE SET NULL
)`);
// 업로드 히스토리에 pension_id 컬럼 추가 (기존 테이블용)
db.run("ALTER TABLE upload_history ADD COLUMN pension_id INTEGER", (err) => {
// 이미 존재하면 에러가 발생하므로 무시
});
// ============================================
// Instagram 연결 정보 테이블
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS instagram_connections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER UNIQUE NOT NULL,
instagram_username TEXT NOT NULL,
encrypted_password TEXT NOT NULL,
encrypted_session TEXT,
is_active INTEGER DEFAULT 1,
last_login_at DATETIME,
two_factor_required INTEGER DEFAULT 0,
connected_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)`);
// ============================================
// Instagram 업로드 설정 테이블
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS instagram_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER UNIQUE NOT NULL,
auto_upload INTEGER DEFAULT 0,
upload_as_reel INTEGER DEFAULT 1,
default_caption_template TEXT,
default_hashtags TEXT,
max_uploads_per_week INTEGER DEFAULT 1,
notify_on_upload INTEGER DEFAULT 1,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)`);
// ============================================
// Instagram 업로드 히스토리 테이블
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS instagram_upload_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
pension_id INTEGER,
history_id INTEGER,
instagram_media_id TEXT,
instagram_post_code TEXT,
permalink TEXT,
caption TEXT,
upload_type TEXT DEFAULT 'reel',
status TEXT DEFAULT 'pending',
error_message TEXT,
retry_count INTEGER DEFAULT 0,
uploaded_at DATETIME,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(pension_id) REFERENCES pension_profiles(id) ON DELETE SET NULL,
FOREIGN KEY(history_id) REFERENCES history(id) ON DELETE SET NULL
)`);
// ============================================
// TikTok 연결 정보 테이블
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS tiktok_connections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER UNIQUE NOT NULL,
open_id TEXT NOT NULL,
display_name TEXT,
avatar_url TEXT,
follower_count INTEGER DEFAULT 0,
following_count INTEGER DEFAULT 0,
access_token TEXT NOT NULL,
refresh_token TEXT,
token_expiry DATETIME,
scopes TEXT,
connected_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)`);
// ============================================
// TikTok 업로드 설정 테이블
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS tiktok_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER UNIQUE NOT NULL,
default_privacy TEXT DEFAULT 'SELF_ONLY',
disable_duet INTEGER DEFAULT 0,
disable_comment INTEGER DEFAULT 0,
disable_stitch INTEGER DEFAULT 0,
auto_upload INTEGER DEFAULT 0,
upload_to_inbox INTEGER DEFAULT 1,
default_hashtags TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)`);
// ============================================
// TikTok 업로드 히스토리 테이블
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS tiktok_upload_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
pension_id INTEGER,
history_id INTEGER,
publish_id TEXT,
video_id TEXT,
title TEXT,
privacy_level TEXT DEFAULT 'SELF_ONLY',
status TEXT DEFAULT 'pending',
error_message TEXT,
uploaded_at DATETIME,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(pension_id) REFERENCES pension_profiles(id) ON DELETE SET NULL,
FOREIGN KEY(history_id) REFERENCES history(id) ON DELETE SET NULL
)`);
// ============================================
// 플랫폼 통합 통계 테이블 (YouTube, Instagram, TikTok)
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS platform_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
pension_id INTEGER,
platform TEXT NOT NULL,
date DATE NOT NULL,
views INTEGER DEFAULT 0,
likes INTEGER DEFAULT 0,
comments INTEGER DEFAULT 0,
shares INTEGER DEFAULT 0,
followers_gained INTEGER DEFAULT 0,
impressions INTEGER DEFAULT 0,
reach INTEGER DEFAULT 0,
engagement_rate REAL DEFAULT 0,
cached_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(pension_id) REFERENCES pension_profiles(id) ON DELETE SET NULL,
UNIQUE(user_id, pension_id, platform, date)
)`);
// ============================================
// 시스템 활동 로그 테이블 (어드민 분석용)
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS activity_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
action_type TEXT NOT NULL,
action_detail TEXT,
ip_address TEXT,
user_agent TEXT,
metadata TEXT,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL
)`);
// ============================================
// 시스템 통계 스냅샷 테이블 (일별 집계)
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS system_stats_daily (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date DATE UNIQUE NOT NULL,
total_users INTEGER DEFAULT 0,
new_users INTEGER DEFAULT 0,
active_users INTEGER DEFAULT 0,
total_videos_generated INTEGER DEFAULT 0,
total_uploads INTEGER DEFAULT 0,
youtube_uploads INTEGER DEFAULT 0,
instagram_uploads INTEGER DEFAULT 0,
tiktok_uploads INTEGER DEFAULT 0,
total_credits_used INTEGER DEFAULT 0,
avg_generation_time REAL DEFAULT 0,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
// ============================================
// 사용자 에셋 테이블 (이미지, 오디오, 비디오)
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS user_assets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
pension_id INTEGER,
history_id INTEGER,
asset_type TEXT NOT NULL,
source_type TEXT NOT NULL,
file_name TEXT NOT NULL,
file_path TEXT NOT NULL,
file_size INTEGER DEFAULT 0,
mime_type TEXT,
thumbnail_path TEXT,
duration REAL,
width INTEGER,
height INTEGER,
metadata TEXT,
is_deleted INTEGER DEFAULT 0,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(pension_id) REFERENCES pension_profiles(id) ON DELETE SET NULL,
FOREIGN KEY(history_id) REFERENCES history(id) ON DELETE SET NULL
)`);
// 사용자별 스토리지 한도 컬럼 추가 (MB 단위, 기본 500MB)
db.run("ALTER TABLE users ADD COLUMN storage_limit INTEGER DEFAULT 500", (err) => {});
// 현재 사용 용량 (캐시)
db.run("ALTER TABLE users ADD COLUMN storage_used INTEGER DEFAULT 0", (err) => {});
// ============================================
// 크레딧 요청 테이블
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS credit_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
requested_credits INTEGER DEFAULT 10,
status TEXT DEFAULT 'pending',
reason TEXT,
admin_note TEXT,
processed_by INTEGER,
processed_at DATETIME,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(processed_by) REFERENCES users(id) ON DELETE SET NULL
)`);
// ============================================
// 크레딧 히스토리 테이블 (변동 내역 추적)
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS credit_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
amount INTEGER NOT NULL,
type TEXT NOT NULL,
description TEXT,
balance_after INTEGER,
related_request_id INTEGER,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(related_request_id) REFERENCES credit_requests(id) ON DELETE SET NULL
)`);
// 기존 테이블에 business_name 컬럼 추가 (존재하지 않을 경우를 대비해 try-catch 대신 별도 실행)
// SQLite는 IF NOT EXISTS 컬럼 추가를 지원하지 않으므로, 에러를 무시하는 방식으로 처리하거나 스키마 버전을 관리해야 함.
// 여기서는 간단히 컬럼 추가 시도 후 에러 무시 패턴을 사용.
db.run("ALTER TABLE users ADD COLUMN business_name TEXT", (err) => {
// 이미 존재하면 에러가 발생하므로 무시
});
db.run("ALTER TABLE history ADD COLUMN final_video_path TEXT", (err) => {
// 이미 존재하면 에러가 발생하므로 무시
});
db.run("ALTER TABLE history ADD COLUMN poster_path TEXT", (err) => {
// 이미 존재하면 에러가 발생하므로 무시
});
db.run("ALTER TABLE history ADD COLUMN pension_id INTEGER", (err) => {
// 이미 존재하면 에러가 발생하므로 무시
});
// History 테이블
db.run(`CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
business_name TEXT,
details TEXT,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id)
)`);
// 기본 관리자 계정 생성 (존재하지 않을 경우)
const adminUsername = 'admin';
const adminPassword = 'admin123'; // 초기 비밀번호
db.get("SELECT * FROM users WHERE username = ?", [adminUsername], (err, row) => {
if (!row) {
const salt = bcrypt.genSaltSync(10);
const hash = bcrypt.hashSync(adminPassword, salt);
db.run(`INSERT INTO users (username, password, name, phone, role, approved, credits)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[adminUsername, hash, 'Super Admin', '000-0000-0000', 'admin', 1, 999999],
(err) => {
if (err) console.error("초기 관리자 생성 실패:", err);
else console.log(`초기 관리자 계정 생성 완료. (ID: ${adminUsername}, PW: ${adminPassword})`);
});
} else if (row.role === 'admin' && (row.credits === null || row.credits < 999999)) {
// 기존 관리자에게 무제한 크레딧 부여
db.run("UPDATE users SET credits = 999999 WHERE id = ?", [row.id]);
}
});
});
module.exports = db;

237
server/emailService.js Normal file
View File

@ -0,0 +1,237 @@
/**
* Email Service using Resend
*
* 환경변수 필요:
* - RESEND_API_KEY: Resend API (https://resend.com에서 발급)
* - RESEND_FROM_EMAIL: 발신 이메일 (도메인 인증 전에는 'onboarding@resend.dev' 사용)
* - FRONTEND_URL: 프론트엔드 URL (인증 링크용)
*/
const { Resend } = require('resend');
// Resend 인스턴스 - API 키가 없으면 null (이메일 기능 비활성화)
let resend = null;
const RESEND_API_KEY = process.env.RESEND_API_KEY;
if (RESEND_API_KEY && RESEND_API_KEY !== 'your-resend-api-key') {
resend = new Resend(RESEND_API_KEY);
console.log('📧 이메일 서비스 활성화됨 (Resend)');
} else {
console.warn('⚠️ 이메일 서비스 비활성화됨: RESEND_API_KEY가 설정되지 않았습니다.');
console.warn(' 이메일 인증 기능을 사용하려면 .env에 RESEND_API_KEY를 설정하세요.');
}
// 기본 발신 이메일 (도메인 미인증 시 Resend 기본 도메인 사용)
const FROM_EMAIL = process.env.RESEND_FROM_EMAIL || 'CastAD <onboarding@resend.dev>';
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000';
/**
* 이메일 인증 메일 발송
*/
async function sendVerificationEmail(to, name, verificationToken) {
if (!resend) {
console.warn('이메일 발송 건너뜀 (서비스 비활성화):', to);
return { success: false, error: '이메일 서비스가 설정되지 않았습니다', disabled: true };
}
const verifyUrl = `${FRONTEND_URL}/verify-email?token=${verificationToken}`;
try {
const { data, error } = await resend.emails.send({
from: FROM_EMAIL,
to: [to],
subject: '[CastAD] 이메일 인증을 완료해주세요',
html: `
<div style="font-family: 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif; max-width: 600px; margin: 0 auto; padding: 40px 20px;">
<div style="text-align: center; margin-bottom: 40px;">
<h1 style="color: #6366f1; margin: 0;">CastAD</h1>
<p style="color: #64748b; margin-top: 8px;">AI 펜션 마케팅 영상 제작</p>
</div>
<div style="background: #f8fafc; border-radius: 12px; padding: 32px;">
<h2 style="margin: 0 0 16px 0; color: #1e293b;">안녕하세요, ${name || '고객'}!</h2>
<p style="color: #475569; line-height: 1.6; margin: 0 0 24px 0;">
CastAD 회원가입을 환영합니다. 아래 버튼을 클릭하여 이메일 인증을 완료해주세요.
</p>
<div style="text-align: center; margin: 32px 0;">
<a href="${verifyUrl}"
style="display: inline-block; background: #6366f1; color: white; padding: 14px 32px; border-radius: 8px; text-decoration: none; font-weight: 600;">
이메일 인증하기
</a>
</div>
<p style="color: #64748b; font-size: 14px; margin: 24px 0 0 0;">
버튼이 작동하지 않으면 아래 링크를 브라우저에 복사해주세요:<br>
<a href="${verifyUrl}" style="color: #6366f1; word-break: break-all;">${verifyUrl}</a>
</p>
</div>
<div style="text-align: center; margin-top: 32px; color: #94a3b8; font-size: 12px;">
<p> 이메일은 CastAD 회원가입 요청으로 발송되었습니다.</p>
<p>본인이 요청하지 않았다면 이메일을 무시해주세요.</p>
</div>
</div>
`
});
if (error) {
console.error('이메일 발송 실패:', error);
return { success: false, error: error.message };
}
console.log('인증 이메일 발송 완료:', data.id);
return { success: true, id: data.id };
} catch (err) {
console.error('이메일 발송 예외:', err);
return { success: false, error: err.message };
}
}
/**
* 비밀번호 재설정 이메일 발송
*/
async function sendPasswordResetEmail(to, name, resetToken) {
if (!resend) {
console.warn('이메일 발송 건너뜀 (서비스 비활성화):', to);
return { success: false, error: '이메일 서비스가 설정되지 않았습니다', disabled: true };
}
const resetUrl = `${FRONTEND_URL}/reset-password?token=${resetToken}`;
try {
const { data, error } = await resend.emails.send({
from: FROM_EMAIL,
to: [to],
subject: '[CastAD] 비밀번호 재설정 안내',
html: `
<div style="font-family: 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif; max-width: 600px; margin: 0 auto; padding: 40px 20px;">
<div style="text-align: center; margin-bottom: 40px;">
<h1 style="color: #6366f1; margin: 0;">CastAD</h1>
<p style="color: #64748b; margin-top: 8px;">AI 펜션 마케팅 영상 제작</p>
</div>
<div style="background: #f8fafc; border-radius: 12px; padding: 32px;">
<h2 style="margin: 0 0 16px 0; color: #1e293b;">비밀번호 재설정</h2>
<p style="color: #475569; line-height: 1.6; margin: 0 0 24px 0;">
${name || '고객'}, 비밀번호 재설정을 요청하셨습니다.<br>
아래 버튼을 클릭하여 비밀번호를 설정해주세요.
</p>
<div style="text-align: center; margin: 32px 0;">
<a href="${resetUrl}"
style="display: inline-block; background: #6366f1; color: white; padding: 14px 32px; border-radius: 8px; text-decoration: none; font-weight: 600;">
비밀번호 재설정하기
</a>
</div>
<div style="background: #fef3c7; border-radius: 8px; padding: 16px; margin: 24px 0;">
<p style="color: #92400e; font-size: 14px; margin: 0;">
링크는 1시간 동안만 유효합니다.
</p>
</div>
<p style="color: #64748b; font-size: 14px; margin: 0;">
버튼이 작동하지 않으면 아래 링크를 브라우저에 복사해주세요:<br>
<a href="${resetUrl}" style="color: #6366f1; word-break: break-all;">${resetUrl}</a>
</p>
</div>
<div style="text-align: center; margin-top: 32px; color: #94a3b8; font-size: 12px;">
<p>본인이 비밀번호 재설정을 요청하지 않았다면 이메일을 무시해주세요.</p>
<p>계정은 안전하게 보호됩니다.</p>
</div>
</div>
`
});
if (error) {
console.error('비밀번호 재설정 이메일 발송 실패:', error);
return { success: false, error: error.message };
}
console.log('비밀번호 재설정 이메일 발송 완료:', data.id);
return { success: true, id: data.id };
} catch (err) {
console.error('이메일 발송 예외:', err);
return { success: false, error: err.message };
}
}
/**
* 환영 이메일 발송 (인증 완료 )
*/
async function sendWelcomeEmail(to, name) {
if (!resend) {
console.warn('이메일 발송 건너뜀 (서비스 비활성화):', to);
return { success: false, error: '이메일 서비스가 설정되지 않았습니다', disabled: true };
}
try {
const { data, error } = await resend.emails.send({
from: FROM_EMAIL,
to: [to],
subject: '[CastAD] 가입을 환영합니다! 🎉',
html: `
<div style="font-family: 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif; max-width: 600px; margin: 0 auto; padding: 40px 20px;">
<div style="text-align: center; margin-bottom: 40px;">
<h1 style="color: #6366f1; margin: 0;">CastAD</h1>
<p style="color: #64748b; margin-top: 8px;">AI 펜션 마케팅 영상 제작</p>
</div>
<div style="background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); border-radius: 12px; padding: 32px; color: white;">
<h2 style="margin: 0 0 16px 0;">🎉 환영합니다, ${name || '고객'}!</h2>
<p style="line-height: 1.6; margin: 0; opacity: 0.9;">
CastAD 가입이 완료되었습니다.<br>
이제 AI가 만드는 펜션 마케팅 영상을 경험해보세요!
</p>
</div>
<div style="margin-top: 32px; padding: 24px; background: #f8fafc; border-radius: 12px;">
<h3 style="margin: 0 0 16px 0; color: #1e293b;">시작하기</h3>
<ul style="color: #475569; line-height: 2; padding-left: 20px; margin: 0;">
<li>펜션 정보를 등록하세요</li>
<li>사진을 업로드하고 AI 영상을 생성하세요</li>
<li>YouTube에 바로 업로드하세요</li>
</ul>
<div style="text-align: center; margin-top: 24px;">
<a href="${FRONTEND_URL}/app"
style="display: inline-block; background: #6366f1; color: white; padding: 14px 32px; border-radius: 8px; text-decoration: none; font-weight: 600;">
CastAD 시작하기
</a>
</div>
</div>
<div style="text-align: center; margin-top: 32px; color: #94a3b8; font-size: 12px;">
<p>문의사항이 있으시면 언제든 연락주세요.</p>
</div>
</div>
`
});
if (error) {
console.error('환영 이메일 발송 실패:', error);
return { success: false, error: error.message };
}
return { success: true, id: data.id };
} catch (err) {
console.error('이메일 발송 예외:', err);
return { success: false, error: err.message };
}
}
/**
* 이메일 서비스 활성화 여부 확인
*/
function isEmailServiceEnabled() {
return resend !== null;
}
module.exports = {
sendVerificationEmail,
sendPasswordResetEmail,
sendWelcomeEmail,
isEmailServiceEnabled
};

View File

@ -0,0 +1,805 @@
const { GoogleGenAI, Type, Modality } = require('@google/genai');
const GEMINI_API_KEY = process.env.VITE_GEMINI_API_KEY; // .env에서 API 키 로드
const getVoiceName = (config) => {
if (config.gender === 'Female') {
if (config.tone === 'Bright' || config.tone === 'Energetic') return 'Zephyr';
return 'Kore';
} else {
if (config.tone === 'Professional' || config.tone === 'Calm') return 'Charon';
if (config.tone === 'Energetic') return 'Fenrir';
return 'Puck';
}
};
/**
* 서버 사이드에서 창의적 콘텐츠 생성 (광고 카피 & 가사/스크립트) - Gemini 2.0 Flash
* 업체의 이미지와 정보를 분석하여 마케팅에 최적화된 문구를 생성합니다.
*
* @param {object} info - 비즈니스 정보 객체 (BusinessInfo와 유사)
* @param {Array<object>} info.images - Base64 인코딩된 이미지 데이터 배열 ({ mimeType, base64 })
* @param {string} info.name - 브랜드 이름
* @param {string} info.description - 브랜드 설명
* @param {string} info.audioMode - 오디오 모드
* @param {string} info.musicGenre - 음악 장르
* @param {string} info.musicDuration - 음악 길이
* @param {object} info.ttsConfig - TTS 설정
* @param {string} info.language - 콘텐츠 생성 언어
* @param {Array<string>} info.pensionCategories - 펜션 카테고리 배열
* @returns {Promise<{adCopy: string[], lyrics: string}>}
*/
const generateCreativeContent = async (info) => {
if (!GEMINI_API_KEY) {
throw new Error("Gemini API Key가 서버에 설정되지 않았습니다.");
}
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
const imageParts = info.images.map((imageData) => ({
inlineData: { mimeType: imageData.mimeType, data: imageData.base64 }
}));
const lyricStructureInstruction = info.musicDuration === 'Short'
? `
- **길이 제약사항**: 노래는 반드시 30 이내여야 .
- **구조**: [Verse 1] -> [Chorus] -> [Outro] 포함할 .
- ** **: 8 이내. 짧고 리듬감 있게 작성.
`
: `
- **길이**: 전체 길이 ( 2).
- **구조**: [Verse 1] -> [Chorus] -> [Verse 2] -> [Chorus] -> [Outro].
`;
const langMap = {
'KO': '한국어 (Korean)',
'EN': '영어 (English)',
'JA': '일본어 (Japanese)',
'ZH': '중국어 (Chinese, Simplified)',
'TH': '태국어 (Thai)',
'VI': '베트남어 (Vietnamese)'
};
const targetLang = langMap[info.language] || '한국어 (Korean)';
// 펜션 카테고리 매핑 (한국어 설명)
const categoryMap = {
'PoolVilla': '풀빌라 (프라이빗 수영장)',
'OceanView': '오션뷰 (바다 전망)',
'Mountain': '산장/계곡 (자연 속 힐링)',
'Private': '독채 (프라이빗 공간)',
'Couple': '커플펜션 (로맨틱)',
'Family': '가족펜션 (단체 숙박)',
'Pet': '애견동반 (반려동물 환영)',
'Glamping': '글램핑 (캠핑+럭셔리)',
'Traditional': '한옥펜션 (전통미)'
};
// 선택된 카테고리를 텍스트로 변환
const pensionTypesText = info.pensionCategories && info.pensionCategories.length > 0
? info.pensionCategories.map(cat => categoryMap[cat] || cat).join(', ')
: '일반 펜션';
const prompt = `
역할: 전문 ${targetLang} 카피라이터 작사가.
클라이언트: ${info.name}
컨텍스트: ${info.description}
펜션 유형: ${pensionTypesText} - 특성을 마케팅 콘텐츠에 반영하세요.
모드: ${info.audioMode} (Song=노래, Narration=내레이션)
스타일: ${info.audioMode === 'Song' ? info.musicGenre : info.ttsConfig.tone}
언어: **${targetLang}** 작성 필수.
과제 1: 임팩트 있는 **${targetLang}** 광고 헤드라인 4개를 작성하라.
- **완벽한 ${targetLang} 사용**: 자연스럽고 세련된 현지 마케팅 표현 사용.
- **펜션 유형 반영**: ${pensionTypesText} 특징(프라이빗, 로맨틱, 자연, 럭셔리 ) 강조하라.
- **형식**: 짧고 강렬한 "빌보드" 스타일.
- **줄바꿈**: 시각적 균형을 위해 문장의 중간에 적절한 줄바꿈(\n) 반드시 넣어라.
과제 2: ${info.audioMode === 'Song' ? '노래 가사' : '내레이션 스크립트'} 작성.
- 언어: **${targetLang}**.
- **펜션 컨셉 활용**: ${pensionTypesText} 매력(: 풀빌라면 프라이빗한 휴식, 오션뷰면 파도소리와 일몰) 자연스럽게 녹여내라.
${info.audioMode === 'Song'
? `- **필수 포맷**: 반드시 다음 헤더를 사용해야 함: "[Verse 1]", "[Chorus]", 등.
${lyricStructureInstruction}
- 내용: ${info.musicGenre} 장르에 맞는 감성적이고 스토리텔링이 있는 가사.
`
: `- 구조: 상업 광고용 30초 분량의 매력적인 내레이션 스크립트.
- 톤앤매너: ${info.ttsConfig.tone}. 자연스러운 구어체 ${targetLang}.`}
출력 형식 (JSON):
{
"adCopy": ["헤드라인 1\n(줄바꿈포함)", "헤드라인 2\n(줄바꿈포함)", ...],
"lyrics": "Verse/Chorus 헤더가 포함된 전체 가사 또는 스크립트 텍스트..."
}
`;
const response = await ai.models.generateContent({
model: 'gemini-2.0-flash',
contents: {
parts: [
{ text: prompt },
...imageParts
]
},
config: {
temperature: 0.7,
responseMimeType: 'application/json',
responseSchema: {
type: Type.OBJECT,
properties: {
adCopy: {
type: Type.ARRAY,
items: { type: Type.STRING }
},
lyrics: { type: Type.STRING }
}
}
}
});
if (response.text) {
try {
return JSON.parse(response.text);
} catch (e) {
console.error("JSON 파싱 오류", response.text);
throw new Error("창의적 콘텐츠 파싱에 실패했습니다.");
}
}
throw new Error("텍스트 콘텐츠 생성 실패");
};
/**
* 서버 사이드에서 고급 음성 합성 (TTS) - Gemini 2.5 Flash TTS
* 텍스트를 입력받아 자연스러운 AI 성우 목소리(Base64) 생성합니다.
*
* @param {string} text - 음성으로 변환할 텍스트
* @param {object} config - TTS 설정 (gender, age, tone)
* @returns {Promise<string>} - Base64 인코딩된 오디오 데이터
*/
const generateAdvancedSpeech = async (
text,
config
) => {
if (!GEMINI_API_KEY) {
throw new Error("Gemini API Key가 서버에 설정되지 않았습니다.");
}
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
const voiceName = getVoiceName(config);
const cleanText = text
.replace(/\[.*?\]/g, '')
.replace(/\(.*?\)/g, '')
.replace(/<.*?>/g, '')
.replace(/(Narrator|나레이터|성우|Speaker|Woman|Man).*?:/gi, '')
.replace(/\*\*/g, '')
.replace(/[•*-]/g, '')
.replace(/\s{2,}/g, ' ')
.trim();
// console.log(`음성 생성 중: 목소리=${voiceName}, 텍스트=${cleanText.substring(0, 50)}...`); // 디버그 로그 제거
const response = await ai.models.generateContent({
model: "gemini-2.5-flash-preview-tts",
contents: [{ parts: [{ text: cleanText }] }],
config: {
responseModalities: [Modality.AUDIO],
speechConfig: {
voiceConfig: {
prebuiltVoiceConfig: { voiceName }
}
}
},
});
const base64Audio = response.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
if (!base64Audio) {
throw new Error("Gemini TTS로부터 오디오 데이터를 받지 못했습니다.");
}
return base64Audio;
};
/**
* 서버 사이드에서 광고 포스터 생성 - Gemini 3 Pro Image
* 업로드된 이미지들을 합성하고 브랜드 분위기에 맞는 고화질 포스터를 생성합니다.
*
* @param {object} info - 비즈니스 정보 객체 (BusinessInfo와 유사)
* @param {Array<object>} info.images - Base64 인코딩된 이미지 데이터 배열 ({ mimeType, base64 })
* @param {string} info.name - 브랜드 이름
* @param {string} info.description - 브랜드 설명
* @param {string} info.aspectRatio - 화면 비율
* @param {boolean} info.useAiImages - AI 이미지 생성 허용 여부
* @returns {Promise<{ base64: string; mimeType: string }>}
*/
const generateAdPoster = async (info) => {
if (!GEMINI_API_KEY) {
throw new Error("Gemini API Key가 서버에 설정되지 않았습니다.");
}
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
const imageParts = info.images.map((imageData) => ({
inlineData: { mimeType: imageData.mimeType, data: imageData.base64 }
}));
const model = 'gemini-3-pro-image-preview';
let prompt = '';
if (imageParts.length > 0) {
if (info.useAiImages) {
prompt = `
역할: 전문 아트 디렉터.
과제: 브랜드 "${info.name}" 위한 하나의, 조화롭고 수상 경력에 빛나는 광고 포스터를 제작하라.
지침:
1. **시각적 합성(Composite Visuals)**: 제공된 여러 참조 이미지를 바탕으로 예술적으로 재창조하라. 필요하다면 요소를 추가하거나 조명을 개선하여 퀄리티를 높여라.
2. 테마: ${info.description}.
3. 스타일: 시네마틱 조명, 4k 해상도, 하이엔드 상업 사진.
4. 종횡비: ${info.aspectRatio === '9:16' ? '9:16 세로 모드' : '16:9 가로 모드'}.
`;
} else {
prompt = `
역할: 사진 편집자.
과제: 제공된 이미지들을 사용하여 하나의 깔끔한 홍보 이미지를 만들어라.
**매우 중요한 제약사항 (Strict Constraints)**:
1. **새로운 사물 생성 금지**: 제공된 이미지에 없는 물체, 사람, 배경을 절대로 새로 그려넣지 마라.
2. **원본 유지**: 제공된 이미지들의 톤과 느낌을 최대한 유지하며 자연스럽게 합성하라. (Digital Collage).
3. **사실성**: 과도한 AI 효과나 비현실적인 변형을 피하라.
4. 종횡비: ${info.aspectRatio === '9:16' ? '9:16 세로 모드' : '16:9 가로 모드'}.
`;
}
} else {
if (!info.useAiImages) {
throw new Error("이미지가 없고 AI 이미지 생성 옵션도 꺼져 있어 포스터를 만들 수 없습니다. 사진을 업로드하거나 옵션을 켜주세요.");
}
prompt = `
역할: 세계적인 상업 사진작가 아트 디렉터.
과제: 브랜드 "${info.name}" 홍보하기 위한 최고급 상업 광고 사진을 생성하라.
브랜드 설명: "${info.description}"
지침:
1. **이미지 생성**: 브랜드 설명에 완벽하게 부합하는, 디테일이 살아있는 고해상도 사진을 창조하라. 실제 매장이나 제품을 촬영한 같은 사실감을 주어라.
2. 스타일:
- 조명: 드라마틱하고 따뜻한 시네마틱 조명.
- 퀄리티: 8k UHD, 초고화질, 잡지 커버 수준의 디테일.
- 분위기: 고객이 방문하고 싶게 만드는 매력적이고 환영하는 분위기.
3. 구도: ${info.aspectRatio === '9:16' ? '9:16 세로 비율 (Vertical/Portrait) 필수. 스마트폰 전체 화면용. 가로 사진을 90도 회전시키지 마라.' : '16:9 와이드 비율 (Horizontal). 중앙이나 한쪽에 텍스트를 배치할 수 있는 여백을 고려한 구도.'}
4. 금지: 흐릿하거나 왜곡된 이미지, 부자연스러운 텍스트 생성 금지.
이미지는 뮤직 비디오의 메인 배경으로 사용될 것이다.
`;
}
const response = await ai.models.generateContent({
model,
contents: {
parts: [
{ text: prompt },
...imageParts
]
},
});
for (const part of response.candidates?.[0]?.content?.parts || []) {
if (part.inlineData) {
return { base64: part.inlineData.data, mimeType: part.inlineData.mimeType || 'image/png' };
}
}
throw new Error("광고 포스터 이미지 생성에 실패했습니다.");
};
/**
* 서버 사이드에서 다수의 비즈니스 관련 이미지 생성 (갤러리/슬라이드쇼용)
* @param {object} info - 비즈니스 정보 객체
* @param {number} count - 생성할 이미지 개수
* @returns {Promise<string[]>} - Base64 Data URL 배열
*/
const generateImageGallery = async (info, count) => {
if (!GEMINI_API_KEY) {
throw new Error("Gemini API Key가 서버에 설정되지 않았습니다.");
}
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
const model = 'gemini-3-pro-image-preview';
const perspectives = [
"Wide angle shot of the interior, welcoming atmosphere, cinematic lighting",
"Close-up detail shot of the main product or service, high resolution, macro photography",
"Exterior view of the storefront or location, inviting and stylish",
"Candid shot of happy customers enjoying the service or atmosphere (soft focus background)",
"Artistic composition of the brand elements or menu items, magazine style"
];
const tasks = Array.from({ length: count }).map(async (_, i) => {
const perspective = perspectives[i % perspectives.length];
const prompt = `
Role: Professional Photographer.
Subject: ${info.name} - ${info.description}
Shot Type: ${perspective}
Style: 4k, Photorealistic, Commercial Advertisement Standard, ${info.textEffect} vibe.
Aspect Ratio: 16:9.
`;
try {
const response = await ai.models.generateContent({
model,
contents: { parts: [{ text: prompt }] },
});
const part = response.candidates?.[0]?.content?.parts?.[0];
if (part && part.inlineData) {
return `data:${part.inlineData.mimeType};base64,${part.inlineData.data}`;
}
return null;
} catch (e) {
console.error(`Image generation failed for index ${i}`, e);
return null;
}
});
const results = await Promise.all(tasks);
return results.filter((img) => img !== null);
};
/**
* 서버 사이드에서 비디오 배경 생성 - Veo (Video Generation Model)
* 생성된 포스터 이미지를 기반으로 움직이는 시네마틱 배경 영상을 만듭니다.
*
* @param {string} posterBase64 - Base64 인코딩된 포스터 이미지 데이터
* @param {string} posterMimeType - 포스터 이미지 MIME 타입
* @param {string} aspectRatio - 비디오 화면 비율
* @returns {Promise<string>} - 생성된 비디오의 원격 URL
*/
const generateVideoBackground = async (
posterBase64,
posterMimeType,
aspectRatio = '16:9'
) => {
if (!GEMINI_API_KEY) {
throw new Error("Gemini API Key가 서버에 설정되지 않았습니다.");
}
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
let operation = await ai.models.generateVideos({
model: 'veo-3.1-fast-generate-preview',
prompt: '시네마틱한 움직임, 상업 광고 영상미, 4k, 느리고 부드러운 움직임, 감성적, 추상적이고 미니멀한 모션 그래픽 스타일',
image: {
imageBytes: posterBase64,
mimeType: posterMimeType,
},
config: {
numberOfVideos: 1,
resolution: '720p',
aspectRatio: aspectRatio
}
});
while (!operation.done) {
await new Promise(resolve => setTimeout(resolve, 5000));
operation = await ai.operations.getVideosOperation({ operation: operation });
}
if (operation.error) {
console.error("Veo 생성 오류:", operation.error);
throw new Error(`비디오 생성 실패: ${operation.error.message || "알 수 없는 오류"}`);
}
let downloadLink = operation.response?.generatedVideos?.[0]?.video?.uri;
if (!downloadLink) {
const anyOp = operation;
downloadLink = anyOp.result?.generatedVideos?.[0]?.video?.uri;
}
if (!downloadLink) {
console.error("작업 내 다운로드 링크 누락. 응답 덤프:", JSON.stringify(operation, null, 2));
throw new Error("비디오 생성이 완료되었으나 URI가 반환되지 않았습니다. 안전 필터에 의해 콘텐츠가 차단되었을 수 있습니다. 다른 이미지나 설명으로 시도해보세요.");
}
// API 키를 직접 노출하지 않으므로, downloadLink만 반환
return downloadLink;
};
/**
* 서버 사이드에서 AI 이미지 검수 (Gemini Vision)
* 업로드되거나 크롤링된 이미지 마케팅에 적합한 사진만 선별합니다.
*
* @param {Array<object>} imagesData - Base64 인코딩된 이미지 데이터 배열 ({ mimeType, base64 })
* @returns {Promise<Array<object>>} - 선별된 Base64 이미지 데이터 배열
*/
const filterBestImages = async (imagesData) => {
if (!GEMINI_API_KEY) {
throw new Error("Gemini API Key가 서버에 설정되지 않았습니다.");
}
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
if (imagesData.length === 0) {
// console.warn("검수할 유효한 이미지(Base64)가 없습니다."); // 디버그 로그 제거
return [];
}
// console.log(`AI 이미지 검수 시작: 총 ${imagesData.length}장 분석 중...`); // 디버그 로그 제거
const imageParts = imagesData.map((imageData) => ({
inlineData: { mimeType: imageData.mimeType, data: imageData.base64 }
}));
const prompt = `
역할: 엄격한 상업 사진 편집장.
작업: 다음 이미지들을 분석하여 "마케팅 비디오" 사용할 있는 **최고의 사진** 골라내라.
**엄격한 제외 기준 (무조건 탈락)**:
1. **사람 얼굴**: 정면이든 측면이든 식별 가능한 사람 얼굴이 포함된 경우 (초상권 보호).
2. **지저분함**: 먹다 남은 음식, 그릇, 쓰레기, 지저분한 테이블.
3. **무의미한 문서**: 영수증, 택배 송장, 흐릿한 A4 용지 문서. (, **메뉴판은 허용**).
4. **저품질**: 심하게 흔들림, 너무 어두움, 해상도 낮음.
**선정 기준 (우선 순위)**:
1. 맛있어 보이는 음식 클로즈업.
2. 분위기 있는 매장 인테리어 또는 익스테리어.
3. **정보가 담긴 메뉴판**: 고객에게 메뉴와 가격을 알려주는 깔끔한 메뉴판 사진은 **반드시 포함**하라.
4. 구도가 잡힌 감성적인 사진.
출력 형식:
오직 선정된 이미지의 **인덱스 번호(0부터 시작)** 포함된 JSON 배열.
예시: [0, 2, 5]
(만약 모든 사진이 부적합하다면 배열 [] 반환)
`;
try {
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: {
parts: [
{ text: prompt },
...imageParts
]
},
config: {
responseMimeType: 'application/json',
temperature: 0.1
}
});
if (response.text) {
const indices = JSON.parse(response.text);
// console.log(`AI 검수 결과: ${indices.length}장 선정됨 (인덱스: ${indices.join(', ')})`); // 디버그 로그 제거
return indices
.filter(i => i >= 0 && i < imagesData.length)
.map(i => imagesData[i]);
}
} catch (e) {
console.error("AI 이미지 검수 실패:", e);
return imagesData; // 실패 시 안전하게 모든 이미지 반환
}
return imagesData;
};
/**
* 서버 사이드에서 리뷰 기반 마케팅 설명 생성
* @param {string} name - 업체명
* @param {string} rawDescription - 기본 설명
* @param {string[]} reviews - 고객 리뷰 배열
* @param {number} rating - 평균 별점
* @returns {Promise<string>} - 생성된 설명
*/
const enrichDescriptionWithReviews = async (
name,
rawDescription,
reviews,
rating
) => {
if (!GEMINI_API_KEY) {
throw new Error("Gemini API Key가 서버에 설정되지 않았습니다.");
}
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
const prompt = `
역할: 전문 마케팅 카피라이터.
업체명: ${name}
기본 정보: ${rawDescription}
평균 별점: ${rating}
실제 고객 리뷰 요약:
${reviews.map(r => `- ${r}`).join('\n')}
지침:
정보를 바탕으로 뮤직 비디오 제작을 위한 **매력적이고 풍부한 업체 설명(Description)** 단락으로 작성하라.
요구사항:
1. 실제 고객의 목소리(리뷰) 자연스럽게 녹여내라.
2. 별점이 높다면(4.5 이상) 이를 강조하라.
3. 분위기, , 서비스의 특징을 감성적으로 묘사하라.
4. 길이는 3~4문장 정도로 요약하라.
5. 한국어로 작성하라.
`;
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: [{ parts: [{ text: prompt }] }]
});
return response.text?.trim() || rawDescription;
};
/**
* 서버 사이드에서 이미지에서 텍스트 스타일(CSS) 추출 - Gemini 1.5 Flash
* @param {object} imageFile - Base64 인코딩된 이미지 데이터 ({ mimeType, base64 })
* @returns {Promise<string>} - 생성된 CSS 코드
*/
const extractTextEffectFromImage = async (
imageFile
) => {
if (!GEMINI_API_KEY) {
throw new Error("Gemini API Key가 서버에 설정되지 않았습니다.");
}
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
const imageBase64 = imageFile.base64;
const mimeType = imageFile.mimeType;
const prompt = `
역할: 전문 프론트엔드 개발자 UI 디자이너.
작업: 제공된 이미지에 있는 **메인 텍스트의 스타일** 분석하여 똑같이 재현할 있는 **CSS 코드** 작성하라.
분석 항목:
1. **색상 (Color)**: 텍스트 색상 그라디언트.
2. **폰트 (Font)**: 폰트 두께(weight), 스타일(italic ), 자간(letter-spacing). (폰트 패밀리는 일반적인 sans-serif나 serif 사용)
3. **효과 (Effects)**:
- text-shadow (네온, 그림자, 외곽선 )
- background (배경색, 반투명 )
- transform (기울임, 회전 )
4. **애니메이션 (Animation)**: 이미지의 분위기에 어울리는 적절한 @keyframes 애니메이션을 하나 추가하라. (: 부드러운 등장, 반짝임, 흔들림 )
출력 형식:
- 오직 **CSS 코드만** 출력하라. 설명이나 마크다운 코드블록은 제외하라.
- 클래스 이름은 반드시 **.custom-effect** 정의하라.
- 애니메이션이 있다면 @keyframes도 함께 포함하라.
`;
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: {
parts: [
{ text: prompt },
{ inlineData: { mimeType: mimeType, data: imageBase64 } }
]
}
});
let css = response.text || '';
css = css.replace(/```css/g, '').replace(/```/g, '').trim();
return css;
};
/**
* YouTube SEO 최적화 메타데이터 생성 (다국어 지원)
* @param {object} params - 비즈니스 정보
* @param {string} params.businessName - 펜션 이름 (한국어)
* @param {string} params.businessNameEn - 펜션 이름 (영어)
* @param {string} params.description - 비즈니스 설명
* @param {Array<string>} params.categories - 펜션 카테고리
* @param {string} params.address - 주소
* @param {string} params.region - 지역명
* @param {string} params.targetAudience - 타깃 고객
* @param {Array<string>} params.mainStrengths - 주요 장점
* @param {Array<string>} params.nearbyAttractions - 인근 관광지
* @param {string} params.bookingUrl - 예약 링크
* @param {number} params.videoDuration - 영상 길이 ()
* @param {string} params.seasonTheme - 계절/테마
* @param {string} params.priceRange - 가격대 설명
* @param {string} params.language - 추가 언어 코드 (KO는 기본)
* @returns {Promise<object>} - 다국어 SEO 메타데이터
*/
const generateYouTubeSEO = async (params) => {
if (!GEMINI_API_KEY) {
throw new Error("Gemini API Key가 서버에 설정되지 않았습니다.");
}
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
const langMap = {
'KO': { name: '한국어', code: 'ko' },
'EN': { name: 'English', code: 'en' },
'JA': { name: '日本語', code: 'ja' },
'ZH': { name: '中文', code: 'zh' },
'TH': { name: 'ไทย', code: 'th' },
'VI': { name: 'Tiếng Việt', code: 'vi' }
};
const categoryMap = {
'PoolVilla': { ko: '풀빌라', en: 'Pool Villa' },
'OceanView': { ko: '오션뷰', en: 'Ocean View' },
'Mountain': { ko: '산장/계곡', en: 'Mountain Retreat' },
'Private': { ko: '독채펜션', en: 'Private Pension' },
'Couple': { ko: '커플펜션', en: 'Couple Pension' },
'Family': { ko: '가족펜션', en: 'Family Pension' },
'Pet': { ko: '애견동반', en: 'Pet-Friendly' },
'Glamping': { ko: '글램핑', en: 'Glamping' },
'Traditional': { ko: '한옥펜션', en: 'Traditional Hanok' }
};
const categoryTagsKo = params.categories?.map(cat => categoryMap[cat]?.ko || cat) || [];
const categoryTagsEn = params.categories?.map(cat => categoryMap[cat]?.en || cat) || [];
const pensionType = categoryTagsKo[0] || '펜션';
const pensionTypeEn = categoryTagsEn[0] || 'Pension';
// 지역 정보 추출
const regionKo = params.region || params.address?.split(' ')[0] || '';
const regionEn = params.regionEn || regionKo;
// 영상 길이 (분)
const videoDurationMin = Math.ceil((params.videoDuration || 60) / 60);
// 추가 언어 설정
const additionalLang = params.language && params.language !== 'KO' ? langMap[params.language] : null;
const prompt = `
너는 유튜브 여행/숙소 채널의 SEO 전문가이자 카피라이터다.
나는 "펜션 전문 웹사이트" 운영하고 있고, 아래 입력 정보를 기반으로
특정 펜션을 소개하는 유튜브 영상을 올리려고 한다.
목표:
1) 한국어 시청자에게는 예약 정보를 자세하고 감성적으로 전달
2) ${additionalLang ? `${additionalLang.name} 시청자(외국인 여행객)에게는 핵심 정보와 위치/장점을 명확하게 안내` : '검색 최적화'}
3) YouTube 검색, 연관 동영상, Google 검색, AI 검색(LLM)까지 고려한 SEO 최적화
[입력 정보]
- 펜션 이름: ${params.businessName} / 영어 이름: ${params.businessNameEn || params.businessName}
- 지역/도시: ${regionKo} (영어 표기: ${regionEn})
- 펜션 타입: ${pensionType}
- 주요 타깃 고객: ${params.targetAudience || '커플, 가족'}
- 핵심 장점: ${params.mainStrengths?.join(', ') || categoryTagsKo.join(', ')}
- 인근 관광지: ${params.nearbyAttractions?.join(', ') || ''}
- 예약 링크(URL): ${params.bookingUrl || ''}
- 공식 웹사이트/브랜드: CastAD
- 영상 길이: ${videoDurationMin}
- 계절/테마: ${params.seasonTheme || '사계절'}
- 가격대: ${params.priceRange || '문의'}
- 설명: ${params.description || ''}
[출력 형식 - JSON만 출력]
{
"snippet": {
"title_ko": "70자 이내, 지역 + 펜션타입 + 강점1~2개 포함. 예: '${regionKo} ${pensionType} | 전 객실 개별바비큐 & 야외수영장'",
"title_${additionalLang?.code || 'en'}": "70자 이내 ${additionalLang?.name || 'English'} 제목",
"description_ko": "유튜브 본문 전체 (500~1500자, 아래 구조 포함):\\n\\n[펜션 한 줄 요약]\\n[위치 & 교통]\\n[객실 & 인원 안내]\\n[편의시설 상세]\\n[인근 관광지 추천 코스]\\n[이용 팁 & 주의사항]\\n[예약/문의 안내 + 예약링크]\\n\\n자연스러운 여행 블로그 글처럼 작성",
"description_${additionalLang?.code || 'en'}": "${additionalLang?.name || 'English'} 설명 (300~800자, 핵심 정보 위주, 한국 지명은 영어 표기 + 한글 병기)",
"tags_ko": ["한국어 태그 15~25개, 지역/펜션타입/타깃고객/계절/관광지 키워드 포함, 롱테일 키워드도 포함"],
"tags_${additionalLang?.code || 'en'}": ["${additionalLang?.name || 'English'} 태그 15~25개"],
"hashtags_ko": ["#형식의 해시태그 10~15개"],
"categoryId": "19"
},
"chapters": [
{"time": "00:00", "title_ko": "인트로 & 뷰 소개", "title_${additionalLang?.code || 'en'}": "Intro & View"},
{"time": "00:30", "title_ko": "외관 & 공용시설", "title_${additionalLang?.code || 'en'}": "Exterior & Facilities"},
{"time": "01:30", "title_ko": "객실 내부 투어", "title_${additionalLang?.code || 'en'}": "Room Tour"},
{"time": "03:00", "title_ko": "바비큐/수영장/스파", "title_${additionalLang?.code || 'en'}": "BBQ / Pool / Spa"},
{"time": "04:30", "title_ko": "주변 관광지 & 추천 코스", "title_${additionalLang?.code || 'en'}": "Nearby Attractions"},
{"time": "05:30", "title_ko": "예약 안내 & 팁", "title_${additionalLang?.code || 'en'}": "Booking Tips"}
],
"thumbnail_text": {
"short_ko": "${regionKo} ${pensionType}",
"short_${additionalLang?.code || 'en'}": "${regionEn} ${pensionTypeEn}",
"sub_ko": "핵심 강점 2개 요약",
"sub_${additionalLang?.code || 'en'}": "Key features summary"
},
"pinned_comment_ko": "고정 댓글용 한국어 멘트 (예약 링크, 문의 안내, 타임스탬프 요약 포함)",
"pinned_comment_${additionalLang?.code || 'en'}": "${additionalLang?.name || 'English'} pinned comment for foreign viewers"
}
[작성 규칙]
1) title_ko: 지역 + 펜션타입 + 강점1~2 포함
2) description_ko: 2~3줄에 핵심 키워드와 강력한 후킹 문장, 구조화된 섹션
3) description_${additionalLang?.code || 'en'}: 외국인 기준으로 위치/교통/시설 명확히
4) tags: 지역, 펜션타입, 타깃고객, 계절/테마, 주변관광지 키워드 모두 커버
5) chapters: 영상 길이(${videoDurationMin}) 맞는 타임스탬프
6) : 과한 광고 피하고, 실제 방문 후기처럼 신뢰감 있게
예약 링크가 있으면 description에 반드시 포함: ${params.bookingUrl || '없음'}
`;
try {
const response = await ai.models.generateContent({
model: 'gemini-2.0-flash',
contents: { parts: [{ text: prompt }] }
});
let result = response.text || '';
result = result.replace(/```json/g, '').replace(/```/g, '').trim();
const seoData = JSON.parse(result);
// 태그 검증 (YouTube 제한: 500자)
['tags_ko', 'tags_en', 'tags_ja', 'tags_zh', 'tags_th', 'tags_vi'].forEach(tagKey => {
if (seoData.snippet?.[tagKey]) {
let totalTagLength = seoData.snippet[tagKey].join(',').length;
while (totalTagLength > 500 && seoData.snippet[tagKey].length > 5) {
seoData.snippet[tagKey].pop();
totalTagLength = seoData.snippet[tagKey].join(',').length;
}
}
});
// 제목 길이 검증 (100자)
Object.keys(seoData.snippet || {}).forEach(key => {
if (key.startsWith('title_') && seoData.snippet[key]?.length > 100) {
seoData.snippet[key] = seoData.snippet[key].substring(0, 97) + '...';
}
});
// 설명 길이 검증 (5000자)
Object.keys(seoData.snippet || {}).forEach(key => {
if (key.startsWith('description_') && seoData.snippet[key]?.length > 5000) {
seoData.snippet[key] = seoData.snippet[key].substring(0, 4997) + '...';
}
});
// 메타 정보 추가
seoData.meta = {
businessName: params.businessName,
businessNameEn: params.businessNameEn || params.businessName,
region: regionKo,
regionEn: regionEn,
pensionType: pensionType,
pensionTypeEn: pensionTypeEn,
bookingUrl: params.bookingUrl || '',
videoDuration: params.videoDuration,
language: params.language || 'KO',
additionalLanguage: additionalLang?.code || 'en'
};
console.log(`[YouTube SEO] ${params.businessName}: 다국어 메타데이터 생성 완료`);
return seoData;
} catch (error) {
console.error('[YouTube SEO] 생성 오류:', error.message);
// 폴백: 기본 메타데이터 반환
return {
snippet: {
title_ko: `${regionKo} ${pensionType} | ${params.businessName}`,
title_en: `${regionEn} ${pensionTypeEn} | ${params.businessNameEn || params.businessName}`,
description_ko: `${params.businessName}\n\n${params.description}\n\n📍 위치: ${params.address || regionKo}\n🔗 예약: ${params.bookingUrl || '문의'}\n\n#펜션 #숙소 #여행 #휴가 #${regionKo}`,
description_en: `${params.businessNameEn || params.businessName}\n\n📍 Location: ${params.address || regionEn}\n🔗 Booking: ${params.bookingUrl || 'Contact us'}\n\n#pension #accommodation #travel #korea`,
tags_ko: [params.businessName, pensionType, regionKo, '펜션', '숙소', '여행', '휴가', ...categoryTagsKo],
tags_en: [params.businessNameEn || params.businessName, pensionTypeEn, regionEn, 'pension', 'korea travel', ...categoryTagsEn],
hashtags_ko: [`#${params.businessName}`, `#${regionKo}펜션`, `#${pensionType}`, '#펜션추천', '#국내여행'],
categoryId: '19'
},
chapters: [
{ time: '0:00', title_ko: '인트로', title_en: 'Intro' },
{ time: '0:15', title_ko: '시설 소개', title_en: 'Facilities' },
{ time: '0:45', title_ko: '마무리', title_en: 'Outro' }
],
thumbnail_text: {
short_ko: `${regionKo} ${pensionType}`,
short_en: `${regionEn} ${pensionTypeEn}`,
sub_ko: params.mainStrengths?.[0] || '프라이빗 힐링',
sub_en: 'Private Retreat'
},
pinned_comment_ko: `📍 ${params.businessName} 예약 안내\n🔗 ${params.bookingUrl || '문의하기'}\n\n영상이 도움이 되셨다면 구독과 좋아요 부탁드려요! 🙏`,
pinned_comment_en: `📍 ${params.businessNameEn || params.businessName} Booking\n🔗 ${params.bookingUrl || 'Contact us'}\n\nIf you found this helpful, please subscribe! 🙏`,
meta: {
businessName: params.businessName,
businessNameEn: params.businessNameEn || params.businessName,
region: regionKo,
regionEn: regionEn,
bookingUrl: params.bookingUrl || '',
language: params.language || 'KO'
}
};
}
};
module.exports = {
generateCreativeContent,
generateAdvancedSpeech,
generateAdPoster,
generateImageGallery,
filterBestImages,
enrichDescriptionWithReviews,
extractTextEffectFromImage,
generateVideoBackground,
generateYouTubeSEO,
};

5022
server/index.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,243 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
암호화 서비스
Fernet 대칭 암호화를 사용하여 Instagram 비밀번호와 세션 데이터를
안전하게 암호화/복호화합니다.
Fernet 특징:
- AES-128-CBC 암호화
- HMAC-SHA256 무결성 검증
- 타임스탬프 포함 (TTL 검증 가능)
- URL-safe base64 인코딩
사용 예시:
encryptor = EncryptionService()
encrypted = encryptor.encrypt("비밀번호")
decrypted = encryptor.decrypt(encrypted)
"""
import os
import json
import base64
import logging
from typing import Optional, Any, Dict
from cryptography.fernet import Fernet, InvalidToken
logger = logging.getLogger(__name__)
class EncryptionService:
"""
Fernet 기반 암호화/복호화 서비스
비밀번호, 세션 데이터 민감한 정보를 안전하게 저장하기 위한
암호화 기능을 제공합니다.
Attributes:
_cipher: Fernet 암호화 인스턴스
_key: 암호화 (bytes)
Example:
>>> service = EncryptionService()
>>> encrypted = service.encrypt("my_password")
>>> decrypted = service.decrypt(encrypted)
>>> print(decrypted) # "my_password"
"""
def __init__(self, key: Optional[str] = None):
"""
암호화 서비스 초기화
Args:
key: Base64 인코딩된 Fernet (32바이트)
None이면 생성
Raises:
ValueError: 유효하지 않은 형식
"""
if key:
try:
# Base64 문자열을 바이트로 디코딩
self._key = key.encode() if isinstance(key, str) else key
self._cipher = Fernet(self._key)
logger.info("암호화 서비스 초기화 완료 (기존 키 사용)")
except Exception as e:
raise ValueError(f"유효하지 않은 암호화 키입니다: {e}")
else:
# 새 키 생성
self._key = Fernet.generate_key()
self._cipher = Fernet(self._key)
logger.warning("새 암호화 키가 생성되었습니다. 환경변수에 저장하세요!")
def get_key_string(self) -> str:
"""
암호화 키를 문자열로 반환
환경변수에 저장할 있는 형태로 키를 반환합니다.
Returns:
Base64 인코딩된 문자열
"""
return self._key.decode() if isinstance(self._key, bytes) else self._key
def encrypt(self, data: str) -> str:
"""
문자열 데이터 암호화
Args:
data: 암호화할 평문 문자열
Returns:
Base64 인코딩된 암호문
Raises:
ValueError: 데이터
"""
if not data:
raise ValueError("암호화할 데이터가 없습니다")
try:
# 문자열을 UTF-8 바이트로 변환 후 암호화
encrypted_bytes = self._cipher.encrypt(data.encode('utf-8'))
# Base64 문자열로 반환
return encrypted_bytes.decode('utf-8')
except Exception as e:
logger.error(f"암호화 실패: {e}")
raise
def decrypt(self, encrypted_data: str) -> str:
"""
암호화된 데이터 복호화
Args:
encrypted_data: Base64 인코딩된 암호문
Returns:
복호화된 평문 문자열
Raises:
ValueError: 데이터 또는 유효하지 않은 암호문
InvalidToken: 잘못된 토큰 또는 불일치
"""
if not encrypted_data:
raise ValueError("복호화할 데이터가 없습니다")
try:
# Base64 문자열을 바이트로 변환 후 복호화
encrypted_bytes = encrypted_data.encode('utf-8')
decrypted_bytes = self._cipher.decrypt(encrypted_bytes)
return decrypted_bytes.decode('utf-8')
except InvalidToken:
logger.error("복호화 실패: 유효하지 않은 토큰 또는 키 불일치")
raise ValueError("암호화 키가 올바르지 않거나 데이터가 손상되었습니다")
except Exception as e:
logger.error(f"복호화 실패: {e}")
raise
def encrypt_json(self, data: Dict[str, Any]) -> str:
"""
JSON 객체 암호화
딕셔너리를 JSON 문자열로 변환 암호화합니다.
세션 데이터 저장에 유용합니다.
Args:
data: 암호화할 딕셔너리 데이터
Returns:
암호화된 문자열
"""
json_string = json.dumps(data, ensure_ascii=False)
return self.encrypt(json_string)
def decrypt_json(self, encrypted_data: str) -> Dict[str, Any]:
"""
암호화된 JSON 데이터 복호화
암호문을 복호화하여 딕셔너리로 파싱합니다.
Args:
encrypted_data: 암호화된 문자열
Returns:
복호화된 딕셔너리
Raises:
ValueError: JSON 파싱 실패
"""
decrypted_string = self.decrypt(encrypted_data)
try:
return json.loads(decrypted_string)
except json.JSONDecodeError as e:
logger.error(f"JSON 파싱 실패: {e}")
raise ValueError("유효하지 않은 JSON 데이터입니다")
@staticmethod
def generate_new_key() -> str:
"""
암호화 생성
환경변수 설정에 사용할 있는 Fernet 키를 생성합니다.
Returns:
새로 생성된 Base64 인코딩 문자열
Example:
>>> new_key = EncryptionService.generate_new_key()
>>> print(f"INSTAGRAM_ENCRYPTION_KEY={new_key}")
"""
return Fernet.generate_key().decode()
# ============================================
# 테스트용 메인 함수
# ============================================
if __name__ == '__main__':
# 새 키 생성 테스트
print("=" * 50)
print("새 암호화 키 생성 테스트")
print("=" * 50)
new_key = EncryptionService.generate_new_key()
print(f"\n생성된 키: {new_key}")
print(f"\n환경변수 설정:")
print(f"INSTAGRAM_ENCRYPTION_KEY={new_key}")
# 암호화/복호화 테스트
print("\n" + "=" * 50)
print("암호화/복호화 테스트")
print("=" * 50)
encryptor = EncryptionService(new_key)
# 문자열 테스트
test_password = "my_instagram_password_123!"
encrypted = encryptor.encrypt(test_password)
decrypted = encryptor.decrypt(encrypted)
print(f"\n원본: {test_password}")
print(f"암호화: {encrypted[:50]}...")
print(f"복호화: {decrypted}")
print(f"일치: {test_password == decrypted}")
# JSON 테스트
print("\n" + "=" * 50)
print("JSON 암호화 테스트")
print("=" * 50)
test_session = {
'session_id': 'abc123',
'cookies': {'ds_user_id': '12345'},
'user_agent': 'Instagram 123.0'
}
encrypted_json = encryptor.encrypt_json(test_session)
decrypted_json = encryptor.decrypt_json(encrypted_json)
print(f"\n원본: {test_session}")
print(f"암호화: {encrypted_json[:50]}...")
print(f"복호화: {decrypted_json}")
print(f"일치: {test_session == decrypted_json}")

View File

@ -0,0 +1,558 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Instagram 클라이언트 매니저
instagrapi 라이브러리를 래핑하여 Instagram 로그인, 세션 관리,
영상 업로드 기능을 제공합니다.
주요 클래스:
- InstagramClientManager: Instagram API 작업을 위한 고수준 인터페이스
안전한 사용을 위한 권장사항:
- 1 이하 업로드 권장
- 빠른 연속 요청 자제
- 2FA 활성화된 계정 지원
"""
import os
import json
import time
import logging
from pathlib import Path
from typing import Optional, Dict, Any, Tuple
from datetime import datetime
from instagrapi import Client
from instagrapi.exceptions import (
LoginRequired,
TwoFactorRequired,
ChallengeRequired,
BadPassword,
PleaseWaitFewMinutes,
ClientError
)
from encryption_service import EncryptionService
logger = logging.getLogger(__name__)
# ============================================
# 상수 정의
# ============================================
# 기본 User-Agent (Instagram Android 앱)
DEFAULT_USER_AGENT = (
"Instagram 275.0.0.27.98 Android "
"(33/13; 420dpi; 1080x2400; samsung; SM-G998B; "
"o1s; exynos2100; ko_KR; 458229258)"
)
# 업로드 재시도 설정
MAX_UPLOAD_RETRIES = 2
RETRY_DELAY_SECONDS = 5
# 세션 파일 저장 경로 (디버깅용)
SESSION_DIR = Path(__file__).parent / 'sessions'
class InstagramClientManager:
"""
Instagram API 클라이언트 매니저
Instagram 계정 로그인, 세션 관리, 영상 업로드 등의 기능을 제공합니다.
모든 민감한 데이터는 암호화하여 처리합니다.
Attributes:
encryption: 암호화 서비스 인스턴스
Example:
>>> manager = InstagramClientManager(encryption_service)
>>> result = manager.login("username", "password")
>>> if result['success']:
... print(f"로그인 성공! 세션: {result['encrypted_session']}")
"""
def __init__(self, encryption_service: EncryptionService):
"""
Instagram 클라이언트 매니저 초기화
Args:
encryption_service: 비밀번호/세션 암호화를 위한 서비스
"""
self.encryption = encryption_service
# 세션 디렉토리 생성 (디버깅용)
SESSION_DIR.mkdir(exist_ok=True)
def _create_client(self) -> Client:
"""
Instagram 클라이언트 인스턴스 생성
적절한 설정으로 초기화된 instagrapi Client를 반환합니다.
Returns:
초기화된 Client 인스턴스
"""
client = Client()
# 기본 설정
client.delay_range = [1, 3] # API 호출 간 딜레이 (초)
client.set_locale('ko_KR')
client.set_timezone_offset(9 * 3600) # KST (UTC+9)
return client
def _handle_login_error(self, error: Exception, username: str) -> Dict[str, Any]:
"""
로그인 에러 처리 적절한 응답 생성
Args:
error: 발생한 예외
username: 로그인 시도한 사용자명
Returns:
에러 정보가 담긴 딕셔너리
"""
error_str = str(error).lower()
if isinstance(error, BadPassword):
return {
'success': False,
'error': '아이디 또는 비밀번호가 올바르지 않습니다.',
'error_code': 'BAD_PASSWORD'
}
elif isinstance(error, TwoFactorRequired):
return {
'success': False,
'error': '2단계 인증이 필요합니다. 인증 코드를 입력해주세요.',
'error_code': 'TWO_FACTOR_REQUIRED',
'requires_2fa': True
}
elif isinstance(error, ChallengeRequired):
return {
'success': False,
'error': 'Instagram 보안 확인이 필요합니다. 앱에서 확인 후 다시 시도해주세요.',
'error_code': 'CHALLENGE_REQUIRED'
}
elif isinstance(error, PleaseWaitFewMinutes):
return {
'success': False,
'error': '너무 많은 요청이 발생했습니다. 몇 분 후 다시 시도해주세요.',
'error_code': 'RATE_LIMITED'
}
elif 'checkpoint' in error_str or 'challenge' in error_str:
return {
'success': False,
'error': 'Instagram에서 계정 확인을 요청했습니다. 앱에서 확인 후 다시 시도해주세요.',
'error_code': 'CHECKPOINT_REQUIRED'
}
else:
logger.error(f"[로그인 에러] 사용자: {username}, 에러: {error}", exc_info=True)
return {
'success': False,
'error': f'로그인 중 오류가 발생했습니다: {str(error)}',
'error_code': 'LOGIN_FAILED'
}
def login(
self,
username: str,
password: str,
verification_code: Optional[str] = None
) -> Dict[str, Any]:
"""
Instagram 계정 로그인
사용자명과 비밀번호로 Instagram에 로그인합니다.
성공 암호화된 비밀번호와 세션 정보를 반환합니다.
Args:
username: Instagram 사용자명
password: Instagram 비밀번호
verification_code: 2FA 인증 코드 (선택)
Returns:
성공 : {
'success': True,
'encrypted_password': '암호화된 비밀번호',
'encrypted_session': '암호화된 세션',
'username': '확인된 사용자명',
'user_id': 'Instagram 사용자 ID',
'full_name': '표시 이름'
}
실패 : {
'success': False,
'error': '에러 메시지',
'error_code': '에러 코드'
}
"""
client = self._create_client()
try:
logger.info(f"[로그인 시도] 사용자: {username}")
# 2FA 코드가 있는 경우
if verification_code:
logger.info(f"[2FA 인증] 코드 입력됨")
client.login(
username,
password,
verification_code=verification_code
)
else:
client.login(username, password)
# 로그인 성공 - 세션 정보 추출
session_data = client.get_settings()
user_id = client.user_id
user_info = client.user_info(user_id)
# 비밀번호와 세션 암호화
encrypted_password = self.encryption.encrypt(password)
encrypted_session = self.encryption.encrypt_json(session_data)
logger.info(f"[로그인 성공] 사용자: {username}, ID: {user_id}")
return {
'success': True,
'encrypted_password': encrypted_password,
'encrypted_session': encrypted_session,
'username': user_info.username,
'user_id': str(user_id),
'full_name': user_info.full_name,
'profile_pic_url': str(user_info.profile_pic_url) if user_info.profile_pic_url else None
}
except TwoFactorRequired:
# 2FA 필요 - 사용자에게 코드 입력 요청
logger.info(f"[2FA 필요] 사용자: {username}")
return {
'success': False,
'error': '2단계 인증이 필요합니다. 인증 코드를 입력해주세요.',
'error_code': 'TWO_FACTOR_REQUIRED',
'requires_2fa': True
}
except Exception as e:
return self._handle_login_error(e, username)
def login_with_session(self, encrypted_session: str) -> Tuple[Optional[Client], Optional[str]]:
"""
저장된 세션으로 로그인
암호화된 세션 데이터를 복호화하여 로그인 상태를 복원합니다.
로그인 과정 없이 빠르게 인증된 클라이언트를 얻을 있습니다.
Args:
encrypted_session: 암호화된 세션 JSON 문자열
Returns:
(Client 인스턴스, None) - 성공
(None, 에러 메시지) - 실패
"""
try:
# 세션 복호화
session_data = self.encryption.decrypt_json(encrypted_session)
# 클라이언트 생성 및 세션 설정
client = self._create_client()
client.set_settings(session_data)
# 세션 유효성 확인 (간단한 API 호출)
client.get_timeline_feed()
return client, None
except Exception as e:
logger.warning(f"[세션 로그인 실패] {e}")
return None, str(e)
def verify_and_refresh_session(
self,
encrypted_session: str,
encrypted_password: Optional[str] = None,
username: Optional[str] = None
) -> Dict[str, Any]:
"""
세션 유효성 검증 필요시 갱신
저장된 세션이 유효한지 확인하고, 만료된 경우
비밀번호로 재로그인을 시도합니다.
Args:
encrypted_session: 암호화된 세션
encrypted_password: 암호화된 비밀번호 (재로그인용)
username: 사용자명 (재로그인용)
Returns:
세션 상태 갱신된 세션 정보
"""
# 먼저 기존 세션으로 시도
client, error = self.login_with_session(encrypted_session)
if client:
return {
'success': True,
'valid': True,
'message': '세션이 유효합니다.',
'encrypted_session': encrypted_session
}
# 세션 만료 - 재로그인 시도
if encrypted_password and username:
logger.info(f"[세션 만료] 재로그인 시도: {username}")
try:
password = self.encryption.decrypt(encrypted_password)
return self.login(username, password)
except Exception as e:
logger.error(f"[재로그인 실패] {e}")
return {
'success': False,
'valid': False,
'error': '세션이 만료되었고 재로그인에 실패했습니다.',
'error_code': 'SESSION_EXPIRED'
}
else:
return {
'success': False,
'valid': False,
'error': '세션이 만료되었습니다. 다시 로그인해주세요.',
'error_code': 'SESSION_EXPIRED'
}
def upload_video(
self,
encrypted_session: str,
encrypted_password: Optional[str],
username: Optional[str],
video_path: str,
caption: str = "",
thumbnail_path: Optional[str] = None,
as_reel: bool = True
) -> Dict[str, Any]:
"""
Instagram에 영상 업로드
릴스(Reels) 또는 일반 비디오로 영상을 업로드합니다.
Args:
encrypted_session: 암호화된 세션
encrypted_password: 암호화된 비밀번호 (세션 만료 재로그인용)
username: 사용자명
video_path: 업로드할 영상 파일 경로
caption: 게시물 캡션 (해시태그 포함 가능)
thumbnail_path: 커버 이미지 경로 (선택)
as_reel: True면 릴스로, False면 일반 비디오로 업로드
Returns:
성공 : {
'success': True,
'media_id': 'Instagram 미디어 ID',
'post_code': '게시물 코드',
'permalink': '게시물 URL',
'new_session': '갱신된 세션 (있는 경우)'
}
실패 : {
'success': False,
'error': '에러 메시지',
'error_code': '에러 코드'
}
"""
client = None
new_session = None
# 1. 세션으로 로그인 시도
client, error = self.login_with_session(encrypted_session)
# 2. 세션 만료 시 재로그인
if not client and encrypted_password and username:
logger.info(f"[세션 만료] 재로그인 후 업로드 시도: {username}")
try:
password = self.encryption.decrypt(encrypted_password)
login_result = self.login(username, password)
if login_result['success']:
client, _ = self.login_with_session(login_result['encrypted_session'])
new_session = login_result['encrypted_session']
except Exception as e:
logger.error(f"[재로그인 실패] {e}")
if not client:
return {
'success': False,
'error': '로그인에 실패했습니다. 계정 연결을 다시 해주세요.',
'error_code': 'LOGIN_FAILED'
}
# 3. 업로드 실행 (재시도 로직 포함)
for attempt in range(MAX_UPLOAD_RETRIES + 1):
try:
logger.info(f"[업로드 시도 {attempt + 1}/{MAX_UPLOAD_RETRIES + 1}] {video_path}")
# 파일 경로 처리
video_path_obj = Path(video_path)
if as_reel:
# 릴스로 업로드
if thumbnail_path and Path(thumbnail_path).exists():
media = client.clip_upload(
path=video_path_obj,
caption=caption,
thumbnail=Path(thumbnail_path)
)
else:
media = client.clip_upload(
path=video_path_obj,
caption=caption
)
else:
# 일반 비디오로 업로드
if thumbnail_path and Path(thumbnail_path).exists():
media = client.video_upload(
path=video_path_obj,
caption=caption,
thumbnail=Path(thumbnail_path)
)
else:
media = client.video_upload(
path=video_path_obj,
caption=caption
)
# 업로드 성공
result = {
'success': True,
'media_id': str(media.pk),
'post_code': media.code,
'permalink': f'https://www.instagram.com/p/{media.code}/',
'media_type': 'reel' if as_reel else 'video'
}
if new_session:
result['new_session'] = new_session
logger.info(f"[업로드 성공] 미디어 ID: {media.pk}")
return result
except PleaseWaitFewMinutes as e:
logger.warning(f"[Rate Limit] {e}")
if attempt < MAX_UPLOAD_RETRIES:
time.sleep(RETRY_DELAY_SECONDS * (attempt + 1))
continue
return {
'success': False,
'error': '업로드 제한에 걸렸습니다. 나중에 다시 시도해주세요.',
'error_code': 'RATE_LIMITED'
}
except LoginRequired as e:
logger.error(f"[로그인 필요] {e}")
return {
'success': False,
'error': '세션이 만료되었습니다. 다시 로그인해주세요.',
'error_code': 'SESSION_EXPIRED'
}
except Exception as e:
logger.error(f"[업로드 에러] {e}", exc_info=True)
if attempt < MAX_UPLOAD_RETRIES:
time.sleep(RETRY_DELAY_SECONDS)
continue
return {
'success': False,
'error': f'업로드 중 오류가 발생했습니다: {str(e)}',
'error_code': 'UPLOAD_FAILED'
}
return {
'success': False,
'error': '업로드에 실패했습니다. 최대 재시도 횟수를 초과했습니다.',
'error_code': 'MAX_RETRIES_EXCEEDED'
}
def get_account_info(self, encrypted_session: str) -> Dict[str, Any]:
"""
연결된 Instagram 계정 정보 조회
Args:
encrypted_session: 암호화된 세션
Returns:
계정 정보 딕셔너리
"""
client, error = self.login_with_session(encrypted_session)
if not client:
return {
'success': False,
'error': '세션이 유효하지 않습니다.',
'error_code': 'INVALID_SESSION'
}
try:
user_id = client.user_id
user_info = client.user_info(user_id)
return {
'success': True,
'username': user_info.username,
'full_name': user_info.full_name,
'profile_pic_url': str(user_info.profile_pic_url) if user_info.profile_pic_url else None,
'follower_count': user_info.follower_count,
'following_count': user_info.following_count,
'media_count': user_info.media_count,
'is_private': user_info.is_private,
'is_verified': user_info.is_verified
}
except Exception as e:
logger.error(f"[계정 정보 조회 실패] {e}")
return {
'success': False,
'error': f'계정 정보를 가져오는 데 실패했습니다: {str(e)}',
'error_code': 'INFO_FAILED'
}
def logout(self, encrypted_session: str) -> bool:
"""
로그아웃 (세션 무효화)
실제로 Instagram 세션을 로그아웃하지는 않고,
로컬에서 세션 데이터만 제거합니다.
Args:
encrypted_session: 암호화된 세션
Returns:
항상 True (에러 무시)
"""
try:
client, _ = self.login_with_session(encrypted_session)
if client:
# Instagram API에는 공식 로그아웃이 없음
# 세션 데이터만 버림
pass
except:
pass
return True
# ============================================
# 테스트용 메인 함수
# ============================================
if __name__ == '__main__':
from encryption_service import EncryptionService
print("=" * 50)
print("Instagram 클라이언트 매니저 테스트")
print("=" * 50)
print("\n이 모듈은 직접 실행하지 않습니다.")
print("instagram_service.py를 실행하세요.")
print("\n사용 예:")
print(" python instagram_service.py")

View File

@ -0,0 +1,425 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Instagram 자동 업로드 서비스
모듈은 Instagram 계정 연결 릴스/영상 업로드 기능을 제공합니다.
Flask REST API를 통해 Node.js 백엔드와 통신합니다.
주요 기능:
1. Instagram 계정 로그인 세션 관리
2. 비밀번호/세션 암호화 저장
3. 릴스(Reels) 영상 업로드
4. 업로드 상태 추적
사용법:
python instagram_service.py
환경변수:
INSTAGRAM_ENCRYPTION_KEY: Fernet 암호화 (32바이트 base64)
INSTAGRAM_SERVICE_PORT: 서비스 포트 (기본: 5001)
"""
import os
import json
import logging
from datetime import datetime
from pathlib import Path
from typing import Optional, Dict, Any, Tuple
from flask import Flask, request, jsonify
from flask_cors import CORS
from dotenv import load_dotenv
from encryption_service import EncryptionService
from instagram_client import InstagramClientManager
# ============================================
# 로깅 설정
# ============================================
logging.basicConfig(
level=logging.INFO,
format='[%(asctime)s] [%(levelname)s] %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
logger = logging.getLogger(__name__)
# ============================================
# 환경변수 로드
# ============================================
load_dotenv(dotenv_path=Path(__file__).parent.parent.parent / '.env')
# Flask 앱 초기화
app = Flask(__name__)
CORS(app)
# 서비스 초기화
encryption_service: Optional[EncryptionService] = None
instagram_manager: Optional[InstagramClientManager] = None
def get_encryption_service() -> EncryptionService:
"""암호화 서비스 인스턴스 반환 (지연 초기화)"""
global encryption_service
if encryption_service is None:
key = os.getenv('INSTAGRAM_ENCRYPTION_KEY')
if not key:
# 키가 없으면 새로 생성하고 경고
logger.warning("INSTAGRAM_ENCRYPTION_KEY 환경변수가 없습니다. 새 키를 생성합니다.")
encryption_service = EncryptionService()
logger.warning(f"생성된 키 (환경변수에 저장하세요): {encryption_service.get_key_string()}")
else:
encryption_service = EncryptionService(key)
return encryption_service
def get_instagram_manager() -> InstagramClientManager:
"""Instagram 클라이언트 매니저 인스턴스 반환 (지연 초기화)"""
global instagram_manager
if instagram_manager is None:
instagram_manager = InstagramClientManager(get_encryption_service())
return instagram_manager
# ============================================
# API 엔드포인트
# ============================================
@app.route('/health', methods=['GET'])
def health_check():
"""
헬스 체크 엔드포인트
서비스가 정상 동작 중인지 확인합니다.
"""
return jsonify({
'status': 'ok',
'service': 'instagram-upload-service',
'timestamp': datetime.now().isoformat()
})
@app.route('/connect', methods=['POST'])
def connect_account():
"""
Instagram 계정 연결
사용자의 Instagram 아이디/비밀번호로 로그인을 시도하고,
성공 암호화된 비밀번호와 세션 정보를 반환합니다.
Request Body:
{
"username": "instagram_username",
"password": "instagram_password",
"verification_code": "2FA 코드 (선택사항)"
}
Response:
성공: {
"success": true,
"encrypted_password": "암호화된 비밀번호",
"encrypted_session": "암호화된 세션 데이터",
"username": "확인된 사용자명",
"user_id": "Instagram 사용자 ID"
}
실패: {
"success": false,
"error": "에러 메시지",
"error_code": "에러 코드",
"requires_2fa": true/false
}
"""
try:
data = request.get_json()
if not data:
return jsonify({
'success': False,
'error': '요청 데이터가 없습니다.',
'error_code': 'NO_DATA'
}), 400
username = data.get('username', '').strip()
password = data.get('password', '').strip()
verification_code = data.get('verification_code', '').strip()
if not username or not password:
return jsonify({
'success': False,
'error': '아이디와 비밀번호를 입력해주세요.',
'error_code': 'MISSING_CREDENTIALS'
}), 400
logger.info(f"[Instagram 연결 시도] 사용자: {username}")
manager = get_instagram_manager()
result = manager.login(username, password, verification_code)
if result['success']:
logger.info(f"[Instagram 연결 성공] 사용자: {username}")
return jsonify(result)
else:
logger.warning(f"[Instagram 연결 실패] 사용자: {username}, 에러: {result.get('error')}")
return jsonify(result), 401 if result.get('error_code') == 'LOGIN_FAILED' else 400
except Exception as e:
logger.error(f"[Instagram 연결 에러] {str(e)}", exc_info=True)
return jsonify({
'success': False,
'error': f'서버 오류가 발생했습니다: {str(e)}',
'error_code': 'SERVER_ERROR'
}), 500
@app.route('/verify-session', methods=['POST'])
def verify_session():
"""
저장된 세션 유효성 검증
암호화된 세션으로 로그인이 가능한지 확인합니다.
세션이 만료되었으면 비밀번호로 재로그인을 시도합니다.
Request Body:
{
"encrypted_session": "암호화된 세션",
"encrypted_password": "암호화된 비밀번호 (재로그인용)",
"username": "사용자명"
}
Response:
성공: {
"success": true,
"valid": true,
"new_session": "새 세션 (갱신된 경우)",
"username": "사용자명"
}
"""
try:
data = request.get_json()
encrypted_session = data.get('encrypted_session')
encrypted_password = data.get('encrypted_password')
username = data.get('username')
if not encrypted_session:
return jsonify({
'success': False,
'error': '세션 정보가 없습니다.',
'error_code': 'NO_SESSION'
}), 400
manager = get_instagram_manager()
result = manager.verify_and_refresh_session(
encrypted_session,
encrypted_password,
username
)
return jsonify(result)
except Exception as e:
logger.error(f"[세션 검증 에러] {str(e)}", exc_info=True)
return jsonify({
'success': False,
'error': f'세션 검증 중 오류: {str(e)}',
'error_code': 'SERVER_ERROR'
}), 500
@app.route('/upload', methods=['POST'])
def upload_video():
"""
Instagram에 릴스/영상 업로드
저장된 세션을 사용하여 영상을 Instagram에 업로드합니다.
Request Body:
{
"encrypted_session": "암호화된 세션",
"encrypted_password": "암호화된 비밀번호 (세션 만료 시 재로그인용)",
"username": "사용자명",
"video_path": "업로드할 영상 파일 경로",
"caption": "게시물 캡션",
"thumbnail_path": "썸네일 이미지 경로 (선택)",
"upload_as_reel": true/false (기본: true)
}
Response:
성공: {
"success": true,
"media_id": "Instagram 미디어 ID",
"post_code": "게시물 코드",
"permalink": "게시물 URL"
}
"""
try:
data = request.get_json()
encrypted_session = data.get('encrypted_session')
encrypted_password = data.get('encrypted_password')
username = data.get('username')
video_path = data.get('video_path')
caption = data.get('caption', '')
thumbnail_path = data.get('thumbnail_path')
upload_as_reel = data.get('upload_as_reel', True)
# 필수 필드 검증
if not encrypted_session:
return jsonify({
'success': False,
'error': '세션 정보가 없습니다. 먼저 계정을 연결해주세요.',
'error_code': 'NO_SESSION'
}), 400
if not video_path:
return jsonify({
'success': False,
'error': '업로드할 영상 경로가 없습니다.',
'error_code': 'NO_VIDEO'
}), 400
# 파일 존재 확인
if not os.path.exists(video_path):
return jsonify({
'success': False,
'error': f'영상 파일을 찾을 수 없습니다: {video_path}',
'error_code': 'FILE_NOT_FOUND'
}), 400
logger.info(f"[Instagram 업로드 시작] 사용자: {username}, 파일: {video_path}")
manager = get_instagram_manager()
result = manager.upload_video(
encrypted_session=encrypted_session,
encrypted_password=encrypted_password,
username=username,
video_path=video_path,
caption=caption,
thumbnail_path=thumbnail_path,
as_reel=upload_as_reel
)
if result['success']:
logger.info(f"[Instagram 업로드 성공] 미디어 ID: {result.get('media_id')}")
return jsonify(result)
else:
logger.error(f"[Instagram 업로드 실패] {result.get('error')}")
return jsonify(result), 400
except Exception as e:
logger.error(f"[Instagram 업로드 에러] {str(e)}", exc_info=True)
return jsonify({
'success': False,
'error': f'업로드 중 오류 발생: {str(e)}',
'error_code': 'UPLOAD_ERROR'
}), 500
@app.route('/disconnect', methods=['POST'])
def disconnect_account():
"""
Instagram 계정 연결 해제
로컬에 저장된 세션 정보만 삭제합니다.
Instagram 계정 자체에는 영향이 없습니다.
Request Body:
{
"encrypted_session": "암호화된 세션"
}
Response:
{
"success": true,
"message": "계정 연결이 해제되었습니다."
}
"""
try:
data = request.get_json()
encrypted_session = data.get('encrypted_session')
if encrypted_session:
manager = get_instagram_manager()
manager.logout(encrypted_session)
return jsonify({
'success': True,
'message': '계정 연결이 해제되었습니다.'
})
except Exception as e:
logger.error(f"[연결 해제 에러] {str(e)}", exc_info=True)
return jsonify({
'success': True, # 에러가 나도 성공으로 처리 (어차피 연결 해제)
'message': '계정 연결이 해제되었습니다.'
})
@app.route('/account-info', methods=['POST'])
def get_account_info():
"""
Instagram 계정 정보 조회
연결된 계정의 기본 정보를 조회합니다.
Request Body:
{
"encrypted_session": "암호화된 세션"
}
Response:
{
"success": true,
"username": "사용자명",
"full_name": "이름",
"profile_pic_url": "프로필 사진 URL",
"follower_count": 팔로워 ,
"media_count": 게시물
}
"""
try:
data = request.get_json()
encrypted_session = data.get('encrypted_session')
if not encrypted_session:
return jsonify({
'success': False,
'error': '세션 정보가 없습니다.',
'error_code': 'NO_SESSION'
}), 400
manager = get_instagram_manager()
result = manager.get_account_info(encrypted_session)
return jsonify(result)
except Exception as e:
logger.error(f"[계정 정보 조회 에러] {str(e)}", exc_info=True)
return jsonify({
'success': False,
'error': f'계정 정보 조회 중 오류: {str(e)}',
'error_code': 'SERVER_ERROR'
}), 500
# ============================================
# 메인 실행
# ============================================
if __name__ == '__main__':
port = int(os.getenv('INSTAGRAM_SERVICE_PORT', 5001))
debug = os.getenv('FLASK_DEBUG', 'false').lower() == 'true'
logger.info(f"Instagram 업로드 서비스 시작 - 포트: {port}")
logger.info("엔드포인트:")
logger.info(" GET /health - 헬스 체크")
logger.info(" POST /connect - 계정 연결")
logger.info(" POST /verify-session - 세션 검증")
logger.info(" POST /upload - 영상 업로드")
logger.info(" POST /disconnect - 계정 연결 해제")
logger.info(" POST /account-info - 계정 정보 조회")
app.run(
host='0.0.0.0',
port=port,
debug=debug
)

View File

@ -0,0 +1,9 @@
# Instagram 자동 업로드를 위한 Python 패키지
# pip install -r requirements.txt
instagrapi==2.1.2 # Instagram Private API 클라이언트
Pillow>=8.1.1 # 이미지 처리 (instagrapi 의존성)
cryptography==41.0.7 # 비밀번호/세션 암호화
flask==3.0.0 # REST API 서버
flask-cors==4.0.0 # CORS 지원
python-dotenv==1.0.0 # 환경변수 로드

611
server/instagramService.js Normal file
View File

@ -0,0 +1,611 @@
/**
* Instagram 업로드 서비스
*
* Python Instagram 마이크로서비스와 통신하여
* Instagram 계정 연결 영상 업로드 기능을 제공합니다.
*
* 주요 기능:
* 1. Instagram 계정 연결 (로그인)
* 2. 계정 연결 해제
* 3. 릴스/영상 업로드
* 4. 업로드 히스토리 관리
*
* @module instagramService
*/
const db = require('./db');
const path = require('path');
// ============================================
// 설정
// ============================================
// Python Instagram 서비스 URL
const INSTAGRAM_SERVICE_URL = process.env.INSTAGRAM_SERVICE_URL || 'http://localhost:5001';
// 주당 최대 업로드 횟수 (안전한 사용을 위해)
const MAX_UPLOADS_PER_WEEK = 1;
// ============================================
// 유틸리티 함수
// ============================================
/**
* Python Instagram 서비스에 HTTP 요청
*
* @param {string} endpoint - API 엔드포인트 (: '/connect')
* @param {Object} data - 요청 본문 데이터
* @returns {Promise<Object>} 응답 데이터
*/
async function callInstagramService(endpoint, data = {}) {
const url = `${INSTAGRAM_SERVICE_URL}${endpoint}`;
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
return result;
} catch (error) {
console.error(`[Instagram 서비스 호출 실패] ${endpoint}:`, error.message);
// 서비스 연결 실패
if (error.code === 'ECONNREFUSED') {
throw new Error('Instagram 서비스에 연결할 수 없습니다. 서비스가 실행 중인지 확인하세요.');
}
throw error;
}
}
/**
* Instagram 서비스 헬스 체크
*
* @returns {Promise<boolean>} 서비스 정상 여부
*/
async function checkServiceHealth() {
try {
const response = await fetch(`${INSTAGRAM_SERVICE_URL}/health`);
const data = await response.json();
return data.status === 'ok';
} catch (error) {
console.error('[Instagram 서비스 헬스 체크 실패]', error.message);
return false;
}
}
// ============================================
// 계정 연결 관리
// ============================================
/**
* Instagram 계정 연결
*
* 사용자의 Instagram 계정을 연결하고 인증 정보를 암호화하여 저장합니다.
*
* @param {number} userId - 사용자 ID
* @param {string} username - Instagram 사용자명
* @param {string} password - Instagram 비밀번호
* @param {string} [verificationCode] - 2FA 인증 코드 (선택)
* @returns {Promise<Object>} 연결 결과
*/
async function connectAccount(userId, username, password, verificationCode = null) {
console.log(`[Instagram 연결 시도] 사용자 ID: ${userId}, Instagram: ${username}`);
// Python 서비스에 로그인 요청
const loginResult = await callInstagramService('/connect', {
username,
password,
verification_code: verificationCode
});
if (!loginResult.success) {
return loginResult;
}
// DB에 연결 정보 저장
return new Promise((resolve, reject) => {
db.run(`
INSERT OR REPLACE INTO instagram_connections
(user_id, instagram_username, encrypted_password, encrypted_session,
is_active, last_login_at, connected_at, updated_at)
VALUES (?, ?, ?, ?, 1, datetime('now'), datetime('now'), datetime('now'))
`, [
userId,
loginResult.username,
loginResult.encrypted_password,
loginResult.encrypted_session
], function(err) {
if (err) {
console.error('[Instagram 연결 정보 저장 실패]', err);
reject(err);
return;
}
// 기본 설정도 생성
db.run(`
INSERT OR IGNORE INTO instagram_settings
(user_id, auto_upload, upload_as_reel, max_uploads_per_week)
VALUES (?, 0, 1, ?)
`, [userId, MAX_UPLOADS_PER_WEEK]);
console.log(`[Instagram 연결 성공] 사용자 ID: ${userId}`);
resolve({
success: true,
message: 'Instagram 계정이 연결되었습니다.',
username: loginResult.username,
user_id: loginResult.user_id,
full_name: loginResult.full_name,
profile_pic_url: loginResult.profile_pic_url
});
});
});
}
/**
* Instagram 계정 연결 해제
*
* @param {number} userId - 사용자 ID
* @returns {Promise<Object>} 해제 결과
*/
async function disconnectAccount(userId) {
console.log(`[Instagram 연결 해제] 사용자 ID: ${userId}`);
return new Promise((resolve, reject) => {
// 먼저 저장된 세션 조회
db.get(
'SELECT encrypted_session FROM instagram_connections WHERE user_id = ?',
[userId],
async (err, row) => {
if (err) {
reject(err);
return;
}
// Python 서비스에 로그아웃 요청 (실패해도 무시)
if (row && row.encrypted_session) {
try {
await callInstagramService('/disconnect', {
encrypted_session: row.encrypted_session
});
} catch (e) {
// 무시
}
}
// DB에서 삭제
db.run(
'DELETE FROM instagram_connections WHERE user_id = ?',
[userId],
function(err) {
if (err) {
reject(err);
return;
}
// 설정도 삭제
db.run(
'DELETE FROM instagram_settings WHERE user_id = ?',
[userId]
);
resolve({
success: true,
message: 'Instagram 계정 연결이 해제되었습니다.'
});
}
);
}
);
});
}
/**
* Instagram 연결 상태 조회
*
* @param {number} userId - 사용자 ID
* @returns {Promise<Object>} 연결 상태 정보
*/
async function getConnectionStatus(userId) {
return new Promise((resolve, reject) => {
db.get(`
SELECT
ic.instagram_username,
ic.is_active,
ic.two_factor_required,
ic.connected_at,
ic.last_login_at,
ic.encrypted_session,
inst.auto_upload,
inst.upload_as_reel,
inst.default_caption_template,
inst.default_hashtags,
inst.max_uploads_per_week,
inst.notify_on_upload
FROM instagram_connections ic
LEFT JOIN instagram_settings inst ON ic.user_id = inst.user_id
WHERE ic.user_id = ?
`, [userId], async (err, row) => {
if (err) {
reject(err);
return;
}
if (!row) {
resolve({
connected: false,
message: 'Instagram 계정이 연결되지 않았습니다.'
});
return;
}
// 계정 정보 조회 시도 (세션 유효성 확인)
let accountInfo = null;
if (row.encrypted_session) {
try {
const info = await callInstagramService('/account-info', {
encrypted_session: row.encrypted_session
});
if (info.success) {
accountInfo = info;
}
} catch (e) {
// 세션 만료 가능성
console.warn('[Instagram 계정 정보 조회 실패]', e.message);
}
}
resolve({
connected: true,
username: row.instagram_username,
is_active: row.is_active === 1,
two_factor_required: row.two_factor_required === 1,
connected_at: row.connected_at,
last_login_at: row.last_login_at,
settings: {
auto_upload: row.auto_upload === 1,
upload_as_reel: row.upload_as_reel === 1,
default_caption_template: row.default_caption_template,
default_hashtags: row.default_hashtags,
max_uploads_per_week: row.max_uploads_per_week || MAX_UPLOADS_PER_WEEK,
notify_on_upload: row.notify_on_upload === 1
},
account_info: accountInfo ? {
full_name: accountInfo.full_name,
profile_pic_url: accountInfo.profile_pic_url,
follower_count: accountInfo.follower_count,
media_count: accountInfo.media_count
} : null
});
});
});
}
// ============================================
// 설정 관리
// ============================================
/**
* Instagram 업로드 설정 업데이트
*
* @param {number} userId - 사용자 ID
* @param {Object} settings - 설정 객체
* @returns {Promise<Object>} 업데이트 결과
*/
async function updateSettings(userId, settings) {
return new Promise((resolve, reject) => {
const {
auto_upload,
upload_as_reel,
default_caption_template,
default_hashtags,
max_uploads_per_week,
notify_on_upload
} = settings;
db.run(`
INSERT OR REPLACE INTO instagram_settings
(user_id, auto_upload, upload_as_reel, default_caption_template,
default_hashtags, max_uploads_per_week, notify_on_upload, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
`, [
userId,
auto_upload ? 1 : 0,
upload_as_reel !== false ? 1 : 0, // 기본값 true
default_caption_template || null,
default_hashtags || null,
max_uploads_per_week || MAX_UPLOADS_PER_WEEK,
notify_on_upload !== false ? 1 : 0 // 기본값 true
], function(err) {
if (err) {
reject(err);
return;
}
resolve({
success: true,
message: '설정이 저장되었습니다.'
});
});
});
}
// ============================================
// 영상 업로드
// ============================================
/**
* 주간 업로드 횟수 확인
*
* @param {number} userId - 사용자 ID
* @returns {Promise<Object>} 이번 업로드 정보
*/
async function getWeeklyUploadCount(userId) {
return new Promise((resolve, reject) => {
// 이번 주 월요일 기준
db.get(`
SELECT
COUNT(*) as count,
MAX(uploaded_at) as last_upload
FROM instagram_upload_history
WHERE user_id = ?
AND status = 'success'
AND uploaded_at >= date('now', 'weekday 0', '-6 days')
`, [userId], (err, row) => {
if (err) {
reject(err);
return;
}
db.get(
'SELECT max_uploads_per_week FROM instagram_settings WHERE user_id = ?',
[userId],
(err, settings) => {
resolve({
count: row?.count || 0,
max: settings?.max_uploads_per_week || MAX_UPLOADS_PER_WEEK,
last_upload: row?.last_upload
});
}
);
});
});
}
/**
* Instagram에 영상 업로드
*
* @param {number} userId - 사용자 ID
* @param {number} historyId - 영상 히스토리 ID
* @param {string} videoPath - 영상 파일 경로
* @param {string} caption - 게시물 캡션
* @param {Object} [options] - 추가 옵션
* @param {string} [options.thumbnailPath] - 썸네일 이미지 경로
* @param {boolean} [options.forceUpload] - 주간 제한 무시 여부
* @returns {Promise<Object>} 업로드 결과
*/
async function uploadVideo(userId, historyId, videoPath, caption, options = {}) {
console.log(`[Instagram 업로드 시작] 사용자 ID: ${userId}, 히스토리: ${historyId}`);
const { thumbnailPath, forceUpload = false } = options;
// 1. 주간 업로드 횟수 확인
if (!forceUpload) {
const weeklyStats = await getWeeklyUploadCount(userId);
if (weeklyStats.count >= weeklyStats.max) {
console.log(`[Instagram 업로드 제한] 주간 최대 ${weeklyStats.max}회 초과`);
return {
success: false,
error: `이번 주 업로드 제한(${weeklyStats.max}회)을 초과했습니다. 다음 주에 다시 시도해주세요.`,
error_code: 'WEEKLY_LIMIT_EXCEEDED',
weekly_count: weeklyStats.count,
weekly_max: weeklyStats.max,
last_upload: weeklyStats.last_upload
};
}
}
// 2. 연결 정보 조회
const connection = await new Promise((resolve, reject) => {
db.get(`
SELECT * FROM instagram_connections WHERE user_id = ? AND is_active = 1
`, [userId], (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
if (!connection) {
return {
success: false,
error: 'Instagram 계정이 연결되지 않았습니다.',
error_code: 'NOT_CONNECTED'
};
}
// 3. 설정 조회
const settings = await new Promise((resolve, reject) => {
db.get(
'SELECT * FROM instagram_settings WHERE user_id = ?',
[userId],
(err, row) => {
if (err) reject(err);
else resolve(row || {});
}
);
});
// 4. 업로드 히스토리 레코드 생성 (pending 상태)
const uploadHistoryId = await new Promise((resolve, reject) => {
db.run(`
INSERT INTO instagram_upload_history
(user_id, history_id, caption, upload_type, status, createdAt)
VALUES (?, ?, ?, ?, 'pending', datetime('now'))
`, [
userId,
historyId,
caption,
settings.upload_as_reel ? 'reel' : 'video'
], function(err) {
if (err) reject(err);
else resolve(this.lastID);
});
});
// 5. Python 서비스에 업로드 요청
try {
const result = await callInstagramService('/upload', {
encrypted_session: connection.encrypted_session,
encrypted_password: connection.encrypted_password,
username: connection.instagram_username,
video_path: videoPath,
caption: caption,
thumbnail_path: thumbnailPath,
upload_as_reel: settings.upload_as_reel !== 0
});
if (result.success) {
// 성공 - 히스토리 업데이트
await new Promise((resolve, reject) => {
db.run(`
UPDATE instagram_upload_history
SET instagram_media_id = ?,
instagram_post_code = ?,
permalink = ?,
status = 'success',
uploaded_at = datetime('now')
WHERE id = ?
`, [
result.media_id,
result.post_code,
result.permalink,
uploadHistoryId
], (err) => {
if (err) reject(err);
else resolve();
});
});
// 세션 갱신된 경우 DB 업데이트
if (result.new_session) {
db.run(`
UPDATE instagram_connections
SET encrypted_session = ?, last_login_at = datetime('now'), updated_at = datetime('now')
WHERE user_id = ?
`, [result.new_session, userId]);
}
console.log(`[Instagram 업로드 성공] 미디어 ID: ${result.media_id}`);
return {
success: true,
media_id: result.media_id,
post_code: result.post_code,
permalink: result.permalink,
upload_history_id: uploadHistoryId
};
} else {
// 실패 - 히스토리 업데이트
await new Promise((resolve, reject) => {
db.run(`
UPDATE instagram_upload_history
SET status = 'failed',
error_message = ?,
retry_count = retry_count + 1
WHERE id = ?
`, [result.error, uploadHistoryId], (err) => {
if (err) reject(err);
else resolve();
});
});
console.error(`[Instagram 업로드 실패] ${result.error}`);
return result;
}
} catch (error) {
// 에러 - 히스토리 업데이트
await new Promise((resolve) => {
db.run(`
UPDATE instagram_upload_history
SET status = 'failed',
error_message = ?,
retry_count = retry_count + 1
WHERE id = ?
`, [error.message, uploadHistoryId], () => resolve());
});
console.error(`[Instagram 업로드 에러]`, error);
throw error;
}
}
// ============================================
// 히스토리 조회
// ============================================
/**
* Instagram 업로드 히스토리 조회
*
* @param {number} userId - 사용자 ID
* @param {number} [limit=20] - 최대 조회 개수
* @param {number} [offset=0] - 시작 위치
* @returns {Promise<Array>} 업로드 히스토리 목록
*/
async function getUploadHistory(userId, limit = 20, offset = 0) {
return new Promise((resolve, reject) => {
db.all(`
SELECT
iuh.*,
h.business_name,
h.details as video_details,
pp.brand_name as pension_name
FROM instagram_upload_history iuh
LEFT JOIN history h ON iuh.history_id = h.id
LEFT JOIN pension_profiles pp ON iuh.pension_id = pp.id
WHERE iuh.user_id = ?
ORDER BY iuh.createdAt DESC
LIMIT ? OFFSET ?
`, [userId, limit, offset], (err, rows) => {
if (err) {
reject(err);
return;
}
resolve(rows || []);
});
});
}
// ============================================
// 모듈 익스포트
// ============================================
module.exports = {
// 서비스 상태
checkServiceHealth,
// 계정 관리
connectAccount,
disconnectAccount,
getConnectionStatus,
// 설정
updateSettings,
// 업로드
uploadVideo,
getWeeklyUploadCount,
getUploadHistory,
// 상수
INSTAGRAM_SERVICE_URL,
MAX_UPLOADS_PER_WEEK
};

5202
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
server/package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "bizvibe-render-server",
"version": "0.5.0",
"description": "Puppeteer render server for BizVibe",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"axios": "^1.13.2",
"bcrypt": "^6.0.0",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^4.18.2",
"googleapis": "^166.0.0",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.2",
"open": "^11.0.0",
"puppeteer": "^22.0.0",
"puppeteer-screen-recorder": "^3.0.0",
"resend": "^6.5.2",
"sqlite3": "^5.1.7"
},
"devDependencies": {
"concurrently": "^9.2.1"
}
}

580
server/statisticsService.js Normal file
View File

@ -0,0 +1,580 @@
/**
* CaStAD Statistics Service v3.0.0
* 고급 통계 분석 서비스
*/
const db = require('./db');
/**
* 일별 시스템 통계 스냅샷 생성/업데이트
*/
async function updateDailyStats() {
const today = new Date().toISOString().split('T')[0];
return new Promise((resolve, reject) => {
// 오늘의 통계 계산
db.serialize(() => {
// 전체 사용자 수
db.get('SELECT COUNT(*) as total FROM users', [], (err, totalUsers) => {
if (err) return reject(err);
// 오늘 신규 가입자
db.get(`
SELECT COUNT(*) as count FROM users
WHERE date(createdAt) = date('now')
`, [], (err, newUsers) => {
if (err) return reject(err);
// 오늘 활성 사용자 (영상 생성)
db.get(`
SELECT COUNT(DISTINCT user_id) as count FROM history
WHERE date(createdAt) = date('now')
`, [], (err, activeUsers) => {
if (err) return reject(err);
// 오늘 생성된 영상 수
db.get(`
SELECT COUNT(*) as count FROM history
WHERE date(createdAt) = date('now')
`, [], (err, videos) => {
if (err) return reject(err);
// 오늘 전체 업로드 수 (YouTube + Instagram + TikTok)
db.get(`
SELECT
(SELECT COUNT(*) FROM upload_history WHERE date(uploaded_at) = date('now')) +
(SELECT COUNT(*) FROM instagram_upload_history WHERE date(createdAt) = date('now')) +
(SELECT COUNT(*) FROM tiktok_upload_history WHERE date(createdAt) = date('now'))
as total_uploads,
(SELECT COUNT(*) FROM upload_history WHERE date(uploaded_at) = date('now')) as youtube,
(SELECT COUNT(*) FROM instagram_upload_history WHERE date(createdAt) = date('now')) as instagram,
(SELECT COUNT(*) FROM tiktok_upload_history WHERE date(createdAt) = date('now')) as tiktok
`, [], (err, uploads) => {
if (err) return reject(err);
// 오늘 사용된 크레딧
db.get(`
SELECT COALESCE(SUM(ABS(amount)), 0) as total FROM credit_history
WHERE amount < 0 AND date(createdAt) = date('now')
`, [], (err, credits) => {
if (err) return reject(err);
// 저장 또는 업데이트
db.run(`
INSERT OR REPLACE INTO system_stats_daily
(date, total_users, new_users, active_users, total_videos_generated,
total_uploads, youtube_uploads, instagram_uploads, tiktok_uploads,
total_credits_used, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
`, [
today,
totalUsers.total,
newUsers.count,
activeUsers.count,
videos.count,
uploads?.total_uploads || 0,
uploads?.youtube || 0,
uploads?.instagram || 0,
uploads?.tiktok || 0,
credits.total
], function (err) {
if (err) reject(err);
else resolve({ success: true });
});
});
});
});
});
});
});
});
});
}
/**
* 사용자 성장 트렌드 조회
* @param {number} days - 조회 기간 ()
*/
function getUserGrowthTrend(days = 30) {
return new Promise((resolve, reject) => {
db.all(`
SELECT
date(createdAt) as date,
COUNT(*) as new_users,
SUM(COUNT(*)) OVER (ORDER BY date(createdAt)) as cumulative_users
FROM users
WHERE createdAt >= date('now', '-' || ? || ' days')
GROUP BY date(createdAt)
ORDER BY date ASC
`, [days], (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}
/**
* 영상 생성 트렌드 조회
* @param {number} days - 조회 기간 ()
*/
function getVideoGenerationTrend(days = 30) {
return new Promise((resolve, reject) => {
db.all(`
SELECT
date(createdAt) as date,
COUNT(*) as videos_generated,
COUNT(DISTINCT user_id) as unique_users
FROM history
WHERE createdAt >= date('now', '-' || ? || ' days')
GROUP BY date(createdAt)
ORDER BY date ASC
`, [days], (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}
/**
* 플랫폼별 업로드 통계
* @param {number} days - 조회 기간 ()
*/
function getPlatformUploadStats(days = 30) {
return new Promise((resolve, reject) => {
const result = {
youtube: [],
instagram: [],
tiktok: [],
summary: {}
};
db.all(`
SELECT date(uploaded_at) as date, COUNT(*) as count, status
FROM upload_history
WHERE uploaded_at >= date('now', '-' || ? || ' days')
GROUP BY date(uploaded_at), status
ORDER BY date ASC
`, [days], (err, youtubeRows) => {
if (err) return reject(err);
result.youtube = youtubeRows || [];
db.all(`
SELECT date(createdAt) as date, COUNT(*) as count, status
FROM instagram_upload_history
WHERE createdAt >= date('now', '-' || ? || ' days')
GROUP BY date(createdAt), status
ORDER BY date ASC
`, [days], (err, instaRows) => {
if (err) return reject(err);
result.instagram = instaRows || [];
db.all(`
SELECT date(createdAt) as date, COUNT(*) as count, status
FROM tiktok_upload_history
WHERE createdAt >= date('now', '-' || ? || ' days')
GROUP BY date(createdAt), status
ORDER BY date ASC
`, [days], (err, tiktokRows) => {
if (err) return reject(err);
result.tiktok = tiktokRows || [];
// 요약 통계 계산
db.get(`
SELECT
(SELECT COUNT(*) FROM upload_history WHERE uploaded_at >= date('now', '-' || ? || ' days')) as youtube_total,
(SELECT COUNT(*) FROM upload_history WHERE uploaded_at >= date('now', '-' || ? || ' days') AND status = 'completed') as youtube_success,
(SELECT COUNT(*) FROM instagram_upload_history WHERE createdAt >= date('now', '-' || ? || ' days')) as instagram_total,
(SELECT COUNT(*) FROM instagram_upload_history WHERE createdAt >= date('now', '-' || ? || ' days') AND status = 'completed') as instagram_success,
(SELECT COUNT(*) FROM tiktok_upload_history WHERE createdAt >= date('now', '-' || ? || ' days')) as tiktok_total,
(SELECT COUNT(*) FROM tiktok_upload_history WHERE createdAt >= date('now', '-' || ? || ' days') AND status = 'completed') as tiktok_success
`, [days, days, days, days, days, days], (err, summary) => {
if (err) return reject(err);
result.summary = summary || {};
resolve(result);
});
});
});
});
});
}
/**
* 크레딧 사용 통계
* @param {number} days - 조회 기간 ()
*/
function getCreditUsageStats(days = 30) {
return new Promise((resolve, reject) => {
db.all(`
SELECT
date(createdAt) as date,
type,
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) as credits_added,
SUM(CASE WHEN amount < 0 THEN ABS(amount) ELSE 0 END) as credits_used
FROM credit_history
WHERE createdAt >= date('now', '-' || ? || ' days')
GROUP BY date(createdAt), type
ORDER BY date ASC
`, [days], (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}
/**
* 플랜별 사용자 분포
*/
function getPlanDistribution() {
return new Promise((resolve, reject) => {
db.all(`
SELECT
COALESCE(plan_type, 'free') as plan,
COUNT(*) as count,
ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM users), 2) as percentage
FROM users
GROUP BY plan_type
ORDER BY count DESC
`, [], (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}
/**
* 사용자 (가장 많은 영상 생성)
* @param {number} limit - 조회 개수
*/
function getTopUsers(limit = 10) {
return new Promise((resolve, reject) => {
db.all(`
SELECT
u.id,
u.username,
u.name,
u.plan_type,
u.credits,
COUNT(h.id) as total_videos,
(SELECT COUNT(*) FROM upload_history WHERE user_id = u.id) as youtube_uploads,
(SELECT COUNT(*) FROM instagram_upload_history WHERE user_id = u.id) as instagram_uploads,
(SELECT COUNT(*) FROM tiktok_upload_history WHERE user_id = u.id) as tiktok_uploads,
(SELECT COUNT(*) FROM pension_profiles WHERE user_id = u.id) as pension_count
FROM users u
LEFT JOIN history h ON u.id = h.user_id
WHERE u.role != 'admin'
GROUP BY u.id
ORDER BY total_videos DESC
LIMIT ?
`, [limit], (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}
/**
* 최근 활동 로그
* @param {number} limit - 조회 개수
*/
function getRecentActivityLogs(limit = 50) {
return new Promise((resolve, reject) => {
db.all(`
SELECT
al.*,
u.username,
u.name
FROM activity_logs al
LEFT JOIN users u ON al.user_id = u.id
ORDER BY al.createdAt DESC
LIMIT ?
`, [limit], (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}
/**
* 활동 로그 기록
* @param {number|null} userId - 사용자 ID
* @param {string} actionType - 액션 타입
* @param {string} actionDetail - 액션 상세
* @param {object} metadata - 추가 메타데이터
*/
function logActivity(userId, actionType, actionDetail, metadata = {}) {
return new Promise((resolve, reject) => {
db.run(`
INSERT INTO activity_logs (user_id, action_type, action_detail, metadata, createdAt)
VALUES (?, ?, ?, ?, datetime('now'))
`, [userId, actionType, actionDetail, JSON.stringify(metadata)], function (err) {
if (err) reject(err);
else resolve({ id: this.lastID });
});
});
}
/**
* 대시보드 요약 통계
*/
function getDashboardSummary() {
return new Promise((resolve, reject) => {
const summary = {};
db.serialize(() => {
// 전체 사용자
db.get('SELECT COUNT(*) as total FROM users WHERE role != ?', ['admin'], (err, row) => {
if (err) return reject(err);
summary.totalUsers = row.total;
// 오늘 신규 가입
db.get(`
SELECT COUNT(*) as count FROM users
WHERE date(createdAt) = date('now') AND role != 'admin'
`, [], (err, row) => {
if (err) return reject(err);
summary.newUsersToday = row.count;
// 이번 주 활성 사용자
db.get(`
SELECT COUNT(DISTINCT user_id) as count FROM history
WHERE createdAt >= date('now', '-7 days')
`, [], (err, row) => {
if (err) return reject(err);
summary.activeUsersWeek = row.count;
// 전체 생성 영상 수
db.get('SELECT COUNT(*) as total FROM history', [], (err, row) => {
if (err) return reject(err);
summary.totalVideos = row.total;
// 오늘 생성 영상
db.get(`
SELECT COUNT(*) as count FROM history
WHERE date(createdAt) = date('now')
`, [], (err, row) => {
if (err) return reject(err);
summary.videosToday = row.count;
// 전체 업로드 수
db.get(`
SELECT
(SELECT COUNT(*) FROM upload_history) +
(SELECT COUNT(*) FROM instagram_upload_history) +
(SELECT COUNT(*) FROM tiktok_upload_history) as total
`, [], (err, row) => {
if (err) return reject(err);
summary.totalUploads = row.total;
// 플랫폼별 업로드
db.get(`
SELECT
(SELECT COUNT(*) FROM upload_history) as youtube,
(SELECT COUNT(*) FROM instagram_upload_history) as instagram,
(SELECT COUNT(*) FROM tiktok_upload_history) as tiktok
`, [], (err, row) => {
if (err) return reject(err);
summary.platformUploads = row;
// 전체 펜션 수
db.get('SELECT COUNT(*) as total FROM pension_profiles', [], (err, row) => {
if (err) return reject(err);
summary.totalPensions = row.total;
// 대기중인 승인 요청
db.get(`
SELECT COUNT(*) as count FROM users
WHERE approved = 0 AND role != 'admin'
`, [], (err, row) => {
if (err) return reject(err);
summary.pendingApprovals = row.count;
// 대기중인 크레딧 요청
db.get(`
SELECT COUNT(*) as count FROM credit_requests
WHERE status = 'pending'
`, [], (err, row) => {
if (err) return reject(err);
summary.pendingCreditRequests = row.count;
// 전체 크레딧 발행량
db.get(`
SELECT
COALESCE(SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END), 0) as issued,
COALESCE(SUM(CASE WHEN amount < 0 THEN ABS(amount) ELSE 0 END), 0) as used
FROM credit_history
`, [], (err, row) => {
if (err) return reject(err);
summary.credits = row;
resolve(summary);
});
});
});
});
});
});
});
});
});
});
});
});
});
}
/**
* 시간대별 사용 패턴 분석
*/
function getUsagePattern() {
return new Promise((resolve, reject) => {
db.all(`
SELECT
strftime('%H', createdAt) as hour,
COUNT(*) as count
FROM history
WHERE createdAt >= date('now', '-30 days')
GROUP BY strftime('%H', createdAt)
ORDER BY hour ASC
`, [], (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}
/**
* 지역별 사용자 분포 (펜션 주소 기반)
*/
function getRegionalDistribution() {
return new Promise((resolve, reject) => {
db.all(`
SELECT
COALESCE(region, '미설정') as region,
COUNT(*) as count
FROM pension_profiles
GROUP BY region
ORDER BY count DESC
LIMIT 20
`, [], (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}
/**
* 월별 수익 예측 (플랜 기반)
*/
function getRevenueProjection() {
return new Promise((resolve, reject) => {
const PLAN_PRICES = {
free: 0,
basic: 29000,
pro: 99000,
business: 299000
};
db.all(`
SELECT
COALESCE(plan_type, 'free') as plan,
COUNT(*) as count
FROM users
WHERE role != 'admin'
GROUP BY plan_type
`, [], (err, rows) => {
if (err) return reject(err);
let monthlyRevenue = 0;
const breakdown = {};
(rows || []).forEach(row => {
const price = PLAN_PRICES[row.plan] || 0;
const revenue = price * row.count;
monthlyRevenue += revenue;
breakdown[row.plan] = {
users: row.count,
price,
revenue
};
});
resolve({
monthlyRevenue,
annualProjection: monthlyRevenue * 12,
breakdown
});
});
});
}
/**
* 시스템 헬스 체크
*/
function getSystemHealth() {
return new Promise((resolve, reject) => {
const health = {
database: 'ok',
diskUsage: null,
lastVideoGenerated: null,
uptime: process.uptime()
};
// DB 연결 확인
db.get('SELECT 1', [], (err) => {
if (err) {
health.database = 'error';
}
// 마지막 영상 생성 시간
db.get(`
SELECT createdAt FROM history
ORDER BY createdAt DESC LIMIT 1
`, [], (err, row) => {
if (!err && row) {
health.lastVideoGenerated = row.createdAt;
}
// 디스크 사용량 (downloads 폴더)
const fs = require('fs');
const path = require('path');
const downloadsDir = path.join(__dirname, 'downloads');
try {
let totalSize = 0;
const files = fs.readdirSync(downloadsDir);
files.forEach(file => {
const filePath = path.join(downloadsDir, file);
const stats = fs.statSync(filePath);
totalSize += stats.size;
});
health.diskUsage = {
bytes: totalSize,
mb: Math.round(totalSize / 1024 / 1024),
fileCount: files.length
};
} catch (e) {
health.diskUsage = { error: e.message };
}
resolve(health);
});
});
});
}
module.exports = {
updateDailyStats,
getUserGrowthTrend,
getVideoGenerationTrend,
getPlatformUploadStats,
getCreditUsageStats,
getPlanDistribution,
getTopUsers,
getRecentActivityLogs,
logActivity,
getDashboardSummary,
getUsagePattern,
getRegionalDistribution,
getRevenueProjection,
getSystemHealth
};

Binary file not shown.

606
server/tiktokService.js Normal file
View File

@ -0,0 +1,606 @@
/**
* TikTok Content Posting API Service
* CaStAD v3.0.0
*
* TikTok API Reference: https://developers.tiktok.com/doc/content-posting-api-get-started/
*/
const fs = require('fs');
const path = require('path');
const axios = require('axios');
const db = require('./db');
// TikTok API Endpoints
const TIKTOK_AUTH_URL = 'https://www.tiktok.com/v2/auth/authorize/';
const TIKTOK_TOKEN_URL = 'https://open.tiktokapis.com/v2/oauth/token/';
const TIKTOK_REVOKE_URL = 'https://open.tiktokapis.com/v2/oauth/revoke/';
const TIKTOK_USER_INFO_URL = 'https://open.tiktokapis.com/v2/user/info/';
const TIKTOK_UPLOAD_INIT_URL = 'https://open.tiktokapis.com/v2/post/publish/video/init/';
const TIKTOK_UPLOAD_INBOX_URL = 'https://open.tiktokapis.com/v2/post/publish/inbox/video/init/';
const TIKTOK_PUBLISH_STATUS_URL = 'https://open.tiktokapis.com/v2/post/publish/status/fetch/';
// Scopes
const TIKTOK_SCOPES = [
'user.info.basic',
'user.info.profile',
'user.info.stats',
'video.publish',
'video.upload'
];
// TikTok Client credentials (from environment or config)
const TIKTOK_CLIENT_KEY = process.env.TIKTOK_CLIENT_KEY;
const TIKTOK_CLIENT_SECRET = process.env.TIKTOK_CLIENT_SECRET;
/**
* TikTok OAuth 인증 URL 생성
* @param {number} userId - 사용자 ID
* @param {string} redirectUri - 콜백 URI
*/
function generateAuthUrl(userId, redirectUri = null) {
if (!TIKTOK_CLIENT_KEY) {
throw new Error('TIKTOK_CLIENT_KEY 환경변수가 설정되지 않았습니다.');
}
const finalRedirectUri = redirectUri || `${process.env.BACKEND_URL || 'http://localhost:3001'}/api/tiktok/oauth/callback`;
const params = new URLSearchParams({
client_key: TIKTOK_CLIENT_KEY,
scope: TIKTOK_SCOPES.join(','),
response_type: 'code',
redirect_uri: finalRedirectUri,
state: JSON.stringify({ userId })
});
return `${TIKTOK_AUTH_URL}?${params.toString()}`;
}
/**
* 인증 코드로 토큰 교환
* @param {string} code - 인증 코드
* @param {number} userId - 사용자 ID
* @param {string} redirectUri - 콜백 URI
*/
async function exchangeCodeForTokens(code, userId, redirectUri = null) {
if (!TIKTOK_CLIENT_KEY || !TIKTOK_CLIENT_SECRET) {
throw new Error('TikTok 클라이언트 자격증명이 설정되지 않았습니다.');
}
const finalRedirectUri = redirectUri || `${process.env.BACKEND_URL || 'http://localhost:3001'}/api/tiktok/oauth/callback`;
try {
// 토큰 요청
const tokenResponse = await axios.post(TIKTOK_TOKEN_URL, null, {
params: {
client_key: TIKTOK_CLIENT_KEY,
client_secret: TIKTOK_CLIENT_SECRET,
code,
grant_type: 'authorization_code',
redirect_uri: finalRedirectUri
}
});
const tokens = tokenResponse.data;
if (tokens.error) {
throw new Error(tokens.error.message || 'Token exchange failed');
}
const accessToken = tokens.access_token;
const refreshToken = tokens.refresh_token;
const expiresIn = tokens.expires_in;
const openId = tokens.open_id;
const tokenExpiry = new Date(Date.now() + expiresIn * 1000).toISOString();
// 사용자 정보 가져오기
const userInfo = await getTikTokUserInfo(accessToken, openId);
// DB에 저장
return new Promise((resolve, reject) => {
db.run(`
INSERT OR REPLACE INTO tiktok_connections
(user_id, open_id, display_name, avatar_url, follower_count, following_count,
access_token, refresh_token, token_expiry, scopes, connected_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
`, [
userId,
openId,
userInfo.display_name || null,
userInfo.avatar_url || null,
userInfo.follower_count || 0,
userInfo.following_count || 0,
accessToken,
refreshToken,
tokenExpiry,
TIKTOK_SCOPES.join(',')
], function (err) {
if (err) {
console.error('[TikTok OAuth] 토큰 저장 실패:', err);
reject(err);
} else {
// 기본 설정 생성
db.run(`INSERT OR IGNORE INTO tiktok_settings (user_id) VALUES (?)`, [userId]);
resolve({
success: true,
openId,
displayName: userInfo.display_name,
avatarUrl: userInfo.avatar_url,
followerCount: userInfo.follower_count
});
}
});
});
} catch (error) {
console.error('[TikTok OAuth] 토큰 교환 실패:', error.response?.data || error.message);
throw error;
}
}
/**
* TikTok 사용자 정보 조회
* @param {string} accessToken - 액세스 토큰
* @param {string} openId - TikTok Open ID
*/
async function getTikTokUserInfo(accessToken, openId) {
try {
const response = await axios.get(TIKTOK_USER_INFO_URL, {
headers: {
'Authorization': `Bearer ${accessToken}`
},
params: {
fields: 'open_id,display_name,avatar_url,follower_count,following_count,bio_description'
}
});
return response.data.data?.user || {};
} catch (error) {
console.error('[TikTok] 사용자 정보 조회 실패:', error.response?.data || error.message);
return {};
}
}
/**
* 토큰 갱신
* @param {string} refreshToken - 리프레시 토큰
*/
async function refreshAccessToken(refreshToken) {
try {
const response = await axios.post(TIKTOK_TOKEN_URL, null, {
params: {
client_key: TIKTOK_CLIENT_KEY,
client_secret: TIKTOK_CLIENT_SECRET,
grant_type: 'refresh_token',
refresh_token: refreshToken
}
});
return response.data;
} catch (error) {
console.error('[TikTok] 토큰 갱신 실패:', error.response?.data || error.message);
throw error;
}
}
/**
* 사용자별 인증된 클라이언트 정보 가져오기
* @param {number} userId - 사용자 ID
*/
async function getAuthenticatedCredentials(userId) {
return new Promise((resolve, reject) => {
db.get(`SELECT * FROM tiktok_connections WHERE user_id = ?`, [userId], async (err, row) => {
if (err) {
reject(err);
return;
}
if (!row) {
reject(new Error('TikTok 계정이 연결되지 않았습니다. 설정에서 계정을 연결해주세요.'));
return;
}
// 토큰 만료 체크
const tokenExpiry = new Date(row.token_expiry);
const now = new Date();
if (tokenExpiry <= now) {
// 토큰 갱신 시도
try {
const newTokens = await refreshAccessToken(row.refresh_token);
const newExpiry = new Date(Date.now() + newTokens.expires_in * 1000).toISOString();
// DB 업데이트
db.run(`
UPDATE tiktok_connections
SET access_token = ?, refresh_token = ?, token_expiry = ?
WHERE user_id = ?
`, [newTokens.access_token, newTokens.refresh_token, newExpiry, userId]);
resolve({
accessToken: newTokens.access_token,
openId: row.open_id,
displayName: row.display_name
});
} catch (refreshError) {
console.error('[TikTok] 토큰 갱신 실패:', refreshError);
// 연결 해제
db.run(`DELETE FROM tiktok_connections WHERE user_id = ?`, [userId]);
reject(new Error('TikTok 인증이 만료되었습니다. 다시 연결해주세요.'));
}
} else {
resolve({
accessToken: row.access_token,
openId: row.open_id,
displayName: row.display_name
});
}
});
});
}
/**
* 사용자의 TikTok 연결 상태 확인
*/
function getConnectionStatus(userId) {
return new Promise((resolve, reject) => {
db.get(`
SELECT open_id, display_name, avatar_url, follower_count, following_count, connected_at
FROM tiktok_connections WHERE user_id = ?
`, [userId], (err, row) => {
if (err) reject(err);
else resolve(row || null);
});
});
}
/**
* TikTok 연결 해제
*/
async function disconnectTikTok(userId) {
return new Promise((resolve, reject) => {
db.get(`SELECT access_token FROM tiktok_connections WHERE user_id = ?`, [userId], async (err, row) => {
if (err) {
reject(err);
return;
}
// 토큰 폐기 시도
if (row?.access_token) {
try {
await axios.post(TIKTOK_REVOKE_URL, null, {
params: {
client_key: TIKTOK_CLIENT_KEY,
client_secret: TIKTOK_CLIENT_SECRET,
token: row.access_token
}
});
} catch (revokeError) {
console.error('[TikTok] 토큰 폐기 실패 (무시):', revokeError.message);
}
}
// DB에서 삭제
db.run(`DELETE FROM tiktok_connections WHERE user_id = ?`, [userId], function (delErr) {
if (delErr) reject(delErr);
else resolve({ success: true, deleted: this.changes });
});
});
});
}
/**
* 비디오 업로드 (Direct Post)
* @param {number} userId - 사용자 ID
* @param {string} videoPath - 비디오 파일 경로
* @param {object} metadata - 메타데이터 (title, description, etc.)
* @param {object} options - 업로드 옵션
*/
async function uploadVideo(userId, videoPath, metadata, options = {}) {
try {
const credentials = await getAuthenticatedCredentials(userId);
const fileSize = fs.statSync(videoPath).size;
// Step 1: Initialize upload
const initResponse = await axios.post(
TIKTOK_UPLOAD_INIT_URL,
{
post_info: {
title: (metadata.title || 'CaStAD Video').substring(0, 150),
privacy_level: options.privacyLevel || 'SELF_ONLY', // SELF_ONLY, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, PUBLIC_TO_EVERYONE
disable_duet: options.disableDuet || false,
disable_comment: options.disableComment || false,
disable_stitch: options.disableStitch || false,
video_cover_timestamp_ms: options.coverTimestamp || 1000
},
source_info: {
source: 'FILE_UPLOAD',
video_size: fileSize,
chunk_size: Math.min(fileSize, 10 * 1024 * 1024), // 10MB chunks
total_chunk_count: Math.ceil(fileSize / (10 * 1024 * 1024))
}
},
{
headers: {
'Authorization': `Bearer ${credentials.accessToken}`,
'Content-Type': 'application/json; charset=UTF-8'
}
}
);
if (initResponse.data.error?.code) {
throw new Error(initResponse.data.error.message || 'Upload initialization failed');
}
const uploadUrl = initResponse.data.data?.upload_url;
const publishId = initResponse.data.data?.publish_id;
if (!uploadUrl) {
throw new Error('Upload URL not received from TikTok');
}
// Step 2: Upload video file in chunks
const chunkSize = 10 * 1024 * 1024; // 10MB
const totalChunks = Math.ceil(fileSize / chunkSize);
const fileStream = fs.createReadStream(videoPath);
let uploadedBytes = 0;
let chunkIndex = 0;
for await (const chunk of fileStream) {
const start = uploadedBytes;
const end = Math.min(uploadedBytes + chunk.length - 1, fileSize - 1);
await axios.put(uploadUrl, chunk, {
headers: {
'Content-Type': 'video/mp4',
'Content-Length': chunk.length,
'Content-Range': `bytes ${start}-${end}/${fileSize}`
}
});
uploadedBytes += chunk.length;
chunkIndex++;
if (options.onProgress) {
options.onProgress((uploadedBytes / fileSize) * 100);
}
}
console.log(`[TikTok] 업로드 완료! Publish ID: ${publishId}`);
// Step 3: Check publish status
const status = await checkPublishStatus(credentials.accessToken, publishId);
// 업로드 히스토리 저장
db.run(`
INSERT INTO tiktok_upload_history
(user_id, history_id, publish_id, title, privacy_level, status, uploaded_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
`, [userId, options.historyId || null, publishId, metadata.title, options.privacyLevel || 'SELF_ONLY', status.status]);
return {
publishId,
status: status.status,
videoId: status.video_id
};
} catch (error) {
console.error('[TikTok] 업로드 오류:', error.response?.data || error.message);
// 에러 기록
db.run(`
INSERT INTO tiktok_upload_history
(user_id, history_id, status, error_message, uploaded_at)
VALUES (?, ?, 'failed', ?, datetime('now'))
`, [userId, options.historyId || null, error.message]);
throw error;
}
}
/**
* 비디오 업로드 (Inbox/Draft 방식)
* @param {number} userId - 사용자 ID
* @param {string} videoPath - 비디오 파일 경로
* @param {object} metadata - 메타데이터
* @param {object} options - 업로드 옵션
*/
async function uploadVideoToInbox(userId, videoPath, metadata, options = {}) {
try {
const credentials = await getAuthenticatedCredentials(userId);
const fileSize = fs.statSync(videoPath).size;
// Initialize inbox upload
const initResponse = await axios.post(
TIKTOK_UPLOAD_INBOX_URL,
{
source_info: {
source: 'FILE_UPLOAD',
video_size: fileSize,
chunk_size: Math.min(fileSize, 10 * 1024 * 1024),
total_chunk_count: Math.ceil(fileSize / (10 * 1024 * 1024))
}
},
{
headers: {
'Authorization': `Bearer ${credentials.accessToken}`,
'Content-Type': 'application/json; charset=UTF-8'
}
}
);
if (initResponse.data.error?.code) {
throw new Error(initResponse.data.error.message || 'Inbox upload initialization failed');
}
const uploadUrl = initResponse.data.data?.upload_url;
const publishId = initResponse.data.data?.publish_id;
// Upload file
const fileBuffer = fs.readFileSync(videoPath);
await axios.put(uploadUrl, fileBuffer, {
headers: {
'Content-Type': 'video/mp4',
'Content-Length': fileSize,
'Content-Range': `bytes 0-${fileSize - 1}/${fileSize}`
}
});
console.log(`[TikTok] Inbox 업로드 완료! Publish ID: ${publishId}`);
return {
publishId,
status: 'uploaded_to_inbox',
message: 'TikTok 앱에서 영상을 확인하고 게시해주세요.'
};
} catch (error) {
console.error('[TikTok] Inbox 업로드 오류:', error.response?.data || error.message);
throw error;
}
}
/**
* 게시 상태 확인
* @param {string} accessToken - 액세스 토큰
* @param {string} publishId - 게시 ID
*/
async function checkPublishStatus(accessToken, publishId) {
try {
const response = await axios.post(
TIKTOK_PUBLISH_STATUS_URL,
{ publish_id: publishId },
{
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
}
}
);
return response.data.data || { status: 'unknown' };
} catch (error) {
console.error('[TikTok] 상태 확인 오류:', error.response?.data || error.message);
return { status: 'unknown', error: error.message };
}
}
/**
* 사용자 TikTok 설정 조회
*/
function getUserTikTokSettings(userId) {
return new Promise((resolve, reject) => {
db.get(`SELECT * FROM tiktok_settings WHERE user_id = ?`, [userId], (err, row) => {
if (err) reject(err);
else resolve(row || {
default_privacy: 'SELF_ONLY',
disable_duet: 0,
disable_comment: 0,
disable_stitch: 0,
auto_upload: 0,
upload_to_inbox: 1
});
});
});
}
/**
* 사용자 TikTok 설정 업데이트
*/
function updateUserTikTokSettings(userId, settings) {
return new Promise((resolve, reject) => {
const fields = [];
const values = [];
const allowedFields = [
'default_privacy', 'disable_duet', 'disable_comment',
'disable_stitch', 'auto_upload', 'upload_to_inbox', 'default_hashtags'
];
for (const field of allowedFields) {
if (settings[field] !== undefined) {
fields.push(`${field} = ?`);
values.push(settings[field]);
}
}
if (fields.length === 0) {
resolve({ success: true });
return;
}
values.push(userId);
db.run(`
UPDATE tiktok_settings SET ${fields.join(', ')}, updated_at = datetime('now')
WHERE user_id = ?
`, values, function (err) {
if (err) {
// INSERT 시도
db.run(`
INSERT INTO tiktok_settings (user_id, ${fields.map(f => f.split(' = ')[0]).join(', ')})
VALUES (?, ${fields.map(() => '?').join(', ')})
`, [userId, ...values.slice(0, -1)], function (err2) {
if (err2) reject(err2);
else resolve({ success: true });
});
} else {
resolve({ success: true });
}
});
});
}
/**
* 업로드 히스토리 조회
*/
function getUploadHistory(userId, limit = 20) {
return new Promise((resolve, reject) => {
db.all(`
SELECT * FROM tiktok_upload_history
WHERE user_id = ?
ORDER BY uploaded_at DESC
LIMIT ?
`, [userId, limit], (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}
/**
* TikTok 통계 조회 (사용자별)
*/
function getTikTokStats(userId) {
return new Promise((resolve, reject) => {
db.get(`
SELECT
COUNT(*) as total_uploads,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as successful_uploads,
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed_uploads
FROM tiktok_upload_history
WHERE user_id = ?
`, [userId], (err, row) => {
if (err) reject(err);
else resolve(row || { total_uploads: 0, successful_uploads: 0, failed_uploads: 0 });
});
});
}
module.exports = {
generateAuthUrl,
exchangeCodeForTokens,
getAuthenticatedCredentials,
getConnectionStatus,
disconnectTikTok,
uploadVideo,
uploadVideoToInbox,
checkPublishStatus,
getUserTikTokSettings,
updateUserTikTokSettings,
getUploadHistory,
getTikTokStats,
TIKTOK_SCOPES
};

1
server/tokens.json Normal file
View File

@ -0,0 +1 @@
{"access_token":"ya29.a0ATi6K2v63SXXTZsXQhStk2KUONtfIy0UAEfNGJVezoesFeezqHiLneS1DGEK03Zi_OH5xweYQUE2QLtTLzlhhKz1k5Qjc6I67UJ03JFymrBYSFLQuSgEDP_3eAWCAXTWZaAEm3s7PJafWExpvxmtxJuWgZinqMyGdJ6QPh3rEkLf3lRODuNlv9cLAnGEJ6HLwNoh4mIaCgYKAf0SARYSFQHGX2Mi9Syyrl8EnOnXfBynBDZ80A0206","refresh_token":"1//0e1hhg4utFxpwCgYIARAAGA4SNwF-L9IrW2g5xFxohoak303VFYDgWck8r_Le3IZRSAvDC6ieFlRPIf-Trn-f2YNTMdoCAKtaFhI","scope":"https://www.googleapis.com/auth/youtube.upload","token_type":"Bearer","refresh_token_expires_in":604799,"expiry_date":1764463744341}

610
server/youtubeService.js Normal file
View File

@ -0,0 +1,610 @@
const fs = require('fs');
const path = require('path');
const { google } = require('googleapis');
const db = require('./db');
// 클라이언트 시크릿 파일 (Google Cloud Console에서 다운로드)
const CREDENTIALS_PATH = path.join(__dirname, 'client_secret.json');
const SCOPES = [
'https://www.googleapis.com/auth/youtube.upload',
'https://www.googleapis.com/auth/youtube',
'https://www.googleapis.com/auth/youtube.readonly',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile'
];
/**
* Google OAuth2 클라이언트 생성
* @param {string} redirectUri - 콜백 URI
*/
function createOAuth2Client(redirectUri = null) {
if (!fs.existsSync(CREDENTIALS_PATH)) {
throw new Error("client_secret.json 파일이 없습니다. Google Cloud Console에서 다운로드하여 server 폴더에 넣어주세요.");
}
const content = fs.readFileSync(CREDENTIALS_PATH, 'utf-8');
const credentials = JSON.parse(content);
const { client_secret, client_id, redirect_uris } = credentials.installed || credentials.web;
const finalRedirectUri = redirectUri || redirect_uris[0] || 'http://localhost:3001/api/youtube/oauth/callback';
return new google.auth.OAuth2(client_id, client_secret, finalRedirectUri);
}
/**
* OAuth 인증 URL 생성
* @param {number} userId - 사용자 ID (state 파라미터로 전달)
* @param {string} redirectUri - 콜백 URI
*/
function generateAuthUrl(userId, redirectUri = null) {
const oAuth2Client = createOAuth2Client(redirectUri);
const authUrl = oAuth2Client.generateAuthUrl({
access_type: 'offline',
scope: SCOPES,
prompt: 'consent', // 항상 refresh_token 받기 위해
state: JSON.stringify({ userId }) // 콜백에서 사용자 식별용
});
return authUrl;
}
/**
* 인증 코드로 토큰 교환 저장
* @param {string} code - 인증 코드
* @param {number} userId - 사용자 ID
* @param {string} redirectUri - 콜백 URI
*/
async function exchangeCodeForTokens(code, userId, redirectUri = null) {
const oAuth2Client = createOAuth2Client(redirectUri);
try {
const { tokens } = await oAuth2Client.getToken(code);
oAuth2Client.setCredentials(tokens);
// 사용자 정보 가져오기
const oauth2 = google.oauth2({ version: 'v2', auth: oAuth2Client });
const userInfo = await oauth2.userinfo.get();
// YouTube 채널 정보 가져오기
const youtube = google.youtube({ version: 'v3', auth: oAuth2Client });
const channelRes = await youtube.channels.list({
part: 'snippet',
mine: true
});
const channel = channelRes.data.items?.[0];
// DB에 저장
const tokenExpiry = tokens.expiry_date ? new Date(tokens.expiry_date).toISOString() : null;
return new Promise((resolve, reject) => {
db.run(`
INSERT OR REPLACE INTO youtube_connections
(user_id, google_user_id, google_email, youtube_channel_id, youtube_channel_title,
access_token, refresh_token, token_expiry, scopes, connected_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
`, [
userId,
userInfo.data.id,
userInfo.data.email,
channel?.id || null,
channel?.snippet?.title || null,
tokens.access_token,
tokens.refresh_token,
tokenExpiry,
SCOPES.join(',')
], function(err) {
if (err) {
console.error('[YouTube OAuth] 토큰 저장 실패:', err);
reject(err);
} else {
// 기본 설정도 생성
db.run(`
INSERT OR IGNORE INTO youtube_settings (user_id) VALUES (?)
`, [userId]);
resolve({
success: true,
channelId: channel?.id,
channelTitle: channel?.snippet?.title,
email: userInfo.data.email
});
}
});
});
} catch (error) {
console.error('[YouTube OAuth] 토큰 교환 실패:', error);
throw error;
}
}
/**
* 사용자별 인증된 클라이언트 가져오기
* @param {number} userId - 사용자 ID
*/
async function getAuthenticatedClientForUser(userId) {
return new Promise((resolve, reject) => {
db.get(`
SELECT * FROM youtube_connections WHERE user_id = ?
`, [userId], async (err, row) => {
if (err) {
reject(err);
return;
}
if (!row) {
reject(new Error('YouTube 채널이 연결되지 않았습니다. 설정에서 채널을 연결해주세요.'));
return;
}
const oAuth2Client = createOAuth2Client();
oAuth2Client.setCredentials({
access_token: row.access_token,
refresh_token: row.refresh_token,
expiry_date: row.token_expiry ? new Date(row.token_expiry).getTime() : null
});
// 토큰 만료 체크 및 갱신
try {
const tokenInfo = await oAuth2Client.getAccessToken();
// 새 토큰으로 갱신되었으면 DB 업데이트
if (tokenInfo.token !== row.access_token) {
const credentials = oAuth2Client.credentials;
db.run(`
UPDATE youtube_connections
SET access_token = ?, token_expiry = ?
WHERE user_id = ?
`, [
credentials.access_token,
credentials.expiry_date ? new Date(credentials.expiry_date).toISOString() : null,
userId
]);
}
resolve(oAuth2Client);
} catch (refreshError) {
console.error('[YouTube] 토큰 갱신 실패:', refreshError);
// 연결 해제 처리
db.run(`DELETE FROM youtube_connections WHERE user_id = ?`, [userId]);
reject(new Error('YouTube 인증이 만료되었습니다. 다시 연결해주세요.'));
}
});
});
}
/**
* 사용자의 YouTube 연결 상태 확인
*/
function getConnectionStatus(userId) {
return new Promise((resolve, reject) => {
db.get(`
SELECT youtube_channel_id, youtube_channel_title, google_email, connected_at
FROM youtube_connections WHERE user_id = ?
`, [userId], (err, row) => {
if (err) reject(err);
else resolve(row || null);
});
});
}
/**
* YouTube 연결 해제
*/
function disconnectYouTube(userId) {
return new Promise((resolve, reject) => {
db.run(`DELETE FROM youtube_connections WHERE user_id = ?`, [userId], function(err) {
if (err) reject(err);
else resolve({ success: true, deleted: this.changes });
});
});
}
/**
* 사용자별 비디오 업로드
* @param {number} userId - 사용자 ID
* @param {string} videoPath - 비디오 파일 경로
* @param {object} seoData - SEO 메타데이터
* @param {object} options - 업로드 옵션
*/
async function uploadVideoForUser(userId, videoPath, seoData, options = {}) {
try {
const auth = await getAuthenticatedClientForUser(userId);
const youtube = google.youtube({ version: 'v3', auth });
// 사용자 설정 가져오기
const settings = await getUserYouTubeSettings(userId);
const fileSize = fs.statSync(videoPath).size;
// SEO 데이터 + 기본 설정 병합
const title = (seoData.title || 'CastAD 생성 영상').substring(0, 100);
const description = (seoData.description || '').substring(0, 5000);
const tags = [...(seoData.tags || []), ...(settings.default_tags ? JSON.parse(settings.default_tags) : [])];
const categoryId = options.categoryId || settings.default_category_id || '19';
const privacyStatus = options.privacyStatus || settings.default_privacy || 'private';
const res = await youtube.videos.insert({
part: 'snippet,status',
requestBody: {
snippet: {
title,
description,
tags: tags.slice(0, 500), // YouTube 태그 제한
categoryId,
},
status: {
privacyStatus,
selfDeclaredMadeForKids: false,
},
},
media: {
body: fs.createReadStream(videoPath),
},
}, {
onUploadProgress: evt => {
const progress = (evt.bytesRead / fileSize) * 100;
if (options.onProgress) options.onProgress(progress);
},
});
const videoId = res.data.id;
const youtubeUrl = `https://youtu.be/${videoId}`;
console.log(`[YouTube] 업로드 성공! ${youtubeUrl}`);
// 플레이리스트에 추가
const playlistId = options.playlistId || settings.default_playlist_id;
if (playlistId) {
try {
await addVideoToPlaylist(youtube, playlistId, videoId);
console.log(`[YouTube] 플레이리스트(${playlistId})에 추가 완료`);
} catch (playlistError) {
console.error('[YouTube] 플레이리스트 추가 실패:', playlistError.message);
}
}
// 고정 댓글 달기
if (seoData.pinnedComment) {
try {
await postPinnedComment(youtube, videoId, seoData.pinnedComment);
console.log('[YouTube] 고정 댓글 추가 완료');
} catch (commentError) {
console.error('[YouTube] 고정 댓글 실패:', commentError.message);
}
}
// 업로드 히스토리 저장
db.run(`
INSERT INTO upload_history
(user_id, history_id, youtube_video_id, youtube_url, title, privacy_status, playlist_id, status)
VALUES (?, ?, ?, ?, ?, ?, ?, 'completed')
`, [userId, options.historyId || null, videoId, youtubeUrl, title, privacyStatus, playlistId]);
return { videoId, url: youtubeUrl };
} catch (error) {
console.error('[YouTube] 업로드 오류:', error.message);
// 에러 기록
db.run(`
INSERT INTO upload_history
(user_id, history_id, status, error_message)
VALUES (?, ?, 'failed', ?)
`, [userId, options.historyId || null, error.message]);
throw error;
}
}
/**
* 고정 댓글 달기
*/
async function postPinnedComment(youtube, videoId, commentText) {
const res = await youtube.commentThreads.insert({
part: 'snippet',
requestBody: {
snippet: {
videoId,
topLevelComment: {
snippet: {
textOriginal: commentText
}
}
}
}
});
// 댓글 고정 (채널 소유자만 가능)
// Note: YouTube API에서 직접 고정은 지원하지 않음, 수동으로 해야 함
return res.data;
}
/**
* 플레이리스트에 비디오 추가
*/
async function addVideoToPlaylist(youtube, playlistId, videoId) {
await youtube.playlistItems.insert({
part: 'snippet',
requestBody: {
snippet: {
playlistId,
resourceId: {
kind: 'youtube#video',
videoId,
},
},
},
});
}
/**
* 사용자의 플레이리스트 목록 조회
*/
async function getPlaylistsForUser(userId) {
try {
const auth = await getAuthenticatedClientForUser(userId);
const youtube = google.youtube({ version: 'v3', auth });
const res = await youtube.playlists.list({
part: 'snippet,contentDetails',
mine: true,
maxResults: 50,
});
const playlists = res.data.items.map(item => ({
id: item.id,
title: item.snippet.title,
description: item.snippet.description,
itemCount: item.contentDetails.itemCount,
thumbnail: item.snippet.thumbnails?.default?.url,
}));
// 캐시 업데이트
playlists.forEach(p => {
db.run(`
INSERT OR REPLACE INTO youtube_playlists
(user_id, playlist_id, title, item_count, cached_at)
VALUES (?, ?, ?, ?, datetime('now'))
`, [userId, p.id, p.title, p.itemCount]);
});
return playlists;
} catch (error) {
console.error('[YouTube] 플레이리스트 조회 오류:', error.message);
throw error;
}
}
/**
* 플레이리스트 생성
*/
async function createPlaylistForUser(userId, title, description = '', privacyStatus = 'public') {
try {
const auth = await getAuthenticatedClientForUser(userId);
const youtube = google.youtube({ version: 'v3', auth });
const res = await youtube.playlists.insert({
part: 'snippet,status',
requestBody: {
snippet: {
title: title.substring(0, 150),
description: description.substring(0, 5000),
},
status: { privacyStatus },
},
});
const playlist = {
id: res.data.id,
title: res.data.snippet.title
};
// 캐시에 추가
db.run(`
INSERT INTO youtube_playlists
(user_id, playlist_id, title, item_count, cached_at)
VALUES (?, ?, ?, 0, datetime('now'))
`, [userId, playlist.id, playlist.title]);
return playlist;
} catch (error) {
console.error('[YouTube] 플레이리스트 생성 오류:', error.message);
throw error;
}
}
/**
* 사용자 YouTube 설정 조회
*/
function getUserYouTubeSettings(userId) {
return new Promise((resolve, reject) => {
db.get(`SELECT * FROM youtube_settings WHERE user_id = ?`, [userId], (err, row) => {
if (err) reject(err);
else resolve(row || {
default_privacy: 'private',
default_category_id: '19',
default_tags: '[]',
auto_upload: 0,
upload_timing: 'manual'
});
});
});
}
/**
* 사용자 YouTube 설정 업데이트
*/
function updateUserYouTubeSettings(userId, settings) {
return new Promise((resolve, reject) => {
const fields = [];
const values = [];
const allowedFields = [
'default_privacy', 'default_category_id', 'default_tags',
'default_hashtags', 'auto_upload', 'upload_timing',
'scheduled_day', 'scheduled_time', 'default_playlist_id', 'notify_on_upload'
];
for (const field of allowedFields) {
if (settings[field] !== undefined) {
fields.push(`${field} = ?`);
values.push(typeof settings[field] === 'object' ? JSON.stringify(settings[field]) : settings[field]);
}
}
if (fields.length === 0) {
resolve({ success: true });
return;
}
values.push(userId);
db.run(`
INSERT INTO youtube_settings (user_id, ${allowedFields.map(f => f).join(', ')})
VALUES (?, ${allowedFields.map(() => '?').join(', ')})
ON CONFLICT(user_id) DO UPDATE SET ${fields.join(', ')}, updatedAt = datetime('now')
`.replace('INSERT INTO youtube_settings (user_id, ', `UPDATE youtube_settings SET ${fields.join(', ')}, updatedAt = datetime('now') WHERE user_id = ?`).split('ON CONFLICT')[0] + ' WHERE user_id = ?', values, function(err) {
if (err) {
// INSERT 시도
db.run(`
INSERT OR REPLACE INTO youtube_settings (user_id, ${fields.map(f => f.split(' = ')[0]).join(', ')})
VALUES (?, ${fields.map(() => '?').join(', ')})
`, [userId, ...values.slice(0, -1)], function(err2) {
if (err2) reject(err2);
else resolve({ success: true });
});
} else {
resolve({ success: true });
}
});
});
}
/**
* 사용자의 업로드 히스토리 조회
*/
function getUploadHistory(userId, limit = 20) {
return new Promise((resolve, reject) => {
db.all(`
SELECT * FROM upload_history
WHERE user_id = ?
ORDER BY uploaded_at DESC
LIMIT ?
`, [userId, limit], (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}
// ============================================
// Legacy 함수들 (기존 코드 호환용)
// ============================================
const TOKEN_PATH = path.join(__dirname, 'tokens.json');
async function getAuthenticatedClient() {
if (!fs.existsSync(CREDENTIALS_PATH)) {
throw new Error("client_secret.json 파일이 없습니다.");
}
const content = fs.readFileSync(CREDENTIALS_PATH, 'utf-8');
const credentials = JSON.parse(content);
const { client_secret, client_id, redirect_uris } = credentials.installed || credentials.web;
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, "http://localhost:3001/oauth2callback");
if (fs.existsSync(TOKEN_PATH)) {
const token = fs.readFileSync(TOKEN_PATH, 'utf-8');
oAuth2Client.setCredentials(JSON.parse(token));
return oAuth2Client;
}
throw new Error("YouTube 인증 토큰이 없습니다.");
}
async function uploadVideo(videoPath, seoData, playlistId = null, privacyStatus = 'public') {
// Legacy: 기존 단일 계정 방식
try {
const auth = await getAuthenticatedClient();
const youtube = google.youtube({ version: 'v3', auth });
const fileSize = fs.statSync(videoPath).size;
const title = (seoData.title || 'CastAD 생성 영상').substring(0, 100);
const description = (seoData.description || '').substring(0, 5000);
const tags = seoData.tags || ['AI', 'CastAD'];
const res = await youtube.videos.insert({
part: 'snippet,status',
requestBody: {
snippet: { title, description, tags, categoryId: '22' },
status: { privacyStatus, selfDeclaredMadeForKids: false },
},
media: { body: fs.createReadStream(videoPath) },
});
const videoId = res.data.id;
console.log(`[YouTube] 업로드 성공! https://youtu.be/${videoId}`);
if (playlistId) {
await addVideoToPlaylist(youtube, playlistId, videoId);
}
return { videoId, url: `https://youtu.be/${videoId}` };
} catch (error) {
console.error('[YouTube] 업로드 오류:', error.message);
throw error;
}
}
async function getPlaylists(maxResults = 50) {
const auth = await getAuthenticatedClient();
const youtube = google.youtube({ version: 'v3', auth });
const res = await youtube.playlists.list({ part: 'snippet,contentDetails', mine: true, maxResults });
return res.data.items.map(item => ({
id: item.id,
title: item.snippet.title,
itemCount: item.contentDetails.itemCount,
}));
}
async function createPlaylist(title, description = '', privacyStatus = 'public') {
const auth = await getAuthenticatedClient();
const youtube = google.youtube({ version: 'v3', auth });
const res = await youtube.playlists.insert({
part: 'snippet,status',
requestBody: {
snippet: { title, description },
status: { privacyStatus },
},
});
return { playlistId: res.data.id, title: res.data.snippet.title };
}
module.exports = {
// 새로운 다중 사용자 함수들
createOAuth2Client,
generateAuthUrl,
exchangeCodeForTokens,
getAuthenticatedClientForUser,
getConnectionStatus,
disconnectYouTube,
uploadVideoForUser,
getPlaylistsForUser,
createPlaylistForUser,
getUserYouTubeSettings,
updateUserYouTubeSettings,
getUploadHistory,
SCOPES,
// Legacy 함수들 (기존 코드 호환)
getAuthenticatedClient,
uploadVideo,
getPlaylists,
createPlaylist,
};

144
services/audioUtils.ts Normal file
View File

@ -0,0 +1,144 @@
/// <reference lib="dom" />
/**
* Base64 Uint8Array .
* @param {string} base64 - Base64 (data URI )
* @returns {Uint8Array} -
*/
export function decodeBase64(base64: string): Uint8Array {
// `atob` 함수를 사용하여 Base64 문자열을 이진 문자열로 디코딩합니다.
const binaryString = atob(base64);
const len = binaryString.length;
// 디코딩된 바이트를 저장할 Uint8Array를 생성합니다.
const bytes = new Uint8Array(len);
// 각 문자의 ASCII 코드를 바이트 배열에 저장합니다.
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
/**
* PCM AudioBuffer .
* API .
*
* @param {Uint8Array} data - PCM (Uint8Array )
* @param {AudioContext} ctx - AudioContext
* @param {number} sampleRate - (: 24000 Hz)
* @param {number} numChannels - (: 1, )
* @returns {Promise<AudioBuffer>} - AudioBuffer
*/
export async function decodeAudioData(
data: Uint8Array,
ctx: AudioContext,
sampleRate: number = 24000,
numChannels: number = 1,
): Promise<AudioBuffer> {
// 16비트 정렬을 확인하고 필요한 경우 버퍼 크기를 조정합니다.
// (createBuffer는 짝수 길이의 버퍼를 선호할 수 있습니다.)
let buffer = data.buffer;
if (buffer.byteLength % 2 !== 0) {
const newBuffer = new ArrayBuffer(buffer.byteLength + 1);
new Uint8Array(newBuffer).set(data);
buffer = newBuffer;
}
// Int16Array로 데이터를 해석하여 16비트 PCM 데이터를 처리합니다.
const dataInt16 = new Int16Array(buffer);
// 총 프레임 수를 계산합니다 (샘플 수 / 채널 수).
const frameCount = dataInt16.length / numChannels;
// AudioBuffer를 생성합니다. (채널 수, 프레임 수, 샘플 레이트)
const audioBuffer = ctx.createBuffer(numChannels, frameCount, sampleRate);
// 각 오디오 채널에 대해 데이터를 처리합니다.
for (let channel = 0; channel < numChannels; channel++) {
// 현재 채널의 데이터를 가져옵니다 (Float32Array).
const channelData = audioBuffer.getChannelData(channel);
for (let i = 0; i < frameCount; i++) {
// Int16 값을 Float32 범위 [-1.0, 1.0]으로 변환합니다.
// 16비트 부호 있는 정수(short)의 최대값은 32767이므로 이 값으로 나눕니다.
channelData[i] = dataInt16[i * numChannels + channel] / 32768.0;
}
}
return audioBuffer;
}
/**
* AudioBuffer WAV Blob .
* Blob HTML <audio> .
*
* @param {AudioBuffer} abuffer - WAV AudioBuffer
* @param {number} len - AudioBuffer ( )
* @returns {Blob} - WAV Blob
*/
export function bufferToWaveBlob(abuffer: AudioBuffer, len: number): Blob {
const numOfChan = abuffer.numberOfChannels; // 채널 수 (예: 1=모노, 2=스테레오)
// WAV 파일의 총 길이를 계산합니다. (데이터 길이 + 헤더 길이)
// (샘플 수 * 채널 수 * 2 바이트/샘플 (16비트) + 44 바이트 (WAV 헤더))
const length = len * numOfChan * 2 + 44;
const buffer = new ArrayBuffer(length); // 전체 WAV 파일 크기의 ArrayBuffer
const view = new DataView(buffer); // 데이터를 쓰기 위한 DataView
const channels = []; // 각 채널의 데이터를 저장할 배열
let i;
let sample;
let offset = 0; // 현재 읽고 있는 샘플 오프셋
let pos = 0; // DataView에 쓰는 현재 위치
// 16비트 정수를 DataView에 쓰는 헬퍼 함수
function setUint16(data: number) {
view.setUint16(pos, data, true); // little-endian
pos += 2;
}
// 32비트 정수를 DataView에 쓰는 헬퍼 함수
function setUint32(data: number) {
view.setUint32(pos, data, true); // little-endian
pos += 4;
}
// --- WAV 헤더 작성 ---
setUint32(0x46464952); // "RIFF" chunk ID
setUint32(length - 8); // ChunkSize (파일 길이 - 8 바이트)
setUint32(0x45564157); // "WAVE" format
setUint32(0x20746d66); // "fmt " sub-chunk ID
setUint32(16); // Subchunk1Size (fmt 서브 청크 길이 = 16 바이트)
setUint16(1); // AudioFormat (PCM = 1)
setUint16(numOfChan); // NumChannels
setUint32(abuffer.sampleRate); // SampleRate
setUint32(abuffer.sampleRate * 2 * numOfChan); // ByteRate (SampleRate * NumChannels * BitsPerSample/8)
setUint16(numOfChan * 2); // BlockAlign (NumChannels * BitsPerSample/8)
setUint16(16); // BitsPerSample (현재 예제에서는 16비트로 고정)
setUint32(0x61746164); // "data" sub-chunk ID
setUint32(length - pos - 4); // Subchunk2Size (데이터 길이)
// --- 인터리브된 오디오 데이터 작성 ---
// 각 채널의 오디오 데이터를 배열에 저장
for (i = 0; i < abuffer.numberOfChannels; i++)
channels.push(abuffer.getChannelData(i));
// 각 샘플을 순회하며 채널 데이터를 인터리브하여 DataView에 작성
while (offset < len) {
for (i = 0; i < numOfChan; i++) {
// DataView에 쓰기 전 안전한 경계 검사
if (pos + 2 > length) break;
// 채널 인터리브 (예: 좌, 우, 좌, 우 ...)
const channel = channels[i];
// 채널 데이터에서 현재 오프셋의 샘플을 가져옵니다. (안전하게 접근)
const s = channel && offset < channel.length ? channel[offset] : 0;
// 샘플 값을 -1에서 1 사이로 클램프(clamp)합니다.
sample = Math.max(-1, Math.min(1, s));
// 16비트 부호 있는 정수(-32768 ~ 32767) 스케일로 변환합니다.
sample = (sample < 0 ? sample * 0x8000 : sample * 0x7FFF) | 0;
view.setInt16(pos, sample, true); // 16비트 샘플 작성 (little-endian)
pos += 2;
}
offset++; // 다음 원본 샘플로 이동
}
// 최종적으로 WAV Blob을 생성하여 반환합니다.
return new Blob([buffer], { type: 'audio/wav' });
}

137
services/ffmpegService.ts Normal file
View File

@ -0,0 +1,137 @@
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile, toBlobURL } from '@ffmpeg/util';
let ffmpeg: FFmpeg | null = null; // FFmpeg 인스턴스를 저장할 변수
/**
* FFmpeg WASM .
* FFmpeg .
* FFmpeg worker .
* @returns {Promise<FFmpeg>} - FFmpeg
*/
const loadFFmpeg = async () => {
if (ffmpeg) return ffmpeg; // 이미 로드되어 있다면 기존 인스턴스 반환
ffmpeg = new FFmpeg(); // 새로운 FFmpeg 인스턴스 생성
// FFmpeg 코어 및 관련 파일의 CDN 기본 URL
const coreBaseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm';
const ffmpegBaseURL = 'https://unpkg.com/@ffmpeg/ffmpeg@0.12.10/dist/esm';
/**
* FFmpeg worker import .
* `@ffmpeg/ffmpeg` worker.js `./classes.js` ,
* Blob URL .
* worker.js Blob URL .
* @returns {Promise<string>} - worker Blob URL
*/
const getPatchedWorkerBlob = async () => {
try {
// 원본 worker.js 스크립트를 CDN에서 가져옵니다.
const response = await fetch(`${ffmpegBaseURL}/worker.js`);
let text = await response.text();
console.log("원본 Worker 스크립트 길이:", text.length);
// 상대 경로 임포트(예: from "./classes.js")를 절대 경로로 교체합니다.
// 정규식을 사용하여 "./classes.js" 또는 './classes.js' 패턴을 찾아 교체합니다.
const patchedText = text.replace(
/from\s*["']\.\/classes\.js["']/g,
`from "${ffmpegBaseURL}/classes.js"`
);
console.log("패치된 Worker 스크립트 길이:", patchedText.length);
// 패치된 스크립트 내용을 Blob으로 만들고, 이를 위한 URL을 생성합니다.
const blob = new Blob([patchedText], { type: 'text/javascript' });
const blobUrl = URL.createObjectURL(blob);
console.log("생성된 Worker Blob URL:", blobUrl);
return blobUrl;
} catch (e) {
console.error("Worker 스크립트 패치 실패:", e);
throw e;
}
};
// FFmpeg의 코어, WASM, worker 스크립트를 로드합니다.
// workerURL은 위에서 패치된 Blob URL을 사용합니다.
const workerBlobUrl = await getPatchedWorkerBlob(); // 패치된 worker 스크립트 URL을 먼저 생성
await ffmpeg.load({
coreURL: await toBlobURL(`${coreBaseURL}/ffmpeg-core.js`, 'text/javascript'),
wasmURL: await toBlobURL(`${coreBaseURL}/ffmpeg-core.wasm`, 'application/wasm'),
workerURL: workerBlobUrl, // 패치된 worker URL 사용
});
return ffmpeg;
};
/**
* FFmpeg WASM .
* '빠른 저장' .
* @param {string} videoUrl - URL
* @param {string} audioUrl - URL
* @param {(msg: string) => void} onProgress -
* @returns {Promise<string>} - Blob URL
*/
export const mergeVideoAndAudio = async (
videoUrl: string,
audioUrl: string,
onProgress: (msg: string) => void
): Promise<string> => {
try {
onProgress("FFmpeg 엔진 로딩 중...");
const ffmpeg = await loadFFmpeg(); // FFmpeg 인스턴스 로드
onProgress("비디오/오디오 파일 다운로드 중...");
// 비디오와 오디오 파일을 FFmpeg의 가상 파일 시스템(MEMFS)에 씁니다.
await ffmpeg.writeFile('input_video.mp4', await fetchFile(videoUrl));
// 오디오 파일의 확장자를 감지하여 올바른 파일명으로 저장합니다.
const isWav = audioUrl.endsWith('wav') || audioUrl.startsWith('blob:'); // Blob URL은 대부분 WAV임
const audioExt = isWav ? 'wav' : 'mp3';
const audioFilename = `input_audio.${audioExt}`;
await ffmpeg.writeFile(audioFilename, await fetchFile(audioUrl));
onProgress("비디오/오디오 병합 및 렌더링 중 (무한 루프 & 오디오 길이 맞춤)...");
// FFmpeg 명령어 실행
// -stream_loop -1 : 비디오를 무한 반복 재생합니다. (오디오가 끝나면 멈추도록 -shortest와 함께 사용)
// -i input_video.mp4 : 첫 번째 입력 파일 (비디오)
// -i input_audio.wav : 두 번째 입력 파일 (오디오)
// -shortest : 가장 짧은 스트림(여기서는 오디오)의 길이에 맞춰 출력을 중단합니다.
// -map 0:v:0 : 첫 번째 입력(비디오)의 비디오 스트림을 출력에 매핑합니다.
// -map 1:a:0 : 두 번째 입력(오디오)의 오디오 스트림을 출력에 매핑합니다.
// -c:v libx264 : 비디오 코덱을 H.264로 재인코딩합니다 (호환성 및 압축).
// -preset ultrafast : 인코딩 속도를 최우선으로 설정합니다 (빠른 미리보기용).
// -c:a aac : 오디오 코덱을 AAC로 재인코딩합니다 (MP4 표준 오디오 코덱).
// -strict experimental : 실험적인 기능(예: AAC 인코더) 사용을 허용합니다.
await ffmpeg.exec([
'-stream_loop', '-1',
'-i', 'input_video.mp4',
'-i', audioFilename,
'-shortest',
'-map', '0:v:0',
'-map', '1:a:0',
'-c:v', 'libx264',
'-preset', 'ultrafast',
'-c:a', 'aac',
'-strict', 'experimental',
'output.mp4'
]);
onProgress("최종 파일 생성 중...");
// FFmpeg 가상 파일 시스템에서 결과 파일(output.mp4)을 읽어옵니다.
const data = await ffmpeg.readFile('output.mp4');
// 읽어온 데이터를 Blob으로 만들고, 이를 위한 URL을 생성하여 반환합니다.
const blob = new Blob([data], { type: 'video/mp4' });
return URL.createObjectURL(blob);
} catch (error: any) {
console.error("FFmpeg 병합 오류 발생:", error);
throw new Error(`영상 합성 중 오류가 발생했습니다: ${error.message}`);
}
};

327
services/geminiService.ts Normal file
View File

@ -0,0 +1,327 @@
import { BusinessInfo, TTSConfig, AspectRatio, Language } from '../types';
import { decodeBase64, decodeAudioData, bufferToWaveBlob } from './audioUtils';
const SUPPORTED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif'];
// Helper to convert File object to base64 Data URL (MIME type 포함)
export const fileToBase64 = (file: File): Promise<{ base64: string; mimeType: string }> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
const result = reader.result as string;
const [header, base64] = result.split(',');
const mimeType = header.split(':')[1].split(';')[0];
resolve({ base64, mimeType });
};
reader.onerror = error => reject(error);
});
};
/**
* (Gemini 2.5 Flash + Google Maps Tool) -
* @param {string} query - ( )
* @returns {Promise<{name: string, description: string, mapLink?: string}>}
*/
export const searchBusinessInfo = async (
query: string
): Promise<{ name: string; description: string; mapLink?: string }> => {
try {
const response = await fetch('/api/gemini/search-business', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}` // 인증 토큰 포함
},
body: JSON.stringify({ query })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Server error: ${response.statusText}`);
}
const data = await response.json();
return data;
} catch (e: any) {
console.error("Search Business (Frontend) Failed:", e);
throw new Error(e.message || "업체 정보 검색에 실패했습니다.");
}
};
/**
* ( & /) -
* @param {BusinessInfo} info -
* @returns {Promise<{adCopy: string[], lyrics: string}>}
*/
export const generateCreativeContent = async (
info: BusinessInfo
): Promise<{ adCopy: string[]; lyrics: string }> => {
try {
// 이미지 File 객체를 Base64로 변환하여 백엔드로 전달
const imagesForBackend = await Promise.all(
info.images.map(async (file) => await fileToBase64(file))
);
const payload = { ...info, images: imagesForBackend };
const response = await fetch('/api/gemini/creative-content', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Server error: ${response.statusText}`);
}
const data = await response.json();
return data;
} catch (e: any) {
console.error("Generate Creative Content (Frontend) Failed:", e);
throw new Error(e.message || "창의적 콘텐츠 생성에 실패했습니다.");
}
};
// 사용자 설정(성별/톤)을 Gemini 미리 정의된 목소리 이름으로 매핑 (이 함수는 백엔드에서 사용)
// const getVoiceName = (config: TTSConfig): string => { ... };
/**
* (TTS) -
* @param {string} text -
* @param {TTSConfig} config - TTS
* @returns {Promise<string>} - Base64 URL
*/
export const generateAdvancedSpeech = async (
text: string,
config: TTSConfig
): Promise<string> => {
try {
const response = await fetch('/api/gemini/speech', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({ text, config })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Server error: ${response.statusText}`);
}
const data = await response.json();
// Base64 오디오 데이터를 Blob URL로 변환하여 반환
const audioContext = new ((window as any).AudioContext || (window as any).webkitAudioContext)({ sampleRate: 24000 });
const audioBytes = decodeBase64(data.base64Audio);
const audioBuffer = await decodeAudioData(audioBytes, audioContext, 24000, 1);
const wavBlob = bufferToWaveBlob(audioBuffer, audioBuffer.length);
return URL.createObjectURL(wavBlob);
} catch (e: any) {
console.error("Generate Advanced Speech (Frontend) Failed:", e);
throw new Error(e.message || "성우 음성 생성에 실패했습니다.");
}
};
/**
* -
* @param {BusinessInfo} info -
* @returns {Promise<{ blobUrl: string; base64: string; mimeType: string }>}
*/
export const generateAdPoster = async (
info: BusinessInfo
): Promise<{ blobUrl: string; base64: string; mimeType: string }> => {
try {
const imagesForBackend = await Promise.all(
info.images.map(async (file) => await fileToBase64(file))
);
const payload = { ...info, images: imagesForBackend };
const response = await fetch('/api/gemini/ad-poster', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({ info: payload })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Server error: ${response.statusText}`);
}
const data = await response.json();
// Base64를 Blob URL로 변환
const byteCharacters = atob(data.base64);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: data.mimeType });
const blobUrl = URL.createObjectURL(blob);
return { blobUrl, base64: data.base64, mimeType: data.mimeType };
} catch (e: any) {
console.error("Generate Ad Poster (Frontend) Failed:", e);
throw new Error(e.message || "광고 포스터 생성에 실패했습니다.");
}
};
/**
* (/) -
* @param {BusinessInfo} info -
* @param {number} count -
* @returns {Promise<string[]>} - Base64 Data URL
*/
export const generateImageGallery = async (
info: BusinessInfo,
count: number
): Promise<string[]> => {
try {
const response = await fetch('/api/gemini/image-gallery', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({ info, count })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Server error: ${response.statusText}`);
}
const data = await response.json();
return data.images;
} catch (e: any) {
console.error("Generate Image Gallery (Frontend) Failed:", e);
throw new Error(e.message || "이미지 갤러리 생성에 실패했습니다.");
}
};
/**
* -
* @param {string} posterBase64 - Base64
* @param {string} posterMimeType - MIME
* @param {AspectRatio} aspectRatio -
* @returns {Promise<string>} - URL
*/
export const generateVideoBackground = async (
posterBase64: string,
posterMimeType: string,
aspectRatio: AspectRatio = '16:9'
): Promise<string> => {
try {
const response = await fetch('/api/gemini/video-background', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({ posterBase64, posterMimeType, aspectRatio })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Server error: ${response.statusText}`);
}
const data = await response.json();
// 서버에서 받은 비디오 URL에 API 키를 붙이지 않고 그대로 반환
return data.videoUrl;
} catch (e: any) {
console.error("Generate Video Background (Frontend) Failed:", e);
throw new Error(e.message || "비디오 배경 생성에 실패했습니다.");
}
};
/**
* AI (Gemini Vision) -
* @param {Array<object>} imagesData - Base64 ({ mimeType, base64 })
* @returns {Promise<Array<object>>} - Base64
*/
export const filterBestImages = async (
imagesData: { base64: string; mimeType: string }[]
): Promise<{ base64: string; mimeType: string }[]> => {
try {
const response = await fetch('/api/gemini/filter-images', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({ imagesData })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Server error: ${response.statusText}`);
}
const data = await response.json();
return data.filteredImages;
} catch (e: any) {
console.error("Filter Best Images (Frontend) Failed:", e);
throw new Error(e.message || "이미지 검수에 실패했습니다.");
}
};
/**
* -
* @param {string} name -
* @param {string} rawDescription -
* @param {string[]} reviews -
* @param {number} rating -
* @returns {Promise<string>} -
*/
export const enrichDescriptionWithReviews = async (
name: string,
rawDescription: string,
reviews: string[],
rating: number
): Promise<string> => {
try {
const response = await fetch('/api/gemini/enrich-description', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({ name, rawDescription, reviews, rating })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Server error: ${response.statusText}`);
}
const data = await response.json();
return data.enrichedDescription;
} catch (e: any) {
console.error("Enrich Description (Frontend) Failed:", e);
throw new Error(e.message || "마케팅 설명 생성에 실패했습니다.");
}
};
/**
* (CSS) -
* @param {File} imageFile - File
* @returns {Promise<string>} - CSS
*/
export const extractTextEffectFromImage = async (
imageFile: File
): Promise<string> => {
try {
const imageForBackend = await fileToBase64(imageFile);
const response = await fetch('/api/gemini/text-effect', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({ imageFile: imageForBackend })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Server error: ${response.statusText}`);
}
const data = await response.json();
return data.cssCode;
} catch (e: any) {
console.error("Extract Text Effect (Frontend) Failed:", e);
throw new Error(e.message || "텍스트 스타일 분석에 실패했습니다.");
}
};

View File

@ -0,0 +1,259 @@
import { BusinessInfo } from '../types';
/**
* ( + ).
*/
const getImageFingerprint = async (file: File): Promise<string> => {
const size = file.size;
const slice = file.slice(0, 1024);
const buffer = await slice.arrayBuffer();
const bytes = new Uint8Array(buffer);
let sum = 0;
for (let i = 0; i < bytes.length; i++) {
sum = (sum + bytes[i]) % 65536;
}
return `${size}-${sum}`;
};
export interface CrawlOptions {
maxImages?: number;
existingFingerprints?: Set<string>;
}
/**
* Google Maps URL .
* URL :
* - https://maps.google.com/maps?q=장소이름
* - https://www.google.com/maps/place/장소이름
* - https://goo.gl/maps/...
* - https://maps.app.goo.gl/...
*/
export const parseGoogleMapsUrl = (url: string): string | null => {
try {
// maps.google.com 또는 google.com/maps 형식
if (url.includes('google.com/maps') || url.includes('maps.google.com')) {
// /place/장소이름 형식
const placeMatch = url.match(/\/place\/([^\/\?]+)/);
if (placeMatch) {
return decodeURIComponent(placeMatch[1].replace(/\+/g, ' '));
}
// ?q=장소이름 형식
const urlObj = new URL(url);
const query = urlObj.searchParams.get('q');
if (query) {
return decodeURIComponent(query.replace(/\+/g, ' '));
}
}
return null;
} catch {
return null;
}
};
/**
* Google Maps URL .
*/
export const crawlGooglePlace = async (
urlOrQuery: string,
onProgress?: (msg: string) => void,
options?: CrawlOptions
): Promise<Partial<BusinessInfo>> => {
const maxImages = options?.maxImages ?? 15;
const existingFingerprints = options?.existingFingerprints ?? new Set<string>();
onProgress?.("Google 지도 정보 가져오는 중...");
try {
// URL에서 검색어 추출 시도
let query = parseGoogleMapsUrl(urlOrQuery);
// URL 파싱 실패 시 직접 검색어로 사용
if (!query) {
// URL 형식이 아니면 검색어로 간주
if (!urlOrQuery.startsWith('http')) {
query = urlOrQuery;
} else {
throw new Error("지원되지 않는 Google Maps URL 형식입니다.");
}
}
onProgress?.(`'${query}' 검색 중...`);
// Google Places API로 검색
const placeDetails = await searchPlaceDetails(query);
if (!placeDetails) {
throw new Error("Google Places에서 해당 장소를 찾을 수 없습니다.");
}
const totalPhotos = placeDetails.photos?.length || 0;
onProgress?.(`'${placeDetails.displayName.text}' 정보 수신 완료. 이미지 다운로드 중... (총 ${totalPhotos}장 중 최대 ${maxImages}장)`);
// 사진을 랜덤하게 섞기
const photos = placeDetails.photos || [];
const shuffledPhotos = [...photos].sort(() => Math.random() - 0.5);
// 사진 다운로드 (중복 검사 포함)
const imageFiles: File[] = [];
let skippedDuplicates = 0;
for (let i = 0; i < shuffledPhotos.length && imageFiles.length < maxImages; i++) {
try {
onProgress?.(`이미지 다운로드 중 (${imageFiles.length + 1}/${maxImages})...`);
const file = await fetchPlacePhoto(shuffledPhotos[i].name);
if (file) {
const newFile = new File([file], `google_${i}.jpg`, { type: 'image/jpeg' });
// 중복 검사
const fingerprint = await getImageFingerprint(newFile);
if (existingFingerprints.has(fingerprint)) {
console.log(`중복 이미지 발견, 건너뜁니다`);
skippedDuplicates++;
continue;
}
imageFiles.push(newFile);
}
} catch (e) {
console.warn("이미지 다운로드 실패:", e);
}
}
if (skippedDuplicates > 0) {
console.log(`${skippedDuplicates}장의 중복 이미지를 건너뛰었습니다.`);
}
onProgress?.(`${imageFiles.length}장의 이미지를 가져왔습니다.`);
// 설명 생성
let description = '';
if (placeDetails.generativeSummary?.overview?.text) {
description = placeDetails.generativeSummary.overview.text;
} else if (placeDetails.reviews && placeDetails.reviews.length > 0) {
description = placeDetails.reviews[0].text.text;
}
return {
name: placeDetails.displayName.text,
description: description || `${placeDetails.displayName.text} - ${placeDetails.primaryTypeDisplayName?.text || ''} (${placeDetails.formattedAddress})`,
images: imageFiles,
address: placeDetails.formattedAddress,
category: placeDetails.primaryTypeDisplayName?.text,
sourceUrl: urlOrQuery
};
} catch (error: any) {
console.error("Google Places 크롤링 실패:", error);
throw new Error(error.message || "Google Places 정보를 가져오는데 실패했습니다.");
}
};
interface PlaceDetails {
displayName: { text: string };
formattedAddress: string;
rating?: number;
userRatingCount?: number;
reviews?: {
name: string;
relativePublishTimeDescription: string;
text: { text: string };
rating: number;
}[];
photos?: {
name: string;
widthPx: number;
heightPx: number;
}[];
generativeSummary?: { overview: { text: string } };
primaryTypeDisplayName?: { text: string };
}
/**
* Google Places API .
*/
export const searchPlaceDetails = async (query: string): Promise<PlaceDetails | null> => {
try {
// 1. 텍스트 검색 (Text Search) - 백엔드 프록시 사용
const searchRes = await fetch(`/api/google/places/search`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({
textQuery: query,
languageCode: "ko"
})
});
if (!searchRes.ok) {
const errorData = await searchRes.json();
throw new Error(errorData.error || `Server error: ${searchRes.statusText}`);
}
const searchData = await searchRes.json();
if (!searchData.places || searchData.places.length === 0) return null;
const placeId = searchData.places[0].name.split('/')[1];
// 2. 상세 정보 요청 (Details) - 백엔드 프록시 사용
const fieldMask = [
'displayName',
'formattedAddress',
'rating',
'userRatingCount',
'reviews',
'photos',
'primaryTypeDisplayName'
].join(',');
const detailRes = await fetch(`/api/google/places/details`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({
placeId,
fieldMask
})
});
if (!detailRes.ok) {
const errorData = await detailRes.json();
throw new Error(errorData.error || `Server error: ${detailRes.statusText}`);
}
return await detailRes.json();
} catch (error: any) {
console.error("Google Places API Error (Frontend):", error);
throw new Error(error.message || "Google Places 정보를 가져오는데 실패했습니다.");
}
};
/**
* (media key) Blob .
* .
*/
export const fetchPlacePhoto = async (photoName: string, maxWidth = 800): Promise<File | null> => {
try {
if (!photoName) return null;
const response = await fetch('/api/google/places/photo', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({ photoName, maxWidthPx: maxWidth })
});
if (!response.ok) return null;
const blob = await response.blob();
return new File([blob], "place_photo.jpg", { type: "image/jpeg" });
} catch (error: any) {
console.error("Photo Fetch Error (Frontend):", error);
return null;
}
};

153
services/naverService.ts Normal file
View File

@ -0,0 +1,153 @@
import { BusinessInfo } from '../types';
/**
* : URL File .
* CORS Blob URL .
* @param {string} url - URL
* @param {string} filename - File
* @returns {Promise<File>} - File
*/
const urlToFile = async (url: string, filename: string): Promise<File> => {
try {
// 백엔드 프록시를 통해 이미지 다운로드 (CORS 우회)
const proxyUrl = `/api/proxy/image?url=${encodeURIComponent(url)}`;
const response = await fetch(proxyUrl);
if (!response.ok) {
throw new Error(`Proxy failed with status: ${response.status}`);
}
const blob = await response.blob();
return new File([blob], filename, { type: blob.type || 'image/jpeg' });
} catch (e) {
console.error(`이미지 다운로드 실패 ${url}:`, e);
throw new Error(`이미지 다운로드 실패: ${url}`);
}
};
/**
* ( + ).
* .
*/
const getImageFingerprint = async (file: File): Promise<string> => {
const size = file.size;
const slice = file.slice(0, 1024); // 첫 1KB만 읽기
const buffer = await slice.arrayBuffer();
const bytes = new Uint8Array(buffer);
let sum = 0;
for (let i = 0; i < bytes.length; i++) {
sum = (sum + bytes[i]) % 65536;
}
return `${size}-${sum}`;
};
/**
* fingerprint .
*/
export const getExistingFingerprints = async (existingImages: File[]): Promise<Set<string>> => {
const fingerprints = new Set<string>();
for (const img of existingImages) {
try {
const fp = await getImageFingerprint(img);
fingerprints.add(fp);
} catch (e) {
console.warn('Fingerprint 생성 실패:', e);
}
}
return fingerprints;
};
export interface CrawlOptions {
maxImages?: number; // 가져올 최대 이미지 수 (기본값: 15)
existingFingerprints?: Set<string>; // 중복 검사용 기존 이미지 fingerprints
}
/**
* .
* API .
* @param {string} url - URL ID
* @param {(msg: string) => void} [onProgress] - ( )
* @param {CrawlOptions} [options] - (maxImages, existingFingerprints)
* @returns {Promise<Partial<BusinessInfo>>} -
*/
export const crawlNaverPlace = async (
url: string,
onProgress?: (msg: string) => void,
options?: CrawlOptions
): Promise<Partial<BusinessInfo>> => {
const maxImages = options?.maxImages ?? 15;
const existingFingerprints = options?.existingFingerprints ?? new Set<string>();
onProgress?.("네이버 플레이스 정보 가져오는 중 (서버 요청)...");
try {
// 백엔드 Express 서버의 /api/naver/crawl 엔드포인트 호출
const response = await fetch('/api/naver/crawl', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }) // 크롤링할 URL을 백엔드로 전송
});
if (!response.ok) {
// 서버 응답이 성공적이지 않을 경우 에러 처리
const errData = await response.json().catch(() => ({})); // 에러 JSON 파싱 시도
throw new Error(errData.error || "서버 크롤링 요청 실패");
}
const data = await response.json(); // 성공 시 응답 데이터 파싱
console.log(`백엔드 크롤링 결과: ${data.name}, 총 ${data.totalImages || data.images?.length}장 이미지 (셔플됨)`);
onProgress?.(`'${data.name}' 정보 수신 완료. 이미지 다운로드 중... (총 ${data.totalImages || '?'}장 중 최대 ${maxImages}장)`);
// 크롤링된 이미지 URL들을 File 객체로 변환 (중복 검사 포함)
const imageFiles: File[] = [];
const imageUrls = data.images || [];
let skippedDuplicates = 0;
for (let i = 0; i < imageUrls.length && imageFiles.length < maxImages; i++) {
const imgUrl = imageUrls[i];
try {
onProgress?.(`이미지 다운로드 중 (${imageFiles.length + 1}/${maxImages})...`);
// 각 이미지 URL을 File 객체로 변환
const file = await urlToFile(imgUrl, `naver_${data.place_id}_${i}.jpg`);
// 중복 검사
const fingerprint = await getImageFingerprint(file);
if (existingFingerprints.has(fingerprint)) {
console.log(`중복 이미지 발견, 건너뜁니다: ${imgUrl.slice(-30)}`);
skippedDuplicates++;
continue;
}
imageFiles.push(file);
} catch (e) {
console.warn("이미지 다운로드 실패, 건너뜁니다:", imgUrl, e);
}
}
if (skippedDuplicates > 0) {
console.log(`${skippedDuplicates}장의 중복 이미지를 건너뛰었습니다.`);
}
// 다운로드된 이미지가 없을 경우 에러 발생
if (imageFiles.length === 0) {
throw new Error("유효한 이미지를 하나도 다운로드하지 못했습니다. 다른 URL을 시도해보세요.");
}
onProgress?.(`${imageFiles.length}장의 이미지를 가져왔습니다.`);
// 비즈니스 정보 객체 반환
return {
name: data.name,
description: data.description || `${data.name} - ${data.category} (${data.address})`,
images: imageFiles,
address: data.address,
category: data.category,
sourceUrl: url
};
} catch (error: any) {
console.error("크롤링 실패:", error);
throw new Error(error.message || "네이버 플레이스 정보를 가져오는데 실패했습니다.");
}
};

117
services/sunoService.ts Normal file
View File

@ -0,0 +1,117 @@
// Suno API 응답 인터페이스 (백엔드 응답용)
interface SunoBackendResponse {
audioUrl: string;
}
/**
* (Python _sanitize_lyrics JavaScript ).
* Suno AI [Verse 1] .
* @param {string} lyrics -
* @returns {string} - Suno AI
*/
const sanitizeLyrics = (lyrics: string): string => {
// 섹션 헤더를 감지하는 정규식 (예: Verse, Chorus, Bridge, Hook, Intro, Outro)
const sectionPattern = /^(verse|chorus|bridge|hook|intro|outro)\s*(\d+)?\s*:?/i;
const sanitizedLines: string[] = []; // 정제된 가사 라인들을 저장할 배열
const lines = lyrics.split('\n'); // 가사를 줄 단위로 분리
for (const rawLine of lines) {
const line = rawLine.trim(); // 각 줄의 앞뒤 공백 제거
if (!line) {
sanitizedLines.push(""); // 빈 줄은 그대로 추가
continue;
}
const match = line.match(sectionPattern); // 섹션 패턴 매칭 시도
if (match) {
// 섹션 이름 첫 글자 대문자화
const name = match[1].charAt(0).toUpperCase() + match[1].slice(1).toLowerCase();
const number = match[2] || ""; // 섹션 번호 (있으면 사용, 없으면 빈 문자열)
// [Verse 1] 형태의 태그로 변환하여 추가
const label = `[${name}${number ? ' ' + number : ''}]`;
sanitizedLines.push(label);
} else {
sanitizedLines.push(line); // 패턴에 해당하지 않으면 원본 줄 추가
}
}
const result = sanitizedLines.join('\n').trim(); // 정제된 줄들을 다시 합치고 최종 공백 제거
if (!result) throw new Error("정제된 가사가 비어있습니다. Suno AI에 전달할 유효한 가사가 필요합니다.");
return result;
};
/**
* Suno AI .
* (/api/suno/generate) CORS .
*
* @param {string} rawLyrics -
* @param {string} style - (: Pop, Rock, Acoustic )
* @param {string} title -
* @param {boolean} isFullSong - ( , API )
* @returns {Promise<string>} - URL
*/
export const generateSunoMusic = async (
rawLyrics: string,
style: string,
title: string,
isFullSong: boolean, // 현재는 사용되지 않음
isInstrumental: boolean = false // 연주곡 여부
): Promise<string> => {
// 1. 가사 정제 (연주곡이 아닐 때만 수행)
let sanitizedLyrics = "";
if (!isInstrumental) {
sanitizedLyrics = sanitizeLyrics(rawLyrics);
} else {
// console.log("연주곡 모드: 가사 생성 생략"); // 제거
}
// 2. 요청 페이로드 구성 (Suno OpenAPI v1 Spec 준수)
const payload = {
// Custom Mode에서 instrumental이 false이면 prompt는 가사로 사용됨.
// instrumental이 true이면 prompt는 사용되지 않음 (하지만 예제에는 포함되어 있으므로 안전하게 유지).
prompt: isInstrumental ? "" : sanitizedLyrics,
style: style, // tags -> style 로 복구
title: title.substring(0, 80), // V4/V4_5ALL 기준 80자 제한 안전하게 적용
customMode: true,
instrumental: isInstrumental, // make_instrumental -> instrumental 로 복구
model: "V5", // 필수 필드: V5 사용
callBackUrl: "https://api.example.com/callback" // 필수 필드: 더미 URL이라도 보내야 함
};
try {
// console.log("백엔드를 통해 Suno 음악 생성 요청 시작..."); // 제거
// 백엔드 엔드포인트 호출 (프록시 사용 X, 로컬 서버 사용)
const response = await fetch('/api/suno/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}` // 인증 토큰 포함
},
body: JSON.stringify(payload) // JSON 페이로드 전송
});
if (!response.ok) {
const errorJson = await response.json().catch(() => ({ error: "Unknown Error" }));
console.error("Suno Backend Error:", errorJson);
throw new Error(`음악 생성 실패 (서버): ${errorJson.error || response.statusText}`);
}
const resData: SunoBackendResponse = await response.json();
if (!resData.audioUrl) {
throw new Error("서버에서 오디오 URL을 반환하지 않았습니다.");
}
console.log(`생성 완료! Audio URL: ${resData.audioUrl}`);
return resData.audioUrl;
} catch (e: any) {
console.error("Suno 서비스 오류 발생", e);
throw new Error(e.message || "음악 생성 중 알 수 없는 오류가 발생했습니다.");
}
};

View File

@ -0,0 +1,172 @@
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Button } from './ui/button';
import { cn } from '../lib/utils';
import {
Sparkles,
Video,
Music,
Image as ImageIcon,
Wand2,
ChevronRight,
ChevronLeft,
Rocket
} from 'lucide-react';
const ONBOARDING_KEY = 'castad-onboarding-completed';
interface OnboardingStep {
icon: React.ReactNode;
title: string;
description: string;
image?: string;
}
const STEPS: OnboardingStep[] = [
{
icon: <Sparkles className="w-12 h-12 text-primary" />,
title: 'CastAD Pro에 오신 것을 환영합니다!',
description: 'AI 기반 펜션 홍보 영상 제작 플랫폼입니다. 단 몇 분 만에 전문적인 숏폼 영상을 만들어보세요.',
},
{
icon: <ImageIcon className="w-12 h-12 text-primary" />,
title: '펜션 정보 입력',
description: '펜션 유형을 선택하고, 이름과 URL을 입력하세요. 네이버 플레이스 URL을 입력하면 정보를 자동으로 가져옵니다.',
},
{
icon: <Wand2 className="w-12 h-12 text-accent" />,
title: 'AI가 자동으로 제작',
description: '이미지, 텍스트, 음악을 AI가 분석하고 최적화된 홍보 영상을 자동으로 생성합니다.',
},
{
icon: <Video className="w-12 h-12 text-green-500" />,
title: '다양한 스타일 선택',
description: '11가지 텍스트 이펙트, 다양한 전환 효과, AI 음악 또는 TTS 내레이션 중 선택하세요.',
},
{
icon: <Rocket className="w-12 h-12 text-primary" />,
title: '시작할 준비가 되셨나요?',
description: '첫 번째 프로젝트를 만들어보세요. 언제든지 도움이 필요하면 물어보세요!',
},
];
interface OnboardingDialogProps {
onComplete?: () => void;
}
const OnboardingDialog: React.FC<OnboardingDialogProps> = ({ onComplete }) => {
const [open, setOpen] = useState(false);
const [currentStep, setCurrentStep] = useState(0);
useEffect(() => {
const completed = localStorage.getItem(ONBOARDING_KEY);
if (!completed) {
// Small delay to let the app render first
const timer = setTimeout(() => setOpen(true), 500);
return () => clearTimeout(timer);
}
}, []);
const handleNext = () => {
if (currentStep < STEPS.length - 1) {
setCurrentStep(currentStep + 1);
} else {
handleComplete();
}
};
const handlePrev = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1);
}
};
const handleComplete = () => {
localStorage.setItem(ONBOARDING_KEY, 'true');
setOpen(false);
onComplete?.();
};
const handleSkip = () => {
localStorage.setItem(ONBOARDING_KEY, 'true');
setOpen(false);
};
const step = STEPS[currentStep];
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader className="text-center pb-4">
{/* Step Dots */}
<div className="flex items-center justify-center gap-2 mb-6">
{STEPS.map((_, idx) => (
<div
key={idx}
className={cn(
"w-2 h-2 rounded-full transition-all",
idx === currentStep ? "w-6 bg-primary" : "bg-muted"
)}
/>
))}
</div>
{/* Icon */}
<div className="w-20 h-20 mx-auto rounded-2xl bg-gradient-to-br from-primary/20 to-accent/20 flex items-center justify-center mb-4">
{step.icon}
</div>
<DialogTitle className="text-xl font-bold">
{step.title}
</DialogTitle>
<DialogDescription className="text-base mt-2">
{step.description}
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex-col sm:flex-row gap-2 pt-4">
<div className="flex items-center justify-between w-full gap-2">
{currentStep > 0 ? (
<Button variant="ghost" onClick={handlePrev}>
<ChevronLeft className="w-4 h-4 mr-1" />
</Button>
) : (
<Button variant="ghost" onClick={handleSkip}>
</Button>
)}
<Button onClick={handleNext}>
{currentStep < STEPS.length - 1 ? (
<>
<ChevronRight className="w-4 h-4 ml-1" />
</>
) : (
<>
<Rocket className="w-4 h-4 ml-1" />
</>
)}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default OnboardingDialog;
// Helper to reset onboarding (for testing)
export const resetOnboarding = () => {
localStorage.removeItem(ONBOARDING_KEY);
};

View File

@ -0,0 +1,526 @@
import React, { useState, useEffect } from 'react';
import { useLanguage } from '../contexts/LanguageContext';
import { useAuth } from '../contexts/AuthContext';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Textarea } from './ui/textarea';
import { Badge } from './ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
import { Separator } from './ui/separator';
import {
Youtube,
Loader2,
Sparkles,
Globe,
Tag,
Clock,
MessageSquare,
Image as ImageIcon,
Copy,
Check,
RefreshCw,
Upload,
Link as LinkIcon,
X
} from 'lucide-react';
interface YouTubeSEOPreviewProps {
isOpen: boolean;
onClose: () => void;
businessInfo: {
businessName: string;
description: string;
categories: string[];
address: string;
language: string;
};
videoPath?: string;
videoDuration?: number;
onUpload?: (seoData: any) => void;
}
interface SEOData {
snippet: {
title_ko: string;
title_en?: string;
description_ko: string;
description_en?: string;
tags_ko: string[];
tags_en?: string[];
hashtags_ko: string[];
categoryId: string;
[key: string]: any;
};
chapters: Array<{
time: string;
title_ko: string;
title_en?: string;
[key: string]: any;
}>;
thumbnail_text: {
short_ko: string;
short_en?: string;
sub_ko: string;
sub_en?: string;
[key: string]: any;
};
pinned_comment_ko: string;
pinned_comment_en?: string;
meta?: any;
[key: string]: any;
}
const YouTubeSEOPreview: React.FC<YouTubeSEOPreviewProps> = ({
isOpen,
onClose,
businessInfo,
videoPath,
videoDuration = 60,
onUpload
}) => {
const { t } = useLanguage();
const { token } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const [seoData, setSeoData] = useState<SEOData | null>(null);
const [bookingUrl, setBookingUrl] = useState('');
const [copied, setCopied] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState('korean');
// 추가 언어 코드 (KO 외)
const additionalLangCode = businessInfo.language !== 'KO' ? businessInfo.language.toLowerCase() : 'en';
useEffect(() => {
if (isOpen && !seoData) {
generateSEO();
}
}, [isOpen]);
const generateSEO = async () => {
setIsGenerating(true);
try {
const response = await fetch('/api/youtube/seo', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
businessName: businessInfo.businessName,
description: businessInfo.description,
categories: businessInfo.categories,
address: businessInfo.address,
bookingUrl: bookingUrl,
videoDuration: videoDuration,
language: businessInfo.language
})
});
if (response.ok) {
const data = await response.json();
setSeoData(data);
} else {
console.error('SEO 생성 실패');
}
} catch (error) {
console.error('SEO 생성 오류:', error);
} finally {
setIsGenerating(false);
}
};
const handleCopy = (text: string, key: string) => {
navigator.clipboard.writeText(text);
setCopied(key);
setTimeout(() => setCopied(null), 2000);
};
const updateSeoField = (path: string, value: any) => {
if (!seoData) return;
const keys = path.split('.');
const newData = { ...seoData };
let current: any = newData;
for (let i = 0; i < keys.length - 1; i++) {
current[keys[i]] = { ...current[keys[i]] };
current = current[keys[i]];
}
current[keys[keys.length - 1]] = value;
setSeoData(newData);
};
const handleUpload = () => {
if (seoData && onUpload) {
onUpload(seoData);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4 overflow-y-auto">
<div className="bg-background rounded-xl max-w-5xl w-full max-h-[95vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-red-500/10">
<Youtube className="w-6 h-6 text-red-500" />
</div>
<div>
<h2 className="text-xl font-bold">YouTube SEO </h2>
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="w-5 h-5" />
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{isGenerating ? (
<div className="flex flex-col items-center justify-center py-20">
<Loader2 className="w-12 h-12 animate-spin text-primary mb-4" />
<p className="text-lg font-medium">AI SEO ...</p>
<p className="text-sm text-muted-foreground"> </p>
</div>
) : seoData ? (
<div className="space-y-6">
{/* Booking URL Input */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<LinkIcon className="w-4 h-4" />
URL
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex gap-2">
<Input
placeholder="https://booking.example.com/pension-name"
value={bookingUrl}
onChange={(e) => setBookingUrl(e.target.value)}
className="flex-1"
/>
<Button variant="outline" onClick={generateSEO}>
<RefreshCw className="w-4 h-4 mr-2" />
</Button>
</div>
<p className="text-xs text-muted-foreground mt-2">
URL
</p>
</CardContent>
</Card>
{/* Language Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="korean" className="flex items-center gap-2">
🇰🇷
</TabsTrigger>
<TabsTrigger value="additional" className="flex items-center gap-2">
<Globe className="w-4 h-4" />
{businessInfo.language === 'EN' ? '🇺🇸 English' :
businessInfo.language === 'JA' ? '🇯🇵 日本語' :
businessInfo.language === 'ZH' ? '🇨🇳 中文' :
businessInfo.language === 'TH' ? '🇹🇭 ไทย' :
businessInfo.language === 'VI' ? '🇻🇳 Tiếng Việt' : '🇺🇸 English'}
</TabsTrigger>
</TabsList>
{/* Korean Tab */}
<TabsContent value="korean" className="space-y-4 mt-4">
{/* Title */}
<div className="space-y-2">
<Label className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{seoData.snippet.title_ko?.length || 0}/100
</span>
</Label>
<div className="flex gap-2">
<Input
value={seoData.snippet.title_ko || ''}
onChange={(e) => updateSeoField('snippet.title_ko', e.target.value)}
maxLength={100}
/>
<Button
variant="ghost"
size="icon"
onClick={() => handleCopy(seoData.snippet.title_ko, 'title_ko')}
>
{copied === 'title_ko' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
</Button>
</div>
</div>
{/* Description */}
<div className="space-y-2">
<Label className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{seoData.snippet.description_ko?.length || 0}/5000
</span>
</Label>
<div className="flex gap-2">
<Textarea
value={seoData.snippet.description_ko || ''}
onChange={(e) => updateSeoField('snippet.description_ko', e.target.value)}
rows={10}
className="font-mono text-sm"
maxLength={5000}
/>
<Button
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => handleCopy(seoData.snippet.description_ko, 'desc_ko')}
>
{copied === 'desc_ko' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
</Button>
</div>
</div>
{/* Tags */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Tag className="w-4 h-4" />
<span className="text-xs text-muted-foreground">
({seoData.snippet.tags_ko?.length || 0})
</span>
</Label>
<div className="flex flex-wrap gap-1.5">
{seoData.snippet.tags_ko?.map((tag, i) => (
<Badge key={i} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
<Input
placeholder="태그 추가 (쉼표로 구분)"
onKeyDown={(e) => {
if (e.key === 'Enter') {
const input = e.currentTarget;
const newTags = input.value.split(',').map(t => t.trim()).filter(t => t);
if (newTags.length > 0) {
updateSeoField('snippet.tags_ko', [...(seoData.snippet.tags_ko || []), ...newTags]);
input.value = '';
}
}
}}
/>
</div>
{/* Hashtags */}
<div className="space-y-2">
<Label></Label>
<div className="flex flex-wrap gap-1.5">
{seoData.snippet.hashtags_ko?.map((tag, i) => (
<Badge key={i} variant="outline" className="text-xs">
{tag}
</Badge>
))}
</div>
</div>
{/* Pinned Comment */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<MessageSquare className="w-4 h-4" />
</Label>
<Textarea
value={seoData.pinned_comment_ko || ''}
onChange={(e) => updateSeoField('pinned_comment_ko', e.target.value)}
rows={4}
/>
</div>
</TabsContent>
{/* Additional Language Tab */}
<TabsContent value="additional" className="space-y-4 mt-4">
{/* Title */}
<div className="space-y-2">
<Label className="flex items-center justify-between">
Title
<span className="text-xs text-muted-foreground">
{seoData.snippet[`title_${additionalLangCode}`]?.length || 0}/100
</span>
</Label>
<div className="flex gap-2">
<Input
value={seoData.snippet[`title_${additionalLangCode}`] || seoData.snippet.title_en || ''}
onChange={(e) => updateSeoField(`snippet.title_${additionalLangCode}`, e.target.value)}
maxLength={100}
/>
<Button
variant="ghost"
size="icon"
onClick={() => handleCopy(seoData.snippet[`title_${additionalLangCode}`] || seoData.snippet.title_en || '', 'title_en')}
>
{copied === 'title_en' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
</Button>
</div>
</div>
{/* Description */}
<div className="space-y-2">
<Label className="flex items-center justify-between">
Description
<span className="text-xs text-muted-foreground">
{seoData.snippet[`description_${additionalLangCode}`]?.length || seoData.snippet.description_en?.length || 0}/5000
</span>
</Label>
<div className="flex gap-2">
<Textarea
value={seoData.snippet[`description_${additionalLangCode}`] || seoData.snippet.description_en || ''}
onChange={(e) => updateSeoField(`snippet.description_${additionalLangCode}`, e.target.value)}
rows={10}
className="font-mono text-sm"
maxLength={5000}
/>
<Button
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => handleCopy(seoData.snippet[`description_${additionalLangCode}`] || seoData.snippet.description_en || '', 'desc_en')}
>
{copied === 'desc_en' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
</Button>
</div>
</div>
{/* Tags */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Tag className="w-4 h-4" />
Tags
</Label>
<div className="flex flex-wrap gap-1.5">
{(seoData.snippet[`tags_${additionalLangCode}`] || seoData.snippet.tags_en)?.map((tag: string, i: number) => (
<Badge key={i} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
</div>
{/* Pinned Comment */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<MessageSquare className="w-4 h-4" />
Pinned Comment
</Label>
<Textarea
value={seoData[`pinned_comment_${additionalLangCode}`] || seoData.pinned_comment_en || ''}
onChange={(e) => updateSeoField(`pinned_comment_${additionalLangCode}`, e.target.value)}
rows={4}
/>
</div>
</TabsContent>
</Tabs>
{/* Chapters */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Clock className="w-4 h-4" />
()
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{seoData.chapters?.map((chapter, i) => (
<div key={i} className="flex items-center gap-3 text-sm">
<span className="font-mono text-primary w-12">{chapter.time}</span>
<span className="flex-1">{chapter.title_ko}</span>
<span className="text-muted-foreground">
{chapter[`title_${additionalLangCode}`] || chapter.title_en}
</span>
</div>
))}
</div>
</CardContent>
</Card>
{/* Thumbnail Text */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<ImageIcon className="w-4 h-4" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs text-muted-foreground"> </Label>
<p className="font-bold">{seoData.thumbnail_text?.short_ko}</p>
<p className="text-sm text-muted-foreground">
{seoData.thumbnail_text?.[`short_${additionalLangCode}`] || seoData.thumbnail_text?.short_en}
</p>
</div>
<div>
<Label className="text-xs text-muted-foreground"> </Label>
<p className="font-medium">{seoData.thumbnail_text?.sub_ko}</p>
<p className="text-sm text-muted-foreground">
{seoData.thumbnail_text?.[`sub_${additionalLangCode}`] || seoData.thumbnail_text?.sub_en}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
) : (
<div className="flex flex-col items-center justify-center py-20">
<Sparkles className="w-12 h-12 text-primary mb-4" />
<p className="text-lg font-medium">SEO </p>
<Button className="mt-4" onClick={generateSEO}>
<Sparkles className="w-4 h-4 mr-2" />
AI
</Button>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t bg-muted/30">
<Button variant="outline" onClick={generateSEO} disabled={isGenerating}>
<RefreshCw className={`w-4 h-4 mr-2 ${isGenerating ? 'animate-spin' : ''}`} />
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={onClose}>
</Button>
<Button
onClick={handleUpload}
disabled={!seoData || isLoading}
className="bg-red-600 hover:bg-red-700"
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
<>
<Upload className="w-4 h-4 mr-2" />
YouTube
</>
)}
</Button>
</div>
</div>
</div>
</div>
);
};
export default YouTubeSEOPreview;

View File

@ -0,0 +1,174 @@
import React from 'react';
import {
LayoutDashboard,
Plus,
FolderOpen,
Image,
User,
Moon,
Sun,
LogOut,
Sparkles,
CreditCard,
Settings,
Building2
} from 'lucide-react';
import { useTheme } from '../../contexts/ThemeContext';
import { useLanguage } from '../../contexts/LanguageContext';
import { useAuth } from '../../contexts/AuthContext';
import { cn } from '../../lib/utils';
import { Button } from '../ui/button';
import { Switch } from '../ui/switch';
import { Progress } from '../ui/progress';
export type ViewType = 'dashboard' | 'new-project' | 'library' | 'assets' | 'pensions' | 'account' | 'settings';
interface SidebarProps {
currentView: ViewType;
onViewChange: (view: ViewType) => void;
libraryCount?: number;
}
interface NavItemProps {
icon: React.ReactNode;
label: string;
active?: boolean;
onClick?: () => void;
}
const NavItem: React.FC<NavItemProps> = ({ icon, label, active, onClick }) => {
return (
<button
onClick={onClick}
className={cn(
"w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-all duration-200",
active
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
<span className={cn("w-5 h-5", active && "text-primary-foreground")}>
{icon}
</span>
<span>{label}</span>
</button>
);
};
const Sidebar: React.FC<SidebarProps> = ({
currentView,
onViewChange,
libraryCount = 0
}) => {
const { theme, toggleTheme } = useTheme();
const { t } = useLanguage();
const { user, logout } = useAuth();
// Mock credits - replace with actual user data
const credits = 850;
const maxCredits = 1000;
const creditPercentage = (credits / maxCredits) * 100;
return (
<aside className="w-64 h-screen bg-card border-r border-border flex flex-col shrink-0">
{/* Logo */}
<div className="p-5">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-primary to-accent flex items-center justify-center">
<Sparkles className="w-5 h-5 text-white" />
</div>
<span className="text-lg font-bold text-foreground">CastAD Pro</span>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 px-3 py-2 space-y-1">
<NavItem
icon={<LayoutDashboard className="w-5 h-5" />}
label={user?.name || user?.username || t('dashWelcome').replace('!', '')}
active={currentView === 'dashboard'}
onClick={() => onViewChange('dashboard')}
/>
<NavItem
icon={<Plus className="w-5 h-5" />}
label={t('dashNewProject')}
active={currentView === 'new-project'}
onClick={() => onViewChange('new-project')}
/>
<NavItem
icon={<FolderOpen className="w-5 h-5" />}
label={t('libTitle')}
active={currentView === 'library'}
onClick={() => onViewChange('library')}
/>
<NavItem
icon={<Image className="w-5 h-5" />}
label={t('dashAssetManage')}
active={currentView === 'assets'}
onClick={() => onViewChange('assets')}
/>
<NavItem
icon={<Building2 className="w-5 h-5" />}
label={t('sidebarMyPensions')}
active={currentView === 'pensions'}
onClick={() => onViewChange('pensions')}
/>
<NavItem
icon={<User className="w-5 h-5" />}
label={t('accountTitle')}
active={currentView === 'account'}
onClick={() => onViewChange('account')}
/>
<NavItem
icon={<Settings className="w-5 h-5" />}
label={t('settingsTitle')}
active={currentView === 'settings'}
onClick={() => onViewChange('settings')}
/>
</nav>
{/* Bottom Section */}
<div className="p-4 space-y-4">
{/* Dark Mode Toggle */}
<div className="flex items-center justify-between px-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{theme === 'dark' ? (
<Moon className="w-4 h-4" />
) : (
<Sun className="w-4 h-4" />
)}
<span>{theme === 'dark' ? t('accountThemeDark') : t('accountThemeLight')}</span>
</div>
<Switch
checked={theme === 'dark'}
onCheckedChange={toggleTheme}
/>
</div>
{/* Credits */}
<div className="bg-muted/50 rounded-xl p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">CREDITS</span>
<span className="text-sm font-bold text-foreground">{credits} / {maxCredits}</span>
</div>
<Progress value={creditPercentage} className="h-2" />
<Button variant="outline" className="w-full" size="sm">
<CreditCard className="w-4 h-4 mr-2" />
{t('accountUpgrade')}
</Button>
</div>
{/* Logout */}
<button
onClick={logout}
className="w-full flex items-center gap-3 px-4 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<LogOut className="w-4 h-4" />
<span>{t('authLogout')}</span>
</button>
</div>
</aside>
);
};
export default Sidebar;

View File

@ -0,0 +1,46 @@
import React from 'react';
import { LucideIcon } from 'lucide-react';
import { cn } from '../../lib/utils';
import { Badge } from '../ui/badge';
interface SidebarItemProps {
icon: LucideIcon;
label: string;
active?: boolean;
badge?: number | string;
onClick?: () => void;
}
const SidebarItem: React.FC<SidebarItemProps> = ({
icon: Icon,
label,
active = false,
badge,
onClick
}) => {
return (
<button
onClick={onClick}
className={cn(
"w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200",
"hover:bg-accent/10",
active
? "bg-primary/10 text-primary border border-primary/20"
: "text-muted-foreground hover:text-foreground"
)}
>
<Icon className={cn("w-5 h-5", active && "text-primary")} />
<span className="flex-1 text-left">{label}</span>
{badge !== undefined && (
<Badge
variant={active ? "default" : "secondary"}
className="text-xs px-1.5 py-0.5 min-w-[20px] justify-center"
>
{badge}
</Badge>
)}
</button>
);
};
export default SidebarItem;

View File

@ -0,0 +1,80 @@
import React from 'react';
import { ChevronDown, User } from 'lucide-react';
import { useLanguage } from '../../contexts/LanguageContext';
import { useAuth } from '../../contexts/AuthContext';
import { Language } from '../../../types';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../ui/select';
interface TopHeaderProps {
breadcrumb?: string;
title?: string;
}
const LANGUAGES: { value: Language; label: string; flag: string }[] = [
{ value: 'KO', label: '한국어', flag: 'KR' },
{ value: 'EN', label: 'English', flag: 'EN' },
{ value: 'JA', label: '日本語', flag: 'JP' },
{ value: 'ZH', label: '中文', flag: 'CN' },
{ value: 'TH', label: 'ภาษาไทย', flag: 'TH' },
{ value: 'VI', label: 'Tiếng Việt', flag: 'VN' },
];
const TopHeader: React.FC<TopHeaderProps> = ({ breadcrumb, title }) => {
const { language, setLanguage } = useLanguage();
const { user } = useAuth();
const currentLang = LANGUAGES.find(l => l.value === language) || LANGUAGES[0];
return (
<header className="h-16 border-b border-border bg-card/50 backdrop-blur-sm flex items-center justify-between px-6">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm">
{breadcrumb && (
<>
<span className="text-muted-foreground">{breadcrumb}</span>
<span className="text-muted-foreground">/</span>
</>
)}
{title && (
<span className="font-medium text-foreground">{title}</span>
)}
</div>
{/* Right Section */}
<div className="flex items-center gap-4">
{/* Language Selector */}
<Select value={language} onValueChange={(v) => setLanguage(v as Language)}>
<SelectTrigger className="w-[130px] h-9 bg-muted/50 border-0">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-muted-foreground">{currentLang.flag}</span>
<SelectValue>{currentLang.label}</SelectValue>
</div>
</SelectTrigger>
<SelectContent>
{LANGUAGES.map((lang) => (
<SelectItem key={lang.value} value={lang.value}>
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-muted-foreground">{lang.flag}</span>
<span>{lang.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{/* User Avatar */}
<div className="w-9 h-9 rounded-full bg-primary/20 flex items-center justify-center text-sm font-bold text-primary">
{user?.name?.charAt(0).toUpperCase() || 'U'}
</div>
</div>
</header>
);
};
export default TopHeader;

View File

@ -0,0 +1,139 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/src/lib/utils"
import { buttonVariants } from "@/src/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/src/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/src/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/src/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 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 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
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",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/src/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/src/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("grid place-content-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@ -0,0 +1,11 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@ -0,0 +1,120 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/src/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@ -0,0 +1,198 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/src/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/src/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/src/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@ -0,0 +1,26 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/src/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@ -0,0 +1,42 @@
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/src/lib/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/src/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/src/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/src/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

140
src/components/ui/sheet.tsx Normal file
View File

@ -0,0 +1,140 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/src/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@ -0,0 +1,15 @@
import { cn } from "@/src/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/src/lib/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background 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" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

View File

@ -0,0 +1,43 @@
import {
CircleCheck,
Info,
LoaderCircle,
OctagonX,
TriangleAlert,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheck className="h-4 w-4" />,
info: <Info className="h-4 w-4" />,
warning: <TriangleAlert className="h-4 w-4" />,
error: <OctagonX className="h-4 w-4" />,
loading: <LoaderCircle className="h-4 w-4 animate-spin" />,
}}
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
)
}
export { Toaster }

View File

@ -0,0 +1,27 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/src/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@ -0,0 +1,53 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/src/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/src/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }

View File

@ -0,0 +1,28 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/src/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@ -0,0 +1,71 @@
import React from 'react';
import { cn } from '../../lib/utils';
import { Check } from 'lucide-react';
interface Step {
number: number;
title: string;
}
interface StepIndicatorProps {
steps: Step[];
currentStep: number;
}
const StepIndicator: React.FC<StepIndicatorProps> = ({ steps, currentStep }) => {
return (
<div className="flex items-center justify-between w-full max-w-3xl mx-auto px-4">
{steps.map((step, index) => {
const isCompleted = currentStep > step.number;
const isCurrent = currentStep === step.number;
const isLast = index === steps.length - 1;
return (
<React.Fragment key={step.number}>
<div className="flex flex-col items-center">
{/* Circle */}
<div
className={cn(
"w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold transition-all",
isCompleted && "bg-primary text-primary-foreground",
isCurrent && "bg-primary text-primary-foreground ring-4 ring-primary/20",
!isCompleted && !isCurrent && "bg-muted text-muted-foreground"
)}
>
{isCompleted ? (
<Check className="w-5 h-5" />
) : (
step.number
)}
</div>
{/* Title */}
<span
className={cn(
"mt-2 text-xs font-medium text-center",
isCurrent && "text-foreground",
!isCurrent && "text-muted-foreground"
)}
>
{step.title}
</span>
</div>
{/* Connector Line */}
{!isLast && (
<div className="flex-1 h-0.5 mx-2 -mt-6">
<div
className={cn(
"h-full transition-all",
isCompleted ? "bg-primary" : "bg-muted"
)}
/>
</div>
)}
</React.Fragment>
);
})}
</div>
);
};
export default StepIndicator;

View File

@ -0,0 +1,58 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { User } from '../../types';
interface AuthContextType {
user: User | null;
token: string | null;
login: (token: string, user: User) => void;
logout: () => void;
isAdmin: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(localStorage.getItem('token'));
useEffect(() => {
// 앱 시작 시 로컬 스토리지에서 토큰 복원 및 사용자 정보 확인
const storedUser = localStorage.getItem('user');
if (token && storedUser) {
try {
setUser(JSON.parse(storedUser));
} catch (e) {
console.error("Failed to parse user from local storage", e);
localStorage.removeItem('user');
}
}
}, [token]);
const login = useCallback((newToken: string, newUser: User) => {
localStorage.setItem('token', newToken);
localStorage.setItem('user', JSON.stringify(newUser));
setToken(newToken);
setUser(newUser);
}, []);
const logout = useCallback(() => {
localStorage.removeItem('token');
localStorage.removeItem('user');
setToken(null);
setUser(null);
}, []);
const isAdmin = user?.role === 'admin';
return (
<AuthContext.Provider value={{ user, token, login, logout, isAdmin }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth must be used within an AuthProvider');
return context;
};

View File

@ -0,0 +1,33 @@
import React, { createContext, useState, useContext, ReactNode } from 'react';
import { Language } from '../../types';
import { TRANSLATIONS } from '../locales';
interface LanguageContextType {
language: Language;
setLanguage: (lang: Language) => void;
t: (key: keyof typeof TRANSLATIONS['KO']) => string;
}
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
export const LanguageProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [language, setLanguage] = useState<Language>('KO');
const t = (key: keyof typeof TRANSLATIONS['KO']) => {
return TRANSLATIONS[language][key] || TRANSLATIONS['KO'][key] || key;
};
return (
<LanguageContext.Provider value={{ language, setLanguage, t }}>
{children}
</LanguageContext.Provider>
);
};
export const useLanguage = () => {
const context = useContext(LanguageContext);
if (!context) {
throw new Error('useLanguage must be used within a LanguageProvider');
}
return context;
};

View File

@ -0,0 +1,117 @@
import React, { createContext, useState, useContext, useEffect, ReactNode } from 'react';
export type Theme = 'dark' | 'light';
export type ColorPalette = 'purple' | 'blue' | 'green' | 'orange' | 'rose';
export interface PaletteInfo {
name: string;
primary: string;
accent: string;
preview: string;
}
export const PALETTES: Record<ColorPalette, PaletteInfo> = {
purple: {
name: '퍼플',
primary: '#8B5CF6',
accent: '#06B6D4',
preview: 'from-purple-500 to-cyan-500'
},
blue: {
name: '블루',
primary: '#3B82F6',
accent: '#8B5CF6',
preview: 'from-blue-500 to-purple-500'
},
green: {
name: '그린',
primary: '#10B981',
accent: '#3B82F6',
preview: 'from-emerald-500 to-blue-500'
},
orange: {
name: '오렌지',
primary: '#F97316',
accent: '#EAB308',
preview: 'from-orange-500 to-yellow-500'
},
rose: {
name: '로즈',
primary: '#F43F5E',
accent: '#EC4899',
preview: 'from-rose-500 to-pink-500'
}
};
interface ThemeContextType {
theme: Theme;
palette: ColorPalette;
setTheme: (theme: Theme) => void;
setPalette: (palette: ColorPalette) => void;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
// Safe localStorage access
const getStoredValue = <T extends string>(key: string, defaultValue: T): T => {
if (typeof window === 'undefined') return defaultValue;
try {
const stored = localStorage.getItem(key);
return (stored as T) || defaultValue;
} catch {
return defaultValue;
}
};
export const ThemeProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [theme, setTheme] = useState<Theme>(() => getStoredValue('castad-theme', 'dark'));
const [palette, setPalette] = useState<ColorPalette>(() => getStoredValue('castad-palette', 'purple'));
useEffect(() => {
if (typeof window === 'undefined') return;
try {
localStorage.setItem('castad-theme', theme);
} catch {}
const root = document.documentElement;
if (theme === 'dark') {
root.classList.add('dark');
root.classList.remove('light');
} else {
root.classList.add('light');
root.classList.remove('dark');
}
}, [theme]);
useEffect(() => {
if (typeof window === 'undefined') return;
try {
localStorage.setItem('castad-palette', palette);
} catch {}
const root = document.documentElement;
// Remove all palette classes
root.classList.remove('palette-purple', 'palette-blue', 'palette-green', 'palette-orange', 'palette-rose');
// Add current palette class
root.classList.add(`palette-${palette}`);
}, [palette]);
const toggleTheme = () => {
setTheme(prev => prev === 'dark' ? 'light' : 'dark');
};
return (
<ThemeContext.Provider value={{ theme, palette, setTheme, setPalette, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
};
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};

6
src/lib/utils.ts Normal file
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))
}

2936
src/locales.ts Normal file

File diff suppressed because it is too large Load Diff

1751
src/pages/AdminDashboard.tsx Normal file

File diff suppressed because it is too large Load Diff

597
src/pages/BrandPage.tsx Normal file
View File

@ -0,0 +1,597 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Button } from '../components/ui/button';
import { Card, CardContent } from '../components/ui/card';
import { Badge } from '../components/ui/badge';
import { Separator } from '../components/ui/separator';
import { CaStADLogo, CaStADLogoInline } from '../../components/CaStADLogo';
import {
ArrowRight,
Sparkles,
Heart,
Zap,
Target,
Lightbulb,
Users,
Globe,
TrendingUp,
Award,
Star,
CheckCircle,
Play,
Instagram,
Youtube,
MessageCircle,
Building,
Rocket,
Shield,
Clock,
ChefHat,
Flame,
Thermometer
} from 'lucide-react';
const BrandPage: React.FC = () => {
return (
<div className="min-h-screen bg-background">
{/* ==================== HERO - Brand Introduction ==================== */}
<section className="relative min-h-[90vh] flex items-center justify-center overflow-hidden">
{/* Animated Background - Custard cream colors */}
<div className="absolute inset-0 bg-gradient-to-br from-amber-100/20 via-yellow-50/10 to-orange-100/20 dark:from-amber-950/20 dark:via-yellow-950/10 dark:to-orange-950/20" />
<div className="absolute top-20 left-20 w-96 h-96 bg-amber-400/15 rounded-full blur-3xl animate-pulse" />
<div className="absolute bottom-20 right-20 w-96 h-96 bg-yellow-300/10 rounded-full blur-3xl animate-pulse" style={{ animationDelay: '1s' }} />
{/* Floating Custard Decoration */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
{[...Array(6)].map((_, i) => (
<div
key={i}
className="absolute text-5xl opacity-10"
style={{
left: `${10 + (i * 15)}%`,
top: `${20 + (i % 3) * 25}%`,
animation: `float-custard ${4 + i * 0.5}s ease-in-out infinite`,
animationDelay: `${i * 0.3}s`,
}}
>
🍮
</div>
))}
</div>
<div className="relative z-10 max-w-5xl mx-auto px-4 text-center">
{/* Logo with Animation */}
<div className="mb-8 flex justify-center">
<div className="relative">
<div className="absolute inset-0 bg-amber-400/20 blur-3xl rounded-full scale-150" />
<CaStADLogo size="xl" className="relative z-10" />
</div>
</div>
<Badge variant="outline" className="mb-6 px-6 py-2 text-sm font-medium border-amber-500/50 bg-amber-500/10 backdrop-blur-sm">
<span className="text-2xl mr-2">🍮</span>
</Badge>
<h1 className="text-5xl md:text-7xl font-bold mb-6 leading-tight">
<span className="text-foreground"></span>
<br />
<span className="bg-gradient-to-r from-amber-600 via-amber-500 to-yellow-500 bg-clip-text text-transparent">
</span>
</h1>
<p className="text-xl md:text-2xl text-muted-foreground mb-8 max-w-3xl mx-auto leading-relaxed">
<strong className="text-foreground">CaStAD()</strong>
<span className="text-amber-600 font-semibold"></span>,
<span className="text-amber-600 font-semibold"> </span>
.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button size="lg" className="gap-2 h-14 px-8 bg-gradient-to-r from-amber-600 to-amber-500 hover:from-amber-700 hover:to-amber-600 shadow-lg shadow-amber-500/25" asChild>
<Link to="/register">
<Zap className="w-5 h-5" />
<ArrowRight className="w-4 h-4" />
</Link>
</Button>
<Button size="lg" variant="outline" className="gap-2 h-14 px-8 border-amber-500/30 hover:bg-amber-500/10" asChild>
<Link to="/">
<Play className="w-5 h-5" />
</Link>
</Button>
</div>
</div>
{/* Scroll Indicator */}
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 animate-bounce">
<div className="w-6 h-10 rounded-full border-2 border-amber-500/50 flex items-start justify-center p-2">
<div className="w-1.5 h-3 bg-amber-500 rounded-full animate-pulse" />
</div>
</div>
</section>
{/* ==================== Brand Story ==================== */}
<section className="py-20 px-4 bg-gradient-to-b from-card/50 to-background relative overflow-hidden">
<div className="max-w-5xl mx-auto">
<div className="text-center mb-16">
<Badge variant="secondary" className="mb-4 px-4 py-1.5">
<Lightbulb className="w-4 h-4 mr-2" />
Brand Story
</Badge>
<h2 className="text-3xl md:text-5xl font-bold mb-6">
<span className="text-amber-500"></span>?
</h2>
</div>
<div className="grid md:grid-cols-2 gap-12 items-center">
{/* Left - Custard Philosophy */}
<div className="space-y-8">
<div className="flex gap-4">
<div className="text-5xl">🍮</div>
<div>
<h3 className="text-xl font-bold mb-2 text-amber-600"> </h3>
<p className="text-muted-foreground">
3~6 .
CaStAD AI .
</p>
</div>
</div>
<div className="flex gap-4">
<div className="text-5xl">🥚</div>
<div>
<h3 className="text-xl font-bold mb-2 text-yellow-600"> </h3>
<p className="text-muted-foreground">
, , .
Google Gemini AI + Suno AI + = .
</p>
</div>
</div>
<div className="flex gap-4">
<div className="text-5xl"></div>
<div>
<h3 className="text-xl font-bold mb-2 text-orange-600"> </h3>
<p className="text-muted-foreground">
.
CaStAD .
</p>
</div>
</div>
</div>
{/* Right - Visual */}
<div className="relative">
<div className="absolute inset-0 bg-gradient-to-br from-amber-400/20 to-yellow-500/20 rounded-3xl blur-2xl" />
<Card className="relative bg-gradient-to-br from-amber-50 to-yellow-50 dark:from-amber-950/30 dark:to-yellow-950/30 border-amber-200/50 dark:border-amber-800/30 overflow-hidden">
<CardContent className="p-8">
<div className="flex justify-center mb-6">
<div className="relative">
<div className="text-9xl">🍮</div>
{/* Steam animation */}
<div className="absolute -top-4 left-1/2 -translate-x-1/2 flex gap-2">
{[0, 1, 2].map((i) => (
<div
key={i}
className="w-1 h-6 bg-gradient-to-t from-amber-400/60 to-transparent rounded-full"
style={{
animation: `steam ${1.5 + i * 0.3}s ease-in-out infinite`,
animationDelay: `${i * 0.2}s`
}}
/>
))}
</div>
</div>
</div>
<div className="text-center">
<p className="text-2xl font-bold bg-gradient-to-r from-amber-600 to-yellow-600 bg-clip-text text-transparent mb-2">
CaStAD = Custard
</p>
<p className="text-lg text-muted-foreground">
Cast() + AD() =
</p>
<Separator className="my-4" />
<p className="text-sm text-muted-foreground italic">
" ,<br />
"
</p>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</section>
{/* ==================== The Recipe (Process) ==================== */}
<section className="py-20 px-4 bg-gradient-to-br from-amber-50/30 via-background to-yellow-50/30 dark:from-amber-950/10 dark:to-yellow-950/10">
<div className="max-w-5xl mx-auto">
<div className="text-center mb-16">
<Badge variant="secondary" className="mb-4 px-4 py-1.5">
<ChefHat className="w-4 h-4 mr-2" />
The Recipe
</Badge>
<h2 className="text-3xl md:text-4xl font-bold mb-4">
CaStAD <span className="text-amber-500"> </span>
</h2>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
,
</p>
</div>
<div className="grid md:grid-cols-4 gap-6">
{[
{ step: '01', icon: '🥚', title: '재료 준비', desc: 'URL 입력만으로 펜션 정보 자동 수집', detail: '달걀을 깨듯 간단하게' },
{ step: '02', icon: '🥛', title: '재료 혼합', desc: 'AI가 광고 카피, 음악, 이미지 생성', detail: '재료들이 하나로 섞이듯' },
{ step: '03', icon: '🔥', title: '정교한 조리', desc: '2분 만에 프로급 영상 렌더링', detail: '적절한 온도로 익히듯' },
{ step: '04', icon: '🍮', title: '완성!', desc: 'YouTube, TikTok, Instagram 자동 업로드', detail: '완벽한 커스터드 완성' },
].map((item, idx) => (
<Card key={idx} className="group relative overflow-hidden hover:shadow-xl hover:-translate-y-2 transition-all duration-300 bg-gradient-to-b from-background to-amber-50/30 dark:to-amber-950/20">
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-amber-500 to-yellow-500" />
<CardContent className="p-6 text-center">
<div className="text-xs font-bold text-amber-500 mb-2">STEP {item.step}</div>
<div className="text-5xl mb-4 group-hover:scale-110 transition-transform">{item.icon}</div>
<h3 className="font-bold text-lg mb-2">{item.title}</h3>
<p className="text-sm text-muted-foreground mb-3">{item.desc}</p>
<Badge variant="outline" className="text-xs border-amber-300 text-amber-600">
{item.detail}
</Badge>
</CardContent>
</Card>
))}
</div>
</div>
</section>
{/* ==================== Brand Values ==================== */}
<section className="py-20 px-4">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-16">
<Badge variant="secondary" className="mb-4 px-4 py-1.5">
<Heart className="w-4 h-4 mr-2" />
Core Values
</Badge>
<h2 className="text-3xl md:text-4xl font-bold mb-4">
CaStAD <span className="text-primary"></span>
</h2>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
,
</p>
</div>
<div className="grid md:grid-cols-3 gap-6">
{[
{
icon: Thermometer,
emoji: '🥚',
ingredient: '달걀',
title: '정밀함 (Precision)',
desc: '커스터드의 달걀처럼 핵심 역할. AI가 3~6도의 온도 조절하듯 정밀하게 최적의 콘텐츠를 생성합니다.',
color: 'from-amber-500 to-yellow-500',
stat: '99%',
statLabel: '정확도'
},
{
icon: Flame,
emoji: '🥛',
ingredient: '우유',
title: '부드러움 (Smoothness)',
desc: '우유가 커스터드를 부드럽게 만들듯, 자연스럽고 매끄러운 영상으로 고객의 마음을 사로잡습니다.',
color: 'from-yellow-500 to-orange-500',
stat: '+340%',
statLabel: '예약 증가'
},
{
icon: Sparkles,
emoji: '🍬',
ingredient: '설탕',
title: '달콤함 (Sweetness)',
desc: '설탕이 달콤함을 더하듯, 달콤한 성과와 만족스러운 결과를 약속합니다.',
color: 'from-orange-500 to-red-500',
stat: '98%',
statLabel: '만족도'
}
].map((value, idx) => (
<Card key={idx} className="group relative overflow-hidden hover:shadow-xl hover:-translate-y-2 transition-all duration-300">
<div className={`absolute top-0 left-0 right-0 h-1 bg-gradient-to-r ${value.color}`} />
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className={`w-14 h-14 rounded-2xl bg-gradient-to-br ${value.color} flex items-center justify-center shadow-lg group-hover:scale-110 transition-transform`}>
<span className="text-2xl">{value.emoji}</span>
</div>
<div>
<p className="text-xs text-muted-foreground">{value.ingredient}</p>
<h3 className="text-lg font-bold">{value.title}</h3>
</div>
</div>
<p className="text-muted-foreground text-sm mb-4">{value.desc}</p>
<div className="pt-4 border-t border-border">
<div className={`text-2xl font-bold bg-gradient-to-r ${value.color} bg-clip-text text-transparent`}>
{value.stat}
</div>
<div className="text-sm text-muted-foreground">{value.statLabel}</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
</section>
{/* ==================== Vision & Mission ==================== */}
<section className="py-20 px-4 bg-gradient-to-br from-amber-500/5 via-yellow-500/5 to-orange-500/5">
<div className="max-w-5xl mx-auto">
<div className="grid md:grid-cols-2 gap-8">
{/* Vision */}
<Card className="bg-gradient-to-br from-amber-500/10 to-yellow-500/10 border-amber-500/20 overflow-hidden">
<CardContent className="p-8">
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 rounded-xl bg-amber-500/20 flex items-center justify-center">
<Target className="w-6 h-6 text-amber-600" />
</div>
<div>
<Badge variant="outline" className="border-amber-500/30 text-amber-600 text-xs">VISION</Badge>
<h3 className="text-xl font-bold"> </h3>
</div>
</div>
<p className="text-2xl font-bold mb-4 leading-relaxed">
<span className="text-amber-600"> </span>
</p>
<p className="text-muted-foreground leading-relaxed">
,
.
,
.
</p>
</CardContent>
</Card>
{/* Mission */}
<Card className="bg-gradient-to-br from-yellow-500/10 to-orange-500/10 border-yellow-500/20 overflow-hidden">
<CardContent className="p-8">
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 rounded-xl bg-yellow-500/20 flex items-center justify-center">
<Rocket className="w-6 h-6 text-yellow-600" />
</div>
<div>
<Badge variant="outline" className="border-yellow-500/30 text-yellow-600 text-xs">MISSION</Badge>
<h3 className="text-xl font-bold"> </h3>
</div>
</div>
<p className="text-2xl font-bold mb-4 leading-relaxed">
<span className="text-yellow-600">2</span>
</p>
<p className="text-muted-foreground leading-relaxed">
, , ...
.
AI
.
</p>
</CardContent>
</Card>
</div>
</div>
</section>
{/* ==================== Brand Identity ==================== */}
<section className="py-20 px-4">
<div className="max-w-5xl mx-auto">
<div className="text-center mb-16">
<Badge variant="secondary" className="mb-4 px-4 py-1.5">
<Award className="w-4 h-4 mr-2" />
Brand Identity
</Badge>
<h2 className="text-3xl md:text-4xl font-bold mb-4">
CaStAD <span className="text-amber-500"></span>
</h2>
</div>
<div className="grid md:grid-cols-3 gap-8 mb-12">
{/* Logo Variations */}
<Card className="text-center p-8">
<div className="mb-4 flex justify-center">
<CaStADLogo size="lg" />
</div>
<h4 className="font-bold mb-2">Primary Logo</h4>
<p className="text-sm text-muted-foreground"> - </p>
</Card>
<Card className="text-center p-8">
<div className="mb-4 flex justify-center">
<CaStADLogoInline size="lg" />
</div>
<h4 className="font-bold mb-2">Inline Logo</h4>
<p className="text-sm text-muted-foreground"> - </p>
</Card>
<Card className="text-center p-8">
<div className="mb-4 flex justify-center items-center h-20">
<div className="text-6xl">🍮</div>
</div>
<h4 className="font-bold mb-2">Symbol</h4>
<p className="text-sm text-muted-foreground"> - </p>
</Card>
</div>
{/* Color Palette */}
<Card className="p-8">
<h4 className="font-bold mb-6 text-center text-lg">Color Palette - </h4>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
{[
{ name: 'Custard Cream', color: '#FFF5E1', text: 'dark', desc: '크림 베이스' },
{ name: 'Caramel Gold', color: '#D4A056', text: 'dark', desc: '캐러멜 토핑' },
{ name: 'Egg Yolk', color: '#F5C97A', text: 'dark', desc: '달걀 노른자' },
{ name: 'Warm Amber', color: '#B8860B', text: 'light', desc: '따뜻한 앰버' },
{ name: 'Ramekin Brown', color: '#6B4423', text: 'light', desc: '라메킨 그릇' },
].map((c, idx) => (
<div key={idx} className="text-center">
<div
className="w-full h-20 rounded-xl mb-2 flex flex-col items-center justify-center shadow-lg"
style={{ backgroundColor: c.color }}
>
<span className={`text-xs font-mono ${c.text === 'light' ? 'text-white' : 'text-gray-800'}`}>
{c.color}
</span>
</div>
<p className="text-sm font-medium">{c.name}</p>
<p className="text-xs text-muted-foreground">{c.desc}</p>
</div>
))}
</div>
</Card>
</div>
</section>
{/* ==================== Why Choose Us ==================== */}
<section className="py-20 px-4 bg-card/30">
<div className="max-w-5xl mx-auto">
<div className="text-center mb-16">
<Badge variant="secondary" className="mb-4 px-4 py-1.5">
<Star className="w-4 h-4 mr-2" />
Why CaStAD
</Badge>
<h2 className="text-3xl md:text-4xl font-bold mb-4">
<span className="text-primary">CaStAD</span> ?
</h2>
</div>
<div className="grid md:grid-cols-2 gap-6">
{[
{
icon: Sparkles,
title: 'AI가 만드는 프로급 영상',
desc: 'Google Gemini와 Suno AI가 만드는 광고 카피, 음악, 영상. 전문가 수준의 결과물을 2분 만에.'
},
{
icon: Globe,
title: '6개국 언어 자동 지원',
desc: '한국어로 만들면 영어, 일본어, 중국어, 태국어, 베트남어로 자동 번역. 글로벌 관광객을 잡으세요.'
},
{
icon: Instagram,
title: '원클릭 SNS 업로드',
desc: 'YouTube, TikTok, Instagram에 버튼 하나로 업로드. 해시태그, 설명까지 AI가 최적화.'
},
{
icon: Building,
title: '다중 펜션 관리',
desc: '여러 펜션을 하나의 계정으로 관리. 각 펜션별 영상, 통계, 채널 분리 관리 가능.'
},
{
icon: TrendingUp,
title: '실시간 성과 분석',
desc: '조회수, 좋아요, 예약 전환율까지. 어떤 콘텐츠가 효과적인지 한눈에 파악.'
},
{
icon: Users,
title: '500+ 펜션의 선택',
desc: '이미 500개 이상의 펜션이 CaStAD와 함께합니다. 평균 340% 예약 증가 실현.'
}
].map((item, idx) => (
<div key={idx} className="flex gap-4 p-4 rounded-xl hover:bg-card transition-colors">
<div className="w-12 h-12 rounded-xl bg-amber-500/10 flex items-center justify-center shrink-0">
<item.icon className="w-6 h-6 text-amber-600" />
</div>
<div>
<h4 className="font-bold mb-1">{item.title}</h4>
<p className="text-sm text-muted-foreground">{item.desc}</p>
</div>
</div>
))}
</div>
</div>
</section>
{/* ==================== CTA ==================== */}
<section className="py-20 px-4">
<div className="max-w-4xl mx-auto">
<Card className="bg-gradient-to-br from-amber-600 via-amber-500 to-yellow-500 border-0 text-white overflow-hidden relative">
{/* Decorative Elements */}
<div className="absolute inset-0 overflow-hidden">
{[...Array(6)].map((_, i) => (
<div
key={i}
className="absolute text-white/10 text-8xl"
style={{
left: `${i * 18}%`,
top: `${(i % 2) * 40 + 10}%`,
transform: `rotate(${-20 + i * 10}deg)`,
}}
>
🍮
</div>
))}
</div>
<CardContent className="relative p-10 md:p-16 text-center">
<div className="text-6xl mb-6">🍮</div>
<h2 className="text-3xl md:text-4xl font-bold mb-4">
, ?
</h2>
<p className="text-lg text-white/90 mb-8 max-w-xl mx-auto">
.
.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button size="lg" variant="secondary" className="gap-2 h-14 px-8 bg-white text-amber-600 hover:bg-white/90" asChild>
<Link to="/register">
<Zap className="w-5 h-5" />
<ArrowRight className="w-4 h-4" />
</Link>
</Button>
<Button size="lg" variant="outline" className="gap-2 h-14 px-8 border-white/30 bg-transparent text-white hover:bg-white/10" asChild>
<Link to="/"> </Link>
</Button>
</div>
</CardContent>
</Card>
</div>
</section>
{/* ==================== FOOTER ==================== */}
<footer className="py-12 px-4 border-t border-border bg-card/30">
<div className="max-w-6xl mx-auto">
<div className="flex flex-col items-center justify-center text-center">
<CaStADLogoInline size="md" className="mb-4" />
<p className="text-muted-foreground text-sm mb-4">
AI -
</p>
<p className="text-xs text-muted-foreground">
© 2025 CaStAD. All rights reserved.
</p>
</div>
</div>
</footer>
{/* CSS Animations */}
<style>{`
@keyframes float-custard {
0%, 100% {
transform: translateY(0) rotate(0deg);
}
50% {
transform: translateY(-15px) rotate(5deg);
}
}
@keyframes steam {
0%, 100% {
opacity: 0.3;
transform: translateY(0) scaleY(1);
}
50% {
opacity: 0.6;
transform: translateY(-10px) scaleY(1.2);
}
}
`}</style>
</div>
);
};
export default BrandPage;

174
src/pages/CastADApp.tsx Normal file
View File

@ -0,0 +1,174 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { ThemeProvider } from '../contexts/ThemeContext';
import Sidebar, { ViewType } from '../components/layout/Sidebar';
import TopHeader from '../components/layout/TopHeader';
import OnboardingDialog from '../components/OnboardingDialog';
import DashboardView from '../views/DashboardView';
import NewProjectView from '../views/NewProjectView';
import LibraryView from '../views/LibraryView';
import AssetsView from '../views/AssetsView';
import AccountView from '../views/AccountView';
import SettingsView from '../views/SettingsView';
import PensionsView from '../views/PensionsView';
import { GeneratedAssets } from '../../types';
const CastADApp: React.FC = () => {
const { user, token } = useAuth();
const navigate = useNavigate();
const [currentView, setCurrentView] = useState<ViewType>('dashboard');
const [libraryItems, setLibraryItems] = useState<GeneratedAssets[]>([]);
const [selectedAsset, setSelectedAsset] = useState<GeneratedAssets | null>(null);
// Redirect to login if not authenticated
useEffect(() => {
if (!token) {
navigate('/login');
}
}, [token, navigate]);
// Fetch library items
useEffect(() => {
const fetchLibrary = async () => {
if (!token) return;
try {
const response = await fetch('/api/history', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
const data = await response.json();
const formatted = data.map((item: any) => {
let poster = item.poster_path;
if (!poster && item.details) {
const detailsObj = typeof item.details === 'string' ? JSON.parse(item.details) : item.details;
poster = detailsObj.posterUrl || detailsObj.images?.[0];
}
if (poster && poster.includes('localhost:3001')) {
poster = poster.replace('http://localhost:3001', '');
}
return {
...(typeof item.details === 'string' ? JSON.parse(item.details) : item.details),
id: item.id.toString(),
createdAt: new Date(item.createdAt).getTime(),
finalVideoPath: item.final_video_path ? item.final_video_path.replace('http://localhost:3001', '') : undefined,
posterUrl: poster
};
});
setLibraryItems(formatted);
}
} catch (e) {
console.error("Failed to fetch library", e);
}
};
fetchLibrary();
}, [token]);
const handleViewChange = (view: ViewType) => {
setCurrentView(view);
setSelectedAsset(null);
};
const handleAssetSelect = (asset: GeneratedAssets) => {
setSelectedAsset(asset);
};
const handleProjectCreated = (asset: GeneratedAssets) => {
setLibraryItems(prev => [asset, ...prev]);
setSelectedAsset(asset);
setCurrentView('library');
};
const renderCurrentView = () => {
switch (currentView) {
case 'dashboard':
return (
<DashboardView
libraryItems={libraryItems}
onCreateNew={() => handleViewChange('new-project')}
onViewLibrary={() => handleViewChange('library')}
onSelectAsset={(asset) => {
setSelectedAsset(asset);
setCurrentView('library');
}}
/>
);
case 'new-project':
return (
<NewProjectView
onComplete={handleProjectCreated}
onCancel={() => handleViewChange('dashboard')}
/>
);
case 'library':
return (
<LibraryView
items={libraryItems}
selectedAsset={selectedAsset}
onSelectAsset={handleAssetSelect}
onCreateNew={() => handleViewChange('new-project')}
/>
);
case 'assets':
return <AssetsView />;
case 'pensions':
return <PensionsView />;
case 'account':
return <AccountView />;
case 'settings':
return <SettingsView />;
default:
return <DashboardView libraryItems={libraryItems} onCreateNew={() => handleViewChange('new-project')} onViewLibrary={() => handleViewChange('library')} />;
}
};
if (!token) {
return null; // Will redirect via useEffect
}
const getHeaderInfo = () => {
switch (currentView) {
case 'dashboard':
return { breadcrumb: '', title: '대시보드' };
case 'new-project':
return { breadcrumb: 'Drafts', title: `New Pension Promotion #${libraryItems.length + 1}` };
case 'library':
return { breadcrumb: '', title: '내 보관함' };
case 'assets':
return { breadcrumb: '', title: '에셋 라이브러리' };
case 'pensions':
return { breadcrumb: '', title: '내 펜션 관리' };
case 'account':
return { breadcrumb: '', title: '계정 관리' };
case 'settings':
return { breadcrumb: '', title: '비즈니스 설정' };
default:
return { breadcrumb: '', title: '' };
}
};
const headerInfo = getHeaderInfo();
return (
<ThemeProvider>
<div className="flex h-screen bg-background overflow-hidden">
<Sidebar
currentView={currentView}
onViewChange={handleViewChange}
libraryCount={libraryItems.length}
/>
<div className="flex-1 flex flex-col overflow-hidden">
<TopHeader breadcrumb={headerInfo.breadcrumb} title={headerInfo.title} />
<main className="flex-1 overflow-auto">
{renderCurrentView()}
</main>
</div>
</div>
{/* Onboarding for first-time users */}
<OnboardingDialog onComplete={() => handleViewChange('new-project')} />
</ThemeProvider>
);
};
export default CastADApp;

354
src/pages/CreditsPage.tsx Normal file
View File

@ -0,0 +1,354 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card';
import { Button } from '../components/ui/button';
import { Badge } from '../components/ui/badge';
import { Textarea } from '../components/ui/textarea';
import {
Coins,
History,
Send,
CheckCircle,
XCircle,
Clock,
ArrowLeft,
Sparkles,
AlertCircle,
Video
} from 'lucide-react';
import { cn } from '../lib/utils';
interface CreditHistory {
id: number;
amount: number;
type: string;
description: string;
balance_after: number;
createdAt: string;
}
interface CreditRequest {
id: number;
requested_credits: number;
status: 'pending' | 'approved' | 'rejected';
reason: string;
admin_note: string;
processed_by_username: string;
processed_at: string;
createdAt: string;
}
const CreditsPage: React.FC = () => {
const { user, token } = useAuth();
const navigate = useNavigate();
const [credits, setCredits] = useState<number | null>(null);
const [history, setHistory] = useState<CreditHistory[]>([]);
const [requests, setRequests] = useState<CreditRequest[]>([]);
const [reason, setReason] = useState('');
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const backendPort = import.meta.env.VITE_BACKEND_PORT || '3001';
const apiBase = `http://localhost:${backendPort}`;
useEffect(() => {
if (!user || !token) {
navigate('/login');
return;
}
fetchCredits();
fetchHistory();
fetchRequests();
}, [user, token]);
const fetchCredits = async () => {
try {
const res = await fetch(`${apiBase}/api/credits`, {
headers: { Authorization: `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
setCredits(data.credits);
}
} catch (err) {
console.error('Failed to fetch credits:', err);
}
};
const fetchHistory = async () => {
try {
const res = await fetch(`${apiBase}/api/credits/history`, {
headers: { Authorization: `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
setHistory(data);
}
} catch (err) {
console.error('Failed to fetch history:', err);
}
};
const fetchRequests = async () => {
try {
const res = await fetch(`${apiBase}/api/credits/requests`, {
headers: { Authorization: `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
setRequests(data);
}
} catch (err) {
console.error('Failed to fetch requests:', err);
}
};
const handleRequest = async () => {
if (loading) return;
setLoading(true);
setMessage(null);
try {
const res = await fetch(`${apiBase}/api/credits/request`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({ reason })
});
const data = await res.json();
if (res.ok) {
setMessage({ type: 'success', text: data.message });
setReason('');
fetchRequests();
} else {
setMessage({ type: 'error', text: data.error });
}
} catch (err) {
setMessage({ type: 'error', text: '요청 중 오류가 발생했습니다.' });
} finally {
setLoading(false);
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'pending':
return <Badge variant="outline" className="text-yellow-600 border-yellow-600"><Clock className="w-3 h-3 mr-1" /></Badge>;
case 'approved':
return <Badge variant="outline" className="text-green-600 border-green-600"><CheckCircle className="w-3 h-3 mr-1" /></Badge>;
case 'rejected':
return <Badge variant="outline" className="text-red-600 border-red-600"><XCircle className="w-3 h-3 mr-1" /></Badge>;
default:
return null;
}
};
const getHistoryIcon = (type: string) => {
switch (type) {
case 'video_render':
return <Video className="w-4 h-4 text-red-500" />;
case 'request_approved':
return <CheckCircle className="w-4 h-4 text-green-500" />;
case 'admin_add':
return <Sparkles className="w-4 h-4 text-blue-500" />;
case 'admin_deduct':
return <AlertCircle className="w-4 h-4 text-orange-500" />;
default:
return <Coins className="w-4 h-4 text-gray-500" />;
}
};
const hasPendingRequest = requests.some(r => r.status === 'pending');
if (!user) return null;
return (
<div className="min-h-screen bg-gradient-to-b from-background to-secondary/20 pt-20 pb-10">
<div className="max-w-4xl mx-auto px-4">
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="w-5 h-5" />
</Button>
<div>
<h1 className="text-2xl font-bold"> </h1>
<p className="text-muted-foreground"> </p>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
{/* 현재 크레딧 */}
<Card className={cn(
"border-2",
credits !== null && credits <= 0 ? "border-destructive/50" :
credits !== null && credits <= 3 ? "border-yellow-500/50" : "border-primary/50"
)}>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2">
<Coins className="w-5 h-5" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-baseline gap-2">
<span className={cn(
"text-5xl font-bold",
credits !== null && credits <= 0 ? "text-destructive" :
credits !== null && credits <= 3 ? "text-yellow-600" : "text-primary"
)}>
{credits ?? '-'}
</span>
<span className="text-lg text-muted-foreground"></span>
</div>
<p className="text-sm text-muted-foreground mt-2">
1 1
</p>
</CardContent>
</Card>
{/* 크레딧 요청 */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2">
<Send className="w-5 h-5" />
</CardTitle>
<CardDescription>
10
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{message && (
<div className={cn(
"p-3 rounded-lg text-sm",
message.type === 'error' ? "bg-destructive/10 text-destructive border border-destructive/20" : "bg-green-500/10 text-green-600 border border-green-500/20"
)}>
{message.text}
</div>
)}
{hasPendingRequest ? (
<div className="text-center py-4">
<Clock className="w-12 h-12 text-yellow-500 mx-auto mb-2" />
<p className="font-medium"> </p>
<p className="text-sm text-muted-foreground"> </p>
</div>
) : (
<>
<Textarea
placeholder="요청 사유를 입력해주세요 (선택사항)"
value={reason}
onChange={(e) => setReason(e.target.value)}
className="resize-none"
rows={3}
/>
<Button
className="w-full"
onClick={handleRequest}
disabled={loading}
>
{loading ? '요청 중...' : '10크레딧 충전 요청'}
</Button>
</>
)}
</CardContent>
</Card>
</div>
{/* 요청 내역 */}
<Card className="mt-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<History className="w-5 h-5" />
</CardTitle>
</CardHeader>
<CardContent>
{requests.length === 0 ? (
<p className="text-center text-muted-foreground py-8"> </p>
) : (
<div className="space-y-3">
{requests.map((req) => (
<div
key={req.id}
className="flex items-center justify-between p-3 rounded-lg bg-accent/50"
>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
{getStatusBadge(req.status)}
<span className="font-medium">+{req.requested_credits} </span>
</div>
{req.reason && (
<p className="text-sm text-muted-foreground">{req.reason}</p>
)}
{req.admin_note && (
<p className="text-sm text-muted-foreground mt-1">
: {req.admin_note}
</p>
)}
</div>
<div className="text-right text-xs text-muted-foreground">
{new Date(req.createdAt).toLocaleDateString('ko-KR')}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* 사용 내역 */}
<Card className="mt-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Coins className="w-5 h-5" />
</CardTitle>
</CardHeader>
<CardContent>
{history.length === 0 ? (
<p className="text-center text-muted-foreground py-8"> </p>
) : (
<div className="space-y-2">
{history.map((item) => (
<div
key={item.id}
className="flex items-center justify-between p-3 rounded-lg hover:bg-accent/50 transition-colors"
>
<div className="flex items-center gap-3">
{getHistoryIcon(item.type)}
<div>
<p className="text-sm font-medium">{item.description}</p>
<p className="text-xs text-muted-foreground">
{new Date(item.createdAt).toLocaleString('ko-KR')}
</p>
</div>
</div>
<div className="text-right">
<span className={cn(
"font-bold",
item.amount > 0 ? "text-green-600" : "text-red-600"
)}>
{item.amount > 0 ? '+' : ''}{item.amount}
</span>
<p className="text-xs text-muted-foreground">
: {item.balance_after}
</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
};
export default CreditsPage;

647
src/pages/Dashboard.tsx Normal file
View File

@ -0,0 +1,647 @@
import React, { useEffect, useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import { cn } from '../lib/utils';
import { GeneratedAssets } from '../../types';
import { Button } from '../components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card';
import { Badge } from '../components/ui/badge';
import { Skeleton } from '../components/ui/skeleton';
import { Separator } from '../components/ui/separator';
import { Checkbox } from '../components/ui/checkbox';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '../components/ui/alert-dialog';
import {
Video,
Clock,
Music,
Mic,
Image as ImageIcon,
Download,
Play,
Plus,
LogOut,
LayoutDashboard,
FileVideo,
HardDrive,
Calendar,
ExternalLink,
MoreVertical,
Trash2,
Eye,
CheckSquare,
Square,
X,
AlertTriangle,
Loader2
} from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '../components/ui/dropdown-menu';
const Dashboard: React.FC = () => {
const { user, token, logout } = useAuth();
const { t } = useLanguage();
const [history, setHistory] = useState<GeneratedAssets[]>([]);
const [loading, setLoading] = useState(true);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [isSelectMode, setIsSelectMode] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null); // single ID or 'bulk'
const [isDeleting, setIsDeleting] = useState(false);
const navigate = useNavigate();
useEffect(() => {
if (!token) {
navigate('/login');
return;
}
const fetchHistory = async () => {
try {
const response = await fetch('/api/history', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
const data = await response.json();
const formatted = data.map((item: any) => {
let poster = item.poster_path;
if (!poster && item.details) {
const detailsObj = typeof item.details === 'string' ? JSON.parse(item.details) : item.details;
poster = detailsObj.posterUrl || detailsObj.images?.[0];
}
if (poster && poster.includes('localhost:3001')) {
poster = poster.replace('http://localhost:3001', '');
}
return {
...(typeof item.details === 'string' ? JSON.parse(item.details) : item.details),
id: item.id.toString(),
createdAt: new Date(item.createdAt).getTime(),
finalVideoPath: item.final_video_path ? item.final_video_path.replace('http://localhost:3001', '') : undefined,
posterUrl: poster
};
});
setHistory(formatted);
}
} catch (e) {
console.error("Failed to fetch history", e);
} finally {
setLoading(false);
}
};
fetchHistory();
}, [token, navigate]);
const handleSelect = (asset: GeneratedAssets) => {
navigate('/', { state: { asset } });
};
const handleDirectDownload = async (e: React.MouseEvent, url: string, filename: string) => {
e.stopPropagation();
e.preventDefault();
try {
const response = await fetch(url);
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(blobUrl);
a.remove();
} catch (err) {
console.error("다운로드 실패:", err);
window.open(url, '_blank');
}
};
const handleLogout = () => {
logout();
navigate('/login');
};
// Selection handlers
const toggleSelectMode = () => {
setIsSelectMode(!isSelectMode);
if (isSelectMode) {
setSelectedIds(new Set());
}
};
const toggleSelectItem = (id: string) => {
const newSelected = new Set(selectedIds);
if (newSelected.has(id)) {
newSelected.delete(id);
} else {
newSelected.add(id);
}
setSelectedIds(newSelected);
};
const selectAll = () => {
if (selectedIds.size === history.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(history.map(h => h.id)));
}
};
const handleDeleteClick = (id: string | null) => {
setDeleteTarget(id);
setDeleteDialogOpen(true);
};
const handleDeleteConfirm = async () => {
setIsDeleting(true);
try {
if (deleteTarget === 'bulk') {
// 일괄 삭제
const idsToDelete = Array.from(selectedIds);
const response = await fetch('/api/history', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ ids: idsToDelete })
});
if (response.ok) {
const result = await response.json();
setHistory(prev => prev.filter(h => !selectedIds.has(h.id)));
setSelectedIds(new Set());
setIsSelectMode(false);
alert(`${result.deletedIds?.length || idsToDelete.length}개 항목이 삭제되었습니다.`);
} else {
const error = await response.json();
alert(error.error || '삭제에 실패했습니다.');
}
} else if (deleteTarget) {
// 단일 삭제
const response = await fetch(`/api/history/${deleteTarget}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
setHistory(prev => prev.filter(h => h.id !== deleteTarget));
selectedIds.delete(deleteTarget);
setSelectedIds(new Set(selectedIds));
alert('삭제되었습니다.');
} else {
const error = await response.json();
alert(error.error || '삭제에 실패했습니다.');
}
}
} catch (error) {
console.error('삭제 오류:', error);
alert('삭제 중 오류가 발생했습니다.');
} finally {
setIsDeleting(false);
setDeleteDialogOpen(false);
setDeleteTarget(null);
}
};
// Stats
const totalVideos = history.length;
const completedVideos = history.filter(h => h.finalVideoPath).length;
const thisMonthVideos = history.filter(h => {
const date = new Date(h.createdAt);
const now = new Date();
return date.getMonth() === now.getMonth() && date.getFullYear() === now.getFullYear();
}).length;
return (
<div className="min-h-screen bg-background">
{/* Header */}
<div className="border-b border-border bg-card/50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="heading-3 flex items-center gap-2">
<LayoutDashboard className="w-6 h-6 text-primary" />
{t('menuLibrary')}
</h1>
<p className="text-muted-foreground mt-1">
{user?.name}
</p>
</div>
<div className="flex gap-2">
{history.length > 0 && (
<Button
variant={isSelectMode ? "secondary" : "outline"}
onClick={toggleSelectMode}
>
{isSelectMode ? (
<>
<X className="w-4 h-4 mr-2" />
</>
) : (
<>
<CheckSquare className="w-4 h-4 mr-2" />
</>
)}
</Button>
)}
<Button asChild>
<Link to="/">
<Plus className="w-4 h-4 mr-2" />
{t('menuCreate')}
</Link>
</Button>
<Button variant="outline" onClick={handleLogout}>
<LogOut className="w-4 h-4 mr-2" />
{t('menuLogout')}
</Button>
</div>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-8">
{/* Stats Cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
</CardTitle>
<FileVideo className="w-4 h-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{totalVideos}</div>
<p className="text-xs text-muted-foreground mt-1">
: {completedVideos}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
</CardTitle>
<Calendar className="w-4 h-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{thisMonthVideos}</div>
<p className="text-xs text-muted-foreground mt-1">
{new Date().toLocaleDateString('ko-KR', { year: 'numeric', month: 'long' })}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
</CardTitle>
<HardDrive className="w-4 h-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{(completedVideos * 15).toFixed(0)}MB</div>
<p className="text-xs text-muted-foreground mt-1">
</p>
</CardContent>
</Card>
</div>
<Separator className="mb-8" />
{/* Video List */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Clock className="w-5 h-5 text-primary" />
<h2 className="text-xl font-semibold"> </h2>
<Badge variant="secondary">{history.length}</Badge>
</div>
{/* Selection Toolbar */}
{isSelectMode && history.length > 0 && (
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="sm"
onClick={selectAll}
>
{selectedIds.size === history.length ? (
<><Square className="w-4 h-4 mr-2" /> </>
) : (
<><CheckSquare className="w-4 h-4 mr-2" /> </>
)}
</Button>
{selectedIds.size > 0 && (
<>
<Badge variant="outline">{selectedIds.size} </Badge>
<Button
variant="destructive"
size="sm"
onClick={() => handleDeleteClick('bulk')}
>
<Trash2 className="w-4 h-4 mr-2" />
</Button>
</>
)}
</div>
)}
</div>
{loading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{[...Array(6)].map((_, i) => (
<Card key={i}>
<Skeleton className="aspect-video w-full" />
<CardContent className="p-4">
<Skeleton className="h-5 w-3/4 mb-2" />
<Skeleton className="h-4 w-1/2" />
</CardContent>
</Card>
))}
</div>
) : history.length === 0 ? (
<Card className="py-16">
<CardContent className="flex flex-col items-center justify-center text-center">
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<Video className="w-8 h-8 text-primary" />
</div>
<h3 className="text-lg font-semibold mb-2"> </h3>
<p className="text-muted-foreground mb-6 max-w-sm">
. AI 1 .
</p>
<Button asChild>
<Link to="/">
<Plus className="w-4 h-4 mr-2" />
</Link>
</Button>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{history.map((asset) => (
<Card
key={asset.id}
className={cn(
"group overflow-hidden card-hover cursor-pointer relative",
isSelectMode && selectedIds.has(asset.id) && "ring-2 ring-primary"
)}
onClick={() => {
if (isSelectMode) {
toggleSelectItem(asset.id);
} else {
handleSelect(asset);
}
}}
>
{/* Selection Checkbox */}
{isSelectMode && (
<div
className="absolute top-3 left-3 z-20"
onClick={(e) => {
e.stopPropagation();
toggleSelectItem(asset.id);
}}
>
<div className={cn(
"w-6 h-6 rounded-md border-2 flex items-center justify-center transition-all",
selectedIds.has(asset.id)
? "bg-primary border-primary text-primary-foreground"
: "bg-background/80 border-muted-foreground/50 hover:border-primary"
)}>
{selectedIds.has(asset.id) && (
<CheckSquare className="w-4 h-4" />
)}
</div>
</div>
)}
{/* Thumbnail */}
<div className="relative aspect-video bg-muted overflow-hidden">
{asset.posterUrl ? (
<img
src={asset.posterUrl}
alt={asset.businessName}
className="w-full h-full object-cover transition-transform group-hover:scale-105"
onError={(e) => {
e.currentTarget.style.display = 'none';
}}
/>
) : (
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
<ImageIcon className="w-12 h-12" />
</div>
)}
{/* Overlay */}
{!isSelectMode && (
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<div className="p-3 rounded-full bg-primary/90 shadow-lg">
<Play className="w-6 h-6 text-primary-foreground fill-current" />
</div>
</div>
)}
{/* Badges */}
<div className={cn("absolute top-2 flex gap-1.5", isSelectMode ? "left-12" : "left-2")}>
<Badge variant="secondary" className="bg-background/80 backdrop-blur-sm">
{asset.audioMode === 'Song' ? (
<><Music className="w-3 h-3 mr-1" /> Music</>
) : (
<><Mic className="w-3 h-3 mr-1" /> Voice</>
)}
</Badge>
</div>
{/* Status Badge */}
{asset.finalVideoPath && (
<Badge className="absolute top-2 right-2 bg-green-500/90">
</Badge>
)}
</div>
{/* Content */}
<CardContent className="p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<h3 className="font-semibold truncate">{asset.businessName}</h3>
{asset.sourceUrl && (
<a
href={asset.sourceUrl}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-xs text-primary hover:underline truncate flex items-center gap-1 mt-1"
>
<ExternalLink className="w-3 h-3" />
</a>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0">
<MoreVertical className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleSelect(asset)}>
<Eye className="w-4 h-4 mr-2" />
</DropdownMenuItem>
{asset.finalVideoPath && (
<DropdownMenuItem
onClick={(e) => handleDirectDownload(e as any, asset.finalVideoPath!, `CastAD_${asset.businessName}.mp4`)}
>
<Download className="w-4 h-4 mr-2" />
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDeleteClick(asset.id);
}}
>
<Trash2 className="w-4 h-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Separator className="my-3" />
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{new Date(asset.createdAt).toLocaleDateString('ko-KR')}
</span>
<Badge variant="outline" className="text-[10px]">
{asset.textEffect}
</Badge>
</div>
</CardContent>
{/* Action Buttons */}
<div className="px-4 pb-4 flex gap-2">
{asset.finalVideoPath ? (
<>
<Button
size="sm"
variant="outline"
className="flex-1"
onClick={(e) => handleDirectDownload(e, asset.finalVideoPath!, `CastAD_${asset.businessName}.mp4`)}
>
<Download className="w-4 h-4 mr-1" />
</Button>
<Button
size="sm"
className="flex-1"
onClick={(e) => {
e.stopPropagation();
handleSelect(asset);
}}
>
<Play className="w-4 h-4 mr-1" />
</Button>
</>
) : (
<Button
size="sm"
className="w-full"
onClick={(e) => {
e.stopPropagation();
handleSelect(asset);
}}
>
<Eye className="w-4 h-4 mr-1" />
</Button>
)}
</div>
</Card>
))}
</div>
)}
</div>
{/* Delete Confirmation Dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle className="w-5 h-5" />
{deleteTarget === 'bulk' ? '선택한 항목 삭제' : '항목 삭제'}
</AlertDialogTitle>
<AlertDialogDescription className="space-y-2">
<p>
{deleteTarget === 'bulk'
? `선택한 ${selectedIds.size}개의 항목을 삭제하시겠습니까?`
: '이 항목을 삭제하시겠습니까?'}
</p>
<p className="text-destructive font-medium">
, .
</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}></AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteConfirm}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
<>
<Trash2 className="w-4 h-4 mr-2" />
</>
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
export default Dashboard;

Some files were not shown because too many files have changed in this diff Show More