init commit from ado4
commit
d820394ccc
|
|
@ -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/
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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부터 순차적으로 진행할까요?
|
||||
|
|
@ -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**
|
||||
|
|
@ -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 파일을 서버로 전송하세요."
|
||||
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 ""
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -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}"
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
|
@ -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 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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.
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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}")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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 # 환경변수 로드
|
||||
|
|
@ -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
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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' });
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
};
|
||||
|
|
@ -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 || "텍스트 스타일 분석에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
@ -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 || "네이버 플레이스 정보를 가져오는데 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
|
@ -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 || "음악 생성 중 알 수 없는 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
Loading…
Reference in New Issue