Compare commits
20 Commits
main
...
feature-ss
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efc6e45070 | ||
|
|
40895207ee | ||
|
|
0ca2140b98 | ||
| 4dff60c1ba | |||
| 82b9fef3a1 | |||
| 4fe065378f | |||
| 3b8298bba2 | |||
| fde7c8f6e1 | |||
| 1f6921c587 | |||
| cdddb913a0 | |||
| 18b5271110 | |||
| 1c01dab748 | |||
| 40ebe2cca0 | |||
| b9fd77fb51 | |||
| 3701490728 | |||
| bda4c267dd | |||
| 741539a023 | |||
| 7b1e691715 | |||
| 54e5d14127 | |||
| bb0ab91bcc |
157
DEPLOY_REQUEST.md
Normal file
157
DEPLOY_REQUEST.md
Normal file
@ -0,0 +1,157 @@
|
||||
# 배포 요청서 — castad 프론트엔드 `feature-ssulbox-ado2-ds`
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **요청일** | 2026-08-20 |
|
||||
| **대상 저장소** | `gitea.o2o.kr/castad/o2o-castad-frontend` |
|
||||
| **브랜치** | `feature-ssulbox-ado2-ds` (브랜치 최신 커밋을 그대로 배포) |
|
||||
| **코드 커밋** | `0ca2140` — 이 문서는 그 위에 문서만 얹은 커밋입니다 |
|
||||
| **대상 환경** | dev-ssul.castad.net |
|
||||
| **연관 저장소** | `gitea.o2o.kr/castad/o2o-ado2-poster-to-video` (신규 연동 대상) |
|
||||
|
||||
---
|
||||
|
||||
## 0. 한 장 요약
|
||||
|
||||
이번 브랜치는 **두 덩어리**입니다. 서로 독립적이라 **나눠서 배포할 수 있습니다.**
|
||||
|
||||
| 덩어리 | 내용 | 추가 인프라 | 배포 난이도 |
|
||||
|---|---|---|---|
|
||||
| **A. DS 통일 + 네비게이션** | 썰박스 디자인 시스템 통일, 진입 화면 탭 개편, 레이아웃 버그 수정 | **없음** | 기존과 동일 |
|
||||
| **B. 포스터 파이프라인** | 무빙 포스터(F1), 포스터 스타일링(F2) 탭 | **P2V 서버 신규 배포 필요** | 선행조건 5개 |
|
||||
|
||||
**권장: A를 먼저 배포하고, B는 P2V 서버가 준비된 뒤 여는 2단계 진행.**
|
||||
A만 배포해도 화면은 깨지지 않습니다. 포스터 탭 2개만 "접근 키 입력" 화면이나 연결 실패로
|
||||
떨어지고, ADO2·썰박스 탭은 평소대로 동작합니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. A안 — DS·네비게이션만 배포 (지금 바로 가능)
|
||||
|
||||
### 필요 작업
|
||||
```bash
|
||||
git fetch origin
|
||||
git checkout feature-ssulbox-ado2-ds # 0ca2140
|
||||
npm ci
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 환경변수
|
||||
| 변수 | 값 | 비고 |
|
||||
|---|---|---|
|
||||
| `VITE_API_URL` | 기존 값 유지 | castad 백엔드 |
|
||||
| `VITE_P2V_URL` | **설정하지 않음** | 미설정 시 포스터 탭만 비활성 상태로 남음 |
|
||||
|
||||
### 검증
|
||||
1. 진입 화면에 탭 4개(ADO2 / 썰박스 / 무빙 포스터 / 포스터 스타일링)가 아이콘 타일로 표시
|
||||
2. 썰박스 탭 — 시나리오 카드에 이모지·시나리오별 색이 없고 선택 시 민트 테두리
|
||||
3. **모바일(375px)에서 상단 ADO2 로고가 잘리지 않을 것** ← 이번에 고친 버그
|
||||
4. ADO2 탭 기존 플로우(URL 입력 → 브랜드 분석) 정상
|
||||
|
||||
---
|
||||
|
||||
## 2. B안 — 포스터 파이프라인까지 배포
|
||||
|
||||
### 2-1. 선행조건 (P2V 서버 측)
|
||||
|
||||
P2V 저장소의 `product/DEPLOY.md`가 원본 문서입니다. 아래는 **castad 연동에 필요한 것만** 추린 것입니다.
|
||||
|
||||
| # | 항목 | 상태 | 담당 |
|
||||
|---|---|---|---|
|
||||
| 1 | P2V 서버(`:8010`) 배포 | **미배포** | Dev |
|
||||
| 2 | `SUNO_CALLBACK_URL` — Suno가 결과를 POST할 **공개 주소** | 도메인 확정 필요 | Dev |
|
||||
| 3 | Higgsfield CLI 토큰 — 머신에서 **사람이 1회 로그인** | 자동화 불가 | 담당자 |
|
||||
| 4 | **P2V를 HTTPS로 서빙** | 미확인 | Dev |
|
||||
| 5 | F2 템플릿 이미지 파일 별도 전달 | 저장소에 없음 | 요청자 |
|
||||
|
||||
**4번이 이번에 새로 확인된 항목입니다.** dev-ssul.castad.net은 HTTPS인데 P2V가 HTTP면
|
||||
브라우저가 mixed content로 **전부 차단**합니다. P2V 앞에 리버스 프록시로 TLS를 붙여야 합니다.
|
||||
|
||||
**5번 주의:** P2V 저장소는 `templates/*.jpg`를 gitignore합니다(영화 포스터 = 저작권).
|
||||
`templates.json`(메타데이터)만 커밋돼 있어 **클론 직후 F2는 썸네일이 전부 깨집니다.**
|
||||
compose가 `./server/templates`를 호스트 바인드 마운트하므로, 이미지 21개를 서버에 직접
|
||||
넣어야 합니다. F2를 이관 범위에서 빼는 것도 선택지입니다(F1은 영향 없음).
|
||||
|
||||
### 2-2. P2V 서버 환경변수
|
||||
|
||||
`product/.env.example`을 `.env`로 복사해 채웁니다.
|
||||
|
||||
| 변수 | 필수 | 설명 |
|
||||
|---|---|---|
|
||||
| `CHATGPT_API_KEY` | ✅ | 영역검출·나레이션·모션선정·TTS·F2 |
|
||||
| `SUNO_API_KEY` | ✅ | BGM 생성 |
|
||||
| `SUNO_CALLBACK_URL` | ✅ | Suno 결과 수신용 **공개** URL. 없으면 BGM 단계에서 죽음 |
|
||||
| `P2V_ACCESS_KEY` | ✅ | 팀 공용 키 1개. **비우면 무인증** — 배포에서는 반드시 설정 |
|
||||
| `P2V_ALLOWED_ORIGINS` | ✅ | **`https://dev-ssul.castad.net` 추가 필수.** 없으면 CORS 차단 |
|
||||
| `P2V_QUEUE_PATH` | compose가 지정 | 큐를 볼륨 위로. 이미지 레이어에 두면 재배포 때 소실 |
|
||||
|
||||
Higgsfield는 환경변수가 아니라 **CLI 토큰 인증**입니다:
|
||||
```bash
|
||||
higgsfield auth login && higgsfield workspace set
|
||||
# 생성된 ~/.config/higgsfield/{credentials,config}.json 을 p2v-higgsfield 볼륨에 주입
|
||||
# CLI가 토큰을 자동 갱신하며 파일을 다시 쓰므로 :ro 마운트 금지
|
||||
```
|
||||
|
||||
### 2-3. castad 프론트엔드 빌드
|
||||
|
||||
```bash
|
||||
VITE_P2V_URL="https://<P2V 공개 도메인>" npm run build
|
||||
```
|
||||
|
||||
Vite는 **빌드 시점에** 값을 산출물에 박습니다(런타임 주입 아님). 검증했습니다 —
|
||||
값을 넣으면 번들에 그대로 들어가고 기본값 `localhost:8010`은 남지 않습니다.
|
||||
|
||||
### 2-4. 알아둘 제약
|
||||
|
||||
- **api를 스케일아웃하면 안 됩니다.** 인메모리 큐 + 파일 상태(`data/jobs.json`) 구조라
|
||||
인스턴스가 늘면 큐가 깨집니다. compose에도 명시돼 있습니다.
|
||||
- **처리 시간 1건 4~6분** (Suno ~2분 + veo ~3분).
|
||||
- **렌더 비용** veo 22크레딧/편(≈$1.12) + OpenAI·Suno 소액.
|
||||
- **인증이 단일 팀 키**라 사용자별 크레딧 차감이 없습니다. 현재는 내부 시연 범위이고,
|
||||
대외 공개 시 castad 백엔드에 `/p2v/*` 프록시를 세워 카카오 인증·크레딧을 얹는 것이
|
||||
다음 수순입니다. 프론트에서 갈아끼울 지점은 `src/utils/p2vApi.ts` 한 파일입니다.
|
||||
|
||||
### 2-5. 검증 절차
|
||||
|
||||
```bash
|
||||
# 1) 서버 헬스체크 (인증 없이 열려 있는 유일한 경로)
|
||||
curl -i https://<P2V 도메인>/api/health # → 200
|
||||
|
||||
# 2) 인증 게이트 동작
|
||||
curl -i https://<P2V 도메인>/api/f2/templates # → 401
|
||||
curl -i -H "X-P2V-Key: <키>" https://<P2V 도메인>/api/f2/templates # → 200 + JSON
|
||||
```
|
||||
|
||||
브라우저에서:
|
||||
|
||||
| # | 확인 항목 | 기대 결과 |
|
||||
|---|---|---|
|
||||
| 1 | 무빙 포스터 탭 첫 진입 | 접근 키 입력창 → 키 입력 후 업로드 화면 |
|
||||
| 2 | 포스터 업로드 | 5단계 스텝퍼가 "분석"으로 진행, 진행바·스테이지 라벨 표시 |
|
||||
| 3 | 1~2분 후 | **검수 화면** — 나레이션 3문장, 모션 칩, 메타태그, 우측 분석 이미지 |
|
||||
| 4 | 승인 | "생성" 단계로 넘어가고 4~6분 뒤 영상 재생 + MP4 다운로드 |
|
||||
| 5 | 포스터 스타일링 탭 | 템플릿 썸네일 표시 (**깨지면 5번 선행조건 미이행**) |
|
||||
| 6 | 모바일(375px) 검수 화면 | 포스터가 위, 승인 버튼이 하단 탭바 위에 고정 |
|
||||
|
||||
**F1은 검수 게이트에서 멈춥니다.** 승인 전까지 TTS·BGM·렌더가 시작되지 않으므로,
|
||||
3번까지만 확인하면 유료 API를 거의 쓰지 않고 연동을 검증할 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 롤백
|
||||
|
||||
프론트엔드는 이전 커밋으로 되돌려 재빌드하면 끝입니다. P2V는 castad와 별도 서비스라
|
||||
내려도 castad 본체에 영향이 없습니다 — 포스터 탭만 연결 실패 상태가 됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 이번 브랜치에서 고친 기존 버그
|
||||
|
||||
배포 후 확인해 두면 좋은 항목입니다. 모두 포스터 기능과 무관하게 A안에 포함됩니다.
|
||||
|
||||
1. **진입 화면 세로 잘림** — `justify-content: center` + `overflow: hidden` 조합이라
|
||||
콘텐츠가 뷰포트보다 길면 위로 넘친 부분에 스크롤로도 닿을 수 없었습니다.
|
||||
자식의 `margin: auto 0` 중앙정렬로 교체했습니다.
|
||||
2. **한글 단어 중간 줄바꿈** — `word-break: keep-all` 적용.
|
||||
3. **입력·안내·제출 박스 높이 불일치**(46/38/48) — 48px로 통일.
|
||||
4. **카피 어휘** — 가게→업체, 크롤링→수집.
|
||||
22
README.md
22
README.md
@ -51,6 +51,28 @@ npm run build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## 환경 변수
|
||||
|
||||
| 변수 | 기본값 | 설명 |
|
||||
|---|---|---|
|
||||
| `VITE_API_URL` | `http://40.82.133.44` | castad 백엔드 (ADO2·썰박스) |
|
||||
| `VITE_P2V_URL` | `http://localhost:8010` | Poster to Video 서버 (포스터 영상·스타일링) |
|
||||
|
||||
### 포스터 파이프라인 (Poster to Video)
|
||||
|
||||
'포스터 영상'(F1)·'포스터 스타일'(F2) 탭은 castad 백엔드가 아니라 **별도 P2V 서버**를 직접
|
||||
호출한다(`src/utils/p2vApi.ts`). 붙이려면 세 가지가 필요하다:
|
||||
|
||||
1. P2V 서버 기동 — 저장소는 `o2o-ado2-poster-to-video`, 기본 포트 8010
|
||||
2. 서버의 `P2V_ALLOWED_ORIGINS` 에 이 프론트 오리진 추가 (예: `http://localhost:3000`)
|
||||
3. 첫 진입 시 화면에 뜨는 입력창에 서버의 `P2V_ACCESS_KEY` 입력 (브라우저에 저장된다)
|
||||
|
||||
인증이 단일 팀 키라 **내부 시연 범위**다. 대외 공개 시에는 castad 백엔드에 `/p2v/*` 프록시를
|
||||
세워 카카오 인증·크레딧 차감을 얹고, 프론트는 `p2vApi.ts` 한 파일만 그쪽으로 돌리면 된다.
|
||||
|
||||
포스터 스타일링(F2)은 영화 포스터 레퍼런스가 저작권 자산이라 **결과물의 외부 공개·상업 사용을
|
||||
금한다**(퍼블릭 도메인 명화 제외). 화면 상단과 템플릿 배지로 등급을 표시한다.
|
||||
|
||||
## Docker 실행
|
||||
|
||||
```bash
|
||||
|
||||
25
index.html
25
index.html
@ -36,14 +36,14 @@
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
bg: '#011c1e',
|
||||
card: '#083336',
|
||||
cardHover: '#0b3f42',
|
||||
accent: '#94FBE0',
|
||||
purple: '#a855f7',
|
||||
purpleHover: '#9333ea',
|
||||
text: '#e2e8f0',
|
||||
muted: '#94a3b8'
|
||||
bg: '#001a1c',
|
||||
card: '#003538',
|
||||
cardHover: '#004548',
|
||||
accent: '#a6ffea',
|
||||
purple: '#a682ff',
|
||||
purpleHover: '#9570f0',
|
||||
text: '#d1d5db',
|
||||
muted: '#9ca3af'
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -53,7 +53,8 @@
|
||||
<script src="https://t1.kakaocdn.net/kakao_js_sdk/2.7.4/kakao.min.js" crossorigin="anonymous"></script>
|
||||
<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=Noto+Sans+KR:wght@300;400;500;700&family=Playfair+Display:ital,wght@0,700;1,700&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,700;1,700&display=swap" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--app-height: 100%;
|
||||
@ -63,11 +64,13 @@
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #121a1d;
|
||||
background-color: #001a1c;
|
||||
}
|
||||
body {
|
||||
font-family: 'Noto Sans KR', sans-serif;
|
||||
font-family: 'Pretendard', 'Apple SD Gothic Neo', -apple-system, BlinkMacSystemFont,
|
||||
'Helvetica Neue', 'Segoe UI', system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
letter-spacing: -0.006em;
|
||||
}
|
||||
.font-serif-logo {
|
||||
font-family: 'Playfair Display', serif;
|
||||
|
||||
29
package-lock.json
generated
29
package-lock.json
generated
@ -16,6 +16,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
@ -1292,6 +1294,26 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
||||
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
@ -1400,6 +1422,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
|
||||
@ -5,7 +5,8 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
@ -17,6 +18,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
|
||||
420
src/App.tsx
420
src/App.tsx
@ -1,27 +1,16 @@
|
||||
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Header from './components/Header';
|
||||
import HeroSection from './pages/Landing/HeroSection';
|
||||
import WelcomeSection from './pages/Landing/WelcomeSection';
|
||||
import DisplaySection from './pages/Landing/DisplaySection';
|
||||
import LoadingSection from './pages/Analysis/LoadingSection';
|
||||
import AnalysisResultSection from './pages/Analysis/AnalysisResultSection';
|
||||
import LoginSection from './pages/Login/LoginSection';
|
||||
import GenerationFlow from './pages/Dashboard/GenerationFlow';
|
||||
import SocialConnectSuccess from './pages/Social/SocialConnectSuccess';
|
||||
import SocialConnectError from './pages/Social/SocialConnectError';
|
||||
import YouTubeOAuthCallback from './pages/Social/YouTubeOAuthCallback';
|
||||
import ADO2ContentsPage from './pages/Dashboard/ADO2ContentsPage';
|
||||
import VideoDetailPage from './components/VideoDetailPage';
|
||||
import { crawlUrl, autocomplete, marketingAnalysis, kakaoCallback, isLoggedIn, saveTokens, getVideosList, AutocompleteRequest, storeUtmFromUrl, trackViewContent, trackCompleteRegistration } from './utils/api';
|
||||
import { saveSearchHistory } from './components/SearchHistory/useSearchHistory';
|
||||
import { CrawlingResponse } from './types/api';
|
||||
import SsulDetailPage from './pages/Ssulbox/SsulDetailPage';
|
||||
import { kakaoCallback, saveTokens, storeUtmFromUrl, trackViewContent, trackCompleteRegistration } from './utils/api';
|
||||
import { clearSessionStorage } from './utils/storageKeys';
|
||||
import { NAV } from './components/navItems';
|
||||
|
||||
type ViewMode = 'landing' | 'loading' | 'analysis' | 'login' | 'generation_flow';
|
||||
|
||||
const VIEW_MODE_KEY = 'castad_view_mode';
|
||||
const ANALYSIS_DATA_KEY = 'castad_analysis_data';
|
||||
const SESSION_KEY = 'castad_session_active';
|
||||
|
||||
// 새 탭/새 창에서 접근 시 localStorage 초기화 (sessionStorage로 현재 세션 확인)
|
||||
@ -30,15 +19,7 @@ const initializeOnNewSession = () => {
|
||||
|
||||
if (!isExistingSession) {
|
||||
// 새 세션이면 localStorage 정리하고 세션 표시
|
||||
localStorage.removeItem(VIEW_MODE_KEY);
|
||||
localStorage.removeItem(ANALYSIS_DATA_KEY);
|
||||
localStorage.removeItem('castad_wizard_step');
|
||||
localStorage.removeItem('castad_active_item');
|
||||
localStorage.removeItem('castad_song_task_id');
|
||||
localStorage.removeItem('castad_image_task_id');
|
||||
localStorage.removeItem('castad_song_generation');
|
||||
localStorage.removeItem('castad_video_generation');
|
||||
localStorage.removeItem('castad_video_ratio');
|
||||
clearSessionStorage();
|
||||
sessionStorage.setItem(SESSION_KEY, 'true');
|
||||
}
|
||||
};
|
||||
@ -47,79 +28,9 @@ const initializeOnNewSession = () => {
|
||||
initializeOnNewSession();
|
||||
|
||||
const App: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const containerRef = useRef<HTMLElement>(null);
|
||||
|
||||
// localStorage에서 저장된 상태 복원 (새 세션이면 이미 초기화됨)
|
||||
const savedViewMode = localStorage.getItem(VIEW_MODE_KEY) as ViewMode | null;
|
||||
const savedAnalysisData = localStorage.getItem(ANALYSIS_DATA_KEY);
|
||||
|
||||
// 저장된 분석 데이터 파싱 및 유효성 검사
|
||||
const parseSavedAnalysisData = (): CrawlingResponse | null => {
|
||||
if (!savedAnalysisData) return null;
|
||||
try {
|
||||
const data = JSON.parse(savedAnalysisData) as CrawlingResponse;
|
||||
// 기본값 보장
|
||||
if (data.marketing_analysis) {
|
||||
data.marketing_analysis.brand_identity = data.marketing_analysis.brand_identity || {
|
||||
location_feature_analysis: '',
|
||||
concept_scalability: ''
|
||||
};
|
||||
data.marketing_analysis.market_positioning = data.marketing_analysis.market_positioning || {
|
||||
category_definition: '',
|
||||
core_value: ''
|
||||
};
|
||||
data.marketing_analysis.target_persona = data.marketing_analysis.target_persona || [];
|
||||
data.marketing_analysis.selling_points = data.marketing_analysis.selling_points || [];
|
||||
data.marketing_analysis.target_keywords = data.marketing_analysis.target_keywords || [];
|
||||
}
|
||||
if (data.processed_info) {
|
||||
data.processed_info.customer_name = data.processed_info.customer_name || '알 수 없음';
|
||||
data.processed_info.region = data.processed_info.region || '';
|
||||
data.processed_info.detail_region_info = data.processed_info.detail_region_info || '';
|
||||
}
|
||||
data.image_list = data.image_list || [];
|
||||
return data;
|
||||
} catch {
|
||||
localStorage.removeItem(ANALYSIS_DATA_KEY);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 초기 viewMode 결정: 로그인 상태면 바로 generation_flow로
|
||||
const getInitialViewMode = (): ViewMode => {
|
||||
if (savedViewMode === 'generation_flow') return 'generation_flow';
|
||||
if (isLoggedIn()) return 'generation_flow';
|
||||
return 'landing';
|
||||
};
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(getInitialViewMode());
|
||||
const [initialTab, setInitialTab] = useState('새 프로젝트 만들기');
|
||||
const [analysisData, setAnalysisData] = useState<CrawlingResponse | null>(
|
||||
parseSavedAnalysisData()
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isAnalysisComplete, setIsAnalysisComplete] = useState(false);
|
||||
const [afterLoadTarget, setAfterLoadTarget] = useState<ViewMode>('analysis');
|
||||
const [scrollProgress, setScrollProgress] = useState(0);
|
||||
const { t } = useTranslation();
|
||||
const [initialTab, setInitialTab] = useState<string>(NAV.HOME);
|
||||
const [isProcessingCallback, setIsProcessingCallback] = useState(false);
|
||||
const tutorialVideoCheckedRef = useRef(false);
|
||||
|
||||
// generation_flow 진입 시 영상 보유 여부 확인 → 있으면 튜토리얼 자동 off
|
||||
useEffect(() => {
|
||||
if (viewMode !== 'generation_flow') return;
|
||||
if (tutorialVideoCheckedRef.current) return;
|
||||
tutorialVideoCheckedRef.current = true;
|
||||
const ENABLED_KEY = 'ado2_tutorial_enabled';
|
||||
if (localStorage.getItem(ENABLED_KEY) !== null) return;
|
||||
getVideosList(1, 1).then(response => {
|
||||
const hasVideos = response.items.some(v => v.result_movie_url?.trim());
|
||||
if (hasVideos) {
|
||||
localStorage.setItem(ENABLED_KEY, 'false');
|
||||
window.dispatchEvent(new Event('ado2-tutorial-auto-disable'));
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, [viewMode]);
|
||||
|
||||
// 카카오 로그인 콜백 처리 (URL에서 토큰 또는 code 파라미터 확인)
|
||||
useEffect(() => {
|
||||
@ -146,8 +57,8 @@ const App: React.FC = () => {
|
||||
setIsProcessingCallback(true);
|
||||
handleKakaoCallback(code);
|
||||
}
|
||||
// 콜백이 아닌 일반 진입이고 랜딩이 첫 화면이면 ViewContent 발화
|
||||
else if (viewMode === 'landing') {
|
||||
// 콜백이 아닌 일반 진입 — 광고 유입 추적
|
||||
else {
|
||||
trackViewContent();
|
||||
}
|
||||
}, []);
|
||||
@ -171,21 +82,15 @@ const App: React.FC = () => {
|
||||
await trackCompleteRegistration();
|
||||
}
|
||||
|
||||
const savedData = localStorage.getItem(ANALYSIS_DATA_KEY);
|
||||
if (savedData) {
|
||||
// 분석 데이터가 있으면 에셋 관리(step 1)부터 시작
|
||||
// 이전에 저장된 wizard step이 URL 입력(-2) 등으로 남아있을 수 있으므로 초기화
|
||||
localStorage.removeItem('castad_wizard_step');
|
||||
localStorage.removeItem('castad_active_item');
|
||||
}
|
||||
// 로그인 전 특정 화면(예: /video/{id})에서 유도된 로그인이면 그 경로로 복귀
|
||||
const redirectPath = sessionStorage.getItem('castad_login_redirect');
|
||||
sessionStorage.removeItem('castad_login_redirect');
|
||||
if (redirectPath && redirectPath !== '/') {
|
||||
window.location.href = redirectPath;
|
||||
return;
|
||||
}
|
||||
setInitialTab('새 프로젝트 만들기');
|
||||
setViewMode('generation_flow');
|
||||
// 그 외(사이드바 게이트 등)는 GenerationFlow 가 castad_login_next_item 으로 자체 복귀 처리
|
||||
setInitialTab(NAV.HOME);
|
||||
} catch (err) {
|
||||
console.error('Token callback failed:', err);
|
||||
alert(t('app.loginFailed'));
|
||||
@ -219,231 +124,6 @@ const App: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// viewMode 변경 시 localStorage에 저장
|
||||
useEffect(() => {
|
||||
localStorage.setItem(VIEW_MODE_KEY, viewMode);
|
||||
}, [viewMode]);
|
||||
|
||||
// 스크롤 이벤트 핸들러 - 첫 번째 섹션에서 두 번째 섹션으로 넘어갈 때 0~1 값 계산
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || viewMode !== 'landing') return;
|
||||
|
||||
const handleScroll = () => {
|
||||
const scrollTop = container.scrollTop;
|
||||
const sectionHeight = container.clientHeight;
|
||||
// 첫 번째 섹션 스크롤 진행률 (0 ~ 1)
|
||||
const progress = Math.min(1, Math.max(0, scrollTop / sectionHeight));
|
||||
setScrollProgress(progress);
|
||||
};
|
||||
|
||||
container.addEventListener('scroll', handleScroll);
|
||||
return () => container.removeEventListener('scroll', handleScroll);
|
||||
}, [viewMode]);
|
||||
|
||||
const scrollToSection = (index: number) => {
|
||||
if (containerRef.current) {
|
||||
const h = containerRef.current.clientHeight;
|
||||
containerRef.current.scrollTo({
|
||||
top: h * index,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 크롤링 응답 유효성 검사
|
||||
const validateCrawlingResponse = (data: CrawlingResponse): boolean => {
|
||||
// 필수 필드 존재 여부 확인
|
||||
if (!data) return false;
|
||||
if (!data.processed_info) return false;
|
||||
if (!data.marketing_analysis) return false;
|
||||
|
||||
// marketing_analysis 내부 필드 기본값 보장
|
||||
if (!data.marketing_analysis.brand_identity) {
|
||||
data.marketing_analysis.brand_identity = {
|
||||
location_feature_analysis: '',
|
||||
concept_scalability: ''
|
||||
};
|
||||
}
|
||||
if (!data.marketing_analysis.market_positioning) {
|
||||
data.marketing_analysis.market_positioning = {
|
||||
category_definition: '',
|
||||
core_value: ''
|
||||
};
|
||||
}
|
||||
if (!data.marketing_analysis.target_persona) {
|
||||
data.marketing_analysis.target_persona = [];
|
||||
}
|
||||
if (!data.marketing_analysis.selling_points) {
|
||||
data.marketing_analysis.selling_points = [];
|
||||
}
|
||||
if (!data.marketing_analysis.target_keywords) {
|
||||
data.marketing_analysis.target_keywords = [];
|
||||
}
|
||||
|
||||
// processed_info 내부 필드 기본값 보장
|
||||
if (!data.processed_info.customer_name) {
|
||||
data.processed_info.customer_name = '알 수 없음';
|
||||
}
|
||||
if (!data.processed_info.region) {
|
||||
data.processed_info.region = '';
|
||||
}
|
||||
if (!data.processed_info.detail_region_info) {
|
||||
data.processed_info.detail_region_info = '';
|
||||
}
|
||||
|
||||
// image_list 기본값 보장
|
||||
if (!data.image_list) {
|
||||
data.image_list = [];
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleStartAnalysis = async (url: string) => {
|
||||
if (!url.trim()) return;
|
||||
|
||||
setAfterLoadTarget('analysis');
|
||||
setViewMode('loading');
|
||||
setIsAnalysisComplete(false);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await crawlUrl(url);
|
||||
|
||||
// 응답 유효성 검사
|
||||
if (!validateCrawlingResponse(data)) {
|
||||
throw new Error(t('app.invalidUrl'));
|
||||
}
|
||||
|
||||
setAnalysisData(data);
|
||||
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
|
||||
saveSearchHistory({ type: 'url', value: url });
|
||||
setIsAnalysisComplete(true);
|
||||
} catch (err) {
|
||||
console.error('Crawling failed:', err);
|
||||
setError(t('app.analysisError'));
|
||||
setViewMode('landing');
|
||||
}
|
||||
};
|
||||
|
||||
// 업체명 자동완성으로 분석 시작
|
||||
const handleAutocomplete = async (request: AutocompleteRequest) => {
|
||||
setAfterLoadTarget('analysis');
|
||||
setViewMode('loading');
|
||||
setIsAnalysisComplete(false);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await autocomplete(request);
|
||||
|
||||
// 응답 유효성 검사
|
||||
if (!validateCrawlingResponse(data)) {
|
||||
throw new Error(t('app.autocompleteError'));
|
||||
}
|
||||
|
||||
setAnalysisData(data);
|
||||
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
|
||||
saveSearchHistory({ type: 'name', value: request.title.replace(/<[^>]*>/g, ''), address: request.address, roadAddress: request.roadAddress });
|
||||
setIsAnalysisComplete(true);
|
||||
} catch (err) {
|
||||
console.error('Autocomplete failed:', err);
|
||||
setError(t('app.autocompleteError'));
|
||||
setViewMode('landing');
|
||||
}
|
||||
};
|
||||
|
||||
// 업체명·주소 수동 입력으로 마케팅 분석 API 호출
|
||||
const handleManualInput = async (businessName: string, address: string, category: string, officialSiteUrl?: string) => {
|
||||
setAfterLoadTarget('generation_flow');
|
||||
setViewMode('loading');
|
||||
setIsAnalysisComplete(false);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await marketingAnalysis(businessName, address, category, officialSiteUrl);
|
||||
|
||||
if (!validateCrawlingResponse(data)) {
|
||||
throw new Error(t('app.autocompleteError'));
|
||||
}
|
||||
|
||||
setAnalysisData(data);
|
||||
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
|
||||
saveSearchHistory({ type: 'name', value: businessName, address, roadAddress: address });
|
||||
setIsAnalysisComplete(true);
|
||||
} catch (err) {
|
||||
console.error('Marketing analysis failed:', err);
|
||||
setError(t('app.autocompleteError'));
|
||||
setViewMode('landing');
|
||||
}
|
||||
};
|
||||
|
||||
// 테스트 데이터로 브랜드 분석 페이지 이동
|
||||
const handleTestData = (data: CrawlingResponse) => {
|
||||
const tagged = { ...data, _isTestData: true };
|
||||
setAnalysisData(tagged);
|
||||
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged));
|
||||
setViewMode('analysis');
|
||||
};
|
||||
|
||||
// 언어 변경 시 테스트 데이터 다시 로드
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(ANALYSIS_DATA_KEY);
|
||||
if (!saved) return;
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (!parsed._isTestData) return;
|
||||
const jsonFile = i18n.language === 'en' ? '/example_analysis_en.json' : '/example_analysis.json';
|
||||
fetch(jsonFile)
|
||||
.then(res => res.json())
|
||||
.then((data: CrawlingResponse) => {
|
||||
const tagged = { ...data, _isTestData: true };
|
||||
setAnalysisData(tagged);
|
||||
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged));
|
||||
})
|
||||
.catch(err => console.error('Failed to reload test data:', err));
|
||||
} catch { /* ignore */ }
|
||||
}, [i18n.language]);
|
||||
|
||||
const handleToLogin = async () => {
|
||||
// 이미 로그인된 상태면 바로 generation_flow로 이동
|
||||
if (isLoggedIn()) {
|
||||
// 분석 데이터가 있으면 이전 wizard step 초기화 (에셋 관리부터 시작하도록)
|
||||
const savedData = localStorage.getItem(ANALYSIS_DATA_KEY);
|
||||
if (savedData) {
|
||||
localStorage.removeItem('castad_wizard_step');
|
||||
localStorage.removeItem('castad_active_item');
|
||||
}
|
||||
setInitialTab('새 프로젝트 만들기');
|
||||
setViewMode('generation_flow');
|
||||
return;
|
||||
}
|
||||
|
||||
// 로그인 안 된 상태면 카카오 로그인 페이지로 리다이렉션
|
||||
try {
|
||||
const { getKakaoLoginUrl } = await import('./utils/api');
|
||||
const response = await getKakaoLoginUrl();
|
||||
window.location.href = response.auth_url;
|
||||
} catch (err) {
|
||||
console.error('Failed to get Kakao login URL:', err);
|
||||
alert(t('app.loginUrlFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoginSuccess = () => {
|
||||
setInitialTab('새 프로젝트 만들기');
|
||||
setViewMode('generation_flow');
|
||||
};
|
||||
|
||||
const handleGoBack = () => {
|
||||
// localStorage 정리
|
||||
localStorage.removeItem(VIEW_MODE_KEY);
|
||||
localStorage.removeItem(ANALYSIS_DATA_KEY);
|
||||
localStorage.removeItem('castad_wizard_step');
|
||||
localStorage.removeItem('castad_active_item');
|
||||
setViewMode('landing');
|
||||
};
|
||||
|
||||
// Social OAuth 콜백 페이지 처리
|
||||
const pathname = window.location.pathname;
|
||||
|
||||
@ -466,6 +146,13 @@ const App: React.FC = () => {
|
||||
return <VideoDetailPage videoId={videoDetailMatch[1]} />;
|
||||
}
|
||||
|
||||
// 썰박스 공유 페이지 (/ssul/{id}) — 위와 동일한 패턴, 비로그인 열람 가능.
|
||||
// /video/{id} 와 경로를 나눈 이유: video.id 와 ssul_content.id 가 독립 시퀀스라
|
||||
// 값이 겹치므로, 한 경로로 합치면 다른 종류의 콘텐츠가 열린다.
|
||||
const ssulDetailMatch = pathname.match(/^\/ssul\/(\d+)$/);
|
||||
if (ssulDetailMatch) {
|
||||
return <SsulDetailPage contentId={ssulDetailMatch[1]} />;
|
||||
}
|
||||
|
||||
// 카카오 콜백 처리 중 로딩 화면 표시
|
||||
if (isProcessingCallback) {
|
||||
@ -478,71 +165,8 @@ const App: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (viewMode === 'loading') {
|
||||
return (
|
||||
<LoadingSection
|
||||
isComplete={isAnalysisComplete}
|
||||
onComplete={() => setViewMode(afterLoadTarget)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewMode === 'analysis' && analysisData) {
|
||||
return (
|
||||
<AnalysisResultSection
|
||||
onBack={handleGoBack}
|
||||
onGenerate={handleToLogin}
|
||||
data={analysisData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewMode === 'login') {
|
||||
return <LoginSection onBack={() => setViewMode('analysis')} onLogin={handleLoginSuccess} />;
|
||||
}
|
||||
|
||||
if (viewMode === 'generation_flow') {
|
||||
return (
|
||||
<GenerationFlow
|
||||
onHome={handleGoBack}
|
||||
initialActiveItem={initialTab}
|
||||
initialImageList={analysisData?.image_list || []}
|
||||
businessInfo={analysisData?.processed_info}
|
||||
initialAnalysisData={analysisData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 로그인된 상태에서 "시작하기" 버튼 클릭
|
||||
const handleHeaderStart = () => {
|
||||
setInitialTab('새 프로젝트 만들기');
|
||||
setViewMode('generation_flow');
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="landing-container" ref={containerRef}>
|
||||
<Header onStartClick={handleHeaderStart} />
|
||||
<section className="landing-section">
|
||||
<HeroSection
|
||||
onAnalyze={handleStartAnalysis}
|
||||
onAutocomplete={handleAutocomplete}
|
||||
onManualInput={handleManualInput}
|
||||
onNext={() => scrollToSection(1)}
|
||||
error={error}
|
||||
scrollProgress={scrollProgress}
|
||||
/>
|
||||
</section>
|
||||
<section className="landing-section">
|
||||
<WelcomeSection
|
||||
onStartClick={() => scrollToSection(0)}
|
||||
onNext={() => scrollToSection(0)}
|
||||
/>
|
||||
</section>
|
||||
<section className="landing-section">
|
||||
<DisplaySection onStartClick={() => scrollToSection(0)} />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
// 그 외 전 경로는 ADO2 콘텐츠 갤러리를 첫 화면으로 하는 GenerationFlow 로 수렴
|
||||
return <GenerationFlow initialActiveItem={initialTab} />;
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
||||
67
src/components/BottomNav.tsx
Normal file
67
src/components/BottomNav.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { UserMeResponse } from '../types/api';
|
||||
import { NAV_ITEMS, SETTINGS_ID } from './navItems';
|
||||
import SettingsPanel from './SettingsPanel';
|
||||
|
||||
interface BottomNavProps {
|
||||
activeItem: string;
|
||||
onNavigate: (id: string) => void;
|
||||
userInfo?: UserMeResponse | null;
|
||||
onLogout?: () => void;
|
||||
credits?: number | null;
|
||||
isGuest: boolean;
|
||||
onLoginClick: () => void;
|
||||
}
|
||||
|
||||
const BottomNav: React.FC<BottomNavProps> = ({ activeItem, onNavigate, userInfo, onLogout, credits, isGuest, onLoginClick }) => {
|
||||
const { t } = useTranslation();
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const settingsAnchorRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const handleItemClick = (id: string) => {
|
||||
if (id === SETTINGS_ID) {
|
||||
setSettingsOpen(v => !v);
|
||||
return;
|
||||
}
|
||||
onNavigate(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{settingsOpen && <div className="settings-backdrop" onClick={() => setSettingsOpen(false)} />}
|
||||
{settingsOpen && (
|
||||
<SettingsPanel
|
||||
variant="bottom"
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
onNavigate={onNavigate}
|
||||
activeItem={activeItem}
|
||||
userInfo={userInfo}
|
||||
credits={credits}
|
||||
isGuest={isGuest}
|
||||
onLogout={onLogout}
|
||||
onLoginClick={onLoginClick}
|
||||
anchorRef={settingsAnchorRef}
|
||||
/>
|
||||
)}
|
||||
<nav className="bottom-nav">
|
||||
{NAV_ITEMS.map(item => {
|
||||
const isActive = item.id === SETTINGS_ID ? settingsOpen : activeItem === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
ref={item.id === SETTINGS_ID ? settingsAnchorRef : undefined}
|
||||
className={`bottom-nav-item ${isActive ? 'active' : ''}`}
|
||||
onClick={() => handleItemClick(item.id)}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{t(item.labelKey)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BottomNav;
|
||||
@ -2,10 +2,11 @@ import React, { useState, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CitySelectModal, { REGIONS } from './CitySelectModal';
|
||||
import { useOverlayClose } from '../hooks/useOverlayClose';
|
||||
|
||||
interface BusinessNameInputModalProps {
|
||||
onClose: () => void;
|
||||
onSubmit: (businessName: string, address: string, category: string, officialSiteUrl: string) => void;
|
||||
onSubmit: (businessName: string, address: string, category: string) => void;
|
||||
}
|
||||
|
||||
const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose, onSubmit }) => {
|
||||
@ -14,8 +15,8 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
|
||||
const [selectedCity, setSelectedCity] = useState('');
|
||||
const [detailAddress, setDetailAddress] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const [officialSiteUrl, setOfficialSiteUrl] = useState('');
|
||||
const [isCityModalOpen, setIsCityModalOpen] = useState(false);
|
||||
const overlayCloseHandlers = useOverlayClose(onClose);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = 'hidden';
|
||||
@ -43,12 +44,7 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
|
||||
const handleSubmit = () => {
|
||||
if (!isValid) return;
|
||||
const fullAddress = `${selectedCity} ${detailAddress.trim()}`;
|
||||
// 프로토콜 없이 입력하면 https:// 를 붙여서 전달
|
||||
const trimmedUrl = officialSiteUrl.trim();
|
||||
const normalizedUrl = trimmedUrl && !/^https?:\/\//i.test(trimmedUrl)
|
||||
? `https://${trimmedUrl}`
|
||||
: trimmedUrl;
|
||||
onSubmit(businessName.trim(), fullAddress, category.trim(), normalizedUrl);
|
||||
onSubmit(businessName.trim(), fullAddress, category.trim());
|
||||
onClose();
|
||||
};
|
||||
|
||||
@ -61,7 +57,7 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div className="manual-modal-backdrop" onClick={onClose}>
|
||||
<div className="manual-modal-backdrop" {...overlayCloseHandlers}>
|
||||
<div className="manual-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="manual-modal-header">
|
||||
<span className="manual-modal-title">{t('landing.hero.manualModalTitle')}</span>
|
||||
@ -124,19 +120,6 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="manual-modal-field">
|
||||
<label className="manual-modal-label">{t('landing.hero.manualLabelSiteUrl')}</label>
|
||||
<input
|
||||
type="url"
|
||||
className="manual-modal-input"
|
||||
placeholder={t('landing.hero.manualPlaceholderSiteUrl')}
|
||||
value={officialSiteUrl}
|
||||
onChange={e => setOfficialSiteUrl(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
maxLength={2048}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="manual-modal-actions">
|
||||
<button type="button" className="manual-modal-cancel" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useOverlayClose } from '../hooks/useOverlayClose';
|
||||
|
||||
export const REGIONS: { label: string; cities: string[] }[] = [
|
||||
{
|
||||
@ -96,8 +97,10 @@ const CitySelectModal: React.FC<CitySelectModalProps> = ({ selected, onSelect, o
|
||||
onClose();
|
||||
};
|
||||
|
||||
const overlayCloseHandlers = useOverlayClose(onClose);
|
||||
|
||||
return (
|
||||
<div className="city-modal-backdrop" onClick={onClose}>
|
||||
<div className="city-modal-backdrop" {...overlayCloseHandlers}>
|
||||
<div className="city-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="city-modal-header">
|
||||
<span className="city-modal-title">
|
||||
|
||||
201
src/components/CompletionView.tsx
Normal file
201
src/components/CompletionView.tsx
Normal file
@ -0,0 +1,201 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 생성 완료 화면의 공용 레이아웃 (ADO2 영상 · 썰박스 공통).
|
||||
*
|
||||
* **순수 표현 컴포넌트다** — 생성·폴링·다운로드 로직은 전혀 갖지 않는다.
|
||||
* `CompletionContent`(ADO2)는 영상 생성과 자막 폴링까지 직접 수행하는 컨테이너라
|
||||
* 그대로 재사용할 수 없었고, 그래서 화면만 여기로 분리했다.
|
||||
*
|
||||
* 양쪽의 차이는 정보 패널뿐이다:
|
||||
* - ADO2 : 장르 · 해상도 → 가사 전문
|
||||
* - 썰박스 : 시나리오 · 생성일시 → 시나리오 설명
|
||||
* 그 차이를 `metaLines`/`detail` 슬롯으로 받는다.
|
||||
*
|
||||
* 마크업과 클래스명(`comp2-*`)은 기존 ADO2 완료 화면 그대로다. 클래스를 새로
|
||||
* 만들지 않아야 [generation-flow.css](../styles/generation-flow.css) 의 반응형·
|
||||
* 다크 팔레트가 두 화면에 동일하게 적용된다.
|
||||
*/
|
||||
|
||||
/** 영상 영역이 무엇을 그릴지 */
|
||||
export type CompletionVideoState =
|
||||
/** 생성/렌더 진행 중 — 스피너 + 진행률 */
|
||||
| 'loading'
|
||||
/** 실패 — 메시지 + (있으면) 재시도 버튼 */
|
||||
| 'error'
|
||||
/** 재생 가능 */
|
||||
| 'ready'
|
||||
/** URL 을 아직 못 받았지만 진행 중도 실패도 아님 — 빈 자리만 유지 */
|
||||
| 'empty';
|
||||
|
||||
interface CompletionViewProps {
|
||||
videoState: CompletionVideoState;
|
||||
videoUrl?: string | null;
|
||||
|
||||
/** `videoState === 'loading'` 일 때만 쓰인다 */
|
||||
loadingText?: string;
|
||||
/** 0~100. 표시용 값이며 크리핑 보간은 호출부 책임이다 */
|
||||
progress?: number;
|
||||
|
||||
/** `videoState === 'error'` 일 때만 쓰인다 */
|
||||
errorMessage?: string | null;
|
||||
/** 없으면 재시도 버튼을 그리지 않는다 (썰박스는 이 화면에서 재시도가 없다) */
|
||||
onRetry?: () => void;
|
||||
|
||||
/** 정보 패널 상단 파일명 */
|
||||
fileName: string;
|
||||
/** 메타 줄. 줄 사이에 구분선이 들어간다 */
|
||||
metaLines: React.ReactNode[];
|
||||
/** 메타 아래 상세 영역의 제목 */
|
||||
detailLabel: string;
|
||||
/** 상세 본문 (ADO2=가사 단락, 썰박스=시나리오 설명) */
|
||||
detail: React.ReactNode;
|
||||
|
||||
onDownload: () => void;
|
||||
downloadDisabled?: boolean;
|
||||
/** 진행 중에는 '다운로드 중...' 처럼 바뀌므로 호출부가 문구를 정한다 */
|
||||
downloadLabel: string;
|
||||
|
||||
onUpload: () => void;
|
||||
uploadDisabled?: boolean;
|
||||
|
||||
/**
|
||||
* 이 화면에 딸린 오버레이(SNS 업로드 모달 등).
|
||||
*
|
||||
* `.comp2-page` 안에 그려지므로 오버레이 쪽에서 타이포그래피 상속을
|
||||
* 끊어야 한다 — `.social-posting-overlay` 의 `text-align` 참조.
|
||||
*/
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const CompletionView: React.FC<CompletionViewProps> = ({
|
||||
videoState,
|
||||
videoUrl,
|
||||
loadingText,
|
||||
progress = 0,
|
||||
errorMessage,
|
||||
onRetry,
|
||||
fileName,
|
||||
metaLines,
|
||||
detailLabel,
|
||||
detail,
|
||||
onDownload,
|
||||
downloadDisabled,
|
||||
downloadLabel,
|
||||
onUpload,
|
||||
uploadDisabled,
|
||||
children,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<main className="comp2-page">
|
||||
<div className="comp2-title-row">
|
||||
<h1 className="comp2-page-title">{t('completion.contentComplete')}</h1>
|
||||
<p className="comp2-page-subtitle">{t('completion.contentCompleteDesc')}</p>
|
||||
</div>
|
||||
|
||||
<div className="comp2-container">
|
||||
<div className="comp2-grid">
|
||||
{/* 왼쪽: 영상 */}
|
||||
<div className="comp2-video-section">
|
||||
<div className="comp2-video-wrapper">
|
||||
{videoState === 'loading' ? (
|
||||
<div className="comp2-video-loading">
|
||||
<div className="loading-spinner">
|
||||
<div className="loading-ring"></div>
|
||||
<div className="loading-dot">
|
||||
<div className="loading-dot-inner"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="comp2-loading-text">{loadingText}</p>
|
||||
<div className="loading-progress-wrapper">
|
||||
<div className="loading-progress-bar">
|
||||
<div
|
||||
className="loading-progress-fill"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="loading-progress-text">{Math.floor(progress)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
) : videoState === 'error' ? (
|
||||
<div className="comp2-video-error">
|
||||
<svg
|
||||
className="comp2-error-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4M12 16h.01" />
|
||||
</svg>
|
||||
<p className="comp2-error-text">{errorMessage}</p>
|
||||
{onRetry && (
|
||||
<button onClick={onRetry} className="comp2-retry-btn">
|
||||
{t('completion.retry')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : videoState === 'ready' && videoUrl ? (
|
||||
<video src={videoUrl} className="comp2-video-player" controls playsInline />
|
||||
) : (
|
||||
<div className="comp2-video-placeholder"></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 콘텐츠 정보 */}
|
||||
<div className="comp2-info-section">
|
||||
<div className="comp2-info-header">
|
||||
<span className="comp2-info-label">{t('completion.contentInfo')}</span>
|
||||
</div>
|
||||
<div className="comp2-file-info">
|
||||
<h3 className="comp2-filename">{fileName}</h3>
|
||||
</div>
|
||||
<div className="comp2-info-content">
|
||||
<div className="comp2-meta-grid">
|
||||
{metaLines.map((line, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<div className="comp2-meta-item">
|
||||
<span className="comp2-meta-label">{line}</span>
|
||||
</div>
|
||||
<div className="comp2-meta-divider"></div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
<div className="comp2-lyrics-section">
|
||||
<span className="comp2-meta-label">{detailLabel}</span>
|
||||
{detail}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 하단 버튼 */}
|
||||
<div className="comp2-buttons">
|
||||
<button
|
||||
onClick={onDownload}
|
||||
disabled={downloadDisabled}
|
||||
className="comp2-btn comp2-btn-secondary"
|
||||
>
|
||||
{downloadLabel}
|
||||
</button>
|
||||
<button
|
||||
onClick={onUpload}
|
||||
disabled={uploadDisabled}
|
||||
className="comp2-btn comp2-btn-primary"
|
||||
>
|
||||
{t('completion.uploadToSocial')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompletionView;
|
||||
@ -1,13 +1,19 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API_URL, toggleVideoLike } from '../utils/api';
|
||||
import { buildVideoShareUrl, tryNativeShare } from '../utils/nativeShare';
|
||||
import { API_URL, toggleVideoLike, isLoggedIn } from '../utils/api';
|
||||
import { buildContentShareUrl, tryNativeShare } from '../utils/nativeShare';
|
||||
import { ContentType } from '../types/api';
|
||||
import LoginPromptModal from './LoginPromptModal';
|
||||
|
||||
interface ContentCardSocialActionsProps {
|
||||
videoId: number;
|
||||
/**
|
||||
* 콘텐츠 종류. video.id 와 ssul_content.id 가 겹치므로 좋아요 API 와
|
||||
* 공유 URL(/video/{id} vs /ssul/{id}) 분기에 반드시 필요하다.
|
||||
*/
|
||||
contentType?: ContentType;
|
||||
storeName: string;
|
||||
region?: string;
|
||||
title?: string | null;
|
||||
commentCount: number;
|
||||
initialLikeCount: number;
|
||||
initialIsLiked?: boolean;
|
||||
@ -15,8 +21,9 @@ interface ContentCardSocialActionsProps {
|
||||
|
||||
const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
|
||||
videoId,
|
||||
contentType = 'video',
|
||||
storeName,
|
||||
title,
|
||||
region,
|
||||
commentCount,
|
||||
initialLikeCount,
|
||||
initialIsLiked = false,
|
||||
@ -25,16 +32,19 @@ const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
|
||||
|
||||
const [likeCount, setLikeCount] = useState(initialLikeCount);
|
||||
const [isLiked, setIsLiked] = useState(initialIsLiked);
|
||||
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||
const likeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const shareUrl = buildVideoShareUrl(API_URL, videoId);
|
||||
const shareTitle = title || storeName || t('videoDetail.kakaoDefaultTitle');
|
||||
const shareUrl = buildContentShareUrl(API_URL, videoId, contentType);
|
||||
const shareTitle = storeName || t('videoDetail.kakaoDefaultTitle');
|
||||
const shareDescription = t('videoDetail.kakaoDescription', { region: region ?? '' });
|
||||
|
||||
const handleShareBtnClick = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const handled = await tryNativeShare({
|
||||
title: shareTitle,
|
||||
text: shareDescription,
|
||||
url: shareUrl,
|
||||
});
|
||||
if (handled) {
|
||||
@ -51,6 +61,11 @@ const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
|
||||
const handleLikeClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!isLoggedIn()) {
|
||||
setShowLoginModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const prevLiked = isLiked;
|
||||
const prevCount = likeCount;
|
||||
|
||||
@ -60,7 +75,7 @@ const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
|
||||
if (likeDebounceRef.current) clearTimeout(likeDebounceRef.current);
|
||||
likeDebounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const res = await toggleVideoLike(String(videoId));
|
||||
const res = await toggleVideoLike(String(videoId), contentType);
|
||||
setIsLiked(res.is_liked);
|
||||
setLikeCount(res.like_count);
|
||||
} catch (err) {
|
||||
@ -100,6 +115,10 @@ const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
|
||||
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" /><line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{showLoginModal && (
|
||||
<LoginPromptModal onClose={() => setShowLoginModal(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,64 +0,0 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getKakaoLoginUrl, isLoggedIn } from '../utils/api';
|
||||
import LanguageSwitch from './LanguageSwitch';
|
||||
|
||||
interface HeaderProps {
|
||||
onStartClick?: () => void;
|
||||
}
|
||||
|
||||
const Header: React.FC<HeaderProps> = ({ onStartClick }) => {
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const loggedIn = isLoggedIn();
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (isLoading) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await getKakaoLoginUrl();
|
||||
window.location.href = response.auth_url;
|
||||
} catch (err) {
|
||||
console.error('Failed to get Kakao login URL:', err);
|
||||
alert(t('header.loginFailedAlert'));
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStart = () => {
|
||||
if (onStartClick) {
|
||||
onStartClick();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="landing-header">
|
||||
<div className="header-logo">
|
||||
<img src="/assets/images/ado2-header-logo.svg" alt="ADO2" />
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<LanguageSwitch />
|
||||
{loggedIn ? (
|
||||
<button
|
||||
className="header-start-btn"
|
||||
onClick={handleStart}
|
||||
>
|
||||
{t('header.start')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="header-login-btn"
|
||||
onClick={handleLogin}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? t('header.loading') : t('header.login')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
46
src/components/LicenseBadge.tsx
Normal file
46
src/components/LicenseBadge.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { P2vLicense } from '../utils/p2vApi';
|
||||
|
||||
/**
|
||||
* 스타일 템플릿의 배포 등급 표시.
|
||||
*
|
||||
* 왜 카드마다 붙이나 — 퍼블릭 도메인 명화와 영화 포스터가 한 그리드에 섞여 있어
|
||||
* 페이지 배너로는 **어느 결과물이 공개 가능한지** 구분되지 않는다. 경고 문구는
|
||||
* 실제로 고른 순간에만 띄운다(항상 떠 있으면 아무도 안 읽는다).
|
||||
*/
|
||||
|
||||
const CLASS: Record<P2vLicense, string> = {
|
||||
'public-domain': 'ok',
|
||||
'internal-only': 'warn',
|
||||
// 내부 전용과 색을 나눈다 — 해제 조건도, 책임 주체도 다르다
|
||||
'user-uploaded': 'unknown',
|
||||
};
|
||||
|
||||
export const LicenseBadge: React.FC<{ license: P2vLicense }> = ({ license }) => {
|
||||
const { t } = useTranslation();
|
||||
const variant = CLASS[license] ?? CLASS['internal-only'];
|
||||
return (
|
||||
<span className={`p2v-license p2v-license--${variant}`}>
|
||||
{t(`poster.styling.license.${license}`)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
/** 내부 전용 템플릿을 고른 순간의 경고 */
|
||||
export const InternalOnlyWarning: React.FC<{ note?: string }> = ({ note }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="p2v-notice p2v-notice--warn">
|
||||
<b>{t('poster.styling.internalOnlyTitle')}</b>
|
||||
{' — '}
|
||||
{note || t('poster.styling.internalOnlyBody')}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** 사용자가 올린 레퍼런스 고지 — 남의 저작물일 수 있다 */
|
||||
export const UserReferenceNotice: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
return <div className="p2v-notice p2v-notice--unknown">{t('poster.styling.userRefNotice')}</div>;
|
||||
};
|
||||
@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getKakaoLoginUrl } from '../utils/api';
|
||||
import { useOverlayClose } from '../hooks/useOverlayClose';
|
||||
|
||||
interface LoginPromptModalProps {
|
||||
onClose: () => void;
|
||||
@ -8,6 +9,7 @@ interface LoginPromptModalProps {
|
||||
|
||||
const LoginPromptModal: React.FC<LoginPromptModalProps> = ({ onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
const overlayCloseHandlers = useOverlayClose(onClose);
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
@ -24,22 +26,24 @@ const LoginPromptModal: React.FC<LoginPromptModalProps> = ({ onClose }) => {
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(255, 255, 255, 0.25)',
|
||||
background: 'rgba(0, 0, 0, 0.7)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
padding: '16px',
|
||||
}}
|
||||
onClick={onClose}
|
||||
{...overlayCloseHandlers}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: '#01282A',
|
||||
border: '1px solid rgba(155, 202, 204, 0.2)',
|
||||
borderRadius: '16px',
|
||||
padding: '40px 32px',
|
||||
width: '360px',
|
||||
maxWidth: '90vw',
|
||||
border: '1px solid rgba(166, 255, 234, 0.15)',
|
||||
borderRadius: '20px',
|
||||
boxShadow: '0 24px 60px rgba(0, 0, 0, 0.5)',
|
||||
padding: '44px 32px 36px',
|
||||
width: '380px',
|
||||
maxWidth: '100%',
|
||||
textAlign: 'center',
|
||||
position: 'relative',
|
||||
}}
|
||||
@ -64,27 +68,49 @@ const LoginPromptModal: React.FC<LoginPromptModalProps> = ({ onClose }) => {
|
||||
<line x1="16" y1="4" x2="4" y2="16"/>
|
||||
</svg>
|
||||
</button>
|
||||
<h2 style={{ color: '#FFFFFF', fontSize: '20px', fontWeight: 600, marginBottom: '12px' }}>
|
||||
|
||||
<div
|
||||
style={{
|
||||
width: '56px',
|
||||
height: '56px',
|
||||
margin: '0 auto 20px',
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(166, 255, 234, 0.1)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#A6FFEA" strokeWidth="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h2 style={{ color: '#FFFFFF', fontSize: '22px', fontWeight: 700, marginBottom: '10px', lineHeight: 1.4 }}>
|
||||
{t('loginPrompt.title')}
|
||||
</h2>
|
||||
<p style={{ color: '#9BCACC', fontSize: '14px', lineHeight: 1.5, marginBottom: '28px', whiteSpace: 'pre-line' }}>
|
||||
{t('loginPrompt.description')}
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
style={{
|
||||
width: '50%',
|
||||
margin: '0 auto',
|
||||
padding: '14px',
|
||||
width: '100%',
|
||||
padding: '15px',
|
||||
background: '#FEE500',
|
||||
color: '#3C1E1E',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: '15px',
|
||||
fontWeight: 600,
|
||||
borderRadius: '10px',
|
||||
fontSize: '16px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
boxShadow: '0 4px 14px rgba(254, 229, 0, 0.25)',
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
49
src/components/P2vKeyGate.tsx
Normal file
49
src/components/P2vKeyGate.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getP2vKey, setP2vKey } from '../utils/p2vApi';
|
||||
|
||||
interface P2vKeyGateProps {
|
||||
/** 키를 저장한 뒤 호출 — 호출부가 실패했던 요청을 다시 태운다 */
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* P2V 접근 키 입력.
|
||||
*
|
||||
* P2V 웹은 401 에서 `window.prompt` 를 띄우지만(내부 도구라 그걸로 충분했다),
|
||||
* castad 안에서는 제품 화면 한가운데라 시스템 다이얼로그가 튀어나오면 어색하다.
|
||||
* 디자인 시스템 문법(민트 테두리 알림 + pill 입력)으로 인라인 처리한다.
|
||||
*/
|
||||
const P2vKeyGate: React.FC<P2vKeyGateProps> = ({ onSaved }) => {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState(getP2vKey());
|
||||
|
||||
const save = () => {
|
||||
if (!value.trim()) return;
|
||||
setP2vKey(value);
|
||||
onSaved();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p2v-keygate">
|
||||
<p className="p2v-keygate__title">{t('poster.key.title')}</p>
|
||||
<p className="p2v-keygate__desc">{t('poster.key.desc')}</p>
|
||||
<div className="p2v-keygate__row">
|
||||
<input
|
||||
type="password"
|
||||
className="p2v-input"
|
||||
placeholder={t('poster.key.placeholder')}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && save()}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button type="button" className="p2v-btn-mint" disabled={!value.trim()} onClick={save}>
|
||||
{t('poster.key.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default P2vKeyGate;
|
||||
80
src/components/PipelineTabs.tsx
Normal file
80
src/components/PipelineTabs.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
BookIcon,
|
||||
PaletteIcon,
|
||||
PosterVideoIcon,
|
||||
StorefrontIcon,
|
||||
} from './pipelineIcons';
|
||||
|
||||
/**
|
||||
* 생성할 콘텐츠 종류. 진입 화면에서 고르면 이후 플로우 전체가 갈린다.
|
||||
*
|
||||
* poster·styling 은 castad 백엔드가 아니라 **P2V 서버(:8010)** 를 쓴다(utils/p2vApi.ts).
|
||||
*/
|
||||
export type Pipeline = 'ado2' | 'ssul' | 'poster' | 'styling';
|
||||
|
||||
/** 유효한 파이프라인 값인가 — localStorage 복원 때 쓴다 */
|
||||
export const PIPELINES: readonly Pipeline[] = ['ado2', 'ssul', 'poster', 'styling'];
|
||||
|
||||
export const isPipeline = (value: unknown): value is Pipeline =>
|
||||
typeof value === 'string' && (PIPELINES as readonly string[]).includes(value);
|
||||
|
||||
interface PipelineTabsProps {
|
||||
value: Pipeline;
|
||||
onChange: (value: Pipeline) => void;
|
||||
}
|
||||
|
||||
const TABS: { id: Pipeline; labelKey: string; Icon: React.FC<{ size?: number }> }[] = [
|
||||
{ id: 'ado2', labelKey: 'pipelineTabs.ado2', Icon: StorefrontIcon },
|
||||
{ id: 'ssul', labelKey: 'pipelineTabs.ssul', Icon: BookIcon },
|
||||
{ id: 'poster', labelKey: 'pipelineTabs.poster', Icon: PosterVideoIcon },
|
||||
{ id: 'styling', labelKey: 'pipelineTabs.styling', Icon: PaletteIcon },
|
||||
];
|
||||
|
||||
/**
|
||||
* 파이프라인 전환 탭 (아이콘 타일 + 기능명).
|
||||
*
|
||||
* 밑줄 탭에서 타일로 바꾼 이유: 파이프라인이 2개일 때는 글자만으로 충분했지만
|
||||
* 4개가 되자 "포스터 영상"과 "포스터 스타일"처럼 **글자가 겹치는 항목**이 생겨
|
||||
* 훑어볼 때 구분이 안 됐다. 형태(아이콘)가 붙으면 읽지 않고도 갈라진다.
|
||||
*
|
||||
* 색은 파이프라인별로 나누지 않는다 — 참조한 레퍼런스는 항목마다 파스텔색을 쓰지만,
|
||||
* ADO2 는 민트 액센트 + 퍼플 CTA 한 곳이 규칙이라 4색을 쓰면 팔레트가 깨진다.
|
||||
* 구분은 형태로, 상태(선택)는 민트로 나눈다.
|
||||
*
|
||||
* 스타일은 generation-flow.css 의 `.pipeline-tabs*` 참조.
|
||||
*
|
||||
* 접근성: castad 의 기존 탭(`.myinfo-tab` 등)에는 role/aria 가 없지만,
|
||||
* 이 컴포넌트를 기점으로 표준 tablist 패턴을 도입한다.
|
||||
*/
|
||||
const PipelineTabs: React.FC<PipelineTabsProps> = ({ value, onChange }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pipeline-tabs"
|
||||
data-active={value}
|
||||
role="tablist"
|
||||
aria-label={t('pipelineTabs.ariaLabel')}
|
||||
>
|
||||
{TABS.map(({ id, labelKey, Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={value === id}
|
||||
className="pipeline-tab"
|
||||
onClick={() => onChange(id)}
|
||||
>
|
||||
<span className="pipeline-tab__tile">
|
||||
<Icon size={24} />
|
||||
</span>
|
||||
<span className="pipeline-tab__label">{t(labelKey)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PipelineTabs;
|
||||
106
src/components/PosterDropzone.tsx
Normal file
106
src/components/PosterDropzone.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface PosterDropzoneProps {
|
||||
file: File | null;
|
||||
onFile: (file: File | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 해상도 하한. **차단하지 않고 알리기만 한다** —
|
||||
* 지자체·소상공인 포스터는 고해상도 원본 수급이 어려운 게 현실이라
|
||||
* 막아버리면 실제 소재의 상당수가 들어오지 못한다.
|
||||
*/
|
||||
const SOFT_LONG_EDGE = 1000;
|
||||
|
||||
const ACCEPTED = /^image\/(jpeg|png|webp|gif)$/;
|
||||
|
||||
/**
|
||||
* 포스터 업로드 드롭존. F1(영상)·F2(스타일링) 양쪽이 쓴다.
|
||||
*
|
||||
* 입력창을 4:5 세로로 두는 것은 포스터가 세로물이기 때문이다 — 가로 박스에 세로
|
||||
* 이미지를 넣으면 미리보기가 작게 박혀 무엇을 올렸는지 확인이 안 된다.
|
||||
*/
|
||||
const PosterDropzone: React.FC<PosterDropzoneProps> = ({ file, onFile }) => {
|
||||
const { t } = useTranslation();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
// objectURL 은 명시적으로 놓아줘야 한다. 시연 중 포스터를 여러 장 갈아 끼우면
|
||||
// 해제 안 된 URL 이 그대로 쌓인다(castad 의 revokeAllPreviews 와 같은 이유).
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (preview) URL.revokeObjectURL(preview);
|
||||
};
|
||||
}, [preview]);
|
||||
|
||||
const accept = useCallback((f: File) => {
|
||||
if (!ACCEPTED.test(f.type)) {
|
||||
setError(t('poster.dropzone.badType'));
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
const url = URL.createObjectURL(f);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const edge = Math.max(img.width, img.height);
|
||||
setNotice(edge < SOFT_LONG_EDGE ? t('poster.dropzone.lowRes', { edge }) : null);
|
||||
setPreview((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return url;
|
||||
});
|
||||
onFile(f);
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
setError(t('poster.dropzone.badType'));
|
||||
};
|
||||
img.src = url;
|
||||
}, [onFile, t]);
|
||||
|
||||
return (
|
||||
<div className="p2v-dropzone-wrap">
|
||||
<div
|
||||
className={`p2v-dropzone${preview ? ' has-file' : ''}${dragging ? ' dragging' : ''}`}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
const f = e.dataTransfer.files?.[0];
|
||||
if (f) accept(f);
|
||||
}}
|
||||
>
|
||||
{preview ? (
|
||||
<img src={preview} alt={t('poster.dropzone.previewAlt')} />
|
||||
) : (
|
||||
<div className="p2v-dropzone__empty">
|
||||
<p className="p2v-dropzone__cta">{t('poster.dropzone.cta')}</p>
|
||||
<p className="p2v-dropzone__hint">{t('poster.dropzone.formats')}</p>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
hidden
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) accept(f); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="p2v-note warn">{error}</p>}
|
||||
{notice && <p className="p2v-note">{notice}</p>}
|
||||
{file && (
|
||||
<p className="p2v-note">
|
||||
{file.name} · {(file.size / 1024 / 1024).toFixed(1)}MB
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PosterDropzone;
|
||||
@ -38,7 +38,7 @@ const extractUrl = (text: string): string | null => {
|
||||
interface SearchInputFormProps {
|
||||
onAnalyze?: (value: string, type: SearchType) => void;
|
||||
onAutocomplete?: (data: AutocompleteRequest) => void;
|
||||
onManualInput?: (businessName: string, address: string, category: string, officialSiteUrl?: string) => void;
|
||||
onManualInput?: (businessName: string, address: string, category: string) => void;
|
||||
/** 직접입력 버튼 클릭 시 기본 동작(모달 열기)을 대체합니다. 제공 시 모달을 직접 관리해야 합니다. */
|
||||
onManualButtonClick?: () => void;
|
||||
error?: string | null;
|
||||
@ -340,9 +340,9 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
|
||||
{!onManualButtonClick && isManualModalOpen && (
|
||||
<BusinessNameInputModal
|
||||
onClose={() => setIsManualModalOpen(false)}
|
||||
onSubmit={(businessName, address, category, officialSiteUrl) => {
|
||||
onSubmit={(businessName, address, category) => {
|
||||
setIsManualModalOpen(false);
|
||||
onManualInput?.(businessName, address, category, officialSiteUrl);
|
||||
onManualInput?.(businessName, address, category);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
141
src/components/SettingsPanel.tsx
Normal file
141
src/components/SettingsPanel.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { UserMeResponse } from '../types/api';
|
||||
import LanguageSwitch from './LanguageSwitch';
|
||||
import { NAV } from './navItems';
|
||||
|
||||
interface SettingsPanelProps {
|
||||
variant: 'sidebar' | 'bottom';
|
||||
onClose: () => void;
|
||||
onNavigate: (id: string) => void;
|
||||
activeItem: string;
|
||||
userInfo?: UserMeResponse | null;
|
||||
credits?: number | null;
|
||||
isGuest: boolean;
|
||||
isLoggingOut?: boolean;
|
||||
onLogout?: () => void;
|
||||
onLoginClick: () => void;
|
||||
/** 팝업을 여는 설정 버튼 영역. 외부 클릭 닫기에서 제외해 재클릭 시 토글이 정상 동작하게 한다 */
|
||||
anchorRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
const SettingsPanel: React.FC<SettingsPanelProps> = ({
|
||||
variant,
|
||||
onClose,
|
||||
onNavigate,
|
||||
activeItem,
|
||||
userInfo,
|
||||
credits,
|
||||
isGuest,
|
||||
isLoggingOut,
|
||||
onLogout,
|
||||
onLoginClick,
|
||||
anchorRef,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (anchorRef?.current?.contains(target)) return;
|
||||
if (panelRef.current && !panelRef.current.contains(target)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [onClose, anchorRef]);
|
||||
|
||||
return (
|
||||
<div ref={panelRef} className={`settings-panel settings-panel--${variant}`}>
|
||||
{/* 1) 프로필 — 패널 헤더 역할 */}
|
||||
{!isGuest && (
|
||||
<>
|
||||
<div className="profile-section settings-panel-profile">
|
||||
{userInfo?.profile_image_url || userInfo?.thumbnail_image_url ? (
|
||||
<img
|
||||
src={userInfo.thumbnail_image_url || userInfo.profile_image_url || ''}
|
||||
alt="Profile"
|
||||
className="profile-avatar"
|
||||
/>
|
||||
) : (
|
||||
<div className="profile-avatar profile-avatar-default">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="profile-name">{userInfo?.nickname || t('sidebar.defaultUser')}</p>
|
||||
{credits !== null && credits !== undefined && (
|
||||
<p className="profile-credits">{t('sidebar.credits', { count: credits })}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 2) 메뉴 그룹 — 대시보드 */}
|
||||
<button
|
||||
className={`settings-panel-item ${activeItem === NAV.DASHBOARD ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
onNavigate(NAV.DASHBOARD);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<rect x="3" y="3" width="7" height="9" /><rect x="14" y="3" width="7" height="5" />
|
||||
<rect x="14" y="12" width="7" height="9" /><rect x="3" y="16" width="7" height="5" />
|
||||
</svg>
|
||||
<span>{t('sidebar.dashboard')}</span>
|
||||
</button>
|
||||
|
||||
<div className="settings-panel-divider" />
|
||||
|
||||
{/* 3) 하단 그룹 — 언어 전환 / 로그아웃(로그인) / 고객의견 */}
|
||||
<div className="settings-panel-language">
|
||||
<LanguageSwitch isCollapsed={false} />
|
||||
</div>
|
||||
|
||||
{isGuest ? (
|
||||
<button className="settings-panel-item" onClick={onLoginClick}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" /><polyline points="10 17 15 12 10 7" /><line x1="15" y1="12" x2="3" y2="12" />
|
||||
</svg>
|
||||
<span>{t('sidebar.login')}</span>
|
||||
</button>
|
||||
) : (
|
||||
<button className="settings-panel-item" onClick={onLogout} disabled={isLoggingOut}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><polyline points="16 17 21 12 16 7" /><line x1="21" y1="12" x2="9" y2="12" />
|
||||
</svg>
|
||||
<span>{isLoggingOut ? t('sidebar.loggingOut') : t('sidebar.logout')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<a
|
||||
href="https://forms.gle/4a8mGebBYtdesvby9"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="settings-panel-item"
|
||||
title={t('sidebar.inquiry')}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
<span>{t('sidebar.inquiry')}</span>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPanel;
|
||||
@ -1,32 +1,30 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { UserMeResponse } from '../types/api';
|
||||
import { logout } from '../utils/api';
|
||||
import LanguageSwitch from './LanguageSwitch';
|
||||
import { NAV_ITEMS, SETTINGS_ID } from './navItems';
|
||||
const TOP_NAV_ITEMS = NAV_ITEMS.filter(item => item.id !== SETTINGS_ID);
|
||||
const SETTINGS_NAV_ITEM = NAV_ITEMS.find(item => item.id === SETTINGS_ID)!;
|
||||
import SettingsPanel from './SettingsPanel';
|
||||
|
||||
interface SidebarItemProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
isActive?: boolean;
|
||||
isCollapsed: boolean;
|
||||
isDisabled?: boolean;
|
||||
onClick?: () => void;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const SidebarItem: React.FC<SidebarItemProps> = ({ icon, label, isActive, isCollapsed, isDisabled, onClick, id }) => {
|
||||
const SidebarItem: React.FC<SidebarItemProps> = ({ icon, label, isActive, onClick }) => {
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
onClick={isDisabled ? undefined : onClick}
|
||||
className={`sidebar-item ${isActive ? 'active' : ''} ${isCollapsed ? 'collapsed' : ''} ${isDisabled ? 'disabled' : ''}`}
|
||||
title={isCollapsed ? label : ""}
|
||||
onClick={onClick}
|
||||
className={`sidebar-item ${isActive ? 'active' : ''}`}
|
||||
>
|
||||
<div className="sidebar-item-icon">
|
||||
{icon}
|
||||
</div>
|
||||
{!isCollapsed && <span className="sidebar-item-label">{label}</span>}
|
||||
<span className="sidebar-item-label">{label}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -34,22 +32,20 @@ const SidebarItem: React.FC<SidebarItemProps> = ({ icon, label, isActive, isColl
|
||||
interface SidebarProps {
|
||||
activeItem: string;
|
||||
onNavigate: (id: string) => void;
|
||||
onHome?: () => void;
|
||||
onLogoClick?: () => void;
|
||||
userInfo?: UserMeResponse | null;
|
||||
onLogout?: () => void;
|
||||
credits?: number | null;
|
||||
tutorialAvailable?: boolean;
|
||||
tutorialEnabled?: boolean;
|
||||
onToggleTutorial?: () => void;
|
||||
isGuest: boolean;
|
||||
onLoginClick: () => void;
|
||||
}
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userInfo, onLogout, credits, tutorialAvailable, tutorialEnabled, onToggleTutorial }) => {
|
||||
const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onLogoClick, userInfo, onLogout, credits, isGuest, onLoginClick }) => {
|
||||
const { t } = useTranslation();
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [isMobileOpen, setIsMobileOpen] = useState(false);
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const settingsAnchorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 로그아웃 처리
|
||||
const handleLogout = async () => {
|
||||
if (isLoggingOut) return;
|
||||
setIsLoggingOut(true);
|
||||
@ -58,184 +54,69 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userI
|
||||
onLogout?.();
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
// 에러가 나도 로컬 토큰은 이미 삭제됨, 홈으로 이동
|
||||
onLogout?.();
|
||||
} finally {
|
||||
setIsLoggingOut(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (window.innerWidth < 768) {
|
||||
setIsMobileOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
handleResize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
const handleNavigate = (id: string) => {
|
||||
onNavigate(id);
|
||||
if (window.innerWidth < 768) {
|
||||
setIsMobileOpen(false);
|
||||
const handleItemClick = (id: string) => {
|
||||
if (id === SETTINGS_ID) {
|
||||
setSettingsOpen(v => !v);
|
||||
return;
|
||||
}
|
||||
onNavigate(id);
|
||||
};
|
||||
|
||||
const menuItems = [
|
||||
{ id: '대시보드', label: t('sidebar.dashboard'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg> },
|
||||
{ id: '새 프로젝트 만들기', label: t('sidebar.newProject'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> },
|
||||
{ id: 'ADO2 콘텐츠', label: t('sidebar.ado2Contents'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> },
|
||||
{ id: '내 콘텐츠', label: t('sidebar.myContents'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> },
|
||||
{ id: '콘텐츠 캘린더', label: t('contentCalendar.title'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> },
|
||||
{ id: '내 정보', label: t('sidebar.myInfo'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg> },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
onClick={() => setIsMobileOpen(true)}
|
||||
className="mobile-menu-btn"
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Mobile Overlay */}
|
||||
{isMobileOpen && (
|
||||
<div
|
||||
className="mobile-overlay"
|
||||
onClick={() => setIsMobileOpen(false)}
|
||||
<div className="sidebar expanded">
|
||||
<div className="sidebar-header">
|
||||
<img
|
||||
onClick={onLogoClick}
|
||||
src="/assets/images/ado2-sidebar-logo.svg"
|
||||
alt="ADO2"
|
||||
className="sidebar-logo"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className={`sidebar ${isCollapsed ? 'collapsed' : 'expanded'} ${isMobileOpen ? 'mobile-open' : 'mobile-closed'}`}>
|
||||
<div className={`sidebar-header ${isCollapsed ? 'collapsed' : ''}`}>
|
||||
{!isCollapsed && (
|
||||
<img
|
||||
onClick={onHome}
|
||||
src="/assets/images/ado2-sidebar-logo.svg"
|
||||
alt="ADO2"
|
||||
className="sidebar-logo"
|
||||
<div className="sidebar-menu no-scrollbar">
|
||||
{TOP_NAV_ITEMS.map(item => (
|
||||
<SidebarItem
|
||||
key={item.id}
|
||||
icon={item.icon}
|
||||
label={t(item.labelKey)}
|
||||
isActive={activeItem === item.id}
|
||||
onClick={() => handleItemClick(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<div className="sidebar-settings-anchor" ref={settingsAnchorRef}>
|
||||
<SidebarItem
|
||||
icon={SETTINGS_NAV_ITEM.icon}
|
||||
label={t(SETTINGS_NAV_ITEM.labelKey)}
|
||||
isActive={settingsOpen}
|
||||
onClick={() => handleItemClick(SETTINGS_NAV_ITEM.id)}
|
||||
/>
|
||||
{settingsOpen && (
|
||||
<SettingsPanel
|
||||
variant="sidebar"
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
onNavigate={onNavigate}
|
||||
activeItem={activeItem}
|
||||
userInfo={userInfo}
|
||||
credits={credits}
|
||||
isGuest={isGuest}
|
||||
isLoggingOut={isLoggingOut}
|
||||
onLogout={handleLogout}
|
||||
onLoginClick={onLoginClick}
|
||||
anchorRef={settingsAnchorRef}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.innerWidth < 768) {
|
||||
setIsMobileOpen(false);
|
||||
} else {
|
||||
setIsCollapsed(!isCollapsed);
|
||||
}
|
||||
}}
|
||||
className="p-1.5 text-gray-400 hover:text-white"
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-menu no-scrollbar">
|
||||
{menuItems.map(item => (
|
||||
<SidebarItem
|
||||
key={item.id}
|
||||
id={
|
||||
item.id === '내 정보'
|
||||
? 'sidebar-my-info'
|
||||
: item.id === 'ADO2 콘텐츠'
|
||||
? 'sidebar-ado2-contents'
|
||||
: undefined
|
||||
}
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
isCollapsed={isCollapsed}
|
||||
isActive={activeItem === item.id}
|
||||
isDisabled={item.disabled}
|
||||
onClick={() => handleNavigate(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
{/* 모바일 전용 튜토리얼 토글 */}
|
||||
{tutorialAvailable && (
|
||||
<button
|
||||
className={`sidebar-tutorial-btn ${tutorialEnabled ? 'active' : ''}`}
|
||||
onClick={() => onToggleTutorial?.()}
|
||||
title={tutorialEnabled ? t('sidebar.tutorialOff') : t('sidebar.tutorialOn')}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M12 8v4l3 3"/>
|
||||
</svg>
|
||||
<span className="sidebar-tutorial-label">{t('sidebar.tutorial')}</span>
|
||||
<span className={`sidebar-tutorial-badge ${tutorialEnabled ? 'on' : 'off'}`}>
|
||||
{tutorialEnabled ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="sidebar-language-switch">
|
||||
<LanguageSwitch isCollapsed={isCollapsed} />
|
||||
</div>
|
||||
|
||||
<div className={`profile-section ${isCollapsed ? 'collapsed' : ''}`}>
|
||||
{userInfo?.profile_image_url || userInfo?.thumbnail_image_url ? (
|
||||
<img
|
||||
src={userInfo.thumbnail_image_url || userInfo.profile_image_url || ''}
|
||||
alt="Profile"
|
||||
className="profile-avatar"
|
||||
/>
|
||||
) : (
|
||||
<div className="profile-avatar profile-avatar-default">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="12" cy="7" r="4"/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{!isCollapsed && (
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="profile-name">{userInfo?.nickname || t('sidebar.defaultUser')}</p>
|
||||
{credits !== null && credits !== undefined && (
|
||||
<p className="profile-credits">{t('sidebar.credits', { count: credits })}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sidebar-footer-actions">
|
||||
<button
|
||||
className={`logout-btn ${isCollapsed ? 'collapsed' : ''}`}
|
||||
onClick={handleLogout}
|
||||
disabled={isLoggingOut}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
{!isCollapsed && <span className="logout-btn-label">{isLoggingOut ? t('sidebar.loggingOut') : t('sidebar.logout')}</span>}
|
||||
</button>
|
||||
<a
|
||||
href="https://forms.gle/4a8mGebBYtdesvby9"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`sidebar-inquiry-btn ${isCollapsed ? 'collapsed' : ''}`}
|
||||
title={t('sidebar.inquiry')}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
{!isCollapsed && <span>{t('sidebar.inquiry')}</span>}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -4,9 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { getSocialAccounts, uploadToSocial, waitForUploadComplete, TokenExpiredError, handleSocialReconnect, getAutoSeoYoutube } from '../utils/api';
|
||||
import { SocialAccount, VideoListItem, SocialUploadStatusResponse } from '../types/api';
|
||||
import UploadProgressModal, { UploadStatus } from './UploadProgressModal';
|
||||
import { useTutorial } from './Tutorial/useTutorial';
|
||||
import { TUTORIAL_KEYS } from './Tutorial/tutorialSteps';
|
||||
import TutorialOverlay from './Tutorial/TutorialOverlay';
|
||||
import { useOverlayClose } from '../hooks/useOverlayClose';
|
||||
|
||||
interface SocialPostingModalProps {
|
||||
isOpen: boolean;
|
||||
@ -121,7 +119,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||
onGoToCalendar,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const tutorial = useTutorial();
|
||||
const [socialAccounts, setSocialAccounts] = useState<SocialAccount[]>([]);
|
||||
const [selectedChannel, setSelectedChannel] = useState<string>('');
|
||||
const [title, setTitle] = useState('');
|
||||
@ -141,8 +138,10 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||
const [videoMeta, setVideoMeta] = useState<{ width: number; height: number; duration: number } | null>(null);
|
||||
const channelDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const privacyDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const hasBeenOpenedRef = useRef(false);
|
||||
const loadedForVideoIdRef = useRef<number | null>(null);
|
||||
const loadedForTaskIdRef = useRef<string | null>(null);
|
||||
const loadedAtRef = useRef<number>(0);
|
||||
const seoCache = useRef<Map<string, { title: string; description: string; tags: string }>>(new Map());
|
||||
const SEO_CACHE_TTL = 50 * 60 * 1000;
|
||||
|
||||
// Upload progress modal state
|
||||
const [showUploadProgress, setShowUploadProgress] = useState(false);
|
||||
@ -181,48 +180,15 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, [isOpen]);
|
||||
|
||||
// 모달 오픈/닫힘 시 튜토리얼 트리거
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
hasBeenOpenedRef.current = true;
|
||||
if (!tutorial.hasSeen(TUTORIAL_KEYS.UPLOAD_MODAL)) {
|
||||
const timer = setTimeout(() => {
|
||||
tutorial.startTutorial(TUTORIAL_KEYS.UPLOAD_MODAL);
|
||||
}, 400);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
} else if (hasBeenOpenedRef.current && !tutorial.hasSeen(TUTORIAL_KEYS.FEEDBACK)) {
|
||||
hasBeenOpenedRef.current = false;
|
||||
tutorial.startTutorial(TUTORIAL_KEYS.FEEDBACK);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// SEO 생성 완료 시 UPLOAD_FORM 튜토리얼 트리거
|
||||
useEffect(() => {
|
||||
if (!isLoadingAutoDescription && isOpen && !tutorial.hasSeen(TUTORIAL_KEYS.UPLOAD_FORM)) {
|
||||
const timer = setTimeout(() => {
|
||||
tutorial.startTutorial(TUTORIAL_KEYS.UPLOAD_FORM);
|
||||
}, 400);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isLoadingAutoDescription]);
|
||||
|
||||
// 소셜 계정 로드
|
||||
// 소셜 계정 로드 + SEO 자동 채움
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
loadedForVideoIdRef.current = null;
|
||||
loadedForTaskIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
loadSocialAccounts();
|
||||
|
||||
const videoId = video?.video_id ?? null;
|
||||
if (!videoId || videoId === loadedForVideoIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadedForVideoIdRef.current = videoId;
|
||||
|
||||
if (video?.title) {
|
||||
setTitle(video.title);
|
||||
setDescription(video.description || '');
|
||||
@ -231,8 +197,26 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
loadAutocomplete();
|
||||
}, [isOpen, video?.video_id, video?.title, video?.description, video?.hashtags]);
|
||||
const now = Date.now();
|
||||
const seoKey =
|
||||
video?.type === 'ssul'
|
||||
? `ssul:${video.video_id}`
|
||||
: video?.task_id || null;
|
||||
const expired = now - loadedAtRef.current > SEO_CACHE_TTL;
|
||||
|
||||
if (seoKey && (seoKey !== loadedForTaskIdRef.current || expired)) {
|
||||
loadedForTaskIdRef.current = seoKey;
|
||||
loadedAtRef.current = now;
|
||||
loadAutocomplete(seoKey);
|
||||
} else if (seoKey) {
|
||||
const cached = seoCache.current.get(seoKey);
|
||||
if (cached) {
|
||||
setTitle(cached.title);
|
||||
setDescription(cached.description);
|
||||
setTags(cached.tags);
|
||||
}
|
||||
}
|
||||
}, [isOpen, video?.task_id, video?.type, video?.video_id, video?.title, video?.description, video?.hashtags]);
|
||||
|
||||
const loadSocialAccounts = async () => {
|
||||
setIsLoadingAccounts(true);
|
||||
@ -257,19 +241,34 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const loadAutocomplete = async () => {
|
||||
if (!video?.video_id) return;
|
||||
/**
|
||||
* 제목·설명·태그 자동 채움.
|
||||
*
|
||||
* `seoKey` 는 호출부가 만든 캐시 키다(ADO2=task_id, 썰박스=`ssul:{id}`).
|
||||
* 백엔드에 보낼 식별자는 종류마다 다르다 — ADO2 는 task_id(UUID), 썰박스는
|
||||
* ssul_content.id 문자열이며, 서버가 content_type 으로 어느 테이블을 볼지 가른다.
|
||||
*/
|
||||
const loadAutocomplete = async (seoKey: string) => {
|
||||
if (!video) return;
|
||||
const isSsul = video.type === 'ssul';
|
||||
if (!isSsul && !video.task_id) return;
|
||||
|
||||
setIsLoadingAutoDescription(true);
|
||||
try {
|
||||
const requestPayload = {
|
||||
video_id: video.video_id,
|
||||
content_type: video.type,
|
||||
task_id: isSsul ? String(video.video_id) : video.task_id,
|
||||
};
|
||||
const autoSeoResponse = await getAutoSeoYoutube(requestPayload);
|
||||
|
||||
if (autoSeoResponse.title) setTitle(autoSeoResponse.title);
|
||||
if (autoSeoResponse.description) setDescription(autoSeoResponse.description);
|
||||
if (autoSeoResponse.keywords) setTags(autoSeoResponse.keywords.join(','));
|
||||
seoCache.current.set(seoKey, {
|
||||
title: autoSeoResponse.title || '',
|
||||
description: autoSeoResponse.description || '',
|
||||
tags: autoSeoResponse.keywords?.join(',') || '',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load autocomplete:', error);
|
||||
} finally {
|
||||
@ -341,6 +340,9 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||
}
|
||||
|
||||
const requestPayload = {
|
||||
// 종류를 함께 보낸다 — 없으면 백엔드가 video 로 간주해, 썰박스 항목에서는
|
||||
// id 가 겹치는 엉뚱한 ADO2 영상이 업로드된다.
|
||||
content_type: video.type,
|
||||
video_id: video.video_id,
|
||||
social_account_id: selectedAcc.id,
|
||||
title: title.trim(),
|
||||
@ -432,6 +434,8 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const overlayCloseHandlers = useOverlayClose(handleClose);
|
||||
|
||||
const selectedAccount = socialAccounts.find(acc => acc.platform_user_id === selectedChannel);
|
||||
|
||||
const privacyOptions = [
|
||||
@ -463,23 +467,13 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||
return (
|
||||
<>
|
||||
{showUploadProgress && uploadProgressModalElement}
|
||||
{tutorial.isActive && (
|
||||
<TutorialOverlay
|
||||
hints={tutorial.hints}
|
||||
currentIndex={tutorial.currentHintIndex}
|
||||
onNext={tutorial.nextHint}
|
||||
onPrev={tutorial.prevHint}
|
||||
onSkip={tutorial.skipTutorial}
|
||||
groupProgress={tutorial.groupProgress}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="social-posting-overlay" onClick={handleClose}>
|
||||
<div className="social-posting-overlay" {...overlayCloseHandlers}>
|
||||
<div className="social-posting-modal" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className="social-posting-header">
|
||||
@ -849,16 +843,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
{uploadProgressModalElement}
|
||||
{tutorial.isActive && (
|
||||
<TutorialOverlay
|
||||
hints={tutorial.hints}
|
||||
currentIndex={tutorial.currentHintIndex}
|
||||
onNext={tutorial.nextHint}
|
||||
onPrev={tutorial.prevHint}
|
||||
onSkip={tutorial.skipTutorial}
|
||||
groupProgress={tutorial.groupProgress}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,314 +0,0 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TutorialHint } from './tutorialSteps';
|
||||
|
||||
interface Rect {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
interface TooltipPos {
|
||||
top: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
interface TutorialOverlayProps {
|
||||
hints: TutorialHint[];
|
||||
currentIndex: number;
|
||||
onNext: () => void;
|
||||
onPrev: () => void;
|
||||
onSkip: () => void;
|
||||
groupProgress?: { groupTotal: number; groupOffset: number; isLastKeyInGroup: boolean } | null;
|
||||
}
|
||||
|
||||
const PADDING = 8;
|
||||
|
||||
function getTargetRect(selector: string): Rect | null {
|
||||
const els = Array.from(document.querySelectorAll(selector));
|
||||
const el = els.find(e => {
|
||||
const r = (e as HTMLElement).getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0;
|
||||
}) ?? els[0];
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
return { top: r.top, left: r.left, width: r.width, height: r.height, bottom: r.bottom };
|
||||
}
|
||||
|
||||
function getSpotlightRect(
|
||||
rect: Rect,
|
||||
padding: number,
|
||||
override?: { top?: number; right?: number; bottom?: number; left?: number }
|
||||
): Rect {
|
||||
const pTop = override?.top ?? padding;
|
||||
const pRight = override?.right ?? padding;
|
||||
const pBottom = override?.bottom ?? padding;
|
||||
const pLeft = override?.left ?? padding;
|
||||
const left = Math.max(0, Math.floor(rect.left - pLeft));
|
||||
const top = Math.max(0, Math.floor(rect.top - pTop));
|
||||
const right = Math.min(window.innerWidth, Math.ceil(rect.left + rect.width + pRight));
|
||||
const bottom = Math.min(window.innerHeight, Math.ceil(rect.top + rect.height + pBottom));
|
||||
|
||||
return {
|
||||
top,
|
||||
bottom,
|
||||
left,
|
||||
width: Math.max(0, right - left),
|
||||
height: Math.max(0, bottom - top),
|
||||
};
|
||||
}
|
||||
|
||||
function calcTooltipPos(rect: Rect, position: TutorialHint['position'], tooltipW = 300, tooltipH = 160): TooltipPos {
|
||||
|
||||
switch (position) {
|
||||
case 'bottom':
|
||||
return {
|
||||
top: Math.min(rect.top + rect.height + PADDING, window.innerHeight - tooltipH - 8),
|
||||
left: Math.min(Math.max(rect.left + rect.width / 2 - tooltipW / 2, 8), window.innerWidth - tooltipW - 8),
|
||||
};
|
||||
case 'top':
|
||||
return {
|
||||
top: Math.max(rect.top - tooltipH - PADDING, 8),
|
||||
left: Math.min(Math.max(rect.left + rect.width / 2 - tooltipW / 2, 8), window.innerWidth - tooltipW - 8),
|
||||
};
|
||||
case 'right':
|
||||
return {
|
||||
top: Math.min(Math.max(rect.top + rect.height / 2 - tooltipH / 2, 8), window.innerHeight - tooltipH - 8),
|
||||
left: Math.min(rect.left + rect.width + PADDING, window.innerWidth - tooltipW - 8),
|
||||
};
|
||||
case 'left':
|
||||
return {
|
||||
top: Math.min(Math.max(rect.top + rect.height / 2 - tooltipH / 2, 8), window.innerHeight - tooltipH - 8),
|
||||
left: Math.max(rect.left - tooltipW - PADDING, 8),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const TutorialOverlay: React.FC<TutorialOverlayProps> = ({
|
||||
hints,
|
||||
currentIndex,
|
||||
onNext,
|
||||
onPrev,
|
||||
onSkip,
|
||||
groupProgress,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [targetRect, setTargetRect] = useState<Rect | null>(null);
|
||||
const tooltipRef = React.useRef<HTMLDivElement>(null);
|
||||
const [tooltipSize, setTooltipSize] = useState({ w: 300, h: 160 });
|
||||
|
||||
const hint = hints[currentIndex];
|
||||
const isLast = currentIndex === hints.length - 1;
|
||||
// 그룹이 있으면 그룹의 마지막 키 + 마지막 힌트일 때만 완료, 그룹 없으면 기존대로
|
||||
const isFinish = isLast && (groupProgress ? groupProgress.isLastKeyInGroup : true);
|
||||
|
||||
const updateRect = useCallback(() => {
|
||||
if (!hint) return;
|
||||
setTargetRect(getTargetRect(hint.targetSelector));
|
||||
}, [hint]);
|
||||
|
||||
useEffect(() => {
|
||||
updateRect();
|
||||
window.addEventListener('resize', updateRect);
|
||||
window.addEventListener('scroll', updateRect, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateRect);
|
||||
window.removeEventListener('scroll', updateRect, true);
|
||||
};
|
||||
}, [updateRect]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hint) return;
|
||||
let retryTimer: number | undefined;
|
||||
let rectTimer: number | undefined;
|
||||
let cleanupTarget: (() => void) | undefined;
|
||||
|
||||
const bindToTarget = (el: HTMLElement) => {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
rectTimer = window.setTimeout(updateRect, 200);
|
||||
|
||||
const shouldClickAdvance = hint.clickToAdvance !== false;
|
||||
if (shouldClickAdvance) {
|
||||
el.style.cursor = 'pointer';
|
||||
el.addEventListener('click', onNext);
|
||||
cleanupTarget = () => {
|
||||
el.style.cursor = '';
|
||||
el.removeEventListener('click', onNext);
|
||||
};
|
||||
} else {
|
||||
cleanupTarget = () => {};
|
||||
}
|
||||
};
|
||||
|
||||
const tryBind = (): boolean => {
|
||||
const els = Array.from(document.querySelectorAll(hint.targetSelector));
|
||||
const el = (els.find(e => {
|
||||
const r = (e as HTMLElement).getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0;
|
||||
}) ?? els[0]) as HTMLElement | null;
|
||||
if (!el) return false;
|
||||
bindToTarget(el);
|
||||
return true;
|
||||
};
|
||||
|
||||
if (!tryBind()) {
|
||||
retryTimer = window.setInterval(() => {
|
||||
if (tryBind() && retryTimer) {
|
||||
window.clearInterval(retryTimer);
|
||||
retryTimer = undefined;
|
||||
}
|
||||
}, 80);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (retryTimer) window.clearInterval(retryTimer);
|
||||
if (rectTimer) window.clearTimeout(rectTimer);
|
||||
cleanupTarget?.();
|
||||
};
|
||||
}, [hint, updateRect, onNext]);
|
||||
|
||||
// 툴팁 DOM 크기 측정 — 힌트가 바뀔 때만 재측정
|
||||
useEffect(() => {
|
||||
const el = tooltipRef.current;
|
||||
if (!el) return;
|
||||
const { offsetWidth, offsetHeight } = el;
|
||||
if (offsetWidth && offsetHeight) {
|
||||
setTooltipSize(prev =>
|
||||
prev.w === offsetWidth && prev.h === offsetHeight
|
||||
? prev
|
||||
: { w: offsetWidth, h: offsetHeight }
|
||||
);
|
||||
}
|
||||
}, [currentIndex, hints]);
|
||||
|
||||
if (!hint) return null;
|
||||
|
||||
const isTargetVisible = targetRect
|
||||
? targetRect.top + 60 < window.innerHeight && targetRect.bottom > 60
|
||||
: true;
|
||||
|
||||
if (!isTargetVisible) return null;
|
||||
|
||||
const spotlightPadding = hint.spotlightPadding ?? PADDING;
|
||||
const spotlightRect = (targetRect && !hint.noSpotlight) ? getSpotlightRect(targetRect, spotlightPadding, hint.spotlightPaddingOverride) : null;
|
||||
|
||||
// 툴팁은 spotlightRect 기준으로 배치해야 스포트라이트와 겹치지 않음
|
||||
const tooltipPos: TooltipPos = (spotlightRect ?? targetRect)
|
||||
? calcTooltipPos((spotlightRect ?? targetRect)!, hint.position, tooltipSize.w, tooltipSize.h)
|
||||
: {
|
||||
top: window.innerHeight / 2 - tooltipSize.h / 2,
|
||||
left: window.innerWidth / 2 - tooltipSize.w / 2,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tutorial-overlay-root">
|
||||
{spotlightRect ? (
|
||||
<>
|
||||
<div
|
||||
className="tutorial-overlay-blocker"
|
||||
style={{ top: 0, left: 0, right: 0, height: spotlightRect.top }}
|
||||
/>
|
||||
<div
|
||||
className="tutorial-overlay-blocker"
|
||||
style={{
|
||||
top: spotlightRect.top,
|
||||
left: 0,
|
||||
width: spotlightRect.left,
|
||||
height: spotlightRect.height,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="tutorial-overlay-blocker"
|
||||
style={{
|
||||
top: spotlightRect.top,
|
||||
left: spotlightRect.left + spotlightRect.width,
|
||||
right: 0,
|
||||
height: spotlightRect.height,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="tutorial-overlay-blocker"
|
||||
style={{
|
||||
top: spotlightRect.top + spotlightRect.height,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="tutorial-spotlight-ring"
|
||||
style={{
|
||||
top: spotlightRect.top,
|
||||
left: spotlightRect.left,
|
||||
width: spotlightRect.width,
|
||||
height: spotlightRect.height,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
className={`tutorial-tooltip${hint.variant === 'bubble' ? ` tutorial-tooltip-bubble tutorial-tooltip-bubble--${hint.position}` : ''}`}
|
||||
style={{ top: tooltipPos.top, left: tooltipPos.left }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<p className="tutorial-tooltip-title">{t(hint.titleKey)}</p>
|
||||
<p className="tutorial-tooltip-desc">{t(hint.descriptionKey)}</p>
|
||||
{hint.noteKey && (
|
||||
<p className="tutorial-tooltip-note">{t(hint.noteKey)}</p>
|
||||
)}
|
||||
|
||||
<div className="tutorial-tooltip-footer">
|
||||
<span className="tutorial-tooltip-counter">
|
||||
{groupProgress ? groupProgress.groupOffset + 1 : currentIndex + 1} / {groupProgress ? groupProgress.groupTotal : hints.length}
|
||||
</span>
|
||||
<div className="tutorial-tooltip-actions">
|
||||
<button className="tutorial-btn-skip" onClick={onSkip}>
|
||||
{t('tutorial.skip')}
|
||||
</button>
|
||||
{currentIndex > 0 && (
|
||||
<button className="tutorial-btn-prev" onClick={onPrev}>
|
||||
{t('tutorial.prev')}
|
||||
</button>
|
||||
)}
|
||||
{(hint.clickToAdvance !== true || isFinish) && (
|
||||
<button className="tutorial-btn-next" onClick={onNext}>
|
||||
{isFinish ? t('tutorial.finish') : t('tutorial.next')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TutorialOverlay;
|
||||
|
||||
interface TutorialRestartPopupProps {
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const TutorialRestartPopup: React.FC<TutorialRestartPopupProps> = ({ onConfirm, onCancel }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="tutorial-restart-backdrop">
|
||||
<div className="tutorial-restart-popup" onClick={(e) => e.stopPropagation()}>
|
||||
<p className="tutorial-restart-title">{t('tutorial.restart.title')}</p>
|
||||
<p className="tutorial-restart-desc">{t('tutorial.restart.desc')}</p>
|
||||
<div className="tutorial-restart-actions">
|
||||
<button className="tutorial-restart-cancel" onClick={onCancel}>
|
||||
{t('tutorial.restart.cancel')}
|
||||
</button>
|
||||
<button className="tutorial-restart-confirm" onClick={onConfirm}>
|
||||
{t('tutorial.restart.confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,377 +0,0 @@
|
||||
export interface TutorialHint {
|
||||
targetSelector: string;
|
||||
titleKey: string;
|
||||
descriptionKey: string;
|
||||
position: 'top' | 'bottom' | 'left' | 'right';
|
||||
noteKey?: string;
|
||||
variant?: 'bubble';
|
||||
clickToAdvance?: boolean;
|
||||
advanceSelector?: string;
|
||||
noSpotlight?: boolean;
|
||||
spotlightPadding?: number;
|
||||
spotlightPaddingOverride?: { top?: number; right?: number; bottom?: number; left?: number };
|
||||
}
|
||||
|
||||
export interface TutorialStepDef {
|
||||
key: string;
|
||||
hints: TutorialHint[];
|
||||
}
|
||||
|
||||
export const TUTORIAL_KEYS = {
|
||||
LANDING: 'landing',
|
||||
ANALYSIS: 'analysis',
|
||||
ASSET: 'asset',
|
||||
SOUND: 'sound',
|
||||
SOUND_LYRICS: 'soundLyrics',
|
||||
SOUND_AUDIO: 'soundAudio',
|
||||
GENERATING:'generating',
|
||||
COMPLETION: 'completion',
|
||||
MY_INFO: 'myInfo',
|
||||
ADO2_CONTENTS: 'ado2Contents',
|
||||
UPLOAD_MODAL: 'uploadModal',
|
||||
UPLOAD_FORM: 'uploadForm',
|
||||
DASHBOARD: 'dashboard',
|
||||
CONTENT_CALENDAR: 'contentCalendar',
|
||||
FEEDBACK: 'feedback',
|
||||
} as const;
|
||||
|
||||
// 같은 페이지에 속하는 튜토리얼 키 그룹 — 진행 카운터를 합산해서 표시
|
||||
export const TUTORIAL_PAGE_GROUPS: string[][] = [
|
||||
[TUTORIAL_KEYS.SOUND, TUTORIAL_KEYS.SOUND_LYRICS, TUTORIAL_KEYS.SOUND_AUDIO],
|
||||
[TUTORIAL_KEYS.GENERATING, TUTORIAL_KEYS.COMPLETION],
|
||||
[TUTORIAL_KEYS.UPLOAD_MODAL, TUTORIAL_KEYS.UPLOAD_FORM],
|
||||
];
|
||||
|
||||
export const tutorialSteps: TutorialStepDef[] = [
|
||||
{
|
||||
key: TUTORIAL_KEYS.LANDING,
|
||||
hints: [
|
||||
{
|
||||
targetSelector: '.hero-input-wrapper',
|
||||
titleKey: 'tutorial.landing.field.title',
|
||||
descriptionKey: 'tutorial.landing.field.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
noSpotlight: true,
|
||||
variant: 'bubble',
|
||||
},
|
||||
{
|
||||
targetSelector: '.hero-manual-card-title',
|
||||
titleKey: 'tutorial.landing.manual.title',
|
||||
descriptionKey: 'tutorial.landing.manual.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
noSpotlight: true,
|
||||
variant: 'bubble',
|
||||
},
|
||||
{
|
||||
targetSelector: '.hero-button',
|
||||
titleKey: 'tutorial.landing.button.title',
|
||||
descriptionKey: 'tutorial.landing.button.desc',
|
||||
position: 'bottom',
|
||||
clickToAdvance: true,
|
||||
noSpotlight: true,
|
||||
variant: 'bubble',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: TUTORIAL_KEYS.ASSET,
|
||||
hints: [
|
||||
{
|
||||
targetSelector: '.asset-column.asset-column-left',
|
||||
titleKey: 'tutorial.asset.image.title',
|
||||
descriptionKey: 'tutorial.asset.image.desc',
|
||||
position: 'left',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.asset-upload-zone, .asset-mobile-upload-btn',
|
||||
titleKey: 'tutorial.asset.upload.title',
|
||||
descriptionKey: 'tutorial.asset.upload.desc',
|
||||
position: 'bottom',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.asset-ratio-section',
|
||||
titleKey: 'tutorial.asset.ratio.title',
|
||||
descriptionKey: 'tutorial.asset.ratio.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.asset-next-button',
|
||||
titleKey: 'tutorial.asset.next.title',
|
||||
descriptionKey: 'tutorial.asset.next.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: TUTORIAL_KEYS.SOUND,
|
||||
hints: [
|
||||
{
|
||||
targetSelector: '.genre-grid',
|
||||
titleKey: 'tutorial.sound.genre.title',
|
||||
descriptionKey: 'tutorial.sound.genre.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.language-grid',
|
||||
titleKey: 'tutorial.sound.language.title',
|
||||
descriptionKey: 'tutorial.sound.language.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.btn-generate-sound',
|
||||
titleKey: 'tutorial.sound.generate.title',
|
||||
descriptionKey: 'tutorial.sound.generate.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: TUTORIAL_KEYS.SOUND_LYRICS,
|
||||
hints: [
|
||||
{
|
||||
targetSelector: '.lyrics-display',
|
||||
titleKey: 'tutorial.sound.lyrics.title',
|
||||
descriptionKey: 'tutorial.sound.lyrics.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.status-message-new',
|
||||
titleKey: 'tutorial.sound.lyricsWait.title',
|
||||
descriptionKey: 'tutorial.sound.lyricsWait.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: TUTORIAL_KEYS.SOUND_AUDIO,
|
||||
hints: [
|
||||
{
|
||||
targetSelector: '.audio-player',
|
||||
titleKey: 'tutorial.sound.audioPlayer.title',
|
||||
descriptionKey: 'tutorial.sound.audioPlayer.desc',
|
||||
position: 'bottom',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.btn-video-generate',
|
||||
titleKey: 'tutorial.sound.video.title',
|
||||
descriptionKey: 'tutorial.sound.video.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: TUTORIAL_KEYS.MY_INFO,
|
||||
hints: [
|
||||
{
|
||||
targetSelector: '.youtube-connect-section',
|
||||
titleKey: 'tutorial.myInfo.myInfo.title',
|
||||
descriptionKey: 'tutorial.myInfo.myInfo.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.myinfo-social-btn',
|
||||
titleKey: 'tutorial.myInfo.connect.title',
|
||||
descriptionKey: 'tutorial.myInfo.connect.desc',
|
||||
noteKey: 'tutorial.myInfo.connect.note',
|
||||
position: 'top',
|
||||
clickToAdvance: true,
|
||||
},
|
||||
{
|
||||
targetSelector: '.myinfo-connected-accounts',
|
||||
titleKey: 'tutorial.myInfo.connected.title',
|
||||
descriptionKey: 'tutorial.myInfo.connected.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '#sidebar-ado2-contents',
|
||||
titleKey: 'tutorial.myInfo.ado2.title',
|
||||
descriptionKey: 'tutorial.myInfo.ado2.desc',
|
||||
position: 'right',
|
||||
clickToAdvance: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: TUTORIAL_KEYS.ADO2_CONTENTS,
|
||||
hints: [
|
||||
{
|
||||
targetSelector: '.ado2-content-card',
|
||||
titleKey: 'tutorial.ado2.list.title',
|
||||
descriptionKey: 'tutorial.ado2.list.desc',
|
||||
position: 'right',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.content-upload-btn',
|
||||
titleKey: 'tutorial.ado2.download.title',
|
||||
descriptionKey: 'tutorial.ado2.download.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.content-delete-btn',
|
||||
titleKey: 'tutorial.ado2.delete.title',
|
||||
descriptionKey: 'tutorial.ado2.delete.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.content-download-btn',
|
||||
titleKey: 'tutorial.ado2.upload.title',
|
||||
descriptionKey: 'tutorial.ado2.upload.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: TUTORIAL_KEYS.GENERATING,
|
||||
hints:[
|
||||
{
|
||||
targetSelector: '.comp2-info-section',
|
||||
titleKey: 'tutorial.completion.contentInfo.title',
|
||||
descriptionKey: 'tutorial.completion.contentInfo.desc',
|
||||
position: 'left',
|
||||
},
|
||||
{
|
||||
targetSelector: '.comp2-video-section',
|
||||
titleKey: 'tutorial.completion.generating.title',
|
||||
descriptionKey: 'tutorial.completion.generating.desc',
|
||||
position: 'top',
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: TUTORIAL_KEYS.COMPLETION,
|
||||
hints: [
|
||||
{
|
||||
targetSelector: '.comp2-video-section',
|
||||
titleKey: 'tutorial.completion.completion.title',
|
||||
descriptionKey: 'tutorial.completion.completion.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '#sidebar-my-info',
|
||||
titleKey: 'tutorial.completion.myInfo.title',
|
||||
descriptionKey: 'tutorial.completion.myInfo.desc',
|
||||
position: 'right',
|
||||
clickToAdvance: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: TUTORIAL_KEYS.UPLOAD_MODAL,
|
||||
hints: [
|
||||
{
|
||||
targetSelector: '.social-posting-content',
|
||||
titleKey: 'tutorial.upload.seo.title',
|
||||
descriptionKey: 'tutorial.upload.seo.desc',
|
||||
position: 'right',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: TUTORIAL_KEYS.UPLOAD_FORM,
|
||||
hints: [
|
||||
{
|
||||
targetSelector: '.social-posting-form',
|
||||
titleKey: 'tutorial.upload.required.title',
|
||||
descriptionKey: 'tutorial.upload.required.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.social-posting-radio-group',
|
||||
titleKey: 'tutorial.upload.schedule.title',
|
||||
descriptionKey: 'tutorial.upload.schedule.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.social-posting-btn:not(.cancel)',
|
||||
titleKey: 'tutorial.upload.submit.title',
|
||||
descriptionKey: 'tutorial.upload.submit.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: TUTORIAL_KEYS.DASHBOARD,
|
||||
hints: [
|
||||
{
|
||||
targetSelector: '.stats-grid-8',
|
||||
titleKey: 'tutorial.dashboard.metrics.title',
|
||||
descriptionKey: 'tutorial.dashboard.metrics.desc',
|
||||
position: 'bottom',
|
||||
},
|
||||
{
|
||||
targetSelector: '.yoy-chart-card',
|
||||
titleKey: 'tutorial.dashboard.chart.title',
|
||||
descriptionKey: 'tutorial.dashboard.chart.desc',
|
||||
position: 'top',
|
||||
},
|
||||
{
|
||||
targetSelector: '.tutorial-center-anchor',
|
||||
titleKey: 'tutorial.dashboard.more.title',
|
||||
descriptionKey: 'tutorial.dashboard.more.desc',
|
||||
position: 'bottom',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: TUTORIAL_KEYS.CONTENT_CALENDAR,
|
||||
hints: [
|
||||
{
|
||||
targetSelector: '.calendar-grid-area',
|
||||
titleKey: 'tutorial.contentCalendar.grid.title',
|
||||
descriptionKey: 'tutorial.contentCalendar.grid.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.calendar-side-panel',
|
||||
titleKey: 'tutorial.contentCalendar.panel.title',
|
||||
descriptionKey: 'tutorial.contentCalendar.panel.desc',
|
||||
position: 'left',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: TUTORIAL_KEYS.FEEDBACK,
|
||||
hints: [
|
||||
{
|
||||
targetSelector: '.tutorial-center-anchor',
|
||||
titleKey: 'tutorial.feedback.complete.title',
|
||||
descriptionKey: 'tutorial.feedback.complete.desc',
|
||||
position: 'bottom',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.sidebar-inquiry-btn',
|
||||
titleKey: 'tutorial.feedback.title',
|
||||
descriptionKey: 'tutorial.feedback.desc',
|
||||
position: 'right',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@ -1,220 +0,0 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { tutorialSteps, TutorialHint, TUTORIAL_PAGE_GROUPS } from './tutorialSteps';
|
||||
|
||||
// 현재 키가 속한 그룹의 전체 힌트 수와 현재까지의 offset 반환
|
||||
function getGroupProgress(key: string, currentIndex: number): { groupTotal: number; groupOffset: number; isLastKeyInGroup: boolean } | null {
|
||||
const group = TUTORIAL_PAGE_GROUPS.find(g => g.includes(key));
|
||||
if (!group) return null;
|
||||
let offset = 0;
|
||||
let total = 0;
|
||||
for (const k of group) {
|
||||
const step = tutorialSteps.find(s => s.key === k);
|
||||
const count = step?.hints.length ?? 0;
|
||||
if (k === key) offset = total;
|
||||
total += count;
|
||||
}
|
||||
const isLastKeyInGroup = group[group.length - 1] === key;
|
||||
return { groupTotal: total, groupOffset: offset + currentIndex, isLastKeyInGroup };
|
||||
}
|
||||
|
||||
const SEEN_KEY = 'ado2_tutorial_seen';
|
||||
const PROGRESS_KEY = 'ado2_tutorial_progress';
|
||||
const ENABLED_KEY = 'ado2_tutorial_enabled';
|
||||
|
||||
// 전역 단일 활성 튜토리얼 관리 — 새 튜토리얼 시작 시 이전 것을 skip 처리
|
||||
let globalSkip: (() => void) | null = null;
|
||||
|
||||
function getSeenKeys(): string[] {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(SEEN_KEY) || '[]');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function markSeen(key: string) {
|
||||
const seen = getSeenKeys();
|
||||
if (!seen.includes(key)) {
|
||||
localStorage.setItem(SEEN_KEY, JSON.stringify([...seen, key]));
|
||||
}
|
||||
clearProgress(key);
|
||||
}
|
||||
|
||||
function saveProgress(key: string, index: number) {
|
||||
try {
|
||||
const progress = JSON.parse(localStorage.getItem(PROGRESS_KEY) || '{}');
|
||||
progress[key] = index;
|
||||
localStorage.setItem(PROGRESS_KEY, JSON.stringify(progress));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function loadProgress(key: string): number {
|
||||
try {
|
||||
const progress = JSON.parse(localStorage.getItem(PROGRESS_KEY) || '{}');
|
||||
return progress[key] ?? 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function clearProgress(key: string) {
|
||||
try {
|
||||
const progress = JSON.parse(localStorage.getItem(PROGRESS_KEY) || '{}');
|
||||
delete progress[key];
|
||||
localStorage.setItem(PROGRESS_KEY, JSON.stringify(progress));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
interface UseTutorialReturn {
|
||||
isActive: boolean;
|
||||
isEnabled: boolean;
|
||||
isRestartPopupVisible: boolean;
|
||||
currentHintIndex: number;
|
||||
hints: TutorialHint[];
|
||||
tutorialKey: string | null;
|
||||
groupProgress: { groupTotal: number; groupOffset: number; isLastKeyInGroup: boolean } | null;
|
||||
startTutorial: (key: string, onComplete?: () => void, forceFromStart?: boolean) => void;
|
||||
nextHint: () => void;
|
||||
prevHint: () => void;
|
||||
skipTutorial: () => void;
|
||||
toggleTutorial: (currentKey: string | null) => void;
|
||||
showRestartPopup: (key: string) => void;
|
||||
confirmRestart: () => void;
|
||||
cancelRestart: () => void;
|
||||
hasSeen: (key: string) => boolean;
|
||||
}
|
||||
|
||||
export function useTutorial(): UseTutorialReturn {
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [isEnabled, setIsEnabled] = useState(() => localStorage.getItem(ENABLED_KEY) !== 'false');
|
||||
const [isRestartPopupVisible, setIsRestartPopupVisible] = useState(false);
|
||||
const [pendingRestartKey, setPendingRestartKey] = useState<string | null>(null);
|
||||
const [currentHintIndex, setCurrentHintIndex] = useState(0);
|
||||
const [hints, setHints] = useState<TutorialHint[]>([]);
|
||||
const [tutorialKey, setTutorialKey] = useState<string | null>(null);
|
||||
const onCompleteRef = React.useRef<(() => void) | undefined>(undefined);
|
||||
|
||||
const startTutorial = useCallback((key: string, onComplete?: () => void, forceFromStart?: boolean) => {
|
||||
if (localStorage.getItem(ENABLED_KEY) === 'false') return;
|
||||
const step = tutorialSteps.find(s => s.key === key);
|
||||
if (!step || step.hints.length === 0) return;
|
||||
// 다른 인스턴스에서 활성화된 튜토리얼이 있으면 skip 처리
|
||||
globalSkip?.();
|
||||
const savedIndex = forceFromStart ? 0 : loadProgress(key);
|
||||
const resumeIndex = savedIndex < step.hints.length ? savedIndex : 0;
|
||||
onCompleteRef.current = onComplete;
|
||||
setHints(step.hints);
|
||||
setTutorialKey(key);
|
||||
setCurrentHintIndex(resumeIndex);
|
||||
setIsActive(true);
|
||||
// 이 인스턴스의 skip을 전역에 등록
|
||||
globalSkip = () => {
|
||||
if (key) saveProgress(key, resumeIndex);
|
||||
setIsActive(false);
|
||||
setCurrentHintIndex(0);
|
||||
globalSkip = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const nextHint = useCallback(() => {
|
||||
setCurrentHintIndex(prev => {
|
||||
if (prev < hints.length - 1) {
|
||||
const next = prev + 1;
|
||||
if (tutorialKey) saveProgress(tutorialKey, next);
|
||||
return next;
|
||||
}
|
||||
// 마지막 힌트 완료 → seen 기록 + 진행 상태 삭제
|
||||
setIsActive(false);
|
||||
if (tutorialKey) markSeen(tutorialKey);
|
||||
globalSkip = null; // 완료된 튜토리얼은 globalSkip 해제
|
||||
onCompleteRef.current?.();
|
||||
onCompleteRef.current = undefined;
|
||||
return 0;
|
||||
});
|
||||
}, [hints.length, tutorialKey]);
|
||||
|
||||
const prevHint = useCallback(() => {
|
||||
setCurrentHintIndex(prev => Math.max(0, prev - 1));
|
||||
}, []);
|
||||
|
||||
// 건너뛰기: 현재 진행 인덱스 저장 후 오버레이 닫기 → 다음 방문 시 이어서 표시
|
||||
const skipTutorial = useCallback(() => {
|
||||
if (tutorialKey) saveProgress(tutorialKey, currentHintIndex);
|
||||
setIsActive(false);
|
||||
setCurrentHintIndex(0);
|
||||
}, [tutorialKey, currentHintIndex]);
|
||||
|
||||
const toggleTutorial = useCallback((currentKey: string | null) => {
|
||||
if (isEnabled) {
|
||||
// off: 튜토리얼 중단 + 비활성화
|
||||
setIsActive(false);
|
||||
setIsEnabled(false);
|
||||
localStorage.setItem(ENABLED_KEY, 'false');
|
||||
} else {
|
||||
// on: seen/progress 초기화 + 현재 화면 튜토리얼 시작
|
||||
localStorage.removeItem(SEEN_KEY);
|
||||
localStorage.removeItem(PROGRESS_KEY);
|
||||
localStorage.setItem(ENABLED_KEY, 'true');
|
||||
setIsEnabled(true);
|
||||
if (currentKey) {
|
||||
startTutorial(currentKey, undefined, true);
|
||||
}
|
||||
}
|
||||
}, [isEnabled, startTutorial]);
|
||||
|
||||
// 튜토리얼 다시 보기: 팝업 표시만
|
||||
const showRestartPopup = useCallback((key: string) => {
|
||||
setPendingRestartKey(key);
|
||||
setIsRestartPopupVisible(true);
|
||||
}, []);
|
||||
|
||||
// 팝업에서 확인 → seen/progress 초기화 후 튜토리얼 시작
|
||||
const confirmRestart = useCallback(() => {
|
||||
setIsRestartPopupVisible(false);
|
||||
if (pendingRestartKey) {
|
||||
localStorage.removeItem(SEEN_KEY);
|
||||
localStorage.removeItem(PROGRESS_KEY);
|
||||
startTutorial(pendingRestartKey, undefined, true);
|
||||
}
|
||||
setPendingRestartKey(null);
|
||||
}, [pendingRestartKey, startTutorial]);
|
||||
|
||||
// 팝업에서 취소
|
||||
const cancelRestart = useCallback(() => {
|
||||
setIsRestartPopupVisible(false);
|
||||
setPendingRestartKey(null);
|
||||
}, []);
|
||||
|
||||
const hasSeen = useCallback((key: string) => {
|
||||
return getSeenKeys().includes(key);
|
||||
}, []);
|
||||
|
||||
// 영상 보유 사용자 자동 off 이벤트 수신
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
setIsActive(false);
|
||||
setIsEnabled(false);
|
||||
};
|
||||
window.addEventListener('ado2-tutorial-auto-disable', handler);
|
||||
return () => window.removeEventListener('ado2-tutorial-auto-disable', handler);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isActive,
|
||||
isEnabled,
|
||||
isRestartPopupVisible,
|
||||
currentHintIndex,
|
||||
hints,
|
||||
tutorialKey,
|
||||
groupProgress: tutorialKey ? getGroupProgress(tutorialKey, currentHintIndex) : null,
|
||||
startTutorial,
|
||||
nextHint,
|
||||
prevHint,
|
||||
skipTutorial,
|
||||
toggleTutorial,
|
||||
showRestartPopup,
|
||||
confirmRestart,
|
||||
cancelRestart,
|
||||
hasSeen,
|
||||
};
|
||||
}
|
||||
@ -32,8 +32,8 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
||||
const [isLiked, setIsLiked] = useState(false);
|
||||
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [shareMenuOpen, setShareMenuOpen] = useState(false);
|
||||
const [isLandscape, setIsLandscape] = useState(false);
|
||||
const [showSiteOverlay, setShowSiteOverlay] = useState(false);
|
||||
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||
|
||||
const [comments, setComments] = useState<CommentItem[]>([]);
|
||||
@ -69,7 +69,6 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
||||
const fetchVideo = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setShowSiteOverlay(false);
|
||||
try {
|
||||
const data = await getVideoById(videoId);
|
||||
setVideo(data);
|
||||
@ -105,7 +104,8 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
||||
};
|
||||
|
||||
const shareUrl = buildVideoShareUrl(API_URL, videoId);
|
||||
const shareTitle = video?.title || video?.store_name || t('videoDetail.kakaoDefaultTitle');
|
||||
const shareTitle = video?.store_name ?? t('videoDetail.kakaoDefaultTitle');
|
||||
const shareDescription = t('videoDetail.kakaoDescription', { region: video?.region ?? '' });
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
try {
|
||||
@ -120,14 +120,59 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
||||
const handleShareButtonClick = async () => {
|
||||
const handled = await tryNativeShare({
|
||||
title: shareTitle,
|
||||
text: shareDescription,
|
||||
url: shareUrl,
|
||||
});
|
||||
if (handled) {
|
||||
return;
|
||||
}
|
||||
await handleCopyLink();
|
||||
setShareMenuOpen(v => !v);
|
||||
};
|
||||
|
||||
const handleKakaoShare = () => {
|
||||
const kakao = window.Kakao;
|
||||
if (kakao?.Share) {
|
||||
kakao.Share.sendDefault({
|
||||
objectType: 'feed',
|
||||
content: {
|
||||
title: video?.store_name ?? t('videoDetail.kakaoDefaultTitle'),
|
||||
description: t('videoDetail.kakaoDescription', { region: video?.region ?? '' }),
|
||||
imageUrl: 'https://ado2.o2osolution.ai/favicon_48.svg',
|
||||
link: { mobileWebUrl: shareUrl, webUrl: shareUrl },
|
||||
},
|
||||
buttons: [{ title: t('videoDetail.kakaoButtonTitle'), link: { mobileWebUrl: shareUrl, webUrl: shareUrl } }],
|
||||
});
|
||||
} else if (navigator.share) {
|
||||
navigator.share({ url: shareUrl }).catch(() => {});
|
||||
} else {
|
||||
handleCopyLink();
|
||||
}
|
||||
setShareMenuOpen(false);
|
||||
};
|
||||
|
||||
const handleFacebookShare = () => {
|
||||
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,width=600,height=600');
|
||||
setShareMenuOpen(false);
|
||||
};
|
||||
|
||||
const handleTwitterShare = () => {
|
||||
window.open(`https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,width=600,height=600');
|
||||
setShareMenuOpen(false);
|
||||
};
|
||||
|
||||
const shareMenuRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shareMenuOpen) return;
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (shareMenuRef.current && !shareMenuRef.current.contains(e.target as Node)) {
|
||||
setShareMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [shareMenuOpen]);
|
||||
|
||||
const likeDebounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleLike = () => {
|
||||
@ -199,11 +244,14 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
||||
{/* 헤더 */}
|
||||
<div className="video-detail-header">
|
||||
{isModal ? (
|
||||
<button className="video-detail-close-btn" onClick={handleHeaderAction} aria-label={t('videoDetail.closeAriaLabel')}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<>
|
||||
<h2 className="video-detail-modal-title">{t('sidebar.ado2Contents')}</h2>
|
||||
<button className="video-detail-close-btn" onClick={handleHeaderAction} aria-label={t('videoDetail.closeAriaLabel')}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button className="video-detail-back-btn" onClick={handleHeaderAction}>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
@ -214,6 +262,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="video-detail-body">
|
||||
{loading ? (
|
||||
<div className="ado2-contents-loading">
|
||||
<div className="loading-spinner"></div>
|
||||
@ -223,46 +272,19 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
||||
<div className="ado2-contents-error"><p>{error}</p></div>
|
||||
) : video ? (
|
||||
<div className={`video-detail-content ${isLandscape ? 'landscape' : ''}`}>
|
||||
<div className="video-detail-player-wrap">
|
||||
<video
|
||||
src={video.result_movie_url}
|
||||
controls
|
||||
autoPlay
|
||||
controlsList="nodownload"
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
className="video-detail-player"
|
||||
onLoadedMetadata={(e) => {
|
||||
const v = e.currentTarget;
|
||||
setIsLandscape(v.videoWidth > v.videoHeight);
|
||||
}}
|
||||
onTimeUpdate={(e) => {
|
||||
if (!video.official_site_url) return;
|
||||
const v = e.currentTarget;
|
||||
// 영상 종료 3초 전부터 공식 홈페이지 오버레이 노출
|
||||
setShowSiteOverlay(
|
||||
Number.isFinite(v.duration) && v.duration - v.currentTime <= 3
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{video.official_site_url && showSiteOverlay && (
|
||||
<a
|
||||
href={video.official_site_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="video-site-overlay"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="2" y1="12" x2="22" y2="12"/>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
||||
</svg>
|
||||
<span className="video-site-overlay-text">
|
||||
<strong>{video.store_name}</strong>
|
||||
{t('videoDetail.siteOverlayLabel')}
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<video
|
||||
src={video.result_movie_url}
|
||||
controls
|
||||
autoPlay
|
||||
playsInline
|
||||
controlsList="nodownload"
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
className="video-detail-player"
|
||||
onLoadedMetadata={(e) => {
|
||||
const v = e.currentTarget;
|
||||
setIsLandscape(v.videoWidth > v.videoHeight);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="video-detail-info">
|
||||
<h2 className="video-detail-store">{video.store_name}</h2>
|
||||
@ -280,17 +302,52 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
||||
</svg>
|
||||
{likeCount}
|
||||
</button>
|
||||
<button
|
||||
className="video-detail-copy-btn"
|
||||
onClick={handleShareButtonClick}
|
||||
title={t('videoDetail.share')}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
|
||||
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
|
||||
</svg>
|
||||
{copied ? t('videoDetail.copied') : t('videoDetail.share')}
|
||||
</button>
|
||||
<div style={{ position: 'relative' }} ref={shareMenuRef}>
|
||||
<button
|
||||
className="video-detail-copy-btn"
|
||||
onClick={handleShareButtonClick}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
|
||||
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
|
||||
</svg>
|
||||
{copied ? t('videoDetail.copied') : t('videoDetail.share')}
|
||||
</button>
|
||||
{shareMenuOpen && (
|
||||
<div className="video-detail-share-menu">
|
||||
{/* 카카오톡 */}
|
||||
<button className="video-detail-share-item" onClick={handleKakaoShare}>
|
||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="20" height="20" rx="4" fill="#FEE500"/>
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M10 3.5C6.134 3.5 3 6.01 3 9.1c0 1.98 1.2 3.72 3.01 4.76l-.74 2.75a.19.19 0 0 0 .28.21l3.37-2.23c.34.04.69.06 1.06.06 3.866 0 7-2.51 7-5.6S13.866 3.5 10 3.5z" fill="#3C1E1E"/>
|
||||
</svg>
|
||||
{t('videoDetail.shareKakao')}
|
||||
</button>
|
||||
{/* 페이스북 */}
|
||||
<button className="video-detail-share-item" onClick={handleFacebookShare}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="#1877F2">
|
||||
<path d="M24 12.073C24 5.405 18.627 0 12 0S0 5.405 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.41c0-3.025 1.792-4.697 4.533-4.697 1.312 0 2.686.235 2.686.235v2.97h-1.513c-1.491 0-1.956.93-1.956 1.887v2.268h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z"/>
|
||||
</svg>
|
||||
{t('videoDetail.shareFacebook')}
|
||||
</button>
|
||||
{/* X (트위터) */}
|
||||
<button className="video-detail-share-item" onClick={handleTwitterShare}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
||||
</svg>
|
||||
{t('videoDetail.shareTwitter')}
|
||||
</button>
|
||||
{/* URL 복사 */}
|
||||
<button className="video-detail-share-item" onClick={() => { handleCopyLink(); setShareMenuOpen(false); }}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>
|
||||
{copied ? t('videoDetail.copied') : t('videoDetail.copyUrl')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 댓글 섹션 */}
|
||||
@ -431,6 +488,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{showLoginModal && (
|
||||
<LoginPromptModal onClose={() => setShowLoginModal(false)} />
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import VideoDetailContent from './VideoDetailContent';
|
||||
import { useOverlayClose } from '../hooks/useOverlayClose';
|
||||
|
||||
interface VideoDetailModalProps {
|
||||
videoId: string;
|
||||
@ -23,30 +24,11 @@ const VideoDetailModal: React.FC<VideoDetailModalProps> = ({ videoId, onClose })
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const overlayCloseHandlers = useOverlayClose(onClose);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0, 0, 0, 0.85)',
|
||||
zIndex: 2000,
|
||||
display: 'flex',
|
||||
overflowY: 'auto',
|
||||
padding: '24px 16px',
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: '#01282A',
|
||||
borderRadius: '16px',
|
||||
width: '100%',
|
||||
maxWidth: '800px',
|
||||
margin: 'auto',
|
||||
position: 'relative',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="video-detail-overlay" {...overlayCloseHandlers}>
|
||||
<div className="video-detail-modal-box" onClick={(e) => e.stopPropagation()}>
|
||||
<VideoDetailContent videoId={videoId} isModal={true} onClose={onClose} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -3,13 +3,18 @@ import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface WizardStepperProps {
|
||||
currentStep: number; // 0~3, matching wizardStep in GenerationFlow
|
||||
currentStep: number; // 0-based. ADO2 는 wizardStep 0~3 과 일치
|
||||
/**
|
||||
* 단계 라벨. 생략하면 ADO2 기본 4단계를 쓴다(기존 호출부 무수정).
|
||||
* 썰박스처럼 단계 구성이 다른 파이프라인이 직접 넘긴다.
|
||||
*/
|
||||
steps?: string[];
|
||||
}
|
||||
|
||||
const WizardStepper: React.FC<WizardStepperProps> = ({ currentStep }) => {
|
||||
const WizardStepper: React.FC<WizardStepperProps> = ({ currentStep, steps: stepsProp }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const steps = [
|
||||
const steps = stepsProp ?? [
|
||||
t('wizardSteps.brandAnalysis'),
|
||||
t('wizardSteps.asset'),
|
||||
t('wizardSteps.sound'),
|
||||
|
||||
93
src/components/navItems.tsx
Normal file
93
src/components/navItems.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
|
||||
export const NAV = {
|
||||
HOME: 'ADO2 콘텐츠',
|
||||
NEW: '새 프로젝트 만들기',
|
||||
MY: '내 정보',
|
||||
DASHBOARD: '대시보드',
|
||||
} as const;
|
||||
|
||||
export const SETTINGS_ID = '__settings__';
|
||||
|
||||
export const GATED_ITEMS: string[] = [NAV.NEW, NAV.MY, NAV.DASHBOARD];
|
||||
|
||||
export type MyInfoTab = 'contents' | 'calendar' | 'payment' | 'business';
|
||||
|
||||
export interface NavItemDef {
|
||||
id: string;
|
||||
labelKey: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
export const NAV_ITEMS: NavItemDef[] = [
|
||||
{
|
||||
id: NAV.HOME,
|
||||
labelKey: 'sidebar.ado2Contents',
|
||||
icon: (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
<polyline points="9 22 9 12 15 12 15 22" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: NAV.NEW,
|
||||
labelKey: 'sidebar.newProject',
|
||||
icon: (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: NAV.MY,
|
||||
labelKey: 'sidebar.myInfo',
|
||||
icon: (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: SETTINGS_ID,
|
||||
labelKey: 'sidebar.settings',
|
||||
icon: (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
/** 구 activeItem(6메뉴 체제) → 새 4메뉴 체계 매핑 + 비로그인 게이트 강등을 함께 처리한다. */
|
||||
export function sanitizeActiveItem(
|
||||
raw: string | null,
|
||||
isGuest: boolean
|
||||
): { item: string; myInfoTab?: MyInfoTab } {
|
||||
const gate = (result: { item: string; myInfoTab?: MyInfoTab }) =>
|
||||
isGuest && GATED_ITEMS.includes(result.item) ? { item: NAV.HOME } : result;
|
||||
|
||||
switch (raw) {
|
||||
case '대시보드':
|
||||
return gate({ item: NAV.DASHBOARD });
|
||||
case '내 콘텐츠':
|
||||
return gate({ item: NAV.MY, myInfoTab: 'contents' });
|
||||
case '콘텐츠 캘린더':
|
||||
return gate({ item: NAV.MY, myInfoTab: 'calendar' });
|
||||
case '비즈니스 설정':
|
||||
return gate({ item: NAV.MY, myInfoTab: 'business' });
|
||||
case NAV.MY:
|
||||
return gate({ item: NAV.MY });
|
||||
case NAV.NEW:
|
||||
return gate({ item: NAV.NEW });
|
||||
case NAV.DASHBOARD:
|
||||
return gate({ item: NAV.DASHBOARD });
|
||||
case NAV.HOME:
|
||||
return { item: NAV.HOME };
|
||||
default:
|
||||
return { item: NAV.HOME };
|
||||
}
|
||||
}
|
||||
69
src/components/pipelineIcons.tsx
Normal file
69
src/components/pipelineIcons.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* 파이프라인 타일 아이콘.
|
||||
*
|
||||
* ADO2 Design System 의 아이콘 규칙을 그대로 따른다(`assets/icons/*.svg`):
|
||||
* viewBox 24×24 · fill none · stroke currentColor · stroke-width 2 · round cap/join
|
||||
*
|
||||
* 색은 넣지 않는다 — currentColor 로 두고 타일이 상태(민트/회색)를 정한다.
|
||||
* 파이프라인마다 다른 색을 주면 ADO2 팔레트(민트 액센트 + 퍼플 CTA 한 곳)가 깨진다.
|
||||
* 구분은 **형태**로만 한다.
|
||||
*/
|
||||
|
||||
interface IconProps {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const base = (size: number) => ({
|
||||
width: size,
|
||||
height: size,
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: 2,
|
||||
strokeLinecap: 'round' as const,
|
||||
strokeLinejoin: 'round' as const,
|
||||
});
|
||||
|
||||
/** ADO2 — 업체(가게)를 분석해 콘텐츠를 만든다 → 상점 차양 */
|
||||
export const StorefrontIcon: React.FC<IconProps> = ({ size = 24 }) => (
|
||||
<svg {...base(size)} aria-hidden="true">
|
||||
<path d="M4 10v9a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-9" />
|
||||
<path d="M3 10l1.4-4.3A1 1 0 0 1 5.35 5h13.3a1 1 0 0 1 .95.7L21 10a2.5 2.5 0 0 1-4.5 1.5A2.5 2.5 0 0 1 12 11.4a2.5 2.5 0 0 1-4.5.1A2.5 2.5 0 0 1 3 10z" />
|
||||
<path d="M10 20v-5h4v5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** 썰박스 — 역사 이야기 → 펼친 책 */
|
||||
export const BookIcon: React.FC<IconProps> = ({ size = 24 }) => (
|
||||
<svg {...base(size)} aria-hidden="true">
|
||||
<path d="M12 7.5v12" />
|
||||
<path d="M3 5h5a4 4 0 0 1 4 2.5V19.5A3 3 0 0 0 9.5 18H3V5z" />
|
||||
<path d="M21 5h-5a4 4 0 0 0-4 2.5V19.5A3 3 0 0 1 14.5 18H21V5z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* 무빙 포스터 — 포스터가 영상이 된다 → 세로 포스터 + 재생.
|
||||
*
|
||||
* 프레임은 2:3 세로다(12×18). 가로 프레임으로 그리면 일반 '동영상' 아이콘과
|
||||
* 구별되지 않는다 — 소재가 **포스터**라는 점이 이 기능의 정체성이라 비율로 드러낸다.
|
||||
*/
|
||||
export const PosterVideoIcon: React.FC<IconProps> = ({ size = 24 }) => (
|
||||
<svg {...base(size)} aria-hidden="true">
|
||||
<rect x="6" y="3" width="12" height="18" rx="2" />
|
||||
{/* 재생 삼각형만 채운다 — 아웃라인으로 그리면 이 크기에서 뭉개진다 */}
|
||||
<path d="M10.5 9v6l4.2-3z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** 포스터 스타일 — 화풍 재해석 → 팔레트 */
|
||||
export const PaletteIcon: React.FC<IconProps> = ({ size = 24 }) => (
|
||||
<svg {...base(size)} aria-hidden="true">
|
||||
<path d="M12 3a9 9 0 1 0 0 18 1.8 1.8 0 0 0 1.35-3 1.8 1.8 0 0 1 1.35-3H17a4 4 0 0 0 4-4c0-4.42-4.03-8-9-8z" />
|
||||
<circle cx="7.6" cy="12.4" r="1.1" fill="currentColor" stroke="none" />
|
||||
<circle cx="9.8" cy="8.2" r="1.1" fill="currentColor" stroke="none" />
|
||||
<circle cx="14.6" cy="7.6" r="1.1" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
);
|
||||
24
src/hooks/useOverlayClose.ts
Normal file
24
src/hooks/useOverlayClose.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import React, { useRef } from 'react';
|
||||
|
||||
/**
|
||||
* 모달 오버레이(배경) 클릭 시에만 닫히도록 하는 핸들러 쌍.
|
||||
* 모달 내부에서 텍스트를 드래그 선택하다가 마우스가 오버레이 위에서 놓이면
|
||||
* click 이벤트의 타겟이 오버레이가 되어 의도치 않게 닫히는 문제를 막기 위해,
|
||||
* mousedown과 click(mouseup) 타겟이 모두 오버레이 자신일 때만 닫는다.
|
||||
*/
|
||||
export function useOverlayClose(onClose: () => void) {
|
||||
const mouseDownOnSelfRef = useRef(false);
|
||||
|
||||
const onMouseDown = (e: React.MouseEvent<HTMLElement>) => {
|
||||
mouseDownOnSelfRef.current = e.target === e.currentTarget;
|
||||
};
|
||||
|
||||
const onClick = (e: React.MouseEvent<HTMLElement>) => {
|
||||
if (mouseDownOnSelfRef.current && e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
mouseDownOnSelfRef.current = false;
|
||||
};
|
||||
|
||||
return { onMouseDown, onClick };
|
||||
}
|
||||
97
src/hooks/useP2vJob.ts
Normal file
97
src/hooks/useP2vJob.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
P2vAuthError,
|
||||
P2vJob,
|
||||
P2vNotFoundError,
|
||||
getPosterJob,
|
||||
getStylingJob,
|
||||
isP2vActive,
|
||||
} from '../utils/p2vApi';
|
||||
|
||||
interface UseP2vJobResult {
|
||||
job: P2vJob | null;
|
||||
error: string | null;
|
||||
/** 401 — 화면이 접근 키 입력을 띄워야 한다 */
|
||||
authNeeded: boolean;
|
||||
/** 404 — 서버에 없는 잡이다. 호출부가 화면을 처음으로 되돌려야 한다 */
|
||||
notFound: boolean;
|
||||
/** 즉시 1회 조회하고, 멈춰 있던 폴링을 다시 켠다 (승인·재시도 직후에 쓴다) */
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL = 2500;
|
||||
|
||||
/**
|
||||
* P2V 잡 폴링 훅.
|
||||
*
|
||||
* castad 에는 웹소켓이 없고 썰박스도 폴링(`waitForSsulComplete`)이라 같은 방식을 쓴다.
|
||||
* 다만 썰박스는 "끝날 때까지 기다리는" 재귀 함수인 반면 이쪽은 **중간 상태를 계속
|
||||
* 그려야** 한다 — 검수 게이트에서 멈춰 사용자 입력을 기다리기 때문이다.
|
||||
*
|
||||
* 폴링은 queued/running 에서만 돈다. awaiting_review·done·failed 는 사용자가 뭔가
|
||||
* 하기 전까지 절대 안 바뀌므로 계속 두드리면 서버만 때린다. 승인·재시도 후에는
|
||||
* 호출부가 refresh() 로 다시 켠다.
|
||||
*/
|
||||
export function useP2vJob(kind: 'f1' | 'f2', id: string | null): UseP2vJobResult {
|
||||
const [job, setJob] = useState<P2vJob | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [authNeeded, setAuthNeeded] = useState(false);
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchOnce = useCallback(async (jobId: string) => {
|
||||
try {
|
||||
const next = kind === 'f1' ? await getPosterJob(jobId) : await getStylingJob(jobId);
|
||||
setJob(next);
|
||||
setError(null);
|
||||
setAuthNeeded(false);
|
||||
setNotFound(false);
|
||||
if (!isP2vActive(next)) stop();
|
||||
} catch (e) {
|
||||
if (e instanceof P2vAuthError) {
|
||||
setAuthNeeded(true);
|
||||
stop(); // 키가 없으면 계속 두드려봐야 401만 쌓인다
|
||||
return;
|
||||
}
|
||||
if (e instanceof P2vNotFoundError) {
|
||||
setNotFound(true);
|
||||
stop(); // 없는 잡은 다시 생기지 않는다
|
||||
return;
|
||||
}
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}, [kind, stop]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) {
|
||||
setJob(null);
|
||||
setError(null);
|
||||
setNotFound(false);
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
// 잡이 바뀌면 이전 잡의 상태가 한 틱 남아 보이므로 먼저 비운다
|
||||
setJob(null);
|
||||
setNotFound(false);
|
||||
fetchOnce(id);
|
||||
timerRef.current = setInterval(() => fetchOnce(id), POLL_INTERVAL);
|
||||
return stop;
|
||||
}, [id, fetchOnce, stop]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
if (!id) return;
|
||||
fetchOnce(id);
|
||||
if (!timerRef.current) {
|
||||
timerRef.current = setInterval(() => fetchOnce(id), POLL_INTERVAL);
|
||||
}
|
||||
}, [id, fetchOnce]);
|
||||
|
||||
return { job, error, authNeeded, notFound, refresh };
|
||||
}
|
||||
@ -21,82 +21,9 @@
|
||||
"credits": "Credits left: {{count}}",
|
||||
"loggingOut": "Logging out...",
|
||||
"logout": "Log Out",
|
||||
"tutorialRestart": "Restart Tutorial",
|
||||
"tutorial": "Tutorial",
|
||||
"tutorialOn": "Enable Tutorial",
|
||||
"tutorialOff": "Disable Tutorial",
|
||||
"inquiry": "Feedback"
|
||||
},
|
||||
"tutorial": {
|
||||
"skip": "Skip",
|
||||
"next": "Next",
|
||||
"prev": "Back",
|
||||
"finish": "Done",
|
||||
"landing": {
|
||||
"intro": { "title": "Welcome to ADO2 Tutorial", "desc": "We'll guide you through ADO2 step by step." },
|
||||
"field": { "title": "Enter Search Term", "desc": "Paste a Naver Maps share URL,\nor type a business name and select from the list." },
|
||||
"manual": { "title": "Direct Input", "desc": "You can also enter the business name and address manually to start analysis." },
|
||||
"button": { "title": "Start Brand Analysis", "desc": "Click the button to let AI start analyzing your brand." }
|
||||
},
|
||||
"asset": {
|
||||
"image": { "title": "Image List", "desc": "Photos from Naver Place. Tap 'Show more' to see the rest, or X to remove any." },
|
||||
"upload": { "title": "Add Images", "desc": "You can freely add more images." },
|
||||
"ratio": { "title": "Select Video Ratio", "desc": "Choose the ratio for the video to be generated." },
|
||||
"next": { "title": "Next Step", "desc": "Proceed to the next step when ready." }
|
||||
},
|
||||
"sound": {
|
||||
"genre": { "title": "Select Genre", "desc": "Pick a music genre that fits your brand.", "note": "Background music is coming soon." },
|
||||
"language": { "title": "Select Language", "desc": "You can choose the language for the sound.\nWant to continue with Korean?" },
|
||||
"generate": { "title": "Generate Sound", "desc": "Click the button and AI will generate lyrics and music." },
|
||||
"lyrics": { "title": "Lyrics Complete", "desc": "AI wrote lyrics in your selected language.\nCheck the generated lyrics." },
|
||||
"lyricsWait": { "title": "Generating Music", "desc": "AI is composing music based on the lyrics.\nPlease wait a moment." },
|
||||
"audioPlayer": { "title": "Preview the Music", "desc": "Music generation is complete.\nPress play to listen to the generated music." },
|
||||
"video": { "title": "Generate Video", "desc": "Click the button to start generating your video." }
|
||||
},
|
||||
"completion": {
|
||||
"contentInfo": { "title": "Content Info", "desc": "Check the file name, genre, resolution, and lyrics of the generated content." },
|
||||
"generating": { "title": "Generating Video", "desc": "AI is creating your video.\nPlease wait a moment." },
|
||||
"completion": { "title": "Video Complete!", "desc": "Your video is ready. Want to take a look?" },
|
||||
"myInfo": { "title": "Connect Social Account", "desc": "To upload your video to YouTube, connect your social account in My Info. Click to go there." }
|
||||
},
|
||||
"myInfo": {
|
||||
"myInfo": { "title": "My Info", "desc": "In My Info, you can manage your social connections and view connected accounts." },
|
||||
"connect": { "title": "Connect Now", "desc": "Click the YouTube connect button to go to the connection page.", "note": "Instagram connection is coming soon." },
|
||||
"connected": { "title": "Connected Accounts", "desc": "Your linked social accounts appear here.\nCheck after connecting." },
|
||||
"ado2": { "title": "ADO2 Contents", "desc": "You can now upload the generated video.\nClick to navigate." }
|
||||
},
|
||||
"ado2": {
|
||||
"list": { "title": "Generated Videos", "desc": "View all AI-created videos here." },
|
||||
"download": { "title": "Download", "desc": "Download the video to your device." },
|
||||
"delete": { "title": "Delete", "desc": "Remove videos you no longer need." },
|
||||
"upload": { "title": "Upload to Social Media", "desc": "Select a video and upload it to social media." }
|
||||
},
|
||||
"upload": {
|
||||
"seo": { "title": "Title & Description", "desc": "AI is generating the title and description for your video. Please wait a moment." },
|
||||
"required": { "title": "Required Fields", "desc": "Fields marked with * are required.\nPlease check them before uploading." },
|
||||
"schedule": { "title": "Schedule Upload", "desc": "Post now or schedule for a specific time." },
|
||||
"submit": { "title": "Start Upload", "desc": "Click the Post button to start uploading." }
|
||||
},
|
||||
"dashboard": {
|
||||
"metrics": { "title": "Key Metrics", "desc": "Check views, subscribers, and other stats for content uploaded via ADO2." },
|
||||
"chart": { "title": "Growth Chart", "desc": "Track your channel's growth over time." },
|
||||
"more": { "title": "More Analytics", "desc": "Even more statistics are available at a glance on the dashboard." }
|
||||
},
|
||||
"contentCalendar": {
|
||||
"grid": { "title": "Content Calendar", "desc": "View your content schedule by date.\nWhy not select today?" },
|
||||
"panel": { "title": "Content List", "desc": "Check the detailed content schedule here." }
|
||||
},
|
||||
"feedback": {
|
||||
"complete": { "title": "Tutorial Complete 🎉", "desc": "You've completed the full flow from brand analysis to YouTube upload.\nTo replay the tutorial, click the button in the top right." },
|
||||
"title": "Customer Feedback",
|
||||
"desc": "Share any issues or suggestions to help us improve."
|
||||
},
|
||||
"restart": {
|
||||
"title": "Restart Tutorial?",
|
||||
"desc": "The tutorial will restart from the current screen.",
|
||||
"confirm": "Start",
|
||||
"cancel": "Cancel"
|
||||
}
|
||||
"inquiry": "Feedback",
|
||||
"login": "Log In",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"footer": {
|
||||
"company": "O2O Inc.",
|
||||
@ -208,9 +135,7 @@
|
||||
"manualPlaceholderAddress": "Enter the address",
|
||||
"manualPlaceholderRegion": "Select a region",
|
||||
"manualPlaceholderDetail": "Enter detail address (e.g. Gangnam-gu Teheran-ro 123)",
|
||||
"manualPlaceholderCategory": "Enter the business category (e.g. pension, cafe, salon)",
|
||||
"manualLabelSiteUrl": "Website link (optional)",
|
||||
"manualPlaceholderSiteUrl": "Enter the official website URL (e.g. https://example.com)"
|
||||
"manualPlaceholderCategory": "Enter the business category (e.g. pension, cafe, salon)"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Welcome to ADO2.AI",
|
||||
@ -513,7 +438,9 @@
|
||||
"chargeSubmit": "Submit",
|
||||
"chargeSubmitting": "Submitting...",
|
||||
"chargeSuccess": "Your top-up request has been submitted!",
|
||||
"chargeConfirm": "OK"
|
||||
"chargeConfirm": "OK",
|
||||
"tabContents": "My Contents",
|
||||
"tabCalendar": "Calendar"
|
||||
},
|
||||
"ado2Contents": {
|
||||
"title": "ADO2 Contents",
|
||||
@ -597,8 +524,29 @@
|
||||
"scheduled": "Planned",
|
||||
"failed": "Failed"
|
||||
},
|
||||
"months": ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],
|
||||
"days": ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],
|
||||
"months": [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec"
|
||||
],
|
||||
"days": [
|
||||
"Sun",
|
||||
"Mon",
|
||||
"Tue",
|
||||
"Wed",
|
||||
"Thu",
|
||||
"Fri",
|
||||
"Sat"
|
||||
],
|
||||
"yearMonth": "{{month}} {{year}}",
|
||||
"monthDay": "{{month}} {{day}}",
|
||||
"loading": "Loading...",
|
||||
@ -616,7 +564,8 @@
|
||||
},
|
||||
"loginPrompt": {
|
||||
"title": "Login Required",
|
||||
"loginBtn": "Login"
|
||||
"loginBtn": "Login",
|
||||
"description": "Sign in with Kakao in a few seconds\nto keep going."
|
||||
},
|
||||
"videoDetail": {
|
||||
"dateFormat": "{{month}}/{{day}}/{{year}}",
|
||||
@ -626,7 +575,6 @@
|
||||
"closeAriaLabel": "Close",
|
||||
"share": "Share",
|
||||
"copied": "Copied!",
|
||||
"siteOverlayLabel": "View more",
|
||||
"shareKakao": "KakaoTalk",
|
||||
"shareFacebook": "Facebook",
|
||||
"shareTwitter": "X (Twitter)",
|
||||
@ -652,5 +600,110 @@
|
||||
"autocompleteError": "No results found. Please check your input and try again.",
|
||||
"autocompleteGeneralError": "An error occurred while retrieving business information. Please try again.",
|
||||
"pageComingSoon": "{{page}} page is coming soon."
|
||||
},
|
||||
"pipelineTabs": {
|
||||
"ariaLabel": "Content type",
|
||||
"ado2": "ADO2",
|
||||
"ssul": "Ssulbox",
|
||||
"poster": "Moving Poster",
|
||||
"styling": "Poster Styling",
|
||||
"desc": {
|
||||
"ado2": "One business URL becomes a brand analysis, lyrics, music and a short-form video.",
|
||||
"ssul": "Weaves a business into a historical story people watch to the end.",
|
||||
"poster": "One event poster becomes a short-form video with narration, music and camera moves.",
|
||||
"styling": "Reinterprets a poster in the visual language of paintings and film posters."
|
||||
}
|
||||
},
|
||||
"ssulbox": {
|
||||
"steps": {
|
||||
"pick": "Select",
|
||||
"making": "Generating",
|
||||
"done": "Done"
|
||||
},
|
||||
"create": {
|
||||
"step1": "STEP 1 · Pick a story",
|
||||
"step2": "STEP 2 · Pick a place",
|
||||
"searchPlaceholder": "Business name, or paste a Naver Map link",
|
||||
"searchButton": "Search",
|
||||
"searching": "Searching",
|
||||
"cost": "Cost",
|
||||
"costValue": "1 credit",
|
||||
"submit": "Create Ssulbox",
|
||||
"submitting": "Requesting…"
|
||||
}
|
||||
},
|
||||
"poster": {
|
||||
"steps": {
|
||||
"upload": "Upload",
|
||||
"analyze": "Analyze",
|
||||
"review": "Review",
|
||||
"render": "Generate",
|
||||
"done": "Done"
|
||||
},
|
||||
"key": {
|
||||
"title": "P2V access key required",
|
||||
"desc": "The poster pipeline runs on a separate server. The key you enter is stored in this browser.",
|
||||
"placeholder": "P2V_ACCESS_KEY",
|
||||
"save": "Save"
|
||||
},
|
||||
"dropzone": {
|
||||
"cta": "Drop a poster here\nor click to choose",
|
||||
"formats": "JPG · PNG · WEBP · GIF\nNo resolution limit",
|
||||
"badType": "Only JPG / PNG / WEBP / GIF files are accepted.",
|
||||
"lowRes": "Long edge is {{edge}}px. You can proceed; deep zoom segments may look soft.",
|
||||
"previewAlt": "Poster preview"
|
||||
},
|
||||
"create": {
|
||||
"step": "STEP 1 · Upload poster",
|
||||
"namePlaceholder": "Event name (leave blank to auto-detect)",
|
||||
"submit": "Create short-form",
|
||||
"submitting": "Uploading…"
|
||||
},
|
||||
"making": {
|
||||
"titleAnalyze": "Analyzing the poster",
|
||||
"titleRender": "Generating the video",
|
||||
"descAnalyze": "Detecting regions and drafting narration and motion. The review screen opens when this finishes.",
|
||||
"descRender": "Voice, music, animation and compositing follow. The music step can take a few minutes.",
|
||||
"phaseAnalyze": "Analyzing",
|
||||
"phaseRender": "Generating",
|
||||
"failTitle": "Generation failed",
|
||||
"retry": "Retry",
|
||||
"discard": "Start over",
|
||||
"discardConfirm": "This deletes the job and every generated video, audio and analysis file. This cannot be undone."
|
||||
},
|
||||
"review": {
|
||||
"narration": "Narration review",
|
||||
"motion": "Elements to animate",
|
||||
"metadata": "Metadata",
|
||||
"field": {
|
||||
"event_name": "Event",
|
||||
"date_text": "Date",
|
||||
"place": "Place"
|
||||
},
|
||||
"analysis": "Poster analysis",
|
||||
"approve": "Approve and continue",
|
||||
"approving": "Approving…"
|
||||
},
|
||||
"result": {
|
||||
"title": "Your moving poster is ready",
|
||||
"fileName": "File name",
|
||||
"downloadThumb": "Still JPG",
|
||||
"downloadVideo": "Download MP4",
|
||||
"new": "Start a new poster"
|
||||
},
|
||||
"styling": {
|
||||
"internalScope": "Internal demo only. Because of reference licensing, results must not be published externally or used commercially (public-domain artworks excepted).",
|
||||
"templates": "Style templates",
|
||||
"myPoster": "My poster",
|
||||
"result": "Result",
|
||||
"converting": "Converting (about 30s)",
|
||||
"formats": "Output format",
|
||||
"submit": "Convert style",
|
||||
"license": {
|
||||
"public-domain": "Publishable",
|
||||
"internal-only": "Internal only",
|
||||
"user-uploaded": "Rights unverified"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,85 +21,12 @@
|
||||
"credits": "보유 크레딧: {{count}}",
|
||||
"loggingOut": "로그아웃 중...",
|
||||
"logout": "로그아웃",
|
||||
"tutorialRestart": "튜토리얼 다시 보기",
|
||||
"tutorial": "튜토리얼",
|
||||
"tutorialOn": "튜토리얼 켜기",
|
||||
"tutorialOff": "튜토리얼 끄기",
|
||||
"inquiry": "고객의견"
|
||||
},
|
||||
"tutorial": {
|
||||
"skip": "건너뛰기",
|
||||
"next": "다음",
|
||||
"prev": "이전",
|
||||
"finish": "완료",
|
||||
"landing": {
|
||||
"intro": { "title": "ADO2 튜토리얼 시작", "desc": "ADO2 사용 방법을 단계별로 안내해 드릴게요." },
|
||||
"field": { "title": "입력하기", "desc": "네이버 지도 공유 URL을 붙여넣거나,\n업체명을 입력하면 자동으로 목록에서 선택할 수 있어요." },
|
||||
"manual": { "title": "직접 입력", "desc": "업체명과 주소를 직접 입력해서 분석을 시작할 수도 있어요." },
|
||||
"button": { "title": "브랜드 분석 시작", "desc": "버튼을 누르면 AI가 브랜드를 분석하기 시작해요." }
|
||||
},
|
||||
"asset": {
|
||||
"image": { "title": "이미지 목록", "desc": "네이버 Place에서 가져 온 사진이에요. \n더보기를 누르면 나머지 사진도 볼 수 있고 X를 눌러 삭제 할 수 있어요." },
|
||||
"upload": { "title": "이미지 추가", "desc": "이미지를 자유롭게 추가 할 수 있어요." },
|
||||
"ratio": { "title": "영상 비율 선택", "desc": "생성 할 영상의 비율을 선택하세요." },
|
||||
"next": { "title": "다음 단계로", "desc": "설정이 완료되면 다음 단계로 진행하세요." }
|
||||
},
|
||||
"sound": {
|
||||
"genre": { "title": "장르 선택", "desc": "영상에 어울리는 음악 장르를 선택하세요.", "note": "배경음악은 이후 오픈 예정입니다." },
|
||||
"language": { "title": "언어 선택", "desc": "음악의 언어를 선택할 수 있어요. \n이미 선택된 한국어로 진행해볼까요?" },
|
||||
"generate": { "title": "사운드 생성", "desc": "버튼을 클릭하면 AI가 가사와 음악을 생성해요."},
|
||||
"lyrics": { "title": "가사 생성 완료", "desc": "AI가 선택한 언어로 가사를 만들었어요.\n생성된 가사를 확인하세요." },
|
||||
"lyricsWait": { "title": "음악 생성 중", "desc": "가사를 바탕으로 AI가 음악을 만들고 있어요.\n잠시만 기다려 주세요." },
|
||||
"audioPlayer": { "title": "음악 미리 듣기", "desc": "음악 생성이 완료되었어요.\n재생 버튼을 눌러 생성된 음악을 들어보세요." },
|
||||
"video": { "title": "영상 생성", "desc": "버튼을 클릭해서 영상 생성을 시작하세요." }
|
||||
},
|
||||
"completion": {
|
||||
"contentInfo": { "title": "콘텐츠 정보", "desc": "콘텐츠의 파일명, 장르, 규격, 가사를 확인하세요." },
|
||||
"generating": { "title": "영상 제작 중", "desc": "AI가 영상을 만들고 있어요. \n잠시만 기다려 주세요." },
|
||||
"completion": { "title": "영상 완성!", "desc": "영상 제작이 완료되었어요. \n영상을 확인해 볼까요?" },
|
||||
"myInfo": { "title": "소셜 계정 연동", "desc": "영상을 유튜브에 업로드하려면 내 정보에서 소셜 계정을 연동해야 해요. \n클릭해서 이동하세요." }
|
||||
},
|
||||
"myInfo": {
|
||||
"myInfo": { "title": "내 정보", "desc": "내 정보에서는 소셜 연결과 연결된 계정을 확인 할 수 있어요." },
|
||||
"connect": { "title": "연결하기", "desc": "YouTube 연결 버튼을 누르면 연결 페이지로 이동합니다.", "note": "Instagram 연결은 오픈 예정입니다." },
|
||||
"connected": { "title": "연결 계정", "desc": "연결된 소셜 계정 목록이에요. \n연결 후 여기서 확인할 수 있어요." },
|
||||
"ado2": { "title": "ADO2 콘텐츠", "desc": "이제 생성된 영상을 업로드할 수 있어요. \n클릭해서 이동하세요." }
|
||||
},
|
||||
"ado2": {
|
||||
"list": { "title": "생성된 영상 목록", "desc": "ADO2에서 만든 영상들을 확인할 수 있어요." },
|
||||
"download": { "title": "다운로드", "desc": "영상을 다운로드 할 수 있어요." },
|
||||
"delete": { "title": "삭제", "desc": "필요없는 영상을 삭제할 수 있어요." },
|
||||
"upload": { "title": "소셜 업로드", "desc": "선택해서 소셜미디어에 업로드하세요." }
|
||||
},
|
||||
"upload": {
|
||||
"seo": { "title": "제목 및 설명", "desc": "영상의 제목과 설명을 AI가 만들고 있어요. 잠시만 기다려 주세요." },
|
||||
"required": { "title": "필수 항목", "desc": "영상을 업로드 하기 전 *는 필수 항목으로 \n반드시 확인해 주세요." },
|
||||
"schedule": { "title": "업로드 예약", "desc": "지금 게시하거나 원하는 시간에 예약할 수 있어요." },
|
||||
"submit": { "title": "업로드 시작", "desc": "게시 버튼을 눌러 업로드를 시작하세요." }
|
||||
},
|
||||
"dashboard": {
|
||||
"metrics": { "title": "핵심 지표", "desc": "조회수, 구독자 등 ADO2로 업로드한 콘텐츠의 주요 통계를 확인하세요." },
|
||||
"chart": { "title": "성장 추이 차트", "desc": "기간별 성장 추이를 그래프로 확인할 수 있어요." },
|
||||
"more": { "title": "더 많은 통계", "desc": "그 외에도 다양한 통계를 대시보드에서 한눈에 확인할 수 있어요." }
|
||||
},
|
||||
"contentCalendar": {
|
||||
"grid": { "title": "콘텐츠 캘린더", "desc": "날짜별로 콘텐츠 스케줄을 확인할 수 있어요. \n오늘 날짜를 선택해 볼까요?" },
|
||||
"panel": { "title": "콘텐츠 목록", "desc": "자세한 콘텐츠 스케줄을 확인 할수 있어요." }
|
||||
},
|
||||
"feedback": {
|
||||
"complete": { "title": "튜토리얼 완료 🎉", "desc": "유튜브 업로드까지 모든 과정을 완료했어요. \n튜토리얼을 다시보고 싶다면 우측 상단의 버튼을 눌러주세요." },
|
||||
"title": "고객의견",
|
||||
"desc": "서비스 이용 중 불편한 점이나 개선 의견을 보내주세요."
|
||||
},
|
||||
"restart": {
|
||||
"title": "튜토리얼을 다시 시작할까요?",
|
||||
"desc": "현재 화면부터 튜토리얼이 다시 시작됩니다.",
|
||||
"confirm": "시작하기",
|
||||
"cancel": "취소"
|
||||
}
|
||||
"inquiry": "고객의견",
|
||||
"login": "로그인",
|
||||
"settings": "설정"
|
||||
},
|
||||
"footer": {
|
||||
"company":"㈜에이아이오투오",
|
||||
"company": "㈜에이아이오투오",
|
||||
"businessNumber": "사업자 등록번호 : 620-87-00810 | 대표 : 안성민",
|
||||
"headquarters": "본사 : 대구광역시 북구 옥산로 111, 5층 유니콘랩 대구 A05호",
|
||||
"researchCenter": "연구소 : 경기 성남시 수정구 금토로 32 (금토동) (주)KT 판교빌딩 504호~505호 (East)",
|
||||
@ -182,7 +109,8 @@
|
||||
"close": "닫기",
|
||||
"doNotClose": "업로드가 진행 중입니다. 창을 닫지 마세요.",
|
||||
"goToCalendar": "캘린더에서 확인",
|
||||
"scheduleConflict": "예약 시간이 충돌하여 {{time}}으로 조정되었습니다." },
|
||||
"scheduleConflict": "예약 시간이 충돌하여 {{time}}으로 조정되었습니다."
|
||||
},
|
||||
"landing": {
|
||||
"hero": {
|
||||
"searchTypeLabel": "업체명 | URL",
|
||||
@ -207,9 +135,7 @@
|
||||
"manualPlaceholderAddress": "주소를 입력하세요.",
|
||||
"manualPlaceholderRegion": "지역을 선택하세요.",
|
||||
"manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)",
|
||||
"manualPlaceholderCategory": "업종을 입력하세요. (예: 펜션, 카페, 미용실)",
|
||||
"manualLabelSiteUrl": "홈페이지 링크 (선택)",
|
||||
"manualPlaceholderSiteUrl": "공식 홈페이지 주소를 입력하세요. (예: https://example.com)"
|
||||
"manualPlaceholderCategory": "업종을 입력하세요. (예: 펜션, 카페, 미용실)"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "ADO2.AI에 오신 것을 환영합니다.",
|
||||
@ -512,7 +438,9 @@
|
||||
"chargeSubmit": "요청하기",
|
||||
"chargeSubmitting": "요청 중...",
|
||||
"chargeSuccess": "충전 요청이 완료 되었습니다!",
|
||||
"chargeConfirm": "확인"
|
||||
"chargeConfirm": "확인",
|
||||
"tabContents": "내 콘텐츠",
|
||||
"tabCalendar": "콘텐츠 캘린더"
|
||||
},
|
||||
"ado2Contents": {
|
||||
"title": "ADO2 콘텐츠",
|
||||
@ -596,8 +524,29 @@
|
||||
"scheduled": "예약",
|
||||
"failed": "실패"
|
||||
},
|
||||
"months": ["1월","2월","3월","4월","5월","6월","7월","8월","9월","10월","11월","12월"],
|
||||
"days": ["일","월","화","수","목","금","토"],
|
||||
"months": [
|
||||
"1월",
|
||||
"2월",
|
||||
"3월",
|
||||
"4월",
|
||||
"5월",
|
||||
"6월",
|
||||
"7월",
|
||||
"8월",
|
||||
"9월",
|
||||
"10월",
|
||||
"11월",
|
||||
"12월"
|
||||
],
|
||||
"days": [
|
||||
"일",
|
||||
"월",
|
||||
"화",
|
||||
"수",
|
||||
"목",
|
||||
"금",
|
||||
"토"
|
||||
],
|
||||
"yearMonth": "{{year}}년 {{month}}",
|
||||
"monthDay": "{{month}} {{day}}일",
|
||||
"loading": "불러오는 중...",
|
||||
@ -615,7 +564,8 @@
|
||||
},
|
||||
"loginPrompt": {
|
||||
"title": "로그인이 필요합니다.",
|
||||
"loginBtn": "로그인"
|
||||
"loginBtn": "로그인",
|
||||
"description": "카카오 계정으로 간편하게 로그인하고\n이어서 진행해보세요."
|
||||
},
|
||||
"videoDetail": {
|
||||
"dateFormat": "{{year}}년 {{month}}월 {{day}}일",
|
||||
@ -625,7 +575,6 @@
|
||||
"closeAriaLabel": "닫기",
|
||||
"share": "공유하기",
|
||||
"copied": "복사됨!",
|
||||
"siteOverlayLabel": "자세히 보기",
|
||||
"shareKakao": "카카오톡",
|
||||
"shareFacebook": "페이스북",
|
||||
"shareTwitter": "X (트위터)",
|
||||
@ -651,5 +600,208 @@
|
||||
"autocompleteError": "검색 정보를 찾을 수 없습니다. 입력 정보를 다시 확인해주세요.",
|
||||
"autocompleteGeneralError": "업체 정보 조회 중 오류가 발생했습니다. 다시 시도해주세요.",
|
||||
"pageComingSoon": "{{page}} 페이지 준비 중입니다."
|
||||
},
|
||||
"pipelineTabs": {
|
||||
"ariaLabel": "생성할 콘텐츠 종류",
|
||||
"ado2": "ADO2",
|
||||
"ssul": "썰박스",
|
||||
"poster": "무빙 포스터",
|
||||
"styling": "포스터 스타일링",
|
||||
"desc": {
|
||||
"ado2": "업체 URL 하나로 브랜드를 분석해 가사·음악·숏폼 영상까지 자동 제작합니다.",
|
||||
"ssul": "유명 역사 스토리에 업체 셀링포인트를 녹여 흥미로운 숏폼 콘텐츠를 만듭니다.",
|
||||
"poster": "포스터가 나레이션, 음악, 애니메이션을 추가한 숏폼 콘텐츠로 나옵니다.",
|
||||
"styling": "포스터를 유명 영화의 한 장면 · 고전화 · 영화 포스터 스타일로 재해석합니다."
|
||||
}
|
||||
},
|
||||
"ssulbox": {
|
||||
"steps": {
|
||||
"pick": "선택",
|
||||
"making": "생성",
|
||||
"done": "완성"
|
||||
},
|
||||
"create": {
|
||||
"step1": "STEP 1 · 시나리오 선택",
|
||||
"step2": "STEP 2 · 업체 검색",
|
||||
"searchPlaceholder": "업체명 또는 URL을 입력하세요.",
|
||||
"searchButton": "검색",
|
||||
"searching": "검색 중",
|
||||
"placeLoading": "업체를 찾는 중… (몇 초 걸려요)",
|
||||
"hintUrlOk": "네이버 링크로 해당 업체를 직접 수집합니다.",
|
||||
"hintDefault": "업체를 선택하면 해당 업체의 사진과 정보를 수집해 썰박스에 씁니다.",
|
||||
"hintScenario": "{{name}} 시나리오로 약 5~6분 소요됩니다.",
|
||||
"noResult": "검색 결과가 없어요. 이름을 바꾸거나 네이버 지도 링크를 붙여넣어 주세요.",
|
||||
"cost": "생성 비용",
|
||||
"costValue": "1 크레딧",
|
||||
"submit": "썰박스 만들기",
|
||||
"submitting": "요청 중…"
|
||||
},
|
||||
"making": {
|
||||
"title": "썰박스를 만들고 있어요",
|
||||
"step1": "대본 생성 중",
|
||||
"step2": "스토리보드 구성 중",
|
||||
"step3": "이미지·목소리 생성 중",
|
||||
"step4": "영상 합성 중",
|
||||
"takesTime": "약 5~6분 소요됩니다. 다른 작업을 하셔도 계속 진행됩니다.",
|
||||
"failTitle": "생성에 실패했어요",
|
||||
"failDefault": "잠시 후 다시 시도해주세요.",
|
||||
"refunded": "차감된 크레딧은 환불되었습니다.",
|
||||
"retry": "다시 만들기"
|
||||
},
|
||||
"result": {
|
||||
"title": "썰박스가 완성됐어요",
|
||||
"download": "다운로드",
|
||||
"downloading": "다운로드 중...",
|
||||
"loadFailed": "영상을 불러오지 못했어요. 내 콘텐츠에서 확인해주세요.",
|
||||
"scenarioLabel": "시나리오",
|
||||
"createdLabel": "생성일시"
|
||||
},
|
||||
"viewer": {
|
||||
"untitled": "이름 없는 콘텐츠"
|
||||
},
|
||||
"error": {
|
||||
"createFailed": "생성 요청에 실패했어요. 잠시 후 다시 시도해주세요."
|
||||
},
|
||||
"scenario": {
|
||||
"joseon": {
|
||||
"name": "조선왕",
|
||||
"desc": "조선 27대 왕들의 실화 썰"
|
||||
},
|
||||
"samgukji": {
|
||||
"name": "삼국지",
|
||||
"desc": "위·촉·오 영웅 28인의 야사"
|
||||
},
|
||||
"greek": {
|
||||
"name": "그리스·로마신화",
|
||||
"desc": "올림포스 신·영웅들의 전설"
|
||||
},
|
||||
"odyssey": {
|
||||
"name": "오디세이",
|
||||
"desc": "오디세우스 10년 귀향 대모험"
|
||||
}
|
||||
}
|
||||
},
|
||||
"poster": {
|
||||
"steps": {
|
||||
"upload": "업로드",
|
||||
"analyze": "분석",
|
||||
"review": "검수",
|
||||
"render": "생성",
|
||||
"done": "완성"
|
||||
},
|
||||
"key": {
|
||||
"title": "P2V 접근 키가 필요합니다",
|
||||
"desc": "포스터 파이프라인은 별도 서버에서 돕니다. 서버에 설정된 접근 키를 입력하면 이 브라우저에 저장됩니다.",
|
||||
"placeholder": "P2V_ACCESS_KEY",
|
||||
"save": "저장"
|
||||
},
|
||||
"dropzone": {
|
||||
"cta": "포스터를 끌어다 놓거나\n클릭해서 선택",
|
||||
"formats": "JPG · PNG · WEBP · GIF\n해상도 제한 없음",
|
||||
"badType": "JPG / PNG / WEBP / GIF 파일만 올릴 수 있습니다.",
|
||||
"lowRes": "긴 변 {{edge}}px입니다. 그대로 진행할 수 있고, 깊은 줌 구간만 다소 부드럽게 보일 수 있습니다.",
|
||||
"previewAlt": "포스터 미리보기"
|
||||
},
|
||||
"create": {
|
||||
"step": "STEP 1 · 포스터 업로드",
|
||||
"namePlaceholder": "행사 이름을 입력하세요 (비우면 자동 추출)",
|
||||
"submit": "숏폼 만들기",
|
||||
"submitting": "업로드 중…"
|
||||
},
|
||||
"making": {
|
||||
"titleAnalyze": "포스터를 분석하고 있어요",
|
||||
"titleRender": "영상을 만들고 있어요",
|
||||
"descAnalyze": "영역을 검출하고 나레이션·모션을 초안으로 작성합니다. 끝나면 검수 화면이 열립니다.",
|
||||
"descRender": "음성·음악·애니메이션·합성이 이어집니다. 음악 단계는 몇 분 걸릴 수 있습니다.",
|
||||
"phaseAnalyze": "분석 중",
|
||||
"phaseRender": "생성 중",
|
||||
"failTitle": "생성에 실패했어요",
|
||||
"retry": "다시 시도",
|
||||
"discard": "처음부터",
|
||||
"discardConfirm": "이 작업과 만들어진 영상·음성·분석 파일을 모두 지웁니다. 되돌릴 수 없습니다."
|
||||
},
|
||||
"stages": {
|
||||
"detect": "영역 검출",
|
||||
"narration_text": "나레이션",
|
||||
"motion": "모션 선정",
|
||||
"tts": "음성",
|
||||
"bgm": "음악",
|
||||
"i2v": "애니메이션",
|
||||
"render": "합성",
|
||||
"transfer": "변환"
|
||||
},
|
||||
"review": {
|
||||
"narration": "나레이션 검수",
|
||||
"narrationHelp": "자동 작성된 문장을 확인하고 필요하면 고쳐주세요. 이대로 음성이 됩니다.",
|
||||
"motion": "움직일 요소",
|
||||
"motionHelp": "포스터에서 찾은 요소입니다. 빼거나 더할 수 있습니다.",
|
||||
"motionEmpty": "움직일 요소를 찾지 못했습니다. 직접 고르거나, 비워두면 애니메이션 없이 진행됩니다.",
|
||||
"metadata": "메타태그",
|
||||
"metadataHelp": "아카이브에 영구 저장됩니다. 연도나 지명이 잘못 읽혔는지 포스터와 대조해 주세요.",
|
||||
"field": {
|
||||
"event_name": "행사명",
|
||||
"date_text": "일시",
|
||||
"place": "장소"
|
||||
},
|
||||
"analysis": "포스터 분석 결과",
|
||||
"analysisAlt": "영역 분석 확인 이미지",
|
||||
"analysisMissing": "분석 이미지가 없습니다",
|
||||
"approve": "승인하고 계속",
|
||||
"approving": "승인 중…"
|
||||
},
|
||||
"motion": {
|
||||
"firework": "불꽃놀이",
|
||||
"water": "물살",
|
||||
"wave": "파도",
|
||||
"cloud": "구름",
|
||||
"smoke": "연기",
|
||||
"moon": "달",
|
||||
"sun": "해",
|
||||
"star": "별",
|
||||
"light": "조명",
|
||||
"flag": "깃발·천",
|
||||
"foliage": "나뭇잎·풀",
|
||||
"wheel": "바퀴·관람차",
|
||||
"vessel": "배",
|
||||
"crowd": "사람 무리"
|
||||
},
|
||||
"result": {
|
||||
"title": "무빙 포스터가 완성됐어요",
|
||||
"fileName": "파일명",
|
||||
"videoMissing": "영상 파일을 찾지 못했습니다.",
|
||||
"downloadThumb": "정지 컷 JPG",
|
||||
"downloadVideo": "MP4 다운로드",
|
||||
"new": "새 포스터로 만들기"
|
||||
},
|
||||
"styling": {
|
||||
"internalScope": "내부 시연 전용입니다. 레퍼런스 저작권 때문에 결과물의 외부 공개·상업 사용은 금합니다(퍼블릭 도메인 명화 제외).",
|
||||
"templates": "스타일 템플릿",
|
||||
"pickTemplate": "템플릿을 선택하세요",
|
||||
"count": "{{count}}종",
|
||||
"myRefs": "내 레퍼런스",
|
||||
"myRefsHint": "직접 올린 이미지의 화법으로 변환합니다",
|
||||
"addRef": "레퍼런스 추가",
|
||||
"minEdge": "긴 변 {{px}}px 이상 권장",
|
||||
"analyzing": "화풍 분석 중…",
|
||||
"deleteRef": "레퍼런스 삭제",
|
||||
"confirmDelete": "이 레퍼런스를 삭제할까요? 되돌릴 수 없습니다.",
|
||||
"attribution": "레퍼런스 출처 · {{source}}",
|
||||
"myPoster": "내 포스터",
|
||||
"result": "결과",
|
||||
"resultAlt": "변환 결과",
|
||||
"resultHint": "변환 결과가 여기에 표시됩니다",
|
||||
"converting": "변환 중 (30초 안팎)",
|
||||
"downloadPng": "PNG 다운로드",
|
||||
"formats": "출력 포맷",
|
||||
"submit": "스타일 변환",
|
||||
"license": {
|
||||
"public-domain": "공개 가능",
|
||||
"internal-only": "내부 전용",
|
||||
"user-uploaded": "권리 미확인"
|
||||
},
|
||||
"internalOnlyTitle": "내부 전용 템플릿",
|
||||
"internalOnlyBody": "이 레퍼런스로 만든 결과물은 외부 공개·상업 사용을 금합니다.",
|
||||
"userRefNotice": "직접 올린 레퍼런스는 권리를 가진 이미지만 사용하세요. 타인의 저작물을 올려 생긴 문제의 책임은 업로드한 사람에게 있습니다. 결과물은 권리 미확인으로 분류됩니다."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,10 +1,7 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CrawlingResponse, TargetPersona } from '../../types/api';
|
||||
import { GeometricChart } from './GeometricChart';
|
||||
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
||||
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
||||
import TutorialOverlay from '../../components/Tutorial/TutorialOverlay';
|
||||
|
||||
interface AnalysisResultSectionProps {
|
||||
onBack: () => void;
|
||||
@ -14,19 +11,8 @@ interface AnalysisResultSectionProps {
|
||||
|
||||
const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, onGenerate, data }) => {
|
||||
const { t } = useTranslation();
|
||||
const tutorial = useTutorial();
|
||||
const { processed_info, marketing_analysis } = data;
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (!tutorial.hasSeen(TUTORIAL_KEYS.ANALYSIS)) {
|
||||
tutorial.startTutorial(TUTORIAL_KEYS.ANALYSIS);
|
||||
}
|
||||
}, 600);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const brandIdentity = marketing_analysis?.brand_identity;
|
||||
const marketPositioning = marketing_analysis?.market_positioning;
|
||||
const targetPersonas = marketing_analysis?.target_persona || [];
|
||||
@ -37,7 +23,6 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
|
||||
const sortedSellingPoints = [...sellingPoints].sort((a, b) => b.score - a.score);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bi2-page">
|
||||
{/* Header */}
|
||||
<div className="bi2-header">
|
||||
@ -189,18 +174,6 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tutorial.isActive && (
|
||||
<TutorialOverlay
|
||||
hints={tutorial.hints}
|
||||
currentIndex={tutorial.currentHintIndex}
|
||||
onNext={tutorial.nextHint}
|
||||
onPrev={tutorial.prevHint}
|
||||
onSkip={tutorial.skipTutorial}
|
||||
groupProgress={tutorial.groupProgress}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getAllVideos, isLoggedIn } from '../../utils/api';
|
||||
import { getAllVideos } from '../../utils/api';
|
||||
import { VideoListItem } from '../../types/api';
|
||||
import LoginPromptModal from '../../components/LoginPromptModal';
|
||||
import VideoDetailModal from '../../components/VideoDetailModal';
|
||||
import CitySelectModal from '../../components/CitySelectModal';
|
||||
import ContentCardSocialActions from '../../components/ContentCardSocialActions';
|
||||
import SsulViewerModal from '../Ssulbox/SsulViewerModal';
|
||||
|
||||
interface ADO2ContentsPageProps {
|
||||
onBack?: () => void;
|
||||
@ -13,11 +13,13 @@ interface ADO2ContentsPageProps {
|
||||
|
||||
const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
|
||||
const { t } = useTranslation();
|
||||
const authed = isLoggedIn();
|
||||
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null);
|
||||
// 썰박스는 별도 뷰어를 쓴다 — VideoDetailModal 은 video_id 로 조회하므로
|
||||
// 썰박스 id 를 넘기면 id 가 겹치는 다른 영상이 열린다.
|
||||
const [selectedSsul, setSelectedSsul] = useState<VideoListItem | null>(null);
|
||||
const [videos, setVideos] = useState<VideoListItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(authed);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasNext, setHasNext] = useState(false);
|
||||
@ -33,7 +35,6 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
|
||||
const [showCityModal, setShowCityModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authed) return;
|
||||
fetchVideos();
|
||||
}, [page, sortBy, order, storeName, region]);
|
||||
|
||||
@ -141,14 +142,25 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
|
||||
<>
|
||||
<div className="ado2-contents-grid">
|
||||
{videos.map((video) => (
|
||||
// key·상세 열기 모두 (type, video_id) 로 다뤄야 한다 —
|
||||
// video_id 는 종류별 독립 시퀀스라 값이 겹친다.
|
||||
<div
|
||||
key={video.video_id}
|
||||
key={`${video.type}-${video.video_id}`}
|
||||
className="ado2-content-card"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => handleCardClick(video.video_id)}
|
||||
onClick={() =>
|
||||
video.type === 'ssul'
|
||||
? setSelectedSsul(video)
|
||||
: handleCardClick(video.video_id)
|
||||
}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCardClick(video.video_id)}
|
||||
onKeyDown={(e) =>
|
||||
e.key === 'Enter' &&
|
||||
(video.type === 'ssul'
|
||||
? setSelectedSsul(video)
|
||||
: handleCardClick(video.video_id))
|
||||
}
|
||||
>
|
||||
<div className="content-card-thumbnail ado2-gallery-thumbnail-wrap">
|
||||
{video.poster_url ? (
|
||||
@ -190,11 +202,13 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
|
||||
<h3 className="content-card-title">{video.store_name}</h3>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<p className="content-card-date">{formatDate(video.created_at)}</p>
|
||||
{/* 좋아요·댓글 API 가 type 을 받으므로 썰박스에도 노출한다.
|
||||
contentType 이 없으면 id 가 겹치는 다른 영상에 반영된다. */}
|
||||
<ContentCardSocialActions
|
||||
videoId={video.video_id}
|
||||
contentType={video.type}
|
||||
storeName={video.store_name}
|
||||
region={video.region}
|
||||
title={video.title}
|
||||
commentCount={video.comment_count ?? 0}
|
||||
initialLikeCount={video.like_count ?? 0}
|
||||
initialIsLiked={video.is_liked_by_me}
|
||||
@ -226,9 +240,7 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{!authed && (
|
||||
<LoginPromptModal onClose={() => { window.location.href = '/'; }} />
|
||||
)}
|
||||
<SsulViewerModal item={selectedSsul} onClose={() => setSelectedSsul(null)} />
|
||||
|
||||
{selectedVideoId !== null && (
|
||||
<VideoDetailModal
|
||||
|
||||
@ -3,9 +3,7 @@ import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { generateVideo, waitForVideoComplete, getSubtitleStatus, waitForSubtitleComplete, trackFirstVideoCreated } from '../../utils/api';
|
||||
import SocialPostingModal from '../../components/SocialPostingModal';
|
||||
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
||||
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
||||
import TutorialOverlay from '../../components/Tutorial/TutorialOverlay';
|
||||
import CompletionView from '../../components/CompletionView';
|
||||
|
||||
interface CompletionContentProps {
|
||||
onBack: () => void;
|
||||
@ -46,22 +44,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
||||
const hasStartedGeneration = useRef(false);
|
||||
const displayIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const tutorial = useTutorial();
|
||||
const tutorialIsActiveRef = useRef(tutorial.isActive);
|
||||
tutorialIsActiveRef.current = tutorial.isActive;
|
||||
|
||||
// 영상 생성 중 튜토리얼 트리거 (생성 상태 안내 -> 콘텐츠 정보 -> 내 정보 이동)
|
||||
useEffect(() => {
|
||||
const isComplete = videoStatus === 'complete';
|
||||
const isProcessing = videoStatus === 'generating' || videoStatus === 'polling';
|
||||
|
||||
if (isProcessing && !tutorialIsActiveRef.current && !tutorial.hasSeen(TUTORIAL_KEYS.GENERATING)) {
|
||||
tutorial.startTutorial(TUTORIAL_KEYS.GENERATING);
|
||||
} else if (isComplete && !tutorialIsActiveRef.current && !tutorial.hasSeen(TUTORIAL_KEYS.COMPLETION)) {
|
||||
tutorial.startTutorial(TUTORIAL_KEYS.COMPLETION);
|
||||
}
|
||||
}, [videoStatus]);
|
||||
|
||||
// 소셜 미디어 포스팅 모달
|
||||
const [showSocialModal, setShowSocialModal] = useState(false);
|
||||
const [videoDbId, setVideoDbId] = useState<number | null>(null);
|
||||
@ -410,174 +392,98 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
||||
return `${businessName}.mp4`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 가사 단락 파싱.
|
||||
*
|
||||
* `[Verse]` 처럼 대괄호만 있는 줄을 섹션 머리로 보고, 뒤따르는 줄들을 그 섹션에
|
||||
* 묶는다. 태그 없이 시작하는 가사도 있어 첫 섹션은 태그 없이 열릴 수 있다.
|
||||
*/
|
||||
const renderLyrics = () => {
|
||||
if (!songCompletionData) {
|
||||
return <p className="comp2-lyrics-text">{t('completion.sampleLyrics')}</p>;
|
||||
}
|
||||
if (!songCompletionData.lyrics) {
|
||||
return <p className="comp2-lyrics-text">{t('completion.noLyricsBGM')}</p>;
|
||||
}
|
||||
const lines = songCompletionData.lyrics.split('\n').filter((l: string) => l.trim());
|
||||
const sections: { tag: string | null; lines: string[] }[] = [];
|
||||
lines.forEach((line: string) => {
|
||||
const tagMatch = line.trim().match(/^\[(.+)\]$/);
|
||||
if (tagMatch) {
|
||||
sections.push({ tag: `[${tagMatch[1]}]`, lines: [] });
|
||||
} else if (sections.length === 0) {
|
||||
sections.push({ tag: null, lines: [line] });
|
||||
} else {
|
||||
sections[sections.length - 1].lines.push(line);
|
||||
}
|
||||
});
|
||||
return (
|
||||
<div className="comp2-lyrics-paragraphs">
|
||||
{sections
|
||||
.filter((s) => s.lines.length > 0)
|
||||
.map((section, i) => (
|
||||
<div key={i} className="comp2-lyrics-para-section">
|
||||
{section.tag && <span className="comp2-lyrics-tag">{section.tag}</span>}
|
||||
<p className="comp2-lyrics-text">{section.lines.join('\n')}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="comp2-page">
|
||||
{/* <div className="comp2-header">
|
||||
<button onClick={onBack} className="comp2-back-btn">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
<span>{t('completion.back')}</span>
|
||||
</button>
|
||||
</div> */}
|
||||
|
||||
<div className="comp2-title-row">
|
||||
<h1 className="comp2-page-title">{t('completion.contentComplete')}</h1>
|
||||
<p className="comp2-page-subtitle">{t('completion.contentCompleteDesc')}</p>
|
||||
</div>
|
||||
|
||||
<div className="comp2-container">
|
||||
<div className="comp2-grid">
|
||||
{/* 왼쪽: 영상 */}
|
||||
<div className="comp2-video-section">
|
||||
<div className="comp2-video-wrapper">
|
||||
{isLoading ? (
|
||||
<div className="comp2-video-loading">
|
||||
<div className="loading-spinner">
|
||||
<div className="loading-ring"></div>
|
||||
<div className="loading-dot">
|
||||
<div className="loading-dot-inner"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="comp2-loading-text">{statusMessage}</p>
|
||||
<div className="loading-progress-wrapper">
|
||||
<div className="loading-progress-bar">
|
||||
<div
|
||||
className="loading-progress-fill"
|
||||
style={{ width: `${displayProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="loading-progress-text">{Math.floor(displayProgress)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
) : videoStatus === 'error' ? (
|
||||
<div className="comp2-video-error">
|
||||
<svg className="comp2-error-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4M12 16h.01" />
|
||||
</svg>
|
||||
<p className="comp2-error-text">{errorMessage}</p>
|
||||
<button onClick={handleRetry} className="comp2-retry-btn">
|
||||
{t('completion.retry')}
|
||||
</button>
|
||||
</div>
|
||||
) : videoUrl ? (
|
||||
<video
|
||||
src={videoUrl}
|
||||
className="comp2-video-player"
|
||||
controls
|
||||
playsInline
|
||||
/>
|
||||
) : (
|
||||
<div className="comp2-video-placeholder"></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 콘텐츠 정보 */}
|
||||
<div className="comp2-info-section">
|
||||
<div className="comp2-info-header">
|
||||
<span className="comp2-info-label">{t('completion.contentInfo')}</span>
|
||||
</div>
|
||||
<div className="comp2-file-info">
|
||||
<h3 className="comp2-filename">{getFileName()}</h3>
|
||||
{/* <p className="comp2-filesize">19.6MB</p> */}
|
||||
</div>
|
||||
<div className="comp2-info-content">
|
||||
<div className="comp2-meta-grid">
|
||||
<div className="comp2-meta-item">
|
||||
<span className="comp2-meta-label">{t('completion.genre')} : {songCompletionData?.genre || 'K-POP'}</span>
|
||||
</div>
|
||||
<div className="comp2-meta-divider"></div>
|
||||
<div className="comp2-meta-item">
|
||||
<span className="comp2-meta-label">{t('completion.resolution')} : {getVideoResolution()}</span>
|
||||
</div>
|
||||
<div className="comp2-meta-divider"></div>
|
||||
<div className="comp2-lyrics-section">
|
||||
<span className="comp2-meta-label">{t('completion.lyrics')}</span>
|
||||
{(() => {
|
||||
if (!songCompletionData) {
|
||||
return <p className="comp2-lyrics-text">{t('completion.sampleLyrics')}</p>;
|
||||
}
|
||||
if (!songCompletionData.lyrics) {
|
||||
return <p className="comp2-lyrics-text">{t('completion.noLyricsBGM')}</p>;
|
||||
}
|
||||
const lines = songCompletionData.lyrics.split('\n').filter((l: string) => l.trim());
|
||||
const sections: { tag: string | null; lines: string[] }[] = [];
|
||||
lines.forEach((line: string) => {
|
||||
const tagMatch = line.trim().match(/^\[(.+)\]$/);
|
||||
if (tagMatch) {
|
||||
sections.push({ tag: `[${tagMatch[1]}]`, lines: [] });
|
||||
} else if (sections.length === 0) {
|
||||
sections.push({ tag: null, lines: [line] });
|
||||
} else {
|
||||
sections[sections.length - 1].lines.push(line);
|
||||
}
|
||||
});
|
||||
return (
|
||||
<div className="comp2-lyrics-paragraphs">
|
||||
{sections.filter(s => s.lines.length > 0).map((section, i) => (
|
||||
<div key={i} className="comp2-lyrics-para-section">
|
||||
{section.tag && <span className="comp2-lyrics-tag">{section.tag}</span>}
|
||||
<p className="comp2-lyrics-text">{section.lines.join('\n')}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 하단 버튼 */}
|
||||
<div className="comp2-buttons">
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={videoStatus !== 'complete' || !videoUrl || isDownloading}
|
||||
className="comp2-btn comp2-btn-secondary"
|
||||
>
|
||||
{isDownloading ? t('completion.downloading') : t('completion.download')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleOpenSocialConnect}
|
||||
disabled={videoStatus !== 'complete' || !videoDbId}
|
||||
className="comp2-btn comp2-btn-primary"
|
||||
>
|
||||
{t('completion.uploadToSocial')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 소셜 미디어 포스팅 모달 (기존 SocialPostingModal 컴포넌트 사용) */}
|
||||
<SocialPostingModal
|
||||
isOpen={showSocialModal}
|
||||
onClose={handleCloseSocialConnect}
|
||||
onGoToCalendar={onGoToCalendar}
|
||||
video={videoUrl && videoDbId ? {
|
||||
video_id: videoDbId,
|
||||
store_name: songCompletionData?.businessName || '',
|
||||
region: '',
|
||||
task_id: songTaskId || '',
|
||||
result_movie_url: videoUrl,
|
||||
created_at: new Date().toISOString(),
|
||||
like_count: 0,
|
||||
comment_count: 0,
|
||||
is_liked_by_me: false,
|
||||
} : null}
|
||||
/>
|
||||
|
||||
{tutorial.isActive && (
|
||||
<TutorialOverlay
|
||||
hints={tutorial.hints}
|
||||
currentIndex={tutorial.currentHintIndex}
|
||||
onNext={tutorial.nextHint}
|
||||
onPrev={tutorial.prevHint}
|
||||
onSkip={tutorial.skipTutorial}
|
||||
groupProgress={tutorial.groupProgress}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
<CompletionView
|
||||
videoState={
|
||||
isLoading
|
||||
? 'loading'
|
||||
: videoStatus === 'error'
|
||||
? 'error'
|
||||
: videoUrl
|
||||
? 'ready'
|
||||
: 'empty'
|
||||
}
|
||||
videoUrl={videoUrl}
|
||||
loadingText={statusMessage}
|
||||
progress={displayProgress}
|
||||
errorMessage={errorMessage}
|
||||
onRetry={handleRetry}
|
||||
fileName={getFileName()}
|
||||
metaLines={[
|
||||
`${t('completion.genre')} : ${songCompletionData?.genre || 'K-POP'}`,
|
||||
`${t('completion.resolution')} : ${getVideoResolution()}`,
|
||||
]}
|
||||
detailLabel={t('completion.lyrics')}
|
||||
detail={renderLyrics()}
|
||||
onDownload={handleDownload}
|
||||
downloadDisabled={videoStatus !== 'complete' || !videoUrl || isDownloading}
|
||||
downloadLabel={isDownloading ? t('completion.downloading') : t('completion.download')}
|
||||
onUpload={handleOpenSocialConnect}
|
||||
uploadDisabled={videoStatus !== 'complete' || !videoDbId}
|
||||
>
|
||||
{/* 소셜 미디어 포스팅 모달 (기존 SocialPostingModal 컴포넌트 사용) */}
|
||||
<SocialPostingModal
|
||||
isOpen={showSocialModal}
|
||||
onClose={handleCloseSocialConnect}
|
||||
onGoToCalendar={onGoToCalendar}
|
||||
video={
|
||||
videoUrl && videoDbId
|
||||
? {
|
||||
// ADO2 파이프라인 완료 화면이므로 항상 영상이다
|
||||
type: 'video',
|
||||
video_id: videoDbId,
|
||||
store_name: songCompletionData?.businessName || '',
|
||||
region: '',
|
||||
task_id: songTaskId || '',
|
||||
result_movie_url: videoUrl,
|
||||
created_at: new Date().toISOString(),
|
||||
like_count: 0,
|
||||
comment_count: 0,
|
||||
is_liked_by_me: false,
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</CompletionView>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -67,16 +67,17 @@ const statusLabelKey = (status: UploadStatus) => {
|
||||
|
||||
interface ContentCalendarContentProps {
|
||||
onNavigate?: (id: string) => void;
|
||||
/** 내 정보 탭 안에 임베드될 때: 탭 라벨과 중복되는 자체 제목을 숨기고 여백을 줄인다 */
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavigate }) => {
|
||||
const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavigate, embedded }) => {
|
||||
const { t } = useTranslation();
|
||||
const today = new Date();
|
||||
const [year, setYear] = useState(today.getFullYear());
|
||||
const [month, setMonth] = useState(today.getMonth());
|
||||
const [activeTab, setActiveTab] = useState<TabType>('전체');
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
// allItems: 캘린더 도트용 (전체)
|
||||
// panelItems: 오른쪽 패널용 (탭 필터)
|
||||
const [allItems, setAllItems] = useState<UploadHistoryItem[]>([]);
|
||||
@ -194,11 +195,16 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||
setTimeout(() => {
|
||||
const el = dateRefs.current[dateKey];
|
||||
const panel = panelRef.current;
|
||||
if (el && panel) {
|
||||
if (!el) return;
|
||||
// 패널이 자체 스크롤을 가진 경우(데스크톱 사이드 패널)만 내부 스크롤.
|
||||
// 모바일은 목록이 캘린더 아래에 그대로 펼쳐지므로 페이지 스크롤로 이동한다.
|
||||
if (panel && panel.scrollHeight > panel.clientHeight) {
|
||||
const panelTop = panel.getBoundingClientRect().top;
|
||||
const elTop = el.getBoundingClientRect().top;
|
||||
const offset = elTop - panelTop + panel.scrollTop - 12; // 12px 여백
|
||||
panel.scrollTo({ top: offset, behavior: 'smooth' });
|
||||
} else {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, 50);
|
||||
};
|
||||
@ -396,7 +402,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||
{summary.completed > 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: '#1ba64f', flexShrink: 0 }} />
|
||||
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 11, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}>
|
||||
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 12, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}>
|
||||
{t('contentCalendar.completedCount', { count: summary.completed })}
|
||||
</span>
|
||||
</div>
|
||||
@ -404,7 +410,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||
{summary.scheduled > 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: '#2563eb', flexShrink: 0 }} />
|
||||
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 11, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}>
|
||||
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 12, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}>
|
||||
{t('contentCalendar.scheduledCount', { count: summary.scheduled })}
|
||||
</span>
|
||||
</div>
|
||||
@ -412,7 +418,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||
{summary.failed > 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: '#e15252', flexShrink: 0 }} />
|
||||
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 11, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}>
|
||||
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 12, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}>
|
||||
{t('contentCalendar.failedCount', { count: summary.failed })}
|
||||
</span>
|
||||
</div>
|
||||
@ -428,7 +434,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||
|
||||
// ── 탭 바 ────────────────────────────────────────────────────
|
||||
const renderTabs = () => (
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'center', width: '100%' }}>
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
@ -535,13 +541,13 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||
backgroundColor: statusColor(item.status as UploadStatus), flexShrink: 0,
|
||||
}} />
|
||||
<span style={{
|
||||
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 13,
|
||||
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 14,
|
||||
color: statusColor(item.status as UploadStatus), lineHeight: 1,
|
||||
}}>
|
||||
{t(statusLabelKey(item.status as UploadStatus))}
|
||||
</span>
|
||||
<span style={{
|
||||
fontFamily: 'Pretendard, sans-serif', fontWeight: 400, fontSize: 13,
|
||||
fontFamily: 'Pretendard, sans-serif', fontWeight: 400, fontSize: 14,
|
||||
color: '#9bcacc', lineHeight: 1, marginLeft: 'auto',
|
||||
}}>
|
||||
{formatTime(item)}
|
||||
@ -589,7 +595,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||
style={{
|
||||
height: 30, padding: '0 12px', borderRadius: 8,
|
||||
border: '1px solid #379599', backgroundColor: 'transparent',
|
||||
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 13,
|
||||
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 14,
|
||||
color: '#9bcacc', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
@ -602,7 +608,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||
style={{
|
||||
height: 30, padding: '0 12px', borderRadius: 8,
|
||||
border: 'none', backgroundColor: '#a65eff',
|
||||
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 13,
|
||||
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 14,
|
||||
color: '#ffffff', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
@ -616,10 +622,11 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||
};
|
||||
|
||||
// ── 패널 콘텐츠 ────────────────────────────────────────────
|
||||
const renderPanelContent = () => {
|
||||
// inline=true (모바일): 자체 스크롤 없이 페이지 흐름에 그대로 펼친다
|
||||
const renderPanelContent = (inline = false) => {
|
||||
if (panelLoading) {
|
||||
return (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: inline ? 120 : undefined }}>
|
||||
<span style={{ color: '#9bcacc', fontFamily: 'Pretendard, sans-serif', fontSize: 14 }}>{t('contentCalendar.loading')}</span>
|
||||
</div>
|
||||
);
|
||||
@ -629,6 +636,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||
<div style={{
|
||||
flex: 1, display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'center', justifyContent: 'center', gap: 24, padding: 16,
|
||||
minHeight: inline ? 160 : undefined,
|
||||
}}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center', textAlign: 'center' }}>
|
||||
<p style={{
|
||||
@ -662,7 +670,11 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="calendar-panel-scroll"
|
||||
style={{ flex: 1, overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 20, maxHeight: 700 }}
|
||||
style={{
|
||||
flex: 1, padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 20,
|
||||
overflowY: inline ? 'visible' : 'auto',
|
||||
maxHeight: inline ? undefined : 700,
|
||||
}}
|
||||
>
|
||||
{sortedDateKeys.map(dateKey => (
|
||||
<div
|
||||
@ -691,17 +703,19 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
paddingTop: 24, paddingBottom: 20, width: '100%',
|
||||
paddingTop: embedded ? 0 : 24, paddingBottom: 20, width: '100%',
|
||||
boxSizing: 'border-box', position: 'relative',
|
||||
}}>
|
||||
<div style={{ width: '100%', paddingBottom: 24, paddingLeft: 20, paddingRight: 20, boxSizing: 'border-box' }}>
|
||||
<p style={{
|
||||
fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 30,
|
||||
color: '#ffffff', letterSpacing: '-0.18px', lineHeight: 1.3, margin: 0,
|
||||
}}>
|
||||
{t('contentCalendar.title')}
|
||||
</p>
|
||||
</div>
|
||||
{!embedded && (
|
||||
<div style={{ width: '100%', paddingBottom: 24, paddingLeft: 20, paddingRight: 20, boxSizing: 'border-box' }}>
|
||||
<p style={{
|
||||
fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 30,
|
||||
color: '#ffffff', letterSpacing: '-0.18px', lineHeight: 1.3, margin: 0,
|
||||
}}>
|
||||
{t('contentCalendar.title')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', gap: 16,
|
||||
@ -716,34 +730,24 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||
{renderCalendarGrid()}
|
||||
</div>
|
||||
|
||||
{/* 바텀시트 */}
|
||||
<div style={{ position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 10, display: 'flex', flexDirection: 'column' }}>
|
||||
{/* 업로드 목록 — 캘린더 바로 아래에 펼쳐 둔다(바텀시트로 감추지 않음).
|
||||
좌우 여백 없음: 위 캘린더 그리드와 가로 폭을 맞춘다 */}
|
||||
<div style={{ width: '100%', marginTop: 16, boxSizing: 'border-box' }}>
|
||||
<div style={{
|
||||
backgroundColor: '#01393b',
|
||||
borderTop: '1px solid #046266',
|
||||
borderRadius: '20px 20px 0 0',
|
||||
border: '1px solid #046266',
|
||||
borderRadius: 20,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div
|
||||
onClick={() => setSheetOpen(o => !o)}
|
||||
style={{ display: 'flex', justifyContent: 'center', padding: '8px 0', cursor: 'pointer' }}
|
||||
>
|
||||
<div style={{ width: 49, height: 4, backgroundColor: '#9bcacc', borderRadius: 999 }} />
|
||||
</div>
|
||||
<div style={{
|
||||
borderBottom: sheetOpen ? '1px solid #046266' : 'none',
|
||||
borderBottom: '1px solid #046266',
|
||||
height: 50, display: 'flex', alignItems: 'center', padding: '0 16px',
|
||||
}}>
|
||||
{renderTabs()}
|
||||
</div>
|
||||
{sheetOpen && (
|
||||
<div style={{ height: 400, overflowY: 'auto', backgroundColor: '#01393b', display: 'flex', flexDirection: 'column' }}>
|
||||
{renderPanelContent()}
|
||||
</div>
|
||||
)}
|
||||
{renderPanelContent(true)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ height: sheetOpen ? 516 : 116 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -754,26 +758,28 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
padding: '32px',
|
||||
padding: embedded ? '0' : '32px',
|
||||
width: '100%',
|
||||
minWidth: 1400,
|
||||
height: '100%', boxSizing: 'border-box',
|
||||
height: embedded ? 'auto' : '100%', boxSizing: 'border-box',
|
||||
}}>
|
||||
<div style={{ width: '100%', maxWidth: 1440, paddingBottom: 32, flexShrink: 0 }}>
|
||||
<p style={{
|
||||
fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 30,
|
||||
color: '#ffffff', letterSpacing: '-0.18px', lineHeight: 1.3, margin: 0,
|
||||
}}>
|
||||
{t('contentCalendar.title')}
|
||||
</p>
|
||||
</div>
|
||||
{!embedded && (
|
||||
<div style={{ width: '100%', maxWidth: 1440, paddingBottom: 32, flexShrink: 0 }}>
|
||||
<p style={{
|
||||
fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 30,
|
||||
color: '#ffffff', letterSpacing: '-0.18px', lineHeight: 1.3, margin: 0,
|
||||
}}>
|
||||
{t('contentCalendar.title')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) 340px',
|
||||
gap: 16,
|
||||
width: '100%', maxWidth: 1440,
|
||||
flex: 1, minHeight: 0,
|
||||
flex: 1, minHeight: embedded ? 'calc(100vh - 220px)' : 0,
|
||||
}}>
|
||||
{/* 캘린더 영역 */}
|
||||
<div className="calendar-grid-area" style={{
|
||||
|
||||
@ -740,7 +740,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
|
||||
borderRadius: '8px',
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
padding: '5px 10px',
|
||||
fontSize: '13px',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
minWidth: '160px',
|
||||
}}
|
||||
@ -788,7 +788,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
|
||||
display: 'flex', alignItems: 'center', gap: '8px',
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
fontSize: '14px',
|
||||
background: acc.id === selectedAccountId ? 'rgba(255,255,255,0.08)' : 'transparent',
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
}}
|
||||
@ -940,8 +940,8 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
|
||||
onClick={() => setShowMockData(prev => !prev)}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '24px',
|
||||
right: '24px',
|
||||
top: '10px',
|
||||
right: '5px',
|
||||
zIndex: 9999,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
||||
@ -2,31 +2,24 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Sidebar from '../../components/Sidebar';
|
||||
import BottomNav from '../../components/BottomNav';
|
||||
import LoginPromptModal from '../../components/LoginPromptModal';
|
||||
import AssetManagementContent from './AssetManagementContent';
|
||||
import SoundStudioContent from './SoundStudioContent';
|
||||
import CompletionContent from './CompletionContent';
|
||||
import DashboardContent from './DashboardContent';
|
||||
import BusinessSettingsContent from './BusinessSettingsContent';
|
||||
import UrlInputContent from './UrlInputContent';
|
||||
import ADO2ContentsPage from './ADO2ContentsPage';
|
||||
import MyContentsPage from './MyContentsPage';
|
||||
import MyInfoContent from './MyInfoContent';
|
||||
import ContentCalendarContent from './ContentCalendarContent';
|
||||
import LoadingSection from '../Analysis/LoadingSection';
|
||||
import AnalysisResultSection from '../Analysis/AnalysisResultSection';
|
||||
import { ImageItem, type ImageListItem, CrawlingResponse, UserMeResponse } from '../../types/api';
|
||||
import { crawlUrl, autocomplete, marketingAnalysis, AutocompleteRequest, getUserMe, getUserCredits, clearTokens } from '../../utils/api';
|
||||
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
||||
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
||||
import TutorialOverlay, { TutorialRestartPopup } from '../../components/Tutorial/TutorialOverlay';
|
||||
import { crawlUrl, autocomplete, marketingAnalysis, AutocompleteRequest, getUserMe, getUserCredits, clearTokens, getActiveSsulTask, waitForSsulComplete, isLoggedIn } from '../../utils/api';
|
||||
import WizardStepper from '../../components/WizardStepper';
|
||||
import { createPreviewThumbnail } from '../../utils/imageCompression.ts';
|
||||
import { NAV, GATED_ITEMS, sanitizeActiveItem, MyInfoTab } from '../../components/navItems';
|
||||
import { useIsMobile } from '../../utils/useIsMobile';
|
||||
import { createPreviewThumbnail } from '../../utils/imageCompression';
|
||||
|
||||
const WIZARD_STEP_KEY = 'castad_wizard_step';
|
||||
const ACTIVE_ITEM_KEY = 'castad_active_item';
|
||||
const SONG_TASK_ID_KEY = 'castad_song_task_id';
|
||||
const IMAGE_TASK_ID_KEY = 'castad_image_task_id';
|
||||
const ANALYSIS_DATA_KEY = 'castad_analysis_data';
|
||||
import { saveSearchHistory } from '../../components/SearchHistory/useSearchHistory';
|
||||
|
||||
/** 파일 이미지의 썸네일 objectURL 을 일괄 해제한다. preview 가 null 인 항목은 건너뛴다. */
|
||||
@ -37,22 +30,72 @@ const revokeAllPreviews = (items: ImageItem[]) => {
|
||||
}
|
||||
});
|
||||
};
|
||||
import { Pipeline, isPipeline } from '../../components/PipelineTabs';
|
||||
import { Scen } from '../Ssulbox/ssulData';
|
||||
import SsulMakingContent, { SsulJob } from '../Ssulbox/SsulMakingContent';
|
||||
import SsulResultContent from '../Ssulbox/SsulResultContent';
|
||||
import PosterMakingContent from '../Poster/PosterMakingContent';
|
||||
import PosterReviewContent from '../Poster/PosterReviewContent';
|
||||
import PosterResultContent from '../Poster/PosterResultContent';
|
||||
import { useP2vJob } from '../../hooks/useP2vJob';
|
||||
import { deletePosterJob, isPastReview, retryPosterJob } from '../../utils/p2vApi';
|
||||
import {
|
||||
K,
|
||||
clearProjectStorage,
|
||||
clearSessionStorage,
|
||||
} from '../../utils/storageKeys';
|
||||
|
||||
// 다른 컴포넌트에서 사용하는 storage key들 (초기화용)
|
||||
const SONG_GENERATION_KEY = 'castad_song_generation';
|
||||
const VIDEO_GENERATION_KEY = 'castad_video_generation';
|
||||
const VIDEO_COMPLETE_KEY = 'castad_video_complete'; // 완료된 영상 정보
|
||||
const WIZARD_STEP_KEY = K.WIZARD_STEP;
|
||||
const ACTIVE_ITEM_KEY = K.ACTIVE_ITEM;
|
||||
const SONG_TASK_ID_KEY = K.SONG_TASK_ID;
|
||||
const IMAGE_TASK_ID_KEY = K.IMAGE_TASK_ID;
|
||||
const ANALYSIS_DATA_KEY = K.ANALYSIS_DATA;
|
||||
|
||||
// 모든 프로젝트 관련 localStorage 초기화
|
||||
const clearAllProjectStorage = () => {
|
||||
localStorage.removeItem(WIZARD_STEP_KEY);
|
||||
localStorage.removeItem(SONG_TASK_ID_KEY);
|
||||
localStorage.removeItem(IMAGE_TASK_ID_KEY);
|
||||
localStorage.removeItem(SONG_GENERATION_KEY);
|
||||
localStorage.removeItem(VIDEO_GENERATION_KEY);
|
||||
localStorage.removeItem(VIDEO_COMPLETE_KEY);
|
||||
/**
|
||||
* 랜딩 CTA 에서 썰박스를 고르고 로그인하러 갈 때 남기는 1회성 프리셋.
|
||||
* 카카오 로그인은 외부 리다이렉트 왕복이고 initializeOnNewSession 이
|
||||
* localStorage 만 지우므로 sessionStorage 를 쓴다.
|
||||
*/
|
||||
const PENDING_PIPELINE_KEY = 'castad_pending_pipeline';
|
||||
|
||||
/** 비로그인 게이트 클릭 시 "로그인 후 열어줄 메뉴"를 남기는 1회성 프리셋. 카카오 왕복을 견뎌야 해서 sessionStorage */
|
||||
const LOGIN_NEXT_ITEM_KEY = 'castad_login_next_item';
|
||||
|
||||
/** 썰박스 진행 단계. ADO2 의 wizardStep(-2~3) 과 별도 축이다 */
|
||||
type SsulStep = 'entry' | 'making' | 'result';
|
||||
|
||||
/** 썰박스 스텝퍼 라벨(i18n 키)과 0-based 인덱스 */
|
||||
const SSUL_STEP_LABELS = [
|
||||
'ssulbox.steps.pick',
|
||||
'ssulbox.steps.making',
|
||||
'ssulbox.steps.done',
|
||||
] as const;
|
||||
|
||||
const SSUL_STEP_INDEX: Record<SsulStep, number> = {
|
||||
entry: 0,
|
||||
making: 1,
|
||||
result: 2,
|
||||
};
|
||||
|
||||
/**
|
||||
* 포스터 영상(F1) 진행 단계. 잡 status 에서 파생되므로 state 로 들지 않는다 —
|
||||
* 서버 상태와 화면 상태를 따로 들면 반드시 어긋난다(썰박스에서 겪은 것).
|
||||
*/
|
||||
type PosterStep = 'entry' | 'making' | 'review' | 'result';
|
||||
|
||||
/**
|
||||
* 스텝퍼 라벨. 5단계인 이유는 검수 게이트 **앞뒤가 서로 다른 작업**이기 때문이다 —
|
||||
* 분석(검출·나레이션·모션)과 생성(음성·음악·애니메이션·렌더)을 한 칸에 묶으면
|
||||
* 승인 후에도 스텝퍼가 안 움직여 멈춘 것처럼 보인다.
|
||||
*/
|
||||
const POSTER_STEP_LABELS = [
|
||||
'poster.steps.upload',
|
||||
'poster.steps.analyze',
|
||||
'poster.steps.review',
|
||||
'poster.steps.render',
|
||||
'poster.steps.done',
|
||||
] as const;
|
||||
|
||||
interface BusinessInfo {
|
||||
customer_name: string;
|
||||
region: string;
|
||||
@ -60,7 +103,7 @@ interface BusinessInfo {
|
||||
}
|
||||
|
||||
interface GenerationFlowProps {
|
||||
onHome: () => void;
|
||||
onHome?: () => void;
|
||||
initialActiveItem?: string;
|
||||
initialImageList?: ImageListItem[];
|
||||
businessInfo?: BusinessInfo;
|
||||
@ -77,7 +120,7 @@ interface GenerationFlowProps {
|
||||
|
||||
const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
onHome,
|
||||
initialActiveItem = '대시보드',
|
||||
initialActiveItem = NAV.HOME,
|
||||
initialImageList = [],
|
||||
businessInfo,
|
||||
initialAnalysisData
|
||||
@ -101,7 +144,20 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const [activeItem, setActiveItem] = useState(savedActiveItem || initialActiveItem);
|
||||
const isGuest = !isLoggedIn();
|
||||
|
||||
/** activeItem 초기화: 로그인 복귀 메뉴(최우선) → 저장된 값 → 초기값, 마지막에 구 이름 마이그레이션+게스트 강등 */
|
||||
const getInitialNav = () => {
|
||||
const loginNext = sessionStorage.getItem(LOGIN_NEXT_ITEM_KEY);
|
||||
if (loginNext && isLoggedIn()) {
|
||||
sessionStorage.removeItem(LOGIN_NEXT_ITEM_KEY);
|
||||
return sanitizeActiveItem(loginNext, isGuest);
|
||||
}
|
||||
return sanitizeActiveItem(savedActiveItem || initialActiveItem, isGuest);
|
||||
};
|
||||
const initialNav = getInitialNav();
|
||||
|
||||
const [activeItem, setActiveItem] = useState(initialNav.item);
|
||||
|
||||
// 초기 위저드 단계 결정
|
||||
const getInitialWizardStep = (): number => {
|
||||
@ -121,6 +177,77 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
};
|
||||
|
||||
const [wizardStep, setWizardStep] = useState(getInitialWizardStep());
|
||||
|
||||
/**
|
||||
* 초기 파이프라인 결정 — 우선순위를 지켜야 한다.
|
||||
*
|
||||
* 2번이 없으면 위 getInitialWizardStep() 의 "분석 데이터가 있으면 step 1부터"
|
||||
* 강제 로직과 충돌한다. ADO2 분석을 마치고 돌아온 상태에서 파이프라인만
|
||||
* 'ssul' 로 남아 있으면, 썰박스 화면인데 ADO2 위저드 단계가 그려진다.
|
||||
*/
|
||||
const getInitialPipeline = (): Pipeline => {
|
||||
// 1) 랜딩 CTA 프리셋 (1회성). 카카오 로그인 리다이렉트 왕복을 견뎌야 해서 sessionStorage
|
||||
const pending = sessionStorage.getItem(PENDING_PIPELINE_KEY);
|
||||
if (isPipeline(pending)) {
|
||||
sessionStorage.removeItem(PENDING_PIPELINE_KEY);
|
||||
return pending;
|
||||
}
|
||||
// 2) 분석 데이터가 있으면 ADO2 흐름 한가운데이므로 강제
|
||||
if (initialAnalysisData || savedAnalysisData) return 'ado2';
|
||||
// 3) 저장된 선택
|
||||
const saved = localStorage.getItem(K.PIPELINE);
|
||||
return isPipeline(saved) ? saved : 'ado2';
|
||||
};
|
||||
|
||||
const [pipeline, setPipeline] = useState<Pipeline>(getInitialPipeline);
|
||||
/** 썰박스 전용 진행 축. wizardStep 숫자 축은 확장하지 않는다 */
|
||||
const [ssulStep, setSsulStep] = useState<SsulStep>('entry');
|
||||
const [ssulJob, setSsulJob] = useState<SsulJob | null>(null);
|
||||
/** 언마운트·로그아웃·재시도 시 진행 중인 폴링을 멈추는 플래그 */
|
||||
const ssulCancelledRef = useRef(false);
|
||||
|
||||
/**
|
||||
* 포스터 영상(F1) 잡. 썰박스와 달리 상태 축을 따로 들지 않고 서버 status 에서 파생한다 —
|
||||
* P2V 는 검수 게이트에서 멈춰 사용자를 기다리므로 중간 상태를 계속 그려야 하고,
|
||||
* 그때 화면 상태를 따로 들면 두 축이 어긋난다.
|
||||
*/
|
||||
const [posterJobId, setPosterJobId] = useState<string | null>(
|
||||
() => localStorage.getItem(K.POSTER_JOB_ID),
|
||||
);
|
||||
const [posterBusy, setPosterBusy] = useState(false);
|
||||
const { job: posterJob, notFound: posterNotFound, refresh: refreshPosterJob } =
|
||||
useP2vJob('f1', pipeline === 'poster' ? posterJobId : null);
|
||||
|
||||
const changePipeline = (next: Pipeline) => {
|
||||
setPipeline(next);
|
||||
localStorage.setItem(K.PIPELINE, next);
|
||||
};
|
||||
|
||||
/** 포스터 진행 단계 — 잡 status 에서 파생 */
|
||||
const posterStep: PosterStep = !posterJobId
|
||||
? 'entry'
|
||||
: posterJob?.status === 'awaiting_review'
|
||||
? 'review'
|
||||
: posterJob?.status === 'done'
|
||||
? 'result'
|
||||
: 'making'; // queued · running · failed · 조회 전(null)
|
||||
|
||||
/** 스텝퍼 인덱스. 검수 전 running(1) 과 승인 후 running(3) 을 구분한다 */
|
||||
const posterStepIndex = posterStep === 'entry'
|
||||
? 0
|
||||
: posterStep === 'review'
|
||||
? 2
|
||||
: posterStep === 'result'
|
||||
? 4
|
||||
: isPastReview(posterJob) ? 3 : 1;
|
||||
|
||||
/** 진입 화면(탭이 보이는 단계) 여부. 파이프라인마다 진입 조건이 다르다 */
|
||||
const isEntry =
|
||||
pipeline === 'ado2' ? wizardStep === -2
|
||||
: pipeline === 'ssul' ? ssulStep === 'entry'
|
||||
: pipeline === 'poster' ? posterStep === 'entry'
|
||||
// 스타일링은 전 과정이 진입 화면 안에서 끝난다 (위저드 없음)
|
||||
: true;
|
||||
const [songTaskId, setSongTaskId] = useState<string | null>(savedSongTaskId);
|
||||
const [imageTaskId, setImageTaskId] = useState<string | null>(savedImageTaskId);
|
||||
const [videoGenerationStatus, setVideoGenerationStatus] = useState<'idle' | 'generating' | 'complete' | 'error'>('idle');
|
||||
@ -130,10 +257,10 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
const [isAnalysisComplete, setIsAnalysisComplete] = useState(false);
|
||||
const [userInfo, setUserInfo] = useState<UserMeResponse | null>(null);
|
||||
const [credits, setCredits] = useState<number | null>(null);
|
||||
const [myInfoInitialTab, setMyInfoInitialTab] = useState<'basic' | 'payment' | 'business' | undefined>(undefined);
|
||||
const tutorial = useTutorial();
|
||||
const [myInfoInitialTab, setMyInfoInitialTab] = useState<MyInfoTab | undefined>(initialNav.myInfoTab);
|
||||
|
||||
const refreshCredits = useCallback(async () => {
|
||||
if (!isLoggedIn()) return;
|
||||
try {
|
||||
const { credits } = await getUserCredits();
|
||||
setCredits(credits);
|
||||
@ -149,8 +276,9 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
}
|
||||
}, [wizardStep]);
|
||||
|
||||
// 로그인 직후 사용자 정보 + 크레딧 조회
|
||||
// 로그인 직후 사용자 정보 + 크레딧 조회 (비로그인이면 요청 자체를 하지 않는다 — 401 → 강제 리다이렉트 방지)
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) return;
|
||||
const fetchUserInfo = async () => {
|
||||
try {
|
||||
const [data, creditsData] = await Promise.all([getUserMe(), getUserCredits()]);
|
||||
@ -166,10 +294,9 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
// 로그아웃 핸들러
|
||||
const handleLogout = () => {
|
||||
clearTokens();
|
||||
localStorage.removeItem('castad_view_mode');
|
||||
localStorage.removeItem('castad_analysis_data');
|
||||
localStorage.removeItem(WIZARD_STEP_KEY);
|
||||
localStorage.removeItem(ACTIVE_ITEM_KEY);
|
||||
// 세션 키를 전부 지운다. 이전에는 view_mode/analysis/wizard/active_item 만 지워
|
||||
// 생성 중이던 song·video 상태가 남았고, 다른 계정으로 로그인하면 그대로 복원됐다.
|
||||
clearSessionStorage();
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
@ -245,9 +372,9 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
setImageList(prev => [...newImages, ...prev]);
|
||||
};
|
||||
|
||||
// 홈 버튼(로고) 클릭 시 모든 상태 초기화 후 홈으로 이동
|
||||
// 홈 버튼(로고) 클릭 시 프로젝트 상태를 비우고 ADO2 콘텐츠로 돌아간다.
|
||||
const handleHome = () => {
|
||||
clearAllProjectStorage();
|
||||
clearProjectStorage();
|
||||
localStorage.removeItem(ANALYSIS_DATA_KEY);
|
||||
setWizardStep(-2);
|
||||
setSongTaskId(null);
|
||||
@ -255,7 +382,11 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
setAnalysisData(null);
|
||||
revokeAllPreviews(imageListRef.current);
|
||||
setImageList([]);
|
||||
onHome();
|
||||
if (onHome) {
|
||||
onHome();
|
||||
return;
|
||||
}
|
||||
setActiveItem(NAV.HOME);
|
||||
};
|
||||
|
||||
// 위저드 단계 이동
|
||||
@ -299,13 +430,13 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
};
|
||||
|
||||
// 업체명·주소 수동 입력으로 마케팅 분석 API 호출
|
||||
const handleManualInput = async (businessName: string, address: string, category: string, officialSiteUrl?: string) => {
|
||||
const handleManualInput = async (businessName: string, address: string, category: string) => {
|
||||
goToWizardStep(-1);
|
||||
setIsAnalysisComplete(false);
|
||||
setAnalysisError(null);
|
||||
|
||||
try {
|
||||
const data = await marketingAnalysis(businessName, address, category, officialSiteUrl);
|
||||
const data = await marketingAnalysis(businessName, address, category);
|
||||
|
||||
if (data.processed_info) {
|
||||
data.processed_info.customer_name = data.processed_info.customer_name || businessName;
|
||||
@ -396,51 +527,197 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
localStorage.setItem(ACTIVE_ITEM_KEY, activeItem);
|
||||
}, [activeItem]);
|
||||
|
||||
// wizardStep 변경 시 튜토리얼 트리거 (사이드바 메뉴 화면에서는 제외)
|
||||
const SIDEBAR_ITEMS = ['대시보드', 'ADO2 콘텐츠', '내 정보', '콘텐츠 캘린더'];
|
||||
useEffect(() => {
|
||||
if (tutorial.isActive) tutorial.skipTutorial();
|
||||
if (SIDEBAR_ITEMS.includes(activeItem)) return;
|
||||
const timer = setTimeout(() => {
|
||||
if (wizardStep === 1 && !tutorial.hasSeen(TUTORIAL_KEYS.ASSET)) {
|
||||
tutorial.startTutorial(TUTORIAL_KEYS.ASSET);
|
||||
} else if (wizardStep === 2 && !tutorial.hasSeen(TUTORIAL_KEYS.SOUND)) {
|
||||
tutorial.startTutorial(TUTORIAL_KEYS.SOUND);
|
||||
}
|
||||
}, 600);
|
||||
return () => clearTimeout(timer);
|
||||
}, [wizardStep, activeItem]);
|
||||
const [loginGateOpen, setLoginGateOpen] = useState(false);
|
||||
|
||||
// activeItem 변경 시 튜토리얼 트리거
|
||||
useEffect(() => {
|
||||
if (tutorial.isActive) tutorial.skipTutorial();
|
||||
if (activeItem === '내 정보' && !tutorial.hasSeen(TUTORIAL_KEYS.MY_INFO)) {
|
||||
tutorial.startTutorial(TUTORIAL_KEYS.MY_INFO);
|
||||
} else if (activeItem === '내 콘텐츠' && !tutorial.hasSeen(TUTORIAL_KEYS.ADO2_CONTENTS)) {
|
||||
tutorial.startTutorial(TUTORIAL_KEYS.ADO2_CONTENTS);
|
||||
} else if (activeItem === '대시보드' && !tutorial.hasSeen(TUTORIAL_KEYS.DASHBOARD)) {
|
||||
tutorial.startTutorial(TUTORIAL_KEYS.DASHBOARD);
|
||||
} else if (activeItem === '콘텐츠 캘린더' && !tutorial.hasSeen(TUTORIAL_KEYS.CONTENT_CALENDAR)) {
|
||||
tutorial.startTutorial(TUTORIAL_KEYS.CONTENT_CALENDAR);
|
||||
/** 내 정보의 특정 탭으로 딥링크 이동. 비로그인이면 로그인 관문을 띄운다 */
|
||||
const goToMyInfoTab = (tab: MyInfoTab) => {
|
||||
if (!isLoggedIn()) {
|
||||
sessionStorage.setItem(LOGIN_NEXT_ITEM_KEY, NAV.MY);
|
||||
setLoginGateOpen(true);
|
||||
return;
|
||||
}
|
||||
}, [activeItem]);
|
||||
setMyInfoInitialTab(tab);
|
||||
setActiveItem(NAV.MY);
|
||||
};
|
||||
|
||||
// 결제 탭으로 바로 이동하는 핸들러 (크레딧 충전하기 버튼용)
|
||||
const handleGoToPayment = () => {
|
||||
setMyInfoInitialTab('payment');
|
||||
setActiveItem('내 정보');
|
||||
goToMyInfoTab('payment');
|
||||
};
|
||||
|
||||
/**
|
||||
* 썰박스 잡 폴링을 시작한다.
|
||||
*
|
||||
* 폴링 소유가 GenerationFlow 인 이유: SsulMakingContent 가 들고 있으면
|
||||
* 사이드바로 이동하는 순간 언마운트되면서 폴링이 끊긴다.
|
||||
* 잡은 화면보다 오래 살아야 한다.
|
||||
*/
|
||||
const watchSsulTask = useCallback(
|
||||
(taskId: number, scenario: Scen) => {
|
||||
ssulCancelledRef.current = false;
|
||||
setSsulJob({ taskId, scenario, status: 'queued', step: 0, error: null });
|
||||
|
||||
waitForSsulComplete(
|
||||
taskId,
|
||||
(task) => {
|
||||
if (ssulCancelledRef.current) return;
|
||||
setSsulJob({
|
||||
taskId,
|
||||
scenario,
|
||||
status: task.status,
|
||||
step: task.step,
|
||||
error: task.error,
|
||||
});
|
||||
},
|
||||
() => ssulCancelledRef.current
|
||||
)
|
||||
.then(() => {
|
||||
if (ssulCancelledRef.current) return;
|
||||
localStorage.removeItem(K.SSUL_TASK_ID);
|
||||
setSsulStep('result');
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
if (ssulCancelledRef.current || e.message === 'CANCELLED') return;
|
||||
localStorage.removeItem(K.SSUL_TASK_ID);
|
||||
setSsulJob((prev) =>
|
||||
prev ? { ...prev, status: 'error', error: e.message } : prev
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (ssulCancelledRef.current) return;
|
||||
// 완료든 실패든 잔액을 갱신한다. 실패 시 백엔드가 환불하므로
|
||||
// 갱신하지 않으면 사이드바 잔액이 낡은 값으로 굳는다.
|
||||
refreshCredits();
|
||||
});
|
||||
},
|
||||
[refreshCredits]
|
||||
);
|
||||
|
||||
/** 생성 요청이 접수됐을 때 */
|
||||
const handleSsulJobStarted = (taskId: number, scenario: Scen) => {
|
||||
localStorage.setItem(K.SSUL_TASK_ID, String(taskId));
|
||||
setSsulStep('making');
|
||||
// 생성 시작 시점에 크레딧이 선차감되므로 사이드바 잔액을 즉시 갱신
|
||||
refreshCredits();
|
||||
watchSsulTask(taskId, scenario);
|
||||
};
|
||||
|
||||
/** 실패 후 처음부터 */
|
||||
const handleSsulRetry = () => {
|
||||
ssulCancelledRef.current = true;
|
||||
localStorage.removeItem(K.SSUL_TASK_ID);
|
||||
setSsulJob(null);
|
||||
setSsulStep('entry');
|
||||
};
|
||||
|
||||
// ── 포스터 영상 (P2V F1) ─────────────────────────────────────────────
|
||||
|
||||
const handlePosterJobStarted = (jobId: string) => {
|
||||
localStorage.setItem(K.POSTER_JOB_ID, jobId);
|
||||
setPosterJobId(jobId);
|
||||
};
|
||||
|
||||
/** 잡을 버리고 업로드 화면으로. 서버 잡은 남겨둔다(아카이브·디버깅에 쓰인다) */
|
||||
const handlePosterNew = () => {
|
||||
localStorage.removeItem(K.POSTER_JOB_ID);
|
||||
setPosterJobId(null);
|
||||
};
|
||||
|
||||
/**
|
||||
* 실패한 스테이지부터 다시. 업로드부터 다시 하지 않는다 —
|
||||
* i2v 실패(모델 큐·크레딧)는 같은 입력으로 재시도하면 자주 통과한다.
|
||||
*/
|
||||
const handlePosterRetry = async () => {
|
||||
if (!posterJobId || posterBusy) return;
|
||||
setPosterBusy(true);
|
||||
try {
|
||||
await retryPosterJob(posterJobId);
|
||||
refreshPosterJob();
|
||||
} catch {
|
||||
// 재시도 요청 자체가 실패해도 잡 상태는 그대로다 — 폴링 결과가 계속 화면을 그린다
|
||||
} finally {
|
||||
setPosterBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 실패한 잡과 산출물을 지우고 처음부터. 되돌릴 수 없다 */
|
||||
const handlePosterDiscard = async () => {
|
||||
if (!posterJobId || posterBusy) return;
|
||||
if (!window.confirm(t('poster.making.discardConfirm'))) return;
|
||||
setPosterBusy(true);
|
||||
try {
|
||||
await deletePosterJob(posterJobId);
|
||||
} catch {
|
||||
// 서버에서 이미 사라졌어도 화면은 처음으로 돌려보내는 게 맞다
|
||||
} finally {
|
||||
setPosterBusy(false);
|
||||
handlePosterNew();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 사라진 잡 정리.
|
||||
*
|
||||
* P2V 에는 "내 활성 잡" 엔드포인트가 없어(단일 팀 키라 사용자 개념이 없다)
|
||||
* localStorage 의 id 가 복구의 유일한 근거다. 그런데 서버를 재시작하거나 다른
|
||||
* 사람이 그 잡을 지우면 id 만 남아 **빈 진행 화면에서 영원히 멈춘다** — 실제로 겪었다.
|
||||
* 404 를 만나면 업로드 화면으로 되돌린다.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (posterNotFound) handlePosterNew();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [posterNotFound]);
|
||||
|
||||
/**
|
||||
* 새로고침·새 탭 진입 시 진행 중인 잡을 복구한다.
|
||||
*
|
||||
* 권위는 서버(`/ssul/tasks/active`)다. localStorage 의 SSUL_TASK_ID 는 힌트일 뿐이고,
|
||||
* initializeOnNewSession 이 새 탭에서 그것을 지우기 때문에 서버를 물어야 한다.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) return;
|
||||
let alive = true;
|
||||
getActiveSsulTask().then((task) => {
|
||||
if (!alive || !task) return;
|
||||
changePipeline('ssul');
|
||||
setSsulStep('making');
|
||||
watchSsulTask(task.id, task.scenario as Scen);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
// 마운트 시 1회만 — watchSsulTask 는 refreshCredits 에만 의존한다
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
/** 언마운트 시 폴링 중단 (로그아웃·화면 전환) */
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
ssulCancelledRef.current = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 네비게이션 핸들러 - "새 프로젝트 만들기" 클릭 시 기존 프로젝트 데이터 초기화
|
||||
const handleNavigate = (item: string) => {
|
||||
if (!isLoggedIn() && GATED_ITEMS.includes(item)) {
|
||||
sessionStorage.setItem(LOGIN_NEXT_ITEM_KEY, item);
|
||||
setLoginGateOpen(true);
|
||||
return;
|
||||
}
|
||||
setMyInfoInitialTab(undefined);
|
||||
if (item === '새 프로젝트 만들기') {
|
||||
if (item === NAV.NEW) {
|
||||
// 기존 프로젝트 데이터 초기화
|
||||
clearAllProjectStorage();
|
||||
clearProjectStorage();
|
||||
localStorage.removeItem(ANALYSIS_DATA_KEY);
|
||||
// URL 입력 단계로 명시적 저장 (다음 진입 시 올바른 단계 복원)
|
||||
localStorage.setItem(WIZARD_STEP_KEY, '-2');
|
||||
setWizardStep(-2);
|
||||
// 파이프라인도 기본값으로 되돌린다. clearProjectStorage() 가 K.PIPELINE 을
|
||||
// 지우므로 state 만 남으면 저장소와 어긋난다.
|
||||
setPipeline('ado2');
|
||||
setSsulStep('entry');
|
||||
// clearProjectStorage() 가 POSTER_JOB_ID 를 지우므로 state 도 함께 비운다
|
||||
setPosterJobId(null);
|
||||
setSongTaskId(null);
|
||||
setImageTaskId(null);
|
||||
setAnalysisData(null);
|
||||
@ -455,17 +732,64 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
|
||||
// 새 프로젝트 만들기 - 단계별 컨텐츠 렌더링
|
||||
const renderWizardContent = () => {
|
||||
// 진입 화면은 두 파이프라인이 공유하므로 switch 밖에서 먼저 처리한다.
|
||||
// ADO2 는 wizardStep -2, 썰박스는 ssulStep 'entry' 가 여기 해당한다.
|
||||
if (isEntry) {
|
||||
return (
|
||||
<UrlInputContent
|
||||
pipeline={pipeline}
|
||||
onPipelineChange={changePipeline}
|
||||
onAnalyze={handleStartAnalysis}
|
||||
onAutocomplete={handleAutocomplete}
|
||||
onManualInput={handleManualInput}
|
||||
error={analysisError}
|
||||
onSsulJobStarted={handleSsulJobStarted}
|
||||
onPosterJobStarted={handlePosterJobStarted}
|
||||
onGoToPayment={handleGoToPayment}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 썰박스는 자체 축으로 진행한다
|
||||
if (pipeline === 'ssul') {
|
||||
switch (ssulStep) {
|
||||
case 'making':
|
||||
return (
|
||||
<SsulMakingContent job={ssulJob} onRetry={handleSsulRetry} />
|
||||
);
|
||||
case 'result':
|
||||
return ssulJob ? (
|
||||
<SsulResultContent taskId={ssulJob.taskId} scenario={ssulJob.scenario} />
|
||||
) : null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 포스터 영상도 자체 축으로 진행한다 (단계는 잡 status 파생)
|
||||
if (pipeline === 'poster') {
|
||||
switch (posterStep) {
|
||||
case 'review':
|
||||
return posterJob ? (
|
||||
<PosterReviewContent job={posterJob} onApproved={refreshPosterJob} />
|
||||
) : null;
|
||||
case 'result':
|
||||
return posterJob ? (
|
||||
<PosterResultContent job={posterJob} onNew={handlePosterNew} />
|
||||
) : null;
|
||||
default:
|
||||
return (
|
||||
<PosterMakingContent
|
||||
job={posterJob}
|
||||
onRetry={handlePosterRetry}
|
||||
onDiscard={handlePosterDiscard}
|
||||
busy={posterBusy}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
switch (wizardStep) {
|
||||
case -2:
|
||||
// URL 입력 단계
|
||||
return (
|
||||
<UrlInputContent
|
||||
onAnalyze={handleStartAnalysis}
|
||||
onAutocomplete={handleAutocomplete}
|
||||
onManualInput={handleManualInput}
|
||||
error={analysisError}
|
||||
/>
|
||||
);
|
||||
case -1:
|
||||
// 로딩 단계
|
||||
return (
|
||||
@ -493,8 +817,8 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
onBack={() => goToWizardStep(0)}
|
||||
onNext={(taskId: string) => {
|
||||
// Clear video generation state to start fresh
|
||||
localStorage.removeItem(VIDEO_GENERATION_KEY);
|
||||
localStorage.removeItem(VIDEO_COMPLETE_KEY);
|
||||
localStorage.removeItem(K.VIDEO_GENERATION);
|
||||
localStorage.removeItem(K.VIDEO_COMPLETE);
|
||||
setVideoGenerationStatus('idle');
|
||||
setVideoGenerationProgress(0);
|
||||
|
||||
@ -523,13 +847,6 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
videoGenerationStatus={videoGenerationStatus}
|
||||
videoGenerationProgress={videoGenerationProgress}
|
||||
onGoToPayment={handleGoToPayment}
|
||||
onStatusChange={(status: string) => {
|
||||
if (status === 'generating_song' && !tutorial.hasSeen(TUTORIAL_KEYS.SOUND_LYRICS)) {
|
||||
setTimeout(() => tutorial.startTutorial(TUTORIAL_KEYS.SOUND_LYRICS), 400);
|
||||
} else if (status === 'complete' && !tutorial.hasSeen(TUTORIAL_KEYS.SOUND_AUDIO)) {
|
||||
setTimeout(() => tutorial.startTutorial(TUTORIAL_KEYS.SOUND_AUDIO), 400);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case 3:
|
||||
@ -538,8 +855,8 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
onBack={() => {
|
||||
// 뒤로가기 시 비디오 생성 상태 초기화
|
||||
// 새 노래 생성 후 다시 영상 생성할 수 있도록
|
||||
localStorage.removeItem(VIDEO_GENERATION_KEY);
|
||||
localStorage.removeItem(VIDEO_COMPLETE_KEY);
|
||||
localStorage.removeItem(K.VIDEO_GENERATION);
|
||||
localStorage.removeItem(K.VIDEO_COMPLETE);
|
||||
setVideoGenerationStatus('idle');
|
||||
setVideoGenerationProgress(0);
|
||||
goToWizardStep(2);
|
||||
@ -547,7 +864,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
songTaskId={songTaskId}
|
||||
onVideoStatusChange={setVideoGenerationStatus}
|
||||
onVideoProgressChange={setVideoGenerationProgress}
|
||||
onGoToCalendar={() => handleNavigate('콘텐츠 캘린더')}
|
||||
onGoToCalendar={() => goToMyInfoTab('calendar')}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
@ -557,28 +874,45 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
|
||||
const renderContent = () => {
|
||||
switch (activeItem) {
|
||||
case '대시보드':
|
||||
return <DashboardContent onNavigate={handleNavigate} />;
|
||||
case '비즈니스 설정':
|
||||
return <BusinessSettingsContent />;
|
||||
case 'ADO2 콘텐츠':
|
||||
return <ADO2ContentsPage />;
|
||||
case '내 콘텐츠':
|
||||
case NAV.DASHBOARD:
|
||||
return (
|
||||
<MyContentsPage
|
||||
onBack={() => setActiveItem('새 프로젝트 만들기')}
|
||||
onNavigate={handleNavigate}
|
||||
<DashboardContent
|
||||
onNavigate={(id) => (id === NAV.MY ? goToMyInfoTab('business') : handleNavigate(id))}
|
||||
/>
|
||||
);
|
||||
case '콘텐츠 캘린더':
|
||||
return <ContentCalendarContent onNavigate={handleNavigate} />;
|
||||
case '내 정보':
|
||||
return <MyInfoContent initialTab={myInfoInitialTab} />;
|
||||
case '새 프로젝트 만들기':
|
||||
// 로딩(-1), URL 입력(-2)은 스텝퍼 없이 전체 화면으로 표시
|
||||
if (wizardStep === -1 || wizardStep === -2) {
|
||||
case NAV.HOME:
|
||||
return <ADO2ContentsPage />;
|
||||
case NAV.MY:
|
||||
return <MyInfoContent initialTab={myInfoInitialTab} onNavigate={handleNavigate} />;
|
||||
case NAV.NEW:
|
||||
// 진입 화면(탭)과 ADO2 로딩(-1)은 스텝퍼 없이 전체 화면으로 표시
|
||||
if (isEntry || (pipeline === 'ado2' && wizardStep === -1)) {
|
||||
return renderWizardContent();
|
||||
}
|
||||
// 썰박스는 자체 3단계 스텝퍼를 쓴다
|
||||
if (pipeline === 'ssul') {
|
||||
return (
|
||||
<div className="wizard-page-container">
|
||||
<WizardStepper
|
||||
steps={SSUL_STEP_LABELS.map((key) => t(key))}
|
||||
currentStep={SSUL_STEP_INDEX[ssulStep]}
|
||||
/>
|
||||
{renderWizardContent()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 포스터 영상은 검수 게이트를 포함한 5단계
|
||||
if (pipeline === 'poster') {
|
||||
return (
|
||||
<div className="wizard-page-container">
|
||||
<WizardStepper
|
||||
steps={POSTER_STEP_LABELS.map((key) => t(key))}
|
||||
currentStep={posterStepIndex}
|
||||
/>
|
||||
{renderWizardContent()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 브랜드 분석(0)은 전체 화면 스크롤이지만 스텝퍼는 표시
|
||||
if (wizardStep === 0) {
|
||||
return (
|
||||
@ -603,88 +937,75 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 로딩 화면에서만 Sidebar 숨김
|
||||
// 로딩 화면에서만 Sidebar/BottomNav 숨김
|
||||
const showSidebar = wizardStep !== -1;
|
||||
|
||||
// 브랜드 분석(0)일 때는 전체 페이지 스크롤
|
||||
const isBrandAnalysis = activeItem === '새 프로젝트 만들기' && wizardStep === 0;
|
||||
// 스크롤이 필요한 페이지: 대시보드, 비즈니스 설정, 브랜드 분석(0), ADO2 콘텐츠, 내 정보
|
||||
const needsScroll = activeItem === '대시보드' || activeItem === '비즈니스 설정' || activeItem === 'ADO2 콘텐츠' || activeItem === '내 콘텐츠' || activeItem === '내 정보' || activeItem === '콘텐츠 캘린더' || isBrandAnalysis;
|
||||
const isBrandAnalysis = activeItem === NAV.NEW && wizardStep === 0;
|
||||
// 위저드 진행 중에도 탭바를 노출한다. 각 단계의 고정 CTA 는 모바일에서
|
||||
// 탭바 높이만큼 띄우도록 CSS(@media max-width:767px)에서 처리한다.
|
||||
const showBottomNav = showSidebar;
|
||||
// 스크롤이 필요한 페이지: 새 프로젝트 만들기(위저드) 외 전부 + 브랜드 분석(0)
|
||||
const needsScroll = activeItem !== NAV.NEW || isBrandAnalysis;
|
||||
|
||||
// 현재 화면에 맞는 튜토리얼 키 반환 (없으면 null)
|
||||
const getCurrentTutorialKey = (): string | null => {
|
||||
if (activeItem === '내 정보') return TUTORIAL_KEYS.MY_INFO;
|
||||
if (activeItem === '내 콘텐츠') return TUTORIAL_KEYS.ADO2_CONTENTS;
|
||||
if (activeItem === '대시보드') return TUTORIAL_KEYS.DASHBOARD;
|
||||
if (activeItem === '콘텐츠 캘린더') return TUTORIAL_KEYS.CONTENT_CALENDAR;
|
||||
if (activeItem === '새 프로젝트 만들기') {
|
||||
if (wizardStep === 1) return TUTORIAL_KEYS.ASSET;
|
||||
if (wizardStep === 2) return TUTORIAL_KEYS.SOUND;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const tutorialUI = (
|
||||
<>
|
||||
{getCurrentTutorialKey() && (
|
||||
<button
|
||||
className={`tutorial-toggle-fab ${tutorial.isEnabled ? 'active' : ''}`}
|
||||
onClick={() => tutorial.toggleTutorial(getCurrentTutorialKey())}
|
||||
title={tutorial.isEnabled ? t('sidebar.tutorialOff') : t('sidebar.tutorialOn')}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M12 8v4l3 3"/>
|
||||
</svg>
|
||||
<span>{t('sidebar.tutorial')}</span>
|
||||
<span className={`tutorial-toggle-badge ${tutorial.isEnabled ? 'on' : 'off'}`}>
|
||||
{tutorial.isEnabled ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{tutorial.isActive && (
|
||||
<TutorialOverlay
|
||||
hints={tutorial.hints}
|
||||
currentIndex={tutorial.currentHintIndex}
|
||||
onNext={tutorial.nextHint}
|
||||
onPrev={tutorial.prevHint}
|
||||
onSkip={tutorial.skipTutorial}
|
||||
groupProgress={tutorial.groupProgress}
|
||||
const nav = isMobile
|
||||
? (showBottomNav && (
|
||||
<BottomNav
|
||||
activeItem={activeItem}
|
||||
onNavigate={handleNavigate}
|
||||
userInfo={userInfo}
|
||||
onLogout={handleLogout}
|
||||
credits={credits}
|
||||
isGuest={isGuest}
|
||||
onLoginClick={() => setLoginGateOpen(true)}
|
||||
/>
|
||||
)}
|
||||
{tutorial.isRestartPopupVisible && (
|
||||
<TutorialRestartPopup
|
||||
onConfirm={tutorial.confirmRestart}
|
||||
onCancel={tutorial.cancelRestart}
|
||||
))
|
||||
: (showSidebar && (
|
||||
<Sidebar
|
||||
activeItem={activeItem}
|
||||
onNavigate={handleNavigate}
|
||||
onLogoClick={handleHome}
|
||||
userInfo={userInfo}
|
||||
onLogout={handleLogout}
|
||||
credits={credits}
|
||||
isGuest={isGuest}
|
||||
onLoginClick={() => setLoginGateOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
));
|
||||
|
||||
const loginGateModal = loginGateOpen && (
|
||||
<LoginPromptModal
|
||||
onClose={() => {
|
||||
sessionStorage.removeItem(LOGIN_NEXT_ITEM_KEY);
|
||||
setLoginGateOpen(false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
// 브랜드 분석일 때는 전체 화면 스크롤
|
||||
if (isBrandAnalysis) {
|
||||
return (
|
||||
<div className="analysis-page-wrapper">
|
||||
{showSidebar && (
|
||||
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} credits={credits} tutorialAvailable={!!getCurrentTutorialKey()} tutorialEnabled={tutorial.isEnabled} onToggleTutorial={() => tutorial.toggleTutorial(getCurrentTutorialKey())} />
|
||||
)}
|
||||
<main className="analysis-page-main">
|
||||
{!isMobile && nav}
|
||||
<main className={`analysis-page-main ${showBottomNav ? 'has-bottom-nav' : ''}`}>
|
||||
{renderContent()}
|
||||
</main>
|
||||
{tutorialUI}
|
||||
{isMobile && nav}
|
||||
{loginGateModal}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex w-full bg-[#002224] text-white ${needsScroll ? 'min-h-[100dvh]' : 'h-[100dvh] overflow-hidden'}`}>
|
||||
{showSidebar && (
|
||||
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} credits={credits} tutorialAvailable={!!getCurrentTutorialKey()} tutorialEnabled={tutorial.isEnabled} onToggleTutorial={() => tutorial.toggleTutorial(getCurrentTutorialKey())} />
|
||||
)}
|
||||
{tutorialUI}
|
||||
<div className={`flex-1 relative pl-0 md:pl-0 bg-[#001a1c] ${needsScroll ? 'overflow-auto' : 'h-full overflow-hidden'}`}>
|
||||
{!isMobile && nav}
|
||||
<div className={`flex-1 relative pl-0 md:pl-0 bg-[#001a1c] ${needsScroll ? 'overflow-auto' : 'h-full overflow-hidden'} ${showBottomNav ? 'has-bottom-nav' : ''}`}>
|
||||
{renderContent()}
|
||||
</div>
|
||||
{isMobile && nav}
|
||||
{loginGateModal}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getVideosList, deleteVideo } from '../../utils/api';
|
||||
import { getVideosList, deleteVideo, deleteSsulContent } from '../../utils/api';
|
||||
import { VideoListItem } from '../../types/api';
|
||||
import SocialPostingModal from '../../components/SocialPostingModal';
|
||||
import VideoDetailModal from '../../components/VideoDetailModal';
|
||||
import ContentCardSocialActions from '../../components/ContentCardSocialActions';
|
||||
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
||||
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
||||
import SsulViewerModal from '../Ssulbox/SsulViewerModal';
|
||||
import { useOverlayClose } from '../../hooks/useOverlayClose';
|
||||
|
||||
const VideoPreviewCard: React.FC<{ src: string; className?: string }> = ({ src, className }) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
@ -89,11 +89,12 @@ const VideoPreviewCard: React.FC<{ src: string; className?: string }> = ({ src,
|
||||
interface MyContentsPageProps {
|
||||
onBack?: () => void;
|
||||
onNavigate?: (item: string) => void;
|
||||
/** 내 정보 탭 안에 임베드될 때: 탭 라벨과 중복되는 자체 제목을 숨긴다 */
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
|
||||
const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate, embedded }) => {
|
||||
const { t } = useTranslation();
|
||||
const tutorial = useTutorial();
|
||||
const [videos, setVideos] = useState<VideoListItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -103,11 +104,14 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
|
||||
const [hasPrev, setHasPrev] = useState(false);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<number | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<VideoListItem | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [uploadModalOpen, setUploadModalOpen] = useState(false);
|
||||
const [uploadTargetVideo, setUploadTargetVideo] = useState<VideoListItem | null>(null);
|
||||
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null);
|
||||
// 썰박스는 별도 뷰어를 쓴다 — VideoDetailModal 은 video_id 로 조회하므로
|
||||
// 썰박스 id 를 넘기면 id 가 겹치는 다른 영상이 열린다.
|
||||
const [selectedSsul, setSelectedSsul] = useState<VideoListItem | null>(null);
|
||||
|
||||
const pageSize = 12;
|
||||
|
||||
@ -115,12 +119,6 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
|
||||
fetchVideos();
|
||||
}, [page]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tutorial.hasSeen(TUTORIAL_KEYS.ADO2_CONTENTS)) {
|
||||
tutorial.startTutorial(TUTORIAL_KEYS.ADO2_CONTENTS);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchVideos = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@ -145,9 +143,7 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}.${month}.${day}・${hours}:${minutes}`;
|
||||
return `${year}.${month}.${day}`;
|
||||
};
|
||||
|
||||
const formatTitle = (storeName: string, dateString: string) => {
|
||||
@ -177,8 +173,10 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteClick = (videoId: number) => {
|
||||
setDeleteTargetId(videoId);
|
||||
// 아이템 전체를 저장한다 — id 만 들면 종류를 잃어 어느 삭제 API 를 부를지 알 수 없다
|
||||
// (video.id 와 ssul_content.id 는 값이 겹치는 독립 시퀀스).
|
||||
const handleDeleteClick = (video: VideoListItem) => {
|
||||
setDeleteTarget(video);
|
||||
setDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
@ -194,18 +192,30 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
|
||||
|
||||
const handleDeleteCancel = () => {
|
||||
setDeleteModalOpen(false);
|
||||
setDeleteTargetId(null);
|
||||
setDeleteTarget(null);
|
||||
};
|
||||
const deleteModalOverlayHandlers = useOverlayClose(handleDeleteCancel);
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteTargetId) return;
|
||||
if (!deleteTarget) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteVideo(deleteTargetId);
|
||||
setVideos(prev => prev.filter(video => video.video_id !== deleteTargetId));
|
||||
// 종류별 삭제 API 가 다르다. deleteVideo 는 Video.id 로 지우므로
|
||||
// 썰박스 id 를 넘기면 id 가 겹치는 엉뚱한 ADO2 영상이 삭제된다.
|
||||
if (deleteTarget.type === 'ssul') {
|
||||
await deleteSsulContent(deleteTarget.video_id);
|
||||
} else {
|
||||
await deleteVideo(deleteTarget.video_id);
|
||||
}
|
||||
setVideos(prev =>
|
||||
prev.filter(
|
||||
video =>
|
||||
!(video.type === deleteTarget.type && video.video_id === deleteTarget.video_id)
|
||||
)
|
||||
);
|
||||
setTotal(prev => Math.max(0, prev - 1));
|
||||
setDeleteModalOpen(false);
|
||||
setDeleteTargetId(null);
|
||||
setDeleteTarget(null);
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err);
|
||||
alert(t('ado2Contents.deleteFailed'));
|
||||
@ -215,10 +225,10 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ado2-contents-page">
|
||||
<div className={`ado2-contents-page ${embedded ? 'ado2-contents-page--embedded' : ''}`}>
|
||||
{/* Header */}
|
||||
<div className="ado2-contents-header">
|
||||
<h1 className="ado2-contents-title">{t('sidebar.myContents')}</h1>
|
||||
{!embedded && <h1 className="ado2-contents-title">{t('sidebar.myContents')}</h1>}
|
||||
<span className="ado2-contents-count">{t('ado2Contents.totalCount', { count: total })}</span>
|
||||
</div>
|
||||
|
||||
@ -241,12 +251,18 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
|
||||
<>
|
||||
<div className="ado2-contents-grid">
|
||||
{videos.map((video) => (
|
||||
<div key={video.task_id} className="ado2-content-card">
|
||||
// key 는 (type, video_id) 쌍이어야 한다 — task_id 는 썰박스에 없고(빈 문자열)
|
||||
// video_id 는 종류별 독립 시퀀스라 값이 겹친다.
|
||||
<div key={`${video.type}-${video.video_id}`} className="ado2-content-card">
|
||||
{/* Video Thumbnail */}
|
||||
<div
|
||||
className="content-card-thumbnail"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => setSelectedVideoId(video.video_id)}
|
||||
onClick={() =>
|
||||
video.type === 'ssul'
|
||||
? setSelectedSsul(video)
|
||||
: setSelectedVideoId(video.video_id)
|
||||
}
|
||||
>
|
||||
{video.poster_url ? (
|
||||
<img
|
||||
@ -287,11 +303,13 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
|
||||
<p className="content-card-date">
|
||||
{formatDate(video.created_at)}
|
||||
</p>
|
||||
{/* 좋아요·댓글 API 가 type 을 받으므로 썰박스에도 노출한다.
|
||||
contentType 이 없으면 id 가 겹치는 다른 영상에 반영된다. */}
|
||||
<ContentCardSocialActions
|
||||
videoId={video.video_id}
|
||||
contentType={video.type}
|
||||
storeName={video.store_name}
|
||||
region={video.region}
|
||||
title={video.title}
|
||||
commentCount={video.comment_count ?? 0}
|
||||
initialLikeCount={video.like_count ?? 0}
|
||||
initialIsLiked={video.is_liked_by_me}
|
||||
@ -301,6 +319,8 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="content-card-actions">
|
||||
{/* SocialPostingModal 이 content_type 을 함께 보내므로
|
||||
썰박스 항목도 같은 모달로 업로드한다 (social_upload 병합). */}
|
||||
<button
|
||||
className="content-download-btn"
|
||||
onClick={() => handleUploadClick(video)}
|
||||
@ -323,9 +343,11 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
|
||||
<path d="M3 15v2h14v-2"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/* 삭제는 종류별 API 로 분기한다(handleDeleteConfirm).
|
||||
아이템 전체를 넘겨야 종류를 잃지 않는다. */}
|
||||
<button
|
||||
className="content-delete-btn"
|
||||
onClick={() => handleDeleteClick(video.video_id)}
|
||||
onClick={() => handleDeleteClick(video)}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M3 5h14M8 5V3h4v2M6 5v12h8V5"/>
|
||||
@ -362,7 +384,7 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
|
||||
|
||||
{/* 삭제 확인 모달 */}
|
||||
{deleteModalOpen && (
|
||||
<div className="delete-modal-overlay" onClick={handleDeleteCancel}>
|
||||
<div className="delete-modal-overlay" {...deleteModalOverlayHandlers}>
|
||||
<div className="delete-modal" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<h2 className="delete-modal-title">{t('ado2Contents.deleteConfirmTitle')}</h2>
|
||||
<p className="delete-modal-description">{t('ado2Contents.deleteConfirmDesc')}</p>
|
||||
@ -393,6 +415,8 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
<SsulViewerModal item={selectedSsul} onClose={() => setSelectedSsul(null)} />
|
||||
|
||||
{/* 소셜 미디어 업로드 모달 */}
|
||||
<SocialPostingModal
|
||||
isOpen={uploadModalOpen}
|
||||
|
||||
@ -3,16 +3,21 @@ import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getSocialAccounts, getYouTubeConnectUrl, disconnectSocialAccount, TokenExpiredError, handleSocialReconnect, getUserCredits, requestCreditCharge } from '../../utils/api';
|
||||
import { SocialAccount } from '../../types/api';
|
||||
import { MyInfoTab } from '../../components/navItems';
|
||||
import MyContentsPage from './MyContentsPage';
|
||||
import ContentCalendarContent from './ContentCalendarContent';
|
||||
import { useOverlayClose } from '../../hooks/useOverlayClose';
|
||||
|
||||
type TabType = 'basic' | 'payment' | 'business';
|
||||
type TabType = MyInfoTab;
|
||||
|
||||
interface MyInfoContentProps {
|
||||
initialTab?: TabType;
|
||||
onNavigate?: (id: string) => void;
|
||||
}
|
||||
|
||||
const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
|
||||
const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab, onNavigate }) => {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<TabType>(initialTab || 'business');
|
||||
const [activeTab, setActiveTab] = useState<TabType>(initialTab || 'contents');
|
||||
const [businessUrl, setBusinessUrl] = useState('');
|
||||
const [socialAccounts, setSocialAccounts] = useState<SocialAccount[]>([]);
|
||||
const [isLoadingAccounts, setIsLoadingAccounts] = useState(false);
|
||||
@ -113,20 +118,33 @@ const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
|
||||
const hasConnectedAccounts = youtubeAccounts.length > 0 || instagramAccounts.length > 0;
|
||||
|
||||
const tabs = [
|
||||
// { id: 'basic' as TabType, label: t('myInfo.tabBasic') },
|
||||
{ id: 'contents' as TabType, label: t('myInfo.tabContents') },
|
||||
{ id: 'calendar' as TabType, label: t('myInfo.tabCalendar') },
|
||||
{ id: 'payment' as TabType, label: t('myInfo.tabPayment') },
|
||||
{ id: 'business' as TabType, label: t('myInfo.tabBusiness') },
|
||||
];
|
||||
|
||||
const isWideTab = activeTab === 'contents' || activeTab === 'calendar';
|
||||
|
||||
const handleChildNavigate = (id: string) => {
|
||||
if (id === '내 정보') { setActiveTab('business'); return; }
|
||||
if (id === '콘텐츠 캘린더') { setActiveTab('calendar'); return; }
|
||||
if (id === '내 콘텐츠') { setActiveTab('contents'); return; }
|
||||
onNavigate?.(id);
|
||||
};
|
||||
|
||||
const closeChargePopup = () => { setShowChargePopup(false); setChargeSuccess(false); setChargeAmount(''); setChargeNote(''); };
|
||||
const chargePopupOverlayHandlers = useOverlayClose(closeChargePopup);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showChargePopup && (
|
||||
<div className="myinfo-popup-overlay" onClick={() => { setShowChargePopup(false); setChargeSuccess(false); setChargeAmount(''); setChargeNote(''); }}>
|
||||
<div className="myinfo-popup-overlay" {...chargePopupOverlayHandlers}>
|
||||
<div className="myinfo-popup" onClick={e => e.stopPropagation()}>
|
||||
{chargeSuccess ? (
|
||||
<>
|
||||
<p className="myinfo-popup-message">{t('myInfo.chargeSuccess')}</p>
|
||||
<button className="myinfo-popup-close" onClick={() => { setShowChargePopup(false); setChargeSuccess(false); setChargeAmount(''); setChargeNote(''); }}>{t('myInfo.chargeConfirm')}</button>
|
||||
<button className="myinfo-popup-close" onClick={closeChargePopup}>{t('myInfo.chargeConfirm')}</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@ -187,7 +205,7 @@ const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<main className="myinfo-page">
|
||||
<main className={`myinfo-page ${isWideTab ? 'myinfo-page--wide' : ''}`}>
|
||||
<h1 className="myinfo-title">{t('myInfo.title')}</h1>
|
||||
|
||||
{/* 탭 네비게이션 */}
|
||||
@ -205,11 +223,13 @@ const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
|
||||
|
||||
{/* 탭 컨텐츠 */}
|
||||
<div className="myinfo-content">
|
||||
{/* {activeTab === 'basic' && (
|
||||
<div className="myinfo-section">
|
||||
<p className="myinfo-placeholder">{t('myInfo.basicPlaceholder')}</p>
|
||||
</div>
|
||||
)} */}
|
||||
{activeTab === 'contents' && (
|
||||
<MyContentsPage onNavigate={handleChildNavigate} embedded />
|
||||
)}
|
||||
|
||||
{activeTab === 'calendar' && (
|
||||
<ContentCalendarContent onNavigate={handleChildNavigate} embedded />
|
||||
)}
|
||||
|
||||
{activeTab === 'payment' && (
|
||||
<div className="myinfo-section">
|
||||
|
||||
@ -1,28 +1,87 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AutocompleteRequest } from '../../utils/api';
|
||||
import SearchInputForm, { SearchType } from '../../components/SearchInputForm';
|
||||
import PipelineTabs, { Pipeline } from '../../components/PipelineTabs';
|
||||
import SsulCreateForm from '../Ssulbox/SsulCreateForm';
|
||||
import { Scen } from '../Ssulbox/ssulData';
|
||||
import PosterCreateForm from '../Poster/PosterCreateForm';
|
||||
import StylingContent from '../Poster/StylingContent';
|
||||
|
||||
interface UrlInputContentProps {
|
||||
/** 현재 선택된 파이프라인. 소유자는 GenerationFlow (분기·리셋 때문) */
|
||||
pipeline: Pipeline;
|
||||
onPipelineChange: (pipeline: Pipeline) => void;
|
||||
onAnalyze: (value: string, type?: SearchType) => void;
|
||||
onAutocomplete?: (data: AutocompleteRequest) => void;
|
||||
onManualInput?: (businessName: string, address: string, category: string, officialSiteUrl?: string) => void;
|
||||
onManualInput?: (businessName: string, address: string, category: string) => void;
|
||||
error: string | null;
|
||||
/** 썰박스 생성 요청 접수 시. 폴링 소유는 GenerationFlow */
|
||||
onSsulJobStarted: (taskId: number, scenario: Scen) => void;
|
||||
/** 포스터 영상(F1) 생성 요청 접수 시. 폴링 소유는 GenerationFlow */
|
||||
onPosterJobStarted: (jobId: string) => void;
|
||||
/** 크레딧 부족 시 충전 화면으로 */
|
||||
onGoToPayment: () => void;
|
||||
}
|
||||
|
||||
const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomplete, onManualInput, error }) => {
|
||||
/**
|
||||
* 대시보드 진입 화면.
|
||||
*
|
||||
* 탭바는 로고와 하위 폼 **사이에 형제로** 얹고 하위 폼만 교체한다.
|
||||
*/
|
||||
const UrlInputContent: React.FC<UrlInputContentProps> = ({
|
||||
pipeline,
|
||||
onPipelineChange,
|
||||
onAnalyze,
|
||||
onAutocomplete,
|
||||
onManualInput,
|
||||
error,
|
||||
onSsulJobStarted,
|
||||
onPosterJobStarted,
|
||||
onGoToPayment,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 스타일링은 한 화면짜리 도구라 진입 폼보다 넓은 판이 필요하다 (generation-flow.css 참조)
|
||||
const wide = pipeline === 'styling';
|
||||
|
||||
const renderForm = () => {
|
||||
switch (pipeline) {
|
||||
case 'ado2':
|
||||
return (
|
||||
<SearchInputForm
|
||||
onAnalyze={onAnalyze}
|
||||
onAutocomplete={onAutocomplete}
|
||||
onManualInput={onManualInput}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
case 'ssul':
|
||||
return <SsulCreateForm onSubmitted={onSsulJobStarted} onNeedCredit={onGoToPayment} />;
|
||||
case 'poster':
|
||||
return <PosterCreateForm onSubmitted={onPosterJobStarted} />;
|
||||
case 'styling':
|
||||
// 스타일링은 위저드가 아니라 도구다 — 진입 화면 안에서 처음부터 끝까지 끝난다
|
||||
return <StylingContent />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="url-input-container">
|
||||
<div className="url-input-content">
|
||||
<div className={`url-input-content${wide ? ' url-input-content--wide' : ''}`}>
|
||||
<div className="url-input-logo">
|
||||
<img src="/assets/images/ADO2_with Slogan_white.svg" alt="ADO2" />
|
||||
</div>
|
||||
|
||||
<SearchInputForm
|
||||
onAnalyze={onAnalyze}
|
||||
onAutocomplete={onAutocomplete}
|
||||
onManualInput={onManualInput}
|
||||
error={error}
|
||||
/>
|
||||
<PipelineTabs value={pipeline} onChange={onPipelineChange} />
|
||||
|
||||
{/* 고른 기능이 무엇을 해주는지 한 줄. 탭 라벨(2~6자)만으로는 네 기능의 차이가
|
||||
전달되지 않아 처음 온 사람이 아무거나 눌러본다. STEP 1 위에 둔다. */}
|
||||
<p className="pipeline-desc">{t(`pipelineTabs.desc.${pipeline}`)}</p>
|
||||
|
||||
{renderForm()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,51 +0,0 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Footer from '../../components/Footer';
|
||||
|
||||
interface DisplaySectionProps {
|
||||
onStartClick?: () => void;
|
||||
}
|
||||
|
||||
const DisplaySection: React.FC<DisplaySectionProps> = ({ onStartClick }) => {
|
||||
const { t } = useTranslation();
|
||||
// YouTube Shorts 영상 ID들
|
||||
const videos = [
|
||||
{ id: 1, videoId: 'M3iuPZ59X1I' },
|
||||
{ id: 2, videoId: 'JxWQxELDHSs' },
|
||||
{ id: 3, videoId: 'c2ZdwhaB7S4' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="display-section">
|
||||
<div className="content-safe-area">
|
||||
{/* Main visual frames container */}
|
||||
<div className="display-frames">
|
||||
{videos.map((video, index) => (
|
||||
<div
|
||||
key={video.id}
|
||||
className={`display-frame ${index === 2 ? 'display-frame-hidden-mobile' : ''}`}
|
||||
>
|
||||
<iframe
|
||||
src={`https://www.youtube.com/embed/${video.videoId}?autoplay=1&mute=1&loop=1&playlist=${video.videoId}&controls=0&showinfo=0&rel=0&modestbranding=1&playsinline=1`}
|
||||
title={`YouTube Shorts ${video.id}`}
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
className="display-video"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
<button onClick={onStartClick} className="display-button">
|
||||
{t('landing.display.startButton')}
|
||||
</button>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DisplaySection;
|
||||
@ -1,202 +0,0 @@
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AutocompleteRequest, isLoggedIn } from '../../utils/api';
|
||||
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
||||
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
||||
import TutorialOverlay from '../../components/Tutorial/TutorialOverlay';
|
||||
import BusinessNameInputModal from '../../components/BusinessNameInputModal';
|
||||
import LoginPromptModal from '../../components/LoginPromptModal';
|
||||
import SearchInputForm from '../../components/SearchInputForm';
|
||||
import { SearchType } from '../../components/SearchInputForm';
|
||||
|
||||
// Orb configuration with movement zones to prevent overlap
|
||||
interface OrbConfig {
|
||||
id: string;
|
||||
size: number;
|
||||
initialX: number;
|
||||
initialY: number;
|
||||
color: string;
|
||||
minX: number;
|
||||
maxX: number;
|
||||
minY: number;
|
||||
maxY: number;
|
||||
}
|
||||
|
||||
const orbConfigs: OrbConfig[] = [
|
||||
{ id: 'orb-1', size: 500, initialX: -10, initialY: -10, color: 'radial-gradient(circle, #C490FF 20%, #AE72F9 50%, rgba(94, 235, 195, 0.4) 100%)', minX: -30, maxX: 35, minY: -30, maxY: 40 },
|
||||
{ id: 'orb-2', size: 480, initialX: 70, initialY: -5, color: 'radial-gradient(circle, #5EEBC3 25%, rgba(174, 114, 249, 0.6) 70%, rgba(139, 92, 246, 0.3) 100%)', minX: 50, maxX: 110, minY: -30, maxY: 40 },
|
||||
{ id: 'orb-3', size: 420, initialX: 5, initialY: 35, color: 'radial-gradient(circle, rgba(148, 251, 224, 0.8) 15%, #AE72F9 55%, rgba(94, 235, 195, 0.3) 100%)', minX: -20, maxX: 45, minY: 20, maxY: 65 },
|
||||
{ id: 'orb-4', size: 400, initialX: 60, initialY: 40, color: 'radial-gradient(circle, rgba(220, 200, 255, 0.95) 10%, rgba(148, 251, 224, 0.85) 45%, rgba(174, 114, 249, 0.5) 100%)', minX: 40, maxX: 100, minY: 25, maxY: 70 },
|
||||
{ id: 'orb-5', size: 520, initialX: -8, initialY: 65, color: 'radial-gradient(circle, #B794F6 30%, rgba(148, 251, 224, 0.6) 65%, rgba(174, 114, 249, 0.3) 100%)', minX: -30, maxX: 40, minY: 50, maxY: 110 },
|
||||
{ id: 'orb-6', size: 450, initialX: 65, initialY: 70, color: 'radial-gradient(circle, rgba(180, 255, 235, 0.95) 15%, rgba(200, 160, 255, 0.8) 50%, rgba(94, 235, 195, 0.45) 100%)', minX: 45, maxX: 110, minY: 55, maxY: 110 },
|
||||
];
|
||||
|
||||
interface HeroSectionProps {
|
||||
onAnalyze?: (value: string, type?: SearchType) => void;
|
||||
onAutocomplete?: (data: AutocompleteRequest) => void;
|
||||
onManualInput?: (businessName: string, address: string, category: string, officialSiteUrl?: string) => void;
|
||||
onNext?: () => void;
|
||||
error?: string | null;
|
||||
scrollProgress?: number;
|
||||
}
|
||||
|
||||
const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onManualInput, onNext, error: externalError, scrollProgress = 0 }) => {
|
||||
const { t } = useTranslation();
|
||||
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
|
||||
const [isLoginPromptOpen, setIsLoginPromptOpen] = useState(false);
|
||||
const orbRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const animationRefs = useRef<number[]>([]);
|
||||
const tutorial = useTutorial();
|
||||
|
||||
// 첫 방문 시 랜딩 튜토리얼 시작
|
||||
useEffect(() => {
|
||||
if (!tutorial.hasSeen(TUTORIAL_KEYS.LANDING)) {
|
||||
const timer = setTimeout(() => {
|
||||
tutorial.startTutorial(TUTORIAL_KEYS.LANDING);
|
||||
}, 800);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Orb 랜덤 이동 애니메이션
|
||||
useEffect(() => {
|
||||
const moveOrb = (orb: HTMLDivElement, index: number) => {
|
||||
const config = orbConfigs[index];
|
||||
let currentX = config.initialX;
|
||||
let currentY = config.initialY;
|
||||
let targetX = currentX;
|
||||
let targetY = currentY;
|
||||
let scale = 1;
|
||||
let targetScale = 1;
|
||||
let isFirstMove = true;
|
||||
|
||||
const generateNewTarget = () => {
|
||||
const rangeX = config.maxX - config.minX;
|
||||
const rangeY = config.maxY - config.minY;
|
||||
if (isFirstMove) {
|
||||
const smallRangeX = rangeX * 0.3;
|
||||
const smallRangeY = rangeY * 0.3;
|
||||
targetX = currentX + (Math.random() - 0.5) * smallRangeX;
|
||||
targetY = currentY + (Math.random() - 0.5) * smallRangeY;
|
||||
targetX = Math.max(config.minX, Math.min(config.maxX, targetX));
|
||||
targetY = Math.max(config.minY, Math.min(config.maxY, targetY));
|
||||
isFirstMove = false;
|
||||
} else {
|
||||
targetX = config.minX + Math.random() * rangeX;
|
||||
targetY = config.minY + Math.random() * rangeY;
|
||||
}
|
||||
targetScale = 0.9 + Math.random() * 0.2;
|
||||
};
|
||||
|
||||
const animate = () => {
|
||||
const speed = 0.003;
|
||||
currentX += (targetX - currentX) * speed;
|
||||
currentY += (targetY - currentY) * speed;
|
||||
scale += (targetScale - scale) * speed;
|
||||
orb.style.left = `${currentX}%`;
|
||||
orb.style.top = `${currentY}%`;
|
||||
orb.style.transform = `scale(${scale})`;
|
||||
const distance = Math.sqrt(Math.pow(targetX - currentX, 2) + Math.pow(targetY - currentY, 2));
|
||||
if (distance < 1) generateNewTarget();
|
||||
animationRefs.current[index] = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
generateNewTarget();
|
||||
animate();
|
||||
};
|
||||
|
||||
orbRefs.current.forEach((orb, index) => {
|
||||
if (orb) moveOrb(orb, index);
|
||||
});
|
||||
|
||||
return () => {
|
||||
animationRefs.current.forEach(id => cancelAnimationFrame(id));
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="hero-section">
|
||||
{/* Animated background orbs */}
|
||||
<div
|
||||
className="hero-bg-orbs"
|
||||
style={{ opacity: Math.max(0, 1 - scrollProgress * 3) }}
|
||||
>
|
||||
{orbConfigs.map((config, index) => (
|
||||
<div
|
||||
key={config.id}
|
||||
ref={el => { orbRefs.current[index] = el; }}
|
||||
className="hero-orb-random"
|
||||
style={{
|
||||
width: `${config.size}px`,
|
||||
height: `${config.size}px`,
|
||||
background: config.color,
|
||||
left: `${config.initialX}%`,
|
||||
top: `${config.initialY}%`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hero-content">
|
||||
<img
|
||||
src="/assets/images/ADO2_with Slogan_white.svg"
|
||||
alt="ADO2"
|
||||
className="hero-logo"
|
||||
/>
|
||||
|
||||
<SearchInputForm
|
||||
onAnalyze={onAnalyze}
|
||||
onAutocomplete={onAutocomplete}
|
||||
onManualInput={onManualInput}
|
||||
onManualButtonClick={() => {
|
||||
if (isLoggedIn()) {
|
||||
setIsManualModalOpen(true);
|
||||
} else {
|
||||
setIsLoginPromptOpen(true);
|
||||
}
|
||||
}}
|
||||
error={externalError || null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer Indicator */}
|
||||
<button onClick={onNext} className="scroll-indicator">
|
||||
<span className="scroll-indicator-text">{t('landing.hero.scrollMore')}</span>
|
||||
<div className="scroll-indicator-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M7 10l5 5 5-5" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{tutorial.isActive && !isManualModalOpen && (
|
||||
<TutorialOverlay
|
||||
hints={tutorial.hints}
|
||||
currentIndex={tutorial.currentHintIndex}
|
||||
onNext={tutorial.nextHint}
|
||||
onPrev={tutorial.prevHint}
|
||||
onSkip={tutorial.skipTutorial}
|
||||
groupProgress={tutorial.groupProgress}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isManualModalOpen && (
|
||||
<BusinessNameInputModal
|
||||
onClose={() => setIsManualModalOpen(false)}
|
||||
onSubmit={(businessName, address, category, officialSiteUrl) => {
|
||||
if (tutorial.isActive) tutorial.nextHint();
|
||||
setIsManualModalOpen(false);
|
||||
onManualInput?.(businessName, address, category, officialSiteUrl);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoginPromptOpen && (
|
||||
<LoginPromptModal onClose={() => setIsLoginPromptOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroSection;
|
||||
@ -1,70 +0,0 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface WelcomeSectionProps {
|
||||
onStartClick?: () => void;
|
||||
onNext?: () => void;
|
||||
}
|
||||
|
||||
const WelcomeSection: React.FC<WelcomeSectionProps> = () => {
|
||||
const { t } = useTranslation();
|
||||
const features = [
|
||||
{
|
||||
id: 1,
|
||||
title: t('landing.welcome.feature1Title'),
|
||||
description: t('landing.welcome.feature1Desc'),
|
||||
iconBg: '#9BCACC',
|
||||
icon: '/assets/images/icon-analysis.svg'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: t('landing.welcome.feature2Title'),
|
||||
description: t('landing.welcome.feature2Desc'),
|
||||
iconBg: '#DFC7FD',
|
||||
icon: '/assets/images/icon-content.svg'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: t('landing.welcome.feature3Title'),
|
||||
description: t('landing.welcome.feature3Desc'),
|
||||
iconBg: '#D4FDF3',
|
||||
icon: '/assets/images/icon-deploy.svg'
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="welcome-section">
|
||||
<div className="welcome-content">
|
||||
{/* Star Icon - Top Center */}
|
||||
<div className="welcome-star">
|
||||
<img src="/assets/images/star-icon.svg" alt="star" />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="welcome-header">
|
||||
<h2 className="welcome-title">{t('landing.welcome.title')}</h2>
|
||||
<p className="welcome-subtitle">{t('landing.welcome.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* Feature Cards */}
|
||||
<div className="feature-grid">
|
||||
{features.map((feature) => (
|
||||
<div key={feature.id} className="feature-card">
|
||||
<div className="feature-number-badge">
|
||||
<span>{feature.id}</span>
|
||||
</div>
|
||||
<h3 className="feature-card-title">{feature.title}</h3>
|
||||
<div className="feature-icon-box" style={{ backgroundColor: feature.iconBg }}>
|
||||
<img src={feature.icon} alt={feature.title} className="feature-icon-img" />
|
||||
</div>
|
||||
<p className="feature-card-description">{feature.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WelcomeSection;
|
||||
@ -1,109 +0,0 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getKakaoLoginUrl, kakaoCallback, trackCompleteRegistration } from '../../utils/api';
|
||||
|
||||
interface LoginSectionProps {
|
||||
onBack: () => void;
|
||||
onLogin: () => void;
|
||||
}
|
||||
|
||||
const LoginSection: React.FC<LoginSectionProps> = ({ onBack, onLogin }) => {
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 카카오 콜백 처리 (URL에서 code 파라미터 확인)
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('code');
|
||||
|
||||
if (code) {
|
||||
handleKakaoCallback(code);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleKakaoCallback = async (code: string) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await kakaoCallback(code);
|
||||
|
||||
// 신규 가입이면 Meta CompleteRegistration 전환 이벤트 발화
|
||||
// (서버가 계정당 최초 1회 판정 — App.tsx 경로와 중복 호출돼도 무해)
|
||||
if (response.is_new_user) {
|
||||
await trackCompleteRegistration();
|
||||
}
|
||||
|
||||
// URL에서 code 파라미터 제거
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('code');
|
||||
window.history.replaceState({}, document.title, url.pathname);
|
||||
|
||||
// 로그인 성공
|
||||
onLogin();
|
||||
} catch (err) {
|
||||
console.error('Kakao callback failed:', err);
|
||||
setError(t('login.kakaoLoginFailed'));
|
||||
|
||||
// URL에서 code 파라미터 제거
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('code');
|
||||
window.history.replaceState({}, document.title, url.pathname);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKakaoLogin = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await getKakaoLoginUrl();
|
||||
|
||||
// 카카오 로그인 페이지로 리다이렉트
|
||||
window.location.href = response.auth_url;
|
||||
} catch (err) {
|
||||
console.error('Failed to get Kakao login URL:', err);
|
||||
setError(t('login.loginUrlFailed'));
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
{/* Back Button */}
|
||||
<button onClick={onBack} className="login-back-btn" disabled={isLoading}>
|
||||
<img src="/assets/images/icon-back.svg" alt="Back" />
|
||||
<span>{t('login.back')}</span>
|
||||
</button>
|
||||
|
||||
<div className="login-content">
|
||||
{/* Logo */}
|
||||
<div className="login-logo">
|
||||
<img src="/assets/images/ADO2_with Slogan_white.svg" alt="ADO2" />
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="login-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Kakao Login Button */}
|
||||
<button
|
||||
onClick={handleKakaoLogin}
|
||||
className="btn-kakao"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? t('login.loggingIn') : t('login.kakaoStart')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginSection;
|
||||
79
src/pages/Poster/PosterCreateForm.tsx
Normal file
79
src/pages/Poster/PosterCreateForm.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import PosterDropzone from '../../components/PosterDropzone';
|
||||
import P2vKeyGate from '../../components/P2vKeyGate';
|
||||
import { P2vAuthError, createPosterJob } from '../../utils/p2vApi';
|
||||
|
||||
interface PosterCreateFormProps {
|
||||
/** 생성 요청이 접수되면 호출. 폴링 소유는 GenerationFlow (썰박스와 같은 규칙) */
|
||||
onSubmitted: (jobId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 포스터 → 영상(F1) 생성 폼. 진입 화면의 '포스터 영상' 탭에 들어간다.
|
||||
*
|
||||
* 행사명은 비워도 된다 — 서버가 포스터에서 뽑는다. 다만 뽑은 값이 틀릴 수 있어
|
||||
* 검수 단계에서 다시 고칠 기회를 준다(메타태그는 아카이브에 영구 저장된다).
|
||||
*/
|
||||
const PosterCreateForm: React.FC<PosterCreateFormProps> = ({ onSubmitted }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [authNeeded, setAuthNeeded] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
if (!file || submitting) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { id } = await createPosterJob(file, name.trim());
|
||||
onSubmitted(id);
|
||||
} catch (e) {
|
||||
if (e instanceof P2vAuthError) {
|
||||
setAuthNeeded(true);
|
||||
} else {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (authNeeded) {
|
||||
return (
|
||||
<div className="p2v-scope p2v-create">
|
||||
<P2vKeyGate onSaved={() => { setAuthNeeded(false); submit(); }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p2v-scope p2v-create">
|
||||
<span className="p2v-step">{t('poster.create.step')}</span>
|
||||
|
||||
<PosterDropzone file={file} onFile={setFile} />
|
||||
|
||||
<input
|
||||
className="p2v-input p2v-input--center"
|
||||
placeholder={t('poster.create.namePlaceholder')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
|
||||
{error && <div className="p2v-note warn">{error}</div>}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="p2v-btn-cta"
|
||||
disabled={!file || submitting}
|
||||
onClick={submit}
|
||||
>
|
||||
{submitting ? t('poster.create.submitting') : t('poster.create.submit')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PosterCreateForm;
|
||||
105
src/pages/Poster/PosterMakingContent.tsx
Normal file
105
src/pages/Poster/PosterMakingContent.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { P2vJob, isPastReview, p2vProgress } from '../../utils/p2vApi';
|
||||
|
||||
interface PosterMakingContentProps {
|
||||
job: P2vJob | null;
|
||||
/** 실패 시 같은 잡을 다시 태운다 (업로드부터 다시 하지 않는다) */
|
||||
onRetry: () => void;
|
||||
/** 실패한 잡을 버리고 처음부터 */
|
||||
onDiscard: () => void;
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 포스터 영상 생성 진행 화면. 검수 전(분석)과 검수 후(생성) 양쪽에 쓰인다.
|
||||
*
|
||||
* 두 구간을 한 컴포넌트로 둔 이유: 화면 구성이 같고 문구만 다르다. 나누면
|
||||
* 스피너·진행바·에러 처리가 두 벌이 되어 한쪽만 고치는 사고가 난다.
|
||||
*
|
||||
* 진행바는 스테이지 완료 수 기반이라 구간 사이에서 멈춰 보인다 —
|
||||
* 스피너가 "살아 있다"를 따로 알린다(썰박스 SsulMakingContent 와 같은 판단).
|
||||
*/
|
||||
const PosterMakingContent: React.FC<PosterMakingContentProps> = ({
|
||||
job, onRetry, onDiscard, busy,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!job) return null;
|
||||
|
||||
const failed = job.status === 'failed';
|
||||
const percent = p2vProgress(job);
|
||||
const past = isPastReview(job);
|
||||
|
||||
// 현재 스테이지 라벨. 서버가 stage 를 비워 보내는 순간(스테이지 전환 사이)이 있어
|
||||
// 그때는 구간 이름으로 대신한다 — 라벨이 깜빡이며 사라지면 멈춘 것처럼 보인다.
|
||||
const stageLabel = job.stage
|
||||
? t(`poster.stages.${job.stage}`, { defaultValue: job.stage })
|
||||
: t(past ? 'poster.making.phaseRender' : 'poster.making.phaseAnalyze');
|
||||
|
||||
return (
|
||||
<div className="p2v-scope p2v-making">
|
||||
<span className="p2v-making__name">{job.name}</span>
|
||||
|
||||
{failed ? (
|
||||
<>
|
||||
<h2 className="p2v-making__title warn">{t('poster.making.failTitle')}</h2>
|
||||
<p className="p2v-making__desc">
|
||||
{t(`poster.stages.${job.error?.stage}`, { defaultValue: job.error?.stage ?? '' })}
|
||||
</p>
|
||||
{job.error?.detail && (
|
||||
<pre className="p2v-errorbox">{job.error.detail}</pre>
|
||||
)}
|
||||
<div className="p2v-making__actions">
|
||||
<button type="button" className="p2v-btn-outline" disabled={busy} onClick={onDiscard}>
|
||||
{t('poster.making.discard')}
|
||||
</button>
|
||||
<button type="button" className="p2v-btn-mint" disabled={busy} onClick={onRetry}>
|
||||
{t('poster.making.retry')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="p2v-making__title">
|
||||
{t(past ? 'poster.making.titleRender' : 'poster.making.titleAnalyze')}
|
||||
</h2>
|
||||
|
||||
<div className="p2v-spinner" aria-hidden="true" />
|
||||
|
||||
<div
|
||||
className="p2v-bar"
|
||||
role="progressbar"
|
||||
aria-valuenow={percent}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
>
|
||||
<div className="p2v-bar__fill" style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
|
||||
<div className="p2v-making__meta">
|
||||
<span>{stageLabel}</span>
|
||||
<span className="p2v-making__pct">{percent}%</span>
|
||||
</div>
|
||||
|
||||
<p className="p2v-making__desc">
|
||||
{t(past ? 'poster.making.descRender' : 'poster.making.descAnalyze')}
|
||||
</p>
|
||||
|
||||
{/* 나레이션은 검수 전에 이미 나온다. 먼저 보여주면 대기 시간이 덜 비어 보이고,
|
||||
검수 화면에서 처음 읽는 것보다 훑을 시간이 생긴다 */}
|
||||
{job.narration && job.narration.length > 0 && (
|
||||
<div className="p2v-panel p2v-making__narration">
|
||||
<p className="p2v-label">{t('poster.review.narration')}</p>
|
||||
{job.narration.map((line, i) => (
|
||||
<p key={i} className="p2v-narration-line">{line}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PosterMakingContent;
|
||||
95
src/pages/Poster/PosterResultContent.tsx
Normal file
95
src/pages/Poster/PosterResultContent.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { P2vJob, p2vFile } from '../../utils/p2vApi';
|
||||
|
||||
interface PosterResultContentProps {
|
||||
job: P2vJob;
|
||||
/** 새 포스터로 처음부터 */
|
||||
onNew: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 포스터 영상 완성 화면.
|
||||
*
|
||||
* 다운로드는 `<a download>` 다 — 헤더를 못 붙이므로 p2vFile() 이 접근 키를
|
||||
* 쿼리로 태워준다(P2V 서버가 ?key= 경로를 지원하는 이유가 이것이다).
|
||||
*/
|
||||
const PosterResultContent: React.FC<PosterResultContentProps> = ({ job, onNew }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const video = p2vFile(job.artifacts.video);
|
||||
const thumbnail = job.artifacts.thumbnail ? p2vFile(job.artifacts.thumbnail) : null;
|
||||
|
||||
return (
|
||||
<div className="p2v-scope p2v-result">
|
||||
<h2 className="p2v-result__title">{t('poster.result.title')}</h2>
|
||||
|
||||
<div className="p2v-result__grid">
|
||||
<div className="p2v-panel p2v-result__player">
|
||||
{video ? (
|
||||
<video src={video} controls playsInline />
|
||||
) : (
|
||||
<p className="p2v-note">{t('poster.result.videoMissing')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p2v-result__side">
|
||||
<p className="p2v-label">{t('poster.result.fileName')}</p>
|
||||
<p className="p2v-result__filename">{job.name}.mp4</p>
|
||||
|
||||
{job.metadata && (
|
||||
<div className="p2v-result__block">
|
||||
{([
|
||||
['event_name', job.metadata.event_name],
|
||||
['date_text', job.metadata.date_text],
|
||||
['place', job.metadata.place],
|
||||
] as const).map(([field, value]) => (
|
||||
value ? (
|
||||
<div key={field} className="p2v-result__row">
|
||||
<span>{t(`poster.review.field.${field}`)}</span>
|
||||
<b>{value}</b>
|
||||
</div>
|
||||
) : null
|
||||
))}
|
||||
{job.metadata.keywords?.length > 0 && (
|
||||
<div className="p2v-tags">
|
||||
{job.metadata.keywords.map((kw) => (
|
||||
<span key={kw} className="p2v-tag">#{kw}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{job.narration && job.narration.length > 0 && (
|
||||
<div className="p2v-result__block">
|
||||
<p className="p2v-label">{t('poster.review.narration')}</p>
|
||||
{job.narration.map((line, i) => (
|
||||
<p key={i} className="p2v-narration-line">{line}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p2v-result__actions">
|
||||
{thumbnail && (
|
||||
<a href={thumbnail} download className="p2v-btn-tonal">
|
||||
{t('poster.result.downloadThumb')}
|
||||
</a>
|
||||
)}
|
||||
{video && (
|
||||
<a href={video} download className="p2v-btn-cta p2v-btn-cta--compact">
|
||||
{t('poster.result.downloadVideo')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" className="p2v-btn-outline p2v-result__new" onClick={onNew}>
|
||||
{t('poster.result.new')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PosterResultContent;
|
||||
172
src/pages/Poster/PosterReviewContent.tsx
Normal file
172
src/pages/Poster/PosterReviewContent.tsx
Normal file
@ -0,0 +1,172 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
P2vJob,
|
||||
approvePosterJob,
|
||||
p2vFile,
|
||||
updatePosterMetadata,
|
||||
updatePosterNarration,
|
||||
} from '../../utils/p2vApi';
|
||||
import { META_FIELDS, MOTION_KEYS, MetaField, motionLabel } from './posterData';
|
||||
|
||||
interface PosterReviewContentProps {
|
||||
job: P2vJob;
|
||||
/** 승인 요청이 끝나면 호출 — GenerationFlow 가 폴링을 다시 켠다 */
|
||||
onApproved: () => void;
|
||||
}
|
||||
|
||||
type MetaDraft = Record<MetaField, string>;
|
||||
|
||||
const emptyMeta = (): MetaDraft => ({ event_name: '', date_text: '', place: '' });
|
||||
|
||||
/**
|
||||
* 검수 게이트.
|
||||
*
|
||||
* 이 화면이 파이프라인에서 가장 중요한 지점이다 — 여기서 승인해야 TTS·음악·
|
||||
* 애니메이션·렌더가 이어지고, 그 뒤로는 되돌리려면 전부 다시 만들어야 한다.
|
||||
* 그래서 셋을 한 화면에 모아 **포스터 원본과 대조하며** 고치게 한다:
|
||||
*
|
||||
* 1) 나레이션 3문장 — 그대로 음성이 된다
|
||||
* 2) 움직일 요소 — 애니메이션 대상. 비우면 정지 영상이 된다
|
||||
* 3) 메타태그 — 아카이브에 영구 저장된다. 연도·지명 오독이 여기서 걸린다
|
||||
*
|
||||
* 왼쪽에 편집, 오른쪽에 영역 분석 이미지를 두는 배치는 P2V 웹과 같다.
|
||||
*/
|
||||
const PosterReviewContent: React.FC<PosterReviewContentProps> = ({ job, onApproved }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [lines, setLines] = useState<string[]>([]);
|
||||
const [motions, setMotions] = useState<string[] | null>(null);
|
||||
const [meta, setMeta] = useState<MetaDraft>(emptyMeta);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 서버 값으로 초안을 채우되, 사용자가 고치기 시작한 뒤에는 폴링 응답이 덮지 않아야 한다.
|
||||
// 잡 id 가 바뀔 때만 다시 채운다(같은 잡의 재조회는 무시).
|
||||
useEffect(() => {
|
||||
setLines(job.narration ?? []);
|
||||
// 0종([])도 유효한 값이라 null(미로딩)로만 판별한다
|
||||
setMotions(job.motion_elements ?? []);
|
||||
setMeta({
|
||||
event_name: job.metadata?.event_name ?? '',
|
||||
date_text: job.metadata?.date_text ?? '',
|
||||
place: job.metadata?.place ?? '',
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [job.id]);
|
||||
|
||||
const toggleMotion = (key: string) => {
|
||||
setMotions((prev) => {
|
||||
const cur = prev ?? [];
|
||||
return cur.includes(key) ? cur.filter((m) => m !== key) : [...cur, key];
|
||||
});
|
||||
};
|
||||
|
||||
const approve = async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
// 바뀐 것만 보낸다. 서버는 PUT 마다 잡 파일을 다시 쓰므로 무의미한 쓰기를 줄인다.
|
||||
if (JSON.stringify(lines) !== JSON.stringify(job.narration ?? [])) {
|
||||
await updatePosterNarration(job.id, lines);
|
||||
}
|
||||
const md = job.metadata;
|
||||
const metaChanged = !md || META_FIELDS.some((f) => (md[f] ?? '') !== meta[f]);
|
||||
if (metaChanged) {
|
||||
await updatePosterMetadata(job.id, meta);
|
||||
}
|
||||
// 순서 차이는 의미가 없다(서버가 집합으로 쓴다) — 정렬해 비교해야 헛 전송이 없다
|
||||
const motionsChanged = motions !== null
|
||||
&& JSON.stringify([...motions].sort())
|
||||
!== JSON.stringify([...(job.motion_elements ?? [])].sort());
|
||||
await approvePosterJob(job.id, motionsChanged ? motions : null);
|
||||
onApproved();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const noMotion = motions !== null && motions.length === 0;
|
||||
|
||||
return (
|
||||
<div className="p2v-scope p2v-review">
|
||||
<div className="p2v-review__grid">
|
||||
<div className="p2v-review__edit">
|
||||
{/* ── 나레이션 ─────────────────────────────── */}
|
||||
<p className="p2v-label">{t('poster.review.narration')}</p>
|
||||
<p className="p2v-help">{t('poster.review.narrationHelp')}</p>
|
||||
{lines.map((line, i) => (
|
||||
<input
|
||||
key={i}
|
||||
className="p2v-input"
|
||||
value={line}
|
||||
onChange={(e) => setLines(lines.map((v, j) => (j === i ? e.target.value : v)))}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* ── 움직일 요소 ──────────────────────────── */}
|
||||
<p className="p2v-label">{t('poster.review.motion')}</p>
|
||||
<p className="p2v-help">
|
||||
{t(noMotion ? 'poster.review.motionEmpty' : 'poster.review.motionHelp')}
|
||||
</p>
|
||||
<div className="p2v-chips">
|
||||
{MOTION_KEYS.map((key) => {
|
||||
const on = motions?.includes(key) ?? false;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
className={`p2v-chip${on ? ' active' : ''}`}
|
||||
onClick={() => toggleMotion(key)}
|
||||
>
|
||||
{t(motionLabel(key))}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── 메타태그 ─────────────────────────────── */}
|
||||
<p className="p2v-label">{t('poster.review.metadata')}</p>
|
||||
<p className="p2v-help">{t('poster.review.metadataHelp')}</p>
|
||||
{META_FIELDS.map((field) => (
|
||||
<div key={field} className="p2v-field">
|
||||
<span className="p2v-field__label">{t(`poster.review.field.${field}`)}</span>
|
||||
<input
|
||||
className="p2v-input"
|
||||
value={meta[field]}
|
||||
onChange={(e) => setMeta({ ...meta, [field]: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{error && <div className="p2v-note warn">{error}</div>}
|
||||
|
||||
<button type="button" className="p2v-btn-cta" disabled={busy} onClick={approve}>
|
||||
{busy ? t('poster.review.approving') : t('poster.review.approve')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── 대조용 분석 이미지 ────────────────────── */}
|
||||
<div className="p2v-review__preview">
|
||||
<p className="p2v-label">{t('poster.review.analysis')}</p>
|
||||
{job.artifacts.check_jpg ? (
|
||||
<img
|
||||
className="p2v-review__image"
|
||||
src={p2vFile(job.artifacts.check_jpg)}
|
||||
alt={t('poster.review.analysisAlt')}
|
||||
/>
|
||||
) : (
|
||||
<div className="p2v-panel p2v-review__image-empty">
|
||||
{t('poster.review.analysisMissing')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PosterReviewContent;
|
||||
330
src/pages/Poster/StylingContent.tsx
Normal file
330
src/pages/Poster/StylingContent.tsx
Normal file
@ -0,0 +1,330 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import PosterDropzone from '../../components/PosterDropzone';
|
||||
import P2vKeyGate from '../../components/P2vKeyGate';
|
||||
import {
|
||||
InternalOnlyWarning,
|
||||
LicenseBadge,
|
||||
UserReferenceNotice,
|
||||
} from '../../components/LicenseBadge';
|
||||
import { useP2vJob } from '../../hooks/useP2vJob';
|
||||
import {
|
||||
F2Category,
|
||||
F2Format,
|
||||
F2Template,
|
||||
F2UploadHint,
|
||||
P2vAuthError,
|
||||
createF2Template,
|
||||
createStylingJob,
|
||||
deleteF2Template,
|
||||
getF2UploadHint,
|
||||
isP2vActive,
|
||||
listF2Categories,
|
||||
listF2Formats,
|
||||
listF2Templates,
|
||||
p2vFile,
|
||||
} from '../../utils/p2vApi';
|
||||
|
||||
/** 사용자가 올린 레퍼런스 카테고리. 서버 카테고리 목록에서 따로 떼어 아래에 둔다 */
|
||||
const USER_CATEGORY = 'user';
|
||||
|
||||
/**
|
||||
* 포스터 스타일링(F2).
|
||||
*
|
||||
* **내부 시연 전용이다.** 영화 포스터 레퍼런스는 저작권 자산이라 결과물을 외부에
|
||||
* 공개하면 안 된다(퍼블릭 도메인 명화만 예외). 그래서 화면 상단에 범위를 못박고,
|
||||
* 템플릿마다 배포 등급 배지를 붙인다.
|
||||
*
|
||||
* 다른 파이프라인과 달리 위저드가 아니라 **한 화면짜리 도구**다 — 업로드·템플릿·
|
||||
* 포맷·결과가 한눈에 있어야 여러 스타일을 빠르게 갈아 끼우며 비교할 수 있고,
|
||||
* 30초짜리 작업에 단계를 나누면 왕복만 늘어난다. 그래서 폴링도 GenerationFlow 가
|
||||
* 아니라 이 컴포넌트가 직접 소유한다(F1·썰박스와 다른 유일한 지점).
|
||||
*/
|
||||
const StylingContent: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [templates, setTemplates] = useState<F2Template[]>([]);
|
||||
const [categories, setCategories] = useState<F2Category[]>([]);
|
||||
const [formats, setFormats] = useState<F2Format[]>([]);
|
||||
const [hint, setHint] = useState<F2UploadHint | null>(null);
|
||||
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [format, setFormat] = useState('poster');
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
|
||||
const [tplOpen, setTplOpen] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [refBusy, setRefBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [authNeeded, setAuthNeeded] = useState(false);
|
||||
|
||||
const refInput = useRef<HTMLInputElement>(null);
|
||||
const { job, authNeeded: jobAuthNeeded } = useP2vJob('f2', jobId);
|
||||
|
||||
const selectedTpl = templates.find((tpl) => tpl.id === selected) ?? null;
|
||||
const running = isP2vActive(job);
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
try {
|
||||
const [tpls, cats, fmts, uploadHint] = await Promise.all([
|
||||
listF2Templates(),
|
||||
listF2Categories(),
|
||||
listF2Formats(),
|
||||
getF2UploadHint(),
|
||||
]);
|
||||
setTemplates(tpls);
|
||||
setCategories(cats);
|
||||
setFormats(fmts);
|
||||
setHint(uploadHint);
|
||||
setAuthNeeded(false);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
if (e instanceof P2vAuthError) setAuthNeeded(true);
|
||||
else setError((e as Error).message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadAll(); }, [loadAll]);
|
||||
|
||||
const uploadReference = async (f: File) => {
|
||||
setRefBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const tpl = await createF2Template(f, f.name.replace(/\.[^.]+$/, '').slice(0, 40));
|
||||
setTemplates(await listF2Templates());
|
||||
setSelected(tpl.id); // 방금 올린 것을 바로 고른 상태로
|
||||
} catch (e) {
|
||||
if (e instanceof P2vAuthError) setAuthNeeded(true);
|
||||
else setError((e as Error).message);
|
||||
} finally {
|
||||
setRefBusy(false);
|
||||
if (refInput.current) refInput.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const removeReference = async (id: string) => {
|
||||
if (!window.confirm(t('poster.styling.confirmDelete'))) return;
|
||||
try {
|
||||
await deleteF2Template(id);
|
||||
if (selected === id) setSelected(null);
|
||||
setTemplates(await listF2Templates());
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!file || !selected || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { id } = await createStylingJob(file, selected, format);
|
||||
setJobId(id);
|
||||
} catch (e) {
|
||||
if (e instanceof P2vAuthError) setAuthNeeded(true);
|
||||
else setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (authNeeded || jobAuthNeeded) {
|
||||
return (
|
||||
<div className="p2v-scope p2v-styling">
|
||||
<P2vKeyGate onSaved={() => { setAuthNeeded(false); loadAll(); }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderCard = (tpl: F2Template) => (
|
||||
<div key={tpl.id} className="p2v-tplcard">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={selected === tpl.id}
|
||||
className={`p2v-tpl${selected === tpl.id ? ' active' : ''}`}
|
||||
onClick={() => setSelected(tpl.id)}
|
||||
>
|
||||
{/* 명화는 가로 그림도 있다 — cover 로 자르면 무엇인지 알 수 없어 contain 을 쓴다 */}
|
||||
<img src={p2vFile(tpl.thumb_url)} alt={tpl.name_ko} />
|
||||
<span className="p2v-tpl__name">{tpl.name_ko}</span>
|
||||
{/* 제목이 1줄인 카드와 2줄인 카드가 섞인다. 아래로 밀어붙여야 한 행의 배지가 같은 선에 선다 */}
|
||||
<span className="p2v-tpl__badge">
|
||||
<LicenseBadge license={tpl.license} />
|
||||
</span>
|
||||
</button>
|
||||
{tpl.removable && (
|
||||
<button
|
||||
type="button"
|
||||
className="p2v-tpl__remove"
|
||||
title={t('poster.styling.deleteRef')}
|
||||
onClick={() => removeReference(tpl.id)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p2v-scope p2v-styling">
|
||||
<div className="p2v-notice p2v-notice--warn p2v-styling__scope">
|
||||
{t('poster.styling.internalScope')}
|
||||
</div>
|
||||
|
||||
{/* ── 스타일 템플릿 (접이식) ─────────────────────── */}
|
||||
<button
|
||||
type="button"
|
||||
className="p2v-collapse"
|
||||
aria-expanded={tplOpen}
|
||||
onClick={() => setTplOpen(!tplOpen)}
|
||||
>
|
||||
<span className="p2v-label">{t('poster.styling.templates')}</span>
|
||||
{!tplOpen && selectedTpl && (
|
||||
<span className="p2v-collapse__picked">
|
||||
<img src={p2vFile(selectedTpl.thumb_url)} alt="" />
|
||||
<b>{selectedTpl.name_ko}</b>
|
||||
<LicenseBadge license={selectedTpl.license} />
|
||||
</span>
|
||||
)}
|
||||
{!tplOpen && !selectedTpl && (
|
||||
<span className="p2v-collapse__empty">{t('poster.styling.pickTemplate')}</span>
|
||||
)}
|
||||
<svg
|
||||
className={`p2v-collapse__caret${tplOpen ? ' open' : ''}`}
|
||||
width="18" height="18" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{tplOpen && categories.filter((c) => c.id !== USER_CATEGORY).map((cat) => {
|
||||
const items = templates.filter((tpl) => tpl.category === cat.id);
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div key={cat.id} className="p2v-tplgroup">
|
||||
<p className="p2v-tplgroup__title">
|
||||
{cat.label}
|
||||
<span>{t('poster.styling.count', { count: items.length })}</span>
|
||||
</p>
|
||||
<div className="p2v-tplgrid">{items.map(renderCard)}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 내 레퍼런스 — 목록이 비어도 업로드 타일은 보여야 하므로 categories 가 아니라
|
||||
upload-hint 의 enabled 로 렌더한다 */}
|
||||
{tplOpen && hint?.enabled && (
|
||||
<div className="p2v-tplgroup">
|
||||
<p className="p2v-tplgroup__title">
|
||||
{t('poster.styling.myRefs')}
|
||||
<span>{t('poster.styling.myRefsHint')}</span>
|
||||
</p>
|
||||
<div className="p2v-tplgrid">
|
||||
{templates.filter((tpl) => tpl.category === USER_CATEGORY).map(renderCard)}
|
||||
<button
|
||||
type="button"
|
||||
className="p2v-tpl p2v-tpl--add"
|
||||
disabled={refBusy}
|
||||
onClick={() => refInput.current?.click()}
|
||||
>
|
||||
{refBusy ? (
|
||||
<>
|
||||
<span className="p2v-spinner p2v-spinner--sm" aria-hidden="true" />
|
||||
<span className="p2v-tpl__name">{t('poster.styling.analyzing')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="p2v-tpl__plus" aria-hidden="true">+</span>
|
||||
<span className="p2v-tpl__name">{t('poster.styling.addRef')}</span>
|
||||
<span className="p2v-tpl__sub">
|
||||
{t('poster.styling.minEdge', { px: hint.min_long_edge })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
ref={refInput}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
hidden
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) uploadReference(f); }}
|
||||
/>
|
||||
</div>
|
||||
<UserReferenceNotice />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 경고는 실제로 고른 순간에만 */}
|
||||
{selectedTpl?.license === 'internal-only' && (
|
||||
<InternalOnlyWarning note={selectedTpl.license_note} />
|
||||
)}
|
||||
{selectedTpl?.attribution && (
|
||||
<p className="p2v-note">{t('poster.styling.attribution', { source: selectedTpl.attribution })}</p>
|
||||
)}
|
||||
|
||||
{/* ── 입력 / 결과 ─────────────────────────────── */}
|
||||
<div className="p2v-styling__io">
|
||||
<div>
|
||||
<p className="p2v-label">{t('poster.styling.myPoster')}</p>
|
||||
<PosterDropzone file={file} onFile={setFile} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="p2v-label">{t('poster.styling.result')}</p>
|
||||
{job?.status === 'done' && job.artifacts.image ? (
|
||||
<div className="p2v-panel p2v-styling__out">
|
||||
<img src={p2vFile(job.artifacts.image)} alt={t('poster.styling.resultAlt')} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="p2v-panel p2v-styling__out p2v-styling__out--empty">
|
||||
{running && <span className="p2v-spinner p2v-spinner--md" aria-hidden="true" />}
|
||||
<span>{t(running ? 'poster.styling.converting' : 'poster.styling.resultHint')}</span>
|
||||
</div>
|
||||
)}
|
||||
{job?.status === 'done' && job.artifacts.image && (
|
||||
<div className="p2v-styling__download">
|
||||
<a href={p2vFile(job.artifacts.image)} download className="p2v-btn-tonal">
|
||||
{t('poster.styling.downloadPng')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 출력 포맷 ───────────────────────────────── */}
|
||||
<p className="p2v-label">{t('poster.styling.formats')}</p>
|
||||
<div className="p2v-formats">
|
||||
{formats.map((f) => (
|
||||
<button
|
||||
key={f.id}
|
||||
type="button"
|
||||
aria-pressed={format === f.id}
|
||||
className={`p2v-select${format === f.id ? ' active' : ''}`}
|
||||
onClick={() => setFormat(f.id)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="p2v-btn-cta"
|
||||
disabled={!file || !selected || busy || running}
|
||||
onClick={submit}
|
||||
>
|
||||
{running
|
||||
? t('poster.styling.converting')
|
||||
: busy ? t('poster.create.submitting') : t('poster.styling.submit')}
|
||||
</button>
|
||||
|
||||
{error && <div className="p2v-note warn">{error}</div>}
|
||||
{job?.status === 'failed' && job.error && (
|
||||
<pre className="p2v-errorbox">{job.error.detail}</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StylingContent;
|
||||
28
src/pages/Poster/posterData.ts
Normal file
28
src/pages/Poster/posterData.ts
Normal file
@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 포스터 파이프라인의 코드 목록.
|
||||
*
|
||||
* 썰박스 ssulData.ts 와 같은 규칙 — **코드만 두고 표시 문구는 i18n 에 둔다.**
|
||||
* castad 는 ko/en 다국어라 한글이 코드에 박히면 번역 밖으로 샌다.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 움직일 수 있는 요소.
|
||||
*
|
||||
* P2V `scripts/motion_plan.py` 의 MOTION_PHRASE 화이트리스트와 **1:1 이어야 한다** —
|
||||
* 여기 없는 키를 보내면 서버가 422 로 거른다. 순서는 그쪽 정의 순서를 따른다.
|
||||
*/
|
||||
export const MOTION_KEYS = [
|
||||
'firework', 'water', 'wave', 'cloud', 'smoke',
|
||||
'moon', 'sun', 'star', 'light', 'flag',
|
||||
'foliage', 'wheel', 'vessel', 'crowd',
|
||||
] as const;
|
||||
|
||||
export type MotionKey = (typeof MOTION_KEYS)[number];
|
||||
|
||||
/** i18n 키 헬퍼 — t(motionLabel('firework')) 형태로 쓴다 */
|
||||
export const motionLabel = (key: string): string => `poster.motion.${key}`;
|
||||
|
||||
/** 검수 화면에서 고칠 수 있는 메타 필드. 나머지(category·keywords)는 서버 판단을 따른다 */
|
||||
export const META_FIELDS = ['event_name', 'date_text', 'place'] as const;
|
||||
|
||||
export type MetaField = (typeof META_FIELDS)[number];
|
||||
250
src/pages/Ssulbox/SsulCreateForm.tsx
Normal file
250
src/pages/Ssulbox/SsulCreateForm.tsx
Normal file
@ -0,0 +1,250 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AccommodationSearchItem,
|
||||
InsufficientCreditError,
|
||||
cleanSearchTitle,
|
||||
createSsulJob,
|
||||
searchAccommodation,
|
||||
} from '../../utils/api';
|
||||
import { SCEN_KEYS, Scen, scenDesc, scenName } from './ssulData';
|
||||
|
||||
interface SsulCreateFormProps {
|
||||
/** 생성 요청이 접수되면 호출. 폴링 소유는 GenerationFlow */
|
||||
onSubmitted: (taskId: number, scenario: Scen) => void;
|
||||
/** 크레딧 부족(402) 시 충전 화면으로 유도 */
|
||||
onNeedCredit: () => void;
|
||||
}
|
||||
|
||||
/** 네이버 지도 링크로 보이는가 — 링크면 검색을 건너뛰고 그대로 크롤링한다 */
|
||||
const isNaverUrl = (s: string): boolean =>
|
||||
/naver\.me|map\.naver|place\.naver|^https?:\/\//i.test(s.trim());
|
||||
|
||||
/** 자동완성 디바운스 — ADO2 SearchInputForm 과 동일 */
|
||||
const AUTOCOMPLETE_DEBOUNCE = 300;
|
||||
|
||||
/**
|
||||
* 썰박스 생성 폼. 진입 화면의 썰박스 탭에 들어간다.
|
||||
*
|
||||
* 원본 썰박스의 CreateSheet(바텀시트) 를 시트 크롬 없이 인라인으로 재배치했다.
|
||||
* 시나리오 선택과 업장 검색을 한 화면에 둔다(원본은 2단계로 나뉘어 있었으나,
|
||||
* 여기서는 위저드 스텝퍼가 이미 단계를 보여주므로 중복이다).
|
||||
*
|
||||
* **업장 검색은 ADO2 와 같은 방식**이다(2026-07-31 전환):
|
||||
* 타이핑 중에는 네이버 **검색 API**(`/search/accommodation`, ~0.2초)로 자동완성을
|
||||
* 띄우고, place URL 해석은 제출 후 서버가 한다(ADO2 `_autocomplete_logic` 과 동일).
|
||||
* 이전에는 썰박스 전용 지도 크롤링(`/ssul/search/place`)으로 후보를 받았는데
|
||||
* **17초**가 걸려 UX 가 크게 나빴다.
|
||||
*/
|
||||
const SsulCreateForm: React.FC<SsulCreateFormProps> = ({ onSubmitted, onNeedCredit }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [scenario, setScenario] = useState<Scen | null>(null);
|
||||
const [input, setInput] = useState('');
|
||||
const [results, setResults] = useState<AccommodationSearchItem[]>([]);
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
const [selected, setSelected] = useState<AccommodationSearchItem | null>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isUrl = isNaverUrl(input);
|
||||
// 업장을 골랐거나 링크를 직접 붙여넣었을 때만 생성 가능
|
||||
const canSubmit = !!scenario && (!!selected || isUrl) && !submitting;
|
||||
|
||||
// 바깥 클릭 시 자동완성 닫기
|
||||
useEffect(() => {
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (boxRef.current && !boxRef.current.contains(e.target as Node)) {
|
||||
setShowResults(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', onDown);
|
||||
return () => document.removeEventListener('mousedown', onDown);
|
||||
}, []);
|
||||
|
||||
// 언마운트 시 대기 중인 디바운스 취소 (setState-after-unmount 방지)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const runAutocomplete = async (query: string) => {
|
||||
if (query.trim().length < 2 || isNaverUrl(query)) {
|
||||
setResults([]);
|
||||
setShowResults(false);
|
||||
return;
|
||||
}
|
||||
setSearching(true);
|
||||
try {
|
||||
const res = await searchAccommodation(query);
|
||||
const items = res.items || [];
|
||||
setResults(items);
|
||||
setShowResults(items.length > 0);
|
||||
} catch {
|
||||
// 검색 실패는 조용히 넘긴다 — 링크 직접 입력 우회 경로가 있다
|
||||
setResults([]);
|
||||
setShowResults(false);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (value: string) => {
|
||||
setInput(value);
|
||||
if (selected) setSelected(null);
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => runAutocomplete(value), AUTOCOMPLETE_DEBOUNCE);
|
||||
};
|
||||
|
||||
const handleSelect = (item: AccommodationSearchItem) => {
|
||||
// 검색 API 는 제목에 <b> 하이라이트 태그를 섞어 준다 — 그대로 쓰면
|
||||
// 업장명·SNS 제목에 태그가 노출된다.
|
||||
const title = cleanSearchTitle(item.title);
|
||||
setInput(title);
|
||||
setSelected({ ...item, title });
|
||||
setShowResults(false);
|
||||
setResults([]);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!scenario || submitting) return;
|
||||
|
||||
// 고른 가게가 있으면 업장명을 보내고, 서버가 place URL 을 해석한다
|
||||
// (ADO2 와 동일한 경로 — NvMapPwScraper.get_place_id_url).
|
||||
// 링크를 직접 붙여넣었으면 그대로 넘긴다.
|
||||
const payload = selected ? selected.title : input.trim();
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await createSsulJob({
|
||||
scenario,
|
||||
input: payload,
|
||||
// 고른 가게가 있을 때만 함께 보낸다. 통합 목록의 업장명 표시와
|
||||
// 업장명·지역 필터가 이 값에 걸린다(링크 직접 입력 시에는 없다).
|
||||
// 도로명·지번을 모두 보낸다. 백엔드가 도로명에서 시/군 추출에 실패하면
|
||||
// 지번으로 재시도한다(한쪽만 보내면 지역이 비는 경우가 생긴다).
|
||||
...(selected && {
|
||||
store_name: selected.title,
|
||||
road_address: selected.roadAddress,
|
||||
address: selected.address,
|
||||
}),
|
||||
});
|
||||
onSubmitted(res.id, scenario);
|
||||
} catch (e) {
|
||||
if (e instanceof InsufficientCreditError) {
|
||||
onNeedCredit();
|
||||
} else {
|
||||
setError(t('ssulbox.error.createFailed'));
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ssul-scope ssul-create">
|
||||
{/* ── 1단계: 시나리오 ─────────────────────────────── */}
|
||||
<span className="ssul-create__step">{t('ssulbox.create.step1')}</span>
|
||||
|
||||
<div className="ssul-create__scenarios">
|
||||
{SCEN_KEYS.map((key) => {
|
||||
const active = scenario === key;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
className={`ssul-scn${active ? ' active' : ''}`}
|
||||
onClick={() => setScenario(key)}
|
||||
>
|
||||
<span className="ssul-scn__name">{t(scenName(key))}</span>
|
||||
<span className="ssul-scn__desc">{t(scenDesc(key))}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── 2단계: 업장 ─────────────────────────────────── */}
|
||||
{/* 2단계는 약하게 — 시선이 1단계(아직 안 고른 경우)로 먼저 가야 한다 */}
|
||||
<span className="ssul-create__step muted">{t('ssulbox.create.step2')}</span>
|
||||
|
||||
{/* 자동완성 드롭다운을 붙이려면 상대 위치 컨테이너가 필요하다 */}
|
||||
<div className="ssul-create__search" ref={boxRef}>
|
||||
<input
|
||||
className="ssul-create__input"
|
||||
placeholder={t('ssulbox.create.searchPlaceholder')}
|
||||
value={input}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onFocus={() => results.length > 0 && setShowResults(true)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
{showResults && (
|
||||
<ul className="ssul-places">
|
||||
{results.map((place, i) => (
|
||||
<li key={`${place.title}-${i}`}>
|
||||
<button type="button" onClick={() => handleSelect(place)}>
|
||||
<b>{cleanSearchTitle(place.title)}</b>
|
||||
<span>{place.roadAddress || place.address}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 상태 안내 — 한 번에 하나만 보인다 */}
|
||||
{selected ? (
|
||||
<div className="ssul-picked">
|
||||
<div className="ssul-picked__body">
|
||||
<b>{selected.title}</b>
|
||||
<span>{selected.roadAddress || selected.address}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : isUrl ? (
|
||||
<div className="ssul-hint ok">{t('ssulbox.create.hintUrlOk')}</div>
|
||||
) : searching ? (
|
||||
<div className="ssul-hint">{t('ssulbox.create.placeLoading')}</div>
|
||||
) : scenario ? (
|
||||
// 고른 시나리오를 되짚어 준다. 소요 시간은 기본 옵션 실측 전이라
|
||||
// 분 단위를 못 박지 않는다("수 분 내외").
|
||||
<div className="ssul-hint dot">
|
||||
{t('ssulbox.create.hintScenario', { name: t(scenName(scenario)) })}
|
||||
</div>
|
||||
) : (
|
||||
<div className="ssul-hint">{t('ssulbox.create.hintDefault')}</div>
|
||||
)}
|
||||
|
||||
{error && <div className="ssul-hint warn">{error}</div>}
|
||||
|
||||
{/* ── 비용 고지 + 제출 ────────────────────────────── */}
|
||||
{/* 요청 시점에 즉시 차감되므로 누르기 전에 반드시 알린다 */}
|
||||
<div className="ssul-create__cost">
|
||||
<span>{t('ssulbox.create.cost')}</span>
|
||||
<span className="ssul-create__cost-badge">
|
||||
<span className="ssul-create__coin" aria-hidden="true">
|
||||
🪙
|
||||
</span>
|
||||
{t('ssulbox.create.costValue')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="ssul-create__submit"
|
||||
disabled={!canSubmit}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{submitting ? t('ssulbox.create.submitting') : t('ssulbox.create.submit')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SsulCreateForm;
|
||||
545
src/pages/Ssulbox/SsulDetailContent.tsx
Normal file
545
src/pages/Ssulbox/SsulDetailContent.tsx
Normal file
@ -0,0 +1,545 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
deleteComment,
|
||||
getSsulContentById,
|
||||
getVideoComments,
|
||||
isLoggedIn,
|
||||
postVideoComment,
|
||||
toggleVideoLike,
|
||||
} from '../../utils/api';
|
||||
import type { SsulDetailItem } from '../../utils/api';
|
||||
import { CommentItem } from '../../types/api';
|
||||
import LoginPromptModal from '../../components/LoginPromptModal';
|
||||
|
||||
interface SsulDetailContentProps {
|
||||
contentId: string;
|
||||
isModal?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 썰박스 콘텐츠 상세 — castad `VideoDetailContent` 와 같은 3층 구조의 내용부.
|
||||
* (SsulViewerModal 이 모달로, SsulDetailPage 가 공유 링크 페이지로 감싼다)
|
||||
*
|
||||
* UI·동작(좋아요 낙관 갱신·댓글·대댓글·공유 메뉴·비로그인 게이트)은
|
||||
* `VideoDetailContent` 와 동일하고, 다른 점은 셋뿐이다:
|
||||
* 1. 상세 조회가 `GET /ssul/{id}` (video 조회를 쓰면 id 가 겹치는 남의 영상이 열린다)
|
||||
* 2. 좋아요·댓글 API 에 `contentType='ssul'` 을 얹는다
|
||||
* 3. 공유 URL 이 `/ssul/{id}` 다
|
||||
*/
|
||||
const SsulDetailContent: React.FC<SsulDetailContentProps> = ({
|
||||
contentId,
|
||||
isModal = false,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const authed = isLoggedIn();
|
||||
|
||||
const [content, setContent] = useState<SsulDetailItem | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [likeCount, setLikeCount] = useState(0);
|
||||
const [isLiked, setIsLiked] = useState(false);
|
||||
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [shareMenuOpen, setShareMenuOpen] = useState(false);
|
||||
const [shareMenuUpward, setShareMenuUpward] = useState(false);
|
||||
const [isLandscape, setIsLandscape] = useState(false);
|
||||
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||
|
||||
const [comments, setComments] = useState<CommentItem[]>([]);
|
||||
const [commentsTotal, setCommentsTotal] = useState(0);
|
||||
const [commentsPage, setCommentsPage] = useState(1);
|
||||
const [commentsHasNext, setCommentsHasNext] = useState(false);
|
||||
const [commentsLoading, setCommentsLoading] = useState(false);
|
||||
const [commentInput, setCommentInput] = useState('');
|
||||
const [commentSubmitting, setCommentSubmitting] = useState(false);
|
||||
const commentTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const [commentNickname, setCommentNickname] = useState('');
|
||||
const [commentAvatarSeedIdx, setCommentAvatarSeedIdx] = useState(0);
|
||||
|
||||
// castad 와 동일한 고정 seed 목록 — 브라우저가 캐싱해 중복 요청이 없다
|
||||
const AVATAR_SEEDS = ['42', '77', '123', '256', '512', '888', '1024', '2048', '3141', '9999'];
|
||||
const commentAvatarSeed = AVATAR_SEEDS[commentAvatarSeedIdx % AVATAR_SEEDS.length];
|
||||
|
||||
const handleChangeAvatar = useCallback(() => {
|
||||
setCommentAvatarSeedIdx((prev) => (prev + 1) % AVATAR_SEEDS.length);
|
||||
}, []);
|
||||
|
||||
const fetchComments = useCallback(
|
||||
async (page: number, append = false) => {
|
||||
setCommentsLoading(true);
|
||||
try {
|
||||
const res = await getVideoComments(contentId, page, 20, 'ssul');
|
||||
const sorted = [...res.items].sort(
|
||||
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||
);
|
||||
setComments((prev) => (append ? [...prev, ...sorted] : sorted));
|
||||
setCommentsTotal(res.total);
|
||||
setCommentsHasNext(res.has_next);
|
||||
setCommentsPage(page);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch ssul comments:', err);
|
||||
} finally {
|
||||
setCommentsLoading(false);
|
||||
}
|
||||
},
|
||||
[contentId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchContent = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getSsulContentById(contentId);
|
||||
setContent(data);
|
||||
setLikeCount(data.like_count);
|
||||
setIsLiked(data.is_liked_by_me);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch ssul content:', err);
|
||||
setError(t('ado2Contents.loadFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
setIsLandscape(false);
|
||||
fetchContent();
|
||||
fetchComments(1);
|
||||
}, [contentId, fetchComments, t]);
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return t('videoDetail.dateFormat', {
|
||||
year: date.getFullYear(),
|
||||
month: date.getMonth() + 1,
|
||||
day: date.getDate(),
|
||||
});
|
||||
};
|
||||
|
||||
const formatCommentDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(
|
||||
date.getDate()
|
||||
).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// 썰박스 공개 페이지 경로. /video/{id} 를 쓰면 id 가 겹치는 남의 영상이 열린다.
|
||||
const shareUrl = `${window.location.origin}/ssul/${contentId}`;
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
} catch {
|
||||
// clipboard API 미지원 환경에서는 무시
|
||||
}
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleKakaoShare = () => {
|
||||
const kakao = window.Kakao;
|
||||
if (kakao?.Share) {
|
||||
kakao.Share.sendDefault({
|
||||
objectType: 'feed',
|
||||
content: {
|
||||
title: content?.store_name ?? t('videoDetail.kakaoDefaultTitle'),
|
||||
description: t('videoDetail.kakaoDescription', { region: content?.region ?? '' }),
|
||||
imageUrl: 'https://ado2.o2osolution.ai/favicon_48.svg',
|
||||
link: { mobileWebUrl: shareUrl, webUrl: shareUrl },
|
||||
},
|
||||
buttons: [{ title: t('videoDetail.kakaoButtonTitle'), link: { mobileWebUrl: shareUrl, webUrl: shareUrl } }],
|
||||
});
|
||||
} else if (navigator.share) {
|
||||
navigator.share({ url: shareUrl }).catch(() => {});
|
||||
} else {
|
||||
handleCopyLink();
|
||||
}
|
||||
setShareMenuOpen(false);
|
||||
};
|
||||
|
||||
const handleFacebookShare = () => {
|
||||
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,width=600,height=600');
|
||||
setShareMenuOpen(false);
|
||||
};
|
||||
|
||||
const handleTwitterShare = () => {
|
||||
window.open(`https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,width=600,height=600');
|
||||
setShareMenuOpen(false);
|
||||
};
|
||||
|
||||
const shareMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shareMenuOpen) return;
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (shareMenuRef.current && !shareMenuRef.current.contains(e.target as Node)) {
|
||||
setShareMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [shareMenuOpen]);
|
||||
|
||||
// 모달 내부는 스크롤 영역(.video-detail-body)이 곧 화면 경계라, 버튼이 그 하단에
|
||||
// 가까우면 아래로 펼치는 메뉴가 잘려서 안 보인다. 열기 직전 남은 공간을 재서 뒤집는다.
|
||||
const handleToggleShareMenu = () => {
|
||||
if (!shareMenuOpen) {
|
||||
const btnRect = shareMenuRef.current?.getBoundingClientRect();
|
||||
if (btnRect) {
|
||||
const scrollBox = shareMenuRef.current?.closest('.video-detail-body');
|
||||
const boundaryBottom = scrollBox
|
||||
? scrollBox.getBoundingClientRect().bottom
|
||||
: window.innerHeight;
|
||||
const ESTIMATED_MENU_HEIGHT = 220;
|
||||
setShareMenuUpward(btnRect.bottom + ESTIMATED_MENU_HEIGHT > boundaryBottom);
|
||||
}
|
||||
}
|
||||
setShareMenuOpen(v => !v);
|
||||
};
|
||||
|
||||
const likeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleLike = () => {
|
||||
if (!authed) {
|
||||
setShowLoginModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLiked((prev) => !prev);
|
||||
setLikeCount((prev) => (isLiked ? prev - 1 : prev + 1));
|
||||
|
||||
if (likeDebounceRef.current) clearTimeout(likeDebounceRef.current);
|
||||
likeDebounceRef.current = setTimeout(async () => {
|
||||
const prevLiked = isLiked;
|
||||
const prevCount = likeCount;
|
||||
try {
|
||||
await toggleVideoLike(contentId, 'ssul');
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle ssul like:', err);
|
||||
setIsLiked(prevLiked);
|
||||
setLikeCount(prevCount);
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleHeaderAction = () => {
|
||||
if (!isModal && !authed) {
|
||||
setShowLoginModal(true);
|
||||
return;
|
||||
}
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const handleCommentFocus = () => {
|
||||
if (!authed) setShowLoginModal(true);
|
||||
};
|
||||
|
||||
const handleCommentSubmit = async () => {
|
||||
if (!authed) {
|
||||
setShowLoginModal(true);
|
||||
return;
|
||||
}
|
||||
if (!commentInput.trim() || commentSubmitting) return;
|
||||
setCommentSubmitting(true);
|
||||
try {
|
||||
await postVideoComment(contentId, commentInput.trim(), undefined, 'ssul');
|
||||
setCommentInput('');
|
||||
if (commentTextareaRef.current) {
|
||||
commentTextareaRef.current.style.height = 'auto';
|
||||
}
|
||||
setCommentNickname('');
|
||||
setCommentAvatarSeedIdx((prev) => (prev + 1) % AVATAR_SEEDS.length);
|
||||
await fetchComments(1);
|
||||
} catch (err) {
|
||||
console.error('Failed to post ssul comment:', err);
|
||||
} finally {
|
||||
setCommentSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteComment = async (commentId: number) => {
|
||||
try {
|
||||
await deleteComment(commentId);
|
||||
await fetchComments(commentsPage);
|
||||
} catch (err) {
|
||||
console.error('Failed to delete ssul comment:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const renderCommentContent = (content_: string | null, isDeleted: boolean) => {
|
||||
if (isDeleted) {
|
||||
return (
|
||||
<span style={{ color: '#6B9EA0', fontStyle: 'italic' }}>
|
||||
{t('videoDetail.deletedComment')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return content_;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={isModal ? 'video-detail-modal-content' : 'video-detail-page-content'}>
|
||||
{/* 헤더 */}
|
||||
<div className="video-detail-header">
|
||||
{isModal ? (
|
||||
<>
|
||||
{/* 제목이 없으면 space-between 인 헤더에서 닫기 버튼이 왼쪽으로 붙는다.
|
||||
문구는 ADO2 상세와 같은 키를 쓴다 — 통합 목록에서 열리는 같은
|
||||
모달이므로 콘텐츠 종류에 따라 제목이 바뀌면 오히려 어색하다. */}
|
||||
<h2 className="video-detail-modal-title">{t('sidebar.ado2Contents')}</h2>
|
||||
<button className="video-detail-close-btn" onClick={handleHeaderAction} aria-label={t('videoDetail.closeAriaLabel')}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button className="video-detail-back-btn" onClick={handleHeaderAction}>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M13 4l-6 6 6 6" />
|
||||
</svg>
|
||||
{t('sidebar.ado2Contents')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="video-detail-body">
|
||||
{loading ? (
|
||||
<div className="ado2-contents-loading">
|
||||
<div className="loading-spinner"></div>
|
||||
<p>{t('ado2Contents.loading')}</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="ado2-contents-error"><p>{error}</p></div>
|
||||
) : content ? (
|
||||
<div className={`video-detail-content ${isLandscape ? 'landscape' : ''}`}>
|
||||
<video
|
||||
src={content.video_url}
|
||||
controls
|
||||
autoPlay
|
||||
playsInline
|
||||
controlsList="nodownload"
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
className="video-detail-player"
|
||||
onLoadedMetadata={(e) => {
|
||||
const v = e.currentTarget;
|
||||
setIsLandscape(v.videoWidth > v.videoHeight);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="video-detail-info">
|
||||
<h2 className="video-detail-store">
|
||||
{content.store_name || t('ssulbox.viewer.untitled')}
|
||||
</h2>
|
||||
<p className="video-detail-date">{formatDate(content.created_at)}</p>
|
||||
|
||||
{/* 좋아요 + 공유 */}
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className={`video-detail-like-btn ${isLiked ? 'liked' : ''}`}
|
||||
onClick={handleLike}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill={isLiked ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2">
|
||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
||||
</svg>
|
||||
{likeCount}
|
||||
</button>
|
||||
<div style={{ position: 'relative' }} ref={shareMenuRef}>
|
||||
<button
|
||||
className="video-detail-copy-btn"
|
||||
onClick={handleToggleShareMenu}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" />
|
||||
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" /><line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
|
||||
</svg>
|
||||
{copied ? t('videoDetail.copied') : t('videoDetail.share')}
|
||||
</button>
|
||||
{shareMenuOpen && (
|
||||
<div className={`video-detail-share-menu ${shareMenuUpward ? 'upward' : ''}`}>
|
||||
<button className="video-detail-share-item" onClick={handleKakaoShare}>
|
||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="20" height="20" rx="4" fill="#FEE500" />
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M10 3.5C6.134 3.5 3 6.01 3 9.1c0 1.98 1.2 3.72 3.01 4.76l-.74 2.75a.19.19 0 0 0 .28.21l3.37-2.23c.34.04.69.06 1.06.06 3.866 0 7-2.51 7-5.6S13.866 3.5 10 3.5z" fill="#3C1E1E" />
|
||||
</svg>
|
||||
{t('videoDetail.shareKakao')}
|
||||
</button>
|
||||
<button className="video-detail-share-item" onClick={handleFacebookShare}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="#1877F2">
|
||||
<path d="M24 12.073C24 5.405 18.627 0 12 0S0 5.405 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.41c0-3.025 1.792-4.697 4.533-4.697 1.312 0 2.686.235 2.686.235v2.97h-1.513c-1.491 0-1.956.93-1.956 1.887v2.268h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z" />
|
||||
</svg>
|
||||
{t('videoDetail.shareFacebook')}
|
||||
</button>
|
||||
<button className="video-detail-share-item" onClick={handleTwitterShare}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
{t('videoDetail.shareTwitter')}
|
||||
</button>
|
||||
<button className="video-detail-share-item" onClick={() => { handleCopyLink(); setShareMenuOpen(false); }}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
{copied ? t('videoDetail.copied') : t('videoDetail.copyUrl')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 댓글 섹션 */}
|
||||
<div className="video-detail-comments">
|
||||
<div className="video-detail-comments-header">
|
||||
<h3 className="video-detail-comments-title">{t('videoDetail.commentsTitle')}</h3>
|
||||
<span className="video-detail-comments-count">{commentsTotal}</span>
|
||||
</div>
|
||||
|
||||
{authed && (
|
||||
<div className="video-detail-comment-profile">
|
||||
<img
|
||||
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${commentAvatarSeed}`}
|
||||
alt={t('videoDetail.changeAvatarTitle')}
|
||||
className="video-detail-comment-avatar"
|
||||
onClick={handleChangeAvatar}
|
||||
style={{ cursor: 'pointer' }}
|
||||
title={t('videoDetail.changeAvatarTitle')}
|
||||
/>
|
||||
<input
|
||||
className="video-detail-nickname-input"
|
||||
type="text"
|
||||
placeholder={t('videoDetail.nicknamePlaceholder')}
|
||||
value={commentNickname}
|
||||
onChange={(e) => setCommentNickname(e.target.value)}
|
||||
maxLength={20}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="video-detail-comment-input-wrap">
|
||||
<textarea
|
||||
ref={commentTextareaRef}
|
||||
className="video-detail-comment-input"
|
||||
placeholder={authed ? t('videoDetail.commentPlaceholder') : t('videoDetail.commentLoginRequired')}
|
||||
maxLength={500}
|
||||
rows={1}
|
||||
value={commentInput}
|
||||
onChange={(e) => {
|
||||
setCommentInput(e.target.value);
|
||||
e.target.style.height = 'auto';
|
||||
e.target.style.height = `${e.target.scrollHeight}px`;
|
||||
}}
|
||||
onFocus={handleCommentFocus}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleCommentSubmit();
|
||||
}
|
||||
}}
|
||||
disabled={!authed}
|
||||
/>
|
||||
<button
|
||||
className="video-detail-comment-submit"
|
||||
onClick={handleCommentSubmit}
|
||||
disabled={!authed || !commentInput.trim() || commentSubmitting}
|
||||
>
|
||||
{commentSubmitting ? t('videoDetail.commentSubmitting') : t('videoDetail.commentSubmit')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{comments.length === 0 && !commentsLoading ? (
|
||||
<p className="video-detail-comments-empty">{t('videoDetail.noComments')}</p>
|
||||
) : (
|
||||
<ul className="video-detail-comment-list">
|
||||
{comments.map((c) => (
|
||||
<li key={c.id} className="video-detail-comment-item">
|
||||
<img
|
||||
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${c.id}`}
|
||||
alt="avatar"
|
||||
className="video-detail-comment-avatar"
|
||||
/>
|
||||
<div className="video-detail-comment-body">
|
||||
<span className="video-detail-comment-nickname">
|
||||
{c.nickname || t('videoDetail.anonymous')}
|
||||
</span>
|
||||
<p className="video-detail-comment-text">
|
||||
{renderCommentContent(c.content, c.is_deleted)}
|
||||
</p>
|
||||
<div className="video-detail-comment-bottom">
|
||||
<span className="video-detail-comment-date">{formatCommentDate(c.created_at)}</span>
|
||||
{c.is_mine && !c.is_deleted && (
|
||||
<button
|
||||
className="video-detail-comment-delete"
|
||||
onClick={() => handleDeleteComment(c.id)}
|
||||
>
|
||||
{t('videoDetail.deleteComment')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{c.replies && c.replies.length > 0 && (
|
||||
<ul className="video-detail-reply-list">
|
||||
{c.replies.map((r) => (
|
||||
<li key={r.id} className="video-detail-reply-item">
|
||||
<img
|
||||
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${r.id}`}
|
||||
alt="avatar"
|
||||
className="video-detail-comment-avatar small"
|
||||
/>
|
||||
<div className="video-detail-comment-body">
|
||||
<span className="video-detail-comment-nickname">
|
||||
{r.nickname || t('videoDetail.anonymous')}
|
||||
</span>
|
||||
<p className="video-detail-comment-text">
|
||||
{renderCommentContent(r.content, r.is_deleted)}
|
||||
</p>
|
||||
<div className="video-detail-comment-bottom">
|
||||
<span className="video-detail-comment-date">{formatCommentDate(r.created_at)}</span>
|
||||
{r.is_mine && !r.is_deleted && (
|
||||
<button
|
||||
className="video-detail-comment-delete"
|
||||
onClick={() => handleDeleteComment(r.id)}
|
||||
>
|
||||
{t('videoDetail.deleteComment')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{commentsHasNext && (
|
||||
<button
|
||||
className="video-detail-comments-more"
|
||||
onClick={() => fetchComments(commentsPage + 1, true)}
|
||||
disabled={commentsLoading}
|
||||
>
|
||||
{commentsLoading ? t('videoDetail.loadingComments') : t('videoDetail.loadMoreComments')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{showLoginModal && (
|
||||
<LoginPromptModal onClose={() => setShowLoginModal(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SsulDetailContent;
|
||||
25
src/pages/Ssulbox/SsulDetailPage.tsx
Normal file
25
src/pages/Ssulbox/SsulDetailPage.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import SsulDetailContent from './SsulDetailContent';
|
||||
|
||||
interface SsulDetailPageProps {
|
||||
contentId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 썰박스 공유 링크 페이지 (`/ssul/{id}`) — castad `VideoDetailPage` 와 동일한 패턴.
|
||||
* 비로그인도 볼 수 있다(백엔드 `GET /ssul/{id}` 가 인증 선택).
|
||||
*/
|
||||
const SsulDetailPage: React.FC<SsulDetailPageProps> = ({ contentId }) => {
|
||||
const handleBack = () => {
|
||||
localStorage.setItem('castad_active_item', 'ADO2 콘텐츠');
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="video-detail-page">
|
||||
<SsulDetailContent contentId={contentId} isModal={false} onClose={handleBack} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SsulDetailPage;
|
||||
100
src/pages/Ssulbox/SsulMakingContent.tsx
Normal file
100
src/pages/Ssulbox/SsulMakingContent.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Scen, scenName } from './ssulData';
|
||||
|
||||
/** 생성 잡의 현재 상태. GenerationFlow 가 폴링해 내려준다 */
|
||||
export interface SsulJob {
|
||||
taskId: number;
|
||||
scenario: Scen;
|
||||
status: 'queued' | 'running' | 'done' | 'error';
|
||||
/** 0~4. 0=준비, 4=영상 합성 완료 */
|
||||
step: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface SsulMakingContentProps {
|
||||
job: SsulJob | null;
|
||||
/** 실패 후 처음부터 다시 */
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
/** 백엔드 generator 가 뱉는 4단계. `ssul_content.step` 과 1:1 대응 */
|
||||
const TOTAL_STEPS = 4;
|
||||
|
||||
const STEP_LABEL_KEYS = [
|
||||
'ssulbox.making.step1',
|
||||
'ssulbox.making.step2',
|
||||
'ssulbox.making.step3',
|
||||
'ssulbox.making.step4',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 썰박스 생성 진행 화면.
|
||||
*
|
||||
* 원본은 SSE 로 진행률만 받아 막대 하나를 그렸지만, castad 는 폴링으로
|
||||
* `step`(0~4)을 받으므로 **지금 무슨 작업 중인지 단계명까지** 보여준다.
|
||||
* 수 분~십수 분이 걸리는 작업이라 진행바만 있으면 멈춘 것처럼 보인다.
|
||||
*
|
||||
* 잡 소유는 GenerationFlow 다. 이 컴포넌트는 표시만 하므로
|
||||
* 사이드바로 이동해 언마운트돼도 폴링이 끊기지 않는다.
|
||||
* 그래서 "백그라운드로 전환" 버튼을 두지 않는다 — 사이드바로 그냥 이동하면
|
||||
* 되고, 버튼이 있으면 마치 전환해야 계속 도는 것처럼 오해를 준다.
|
||||
*/
|
||||
const SsulMakingContent: React.FC<SsulMakingContentProps> = ({ job, onRetry }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!job) return null;
|
||||
|
||||
const failed = job.status === 'error';
|
||||
// step 은 "완료한 단계 수"라 진행률은 step/4. queued(0) 면 0%
|
||||
const percent = Math.round((Math.min(job.step, TOTAL_STEPS) / TOTAL_STEPS) * 100);
|
||||
// 진행 중인 단계는 완료 수 + 1 (마지막 단계 완료 시엔 더 올리지 않음)
|
||||
const currentIndex = Math.min(job.step, TOTAL_STEPS - 1);
|
||||
|
||||
return (
|
||||
<div className="ssul-scope ssul-making">
|
||||
<span className="ssul-making__scenario">{t(scenName(job.scenario))}</span>
|
||||
|
||||
{failed ? (
|
||||
<>
|
||||
<h3 className="ssul-making__title warn">{t('ssulbox.making.failTitle')}</h3>
|
||||
<p className="ssul-making__desc">{job.error || t('ssulbox.making.failDefault')}</p>
|
||||
{/* 백엔드가 실패 시 크레딧을 환불한다 — 사용자가 손해 보지 않았음을 알린다 */}
|
||||
<p className="ssul-making__refund">{t('ssulbox.making.refunded')}</p>
|
||||
<button type="button" className="ssul-making__btn" onClick={onRetry}>
|
||||
{t('ssulbox.making.retry')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="ssul-making__title">{t('ssulbox.making.title')}</h3>
|
||||
|
||||
{/*
|
||||
단계 사이 구간에서는 진행바가 멈춰 있어 고장처럼 보인다.
|
||||
스피너는 "살아 있다"는 신호 전용이라 진행률을 표현하지 않는다.
|
||||
*/}
|
||||
<div className="ssul-making__spinner" aria-hidden="true" />
|
||||
|
||||
<div
|
||||
className="ssul-making__bar"
|
||||
role="progressbar"
|
||||
aria-valuenow={percent}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
>
|
||||
<div className="ssul-making__fill" style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
|
||||
<div className="ssul-making__meta">
|
||||
<span>{t(STEP_LABEL_KEYS[currentIndex])}</span>
|
||||
<span className="ssul-making__pct">{percent}%</span>
|
||||
</div>
|
||||
|
||||
<p className="ssul-making__desc">{t('ssulbox.making.takesTime')}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SsulMakingContent;
|
||||
147
src/pages/Ssulbox/SsulResultContent.tsx
Normal file
147
src/pages/Ssulbox/SsulResultContent.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SsulTaskStatus, getSsulTask } from '../../utils/api';
|
||||
import { VideoListItem } from '../../types/api';
|
||||
import SocialPostingModal from '../../components/SocialPostingModal';
|
||||
import CompletionView from '../../components/CompletionView';
|
||||
import { Scen, scenDesc, scenName } from './ssulData';
|
||||
|
||||
interface SsulResultContentProps {
|
||||
taskId: number;
|
||||
scenario: Scen;
|
||||
}
|
||||
|
||||
/**
|
||||
* 썰박스 생성 완료 화면.
|
||||
*
|
||||
* **화면은 ADO2 완료 화면과 같은 `CompletionView` 를 쓴다**(2026-08-10 통일).
|
||||
* 이전에는 `.ssul-scope` 안의 중앙 1단 레이아웃이었는데, 같은 제품 안에서
|
||||
* 완료 화면이 둘로 갈리는 것이 혼란스러웠고 영상 로딩·에러 처리도 없었다.
|
||||
* 정보 패널만 썰박스 재료(시나리오·생성일시·시나리오 설명)로 채운다.
|
||||
*
|
||||
* `.ssul-scope` 를 벗어나면서 부수 효과가 하나 있다: 예전에는 상위의
|
||||
* `text-align: center` 가 SNS 업로드 모달 라벨까지 가운데로 끌어당겼는데
|
||||
* 그 경로가 사라졌다. 다만 오버레이 쪽 방어(`.social-posting-overlay` 의
|
||||
* `text-align`)는 다른 마운트 지점을 위해 그대로 둔다.
|
||||
*
|
||||
* 업로드는 castad `SocialPostingModal` 을 그대로 쓴다 — social_upload 병합으로
|
||||
* 모달이 `content_type` 을 함께 보내므로 종류 분기가 모달 내부에서 끝난다.
|
||||
*/
|
||||
const SsulResultContent: React.FC<SsulResultContentProps> = ({ taskId, scenario }) => {
|
||||
const { t } = useTranslation();
|
||||
const [task, setTask] = useState<SsulTaskStatus | null>(null);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
// 영상 전체를 받아오는 동안 시간이 걸린다 — 중복 클릭을 막고 진행을 알린다
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [showSocialModal, setShowSocialModal] = useState(false);
|
||||
|
||||
// 폴링이 끝난 뒤 최종 상태(video_url 포함)를 한 번 더 읽는다.
|
||||
// 진행 중에는 video_url 이 비어 있으므로 완료 후 조회가 필요하다.
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
getSsulTask(taskId)
|
||||
.then((data) => alive && setTask(data))
|
||||
.catch(() => alive && setLoadError(true));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [taskId]);
|
||||
|
||||
// 파일명은 ADO2 완료 화면과 같은 방식이다 — 업장명 그대로 + .mp4.
|
||||
// store_name 은 완료(done) 시점에 서버가 반드시 채워 보내지만, 조회 실패로
|
||||
// task 가 없을 수 있어 ADO2 와 동일한 폴백('콘텐츠')을 둔다.
|
||||
const fileName = `${task?.store_name || '콘텐츠'}.mp4`;
|
||||
|
||||
/**
|
||||
* 영상 다운로드.
|
||||
*
|
||||
* **Blob URL 을 `<a download>` 에 그대로 걸면 다운로드가 아니라 재생 화면으로
|
||||
* 이동한다** — `download` 속성은 교차 출처 URL 에서 무시되고, 영상은 Azure Blob
|
||||
* 도메인에 있어 앱과 출처가 다르기 때문이다.
|
||||
* 그래서 castad `CompletionContent` 와 동일하게 fetch → Blob → 동일 출처
|
||||
* objectURL 로 바꿔서 내려받는다.
|
||||
*
|
||||
* fetch 가 실패하면(CORS 등) 새 탭으로라도 열어준다 — 사용자가 거기서
|
||||
* 직접 저장할 수 있으므로 아무 일도 안 일어나는 것보다 낫다.
|
||||
*/
|
||||
const handleDownload = async () => {
|
||||
if (!task?.video_url || downloading) return;
|
||||
setDownloading(true);
|
||||
try {
|
||||
const response = await fetch(task.video_url);
|
||||
const blobUrl = URL.createObjectURL(await response.blob());
|
||||
const link = document.createElement('a');
|
||||
link.href = blobUrl;
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
} catch {
|
||||
const link = document.createElement('a');
|
||||
link.href = task.video_url;
|
||||
link.download = fileName;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CompletionView
|
||||
// 여기에 도달했다는 것은 생성이 이미 끝났다는 뜻이라 'loading' 은 쓰지 않는다.
|
||||
// 남는 실패 경우는 최종 상태 조회 실패뿐이며, 재시도 버튼은 두지 않는다
|
||||
// (생성을 다시 돌리는 것이 아니라 조회만 실패한 것이므로 새로고침이 맞다).
|
||||
videoState={loadError ? 'error' : task?.video_url ? 'ready' : 'empty'}
|
||||
videoUrl={task?.video_url}
|
||||
errorMessage={t('ssulbox.result.loadFailed')}
|
||||
fileName={fileName}
|
||||
metaLines={[
|
||||
`${t('ssulbox.result.scenarioLabel')} : ${t(scenName(scenario))}`,
|
||||
`${t('ssulbox.result.createdLabel')} : ${
|
||||
task?.created_at ? new Date(task.created_at).toLocaleString('ko-KR') : '-'
|
||||
}`,
|
||||
]}
|
||||
// ADO2 의 가사 자리. 썰박스는 대본이 영상 안에 그려지므로 내려받을 텍스트가
|
||||
// 없고, 대신 어떤 이야기인지 알려주는 시나리오 설명을 넣는다.
|
||||
detailLabel={t('ssulbox.result.scenarioLabel')}
|
||||
detail={<p className="comp2-lyrics-text">{t(scenDesc(scenario))}</p>}
|
||||
onDownload={handleDownload}
|
||||
downloadDisabled={!task?.video_url || downloading}
|
||||
downloadLabel={
|
||||
downloading ? t('ssulbox.result.downloading') : t('ssulbox.result.download')
|
||||
}
|
||||
onUpload={() => setShowSocialModal(true)}
|
||||
uploadDisabled={!task?.video_url}
|
||||
>
|
||||
{/* castad 모달 재사용 — VideoListItem 형태로 매핑해 넘긴다.
|
||||
type='ssul' 이 핵심이다: 모달이 content_type 으로 보내므로
|
||||
id 가 겹치는 ADO2 영상이 업로드되는 사고가 없다. */}
|
||||
<SocialPostingModal
|
||||
isOpen={showSocialModal}
|
||||
onClose={() => setShowSocialModal(false)}
|
||||
video={
|
||||
task?.video_url
|
||||
? ({
|
||||
type: 'ssul',
|
||||
video_id: taskId,
|
||||
store_name: t(scenName(scenario)),
|
||||
region: '',
|
||||
task_id: '',
|
||||
result_movie_url: task.video_url,
|
||||
created_at: task.created_at,
|
||||
like_count: 0,
|
||||
comment_count: 0,
|
||||
} as VideoListItem)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</CompletionView>
|
||||
);
|
||||
};
|
||||
|
||||
export default SsulResultContent;
|
||||
54
src/pages/Ssulbox/SsulViewerModal.tsx
Normal file
54
src/pages/Ssulbox/SsulViewerModal.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { VideoListItem } from '../../types/api';
|
||||
import SsulDetailContent from './SsulDetailContent';
|
||||
import { useOverlayClose } from '../../hooks/useOverlayClose';
|
||||
|
||||
interface SsulViewerModalProps {
|
||||
/** null 이면 닫힘 */
|
||||
item: VideoListItem | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 썰박스 콘텐츠 모달 — castad `VideoDetailModal` 과 같은 백드롭 래퍼.
|
||||
* 내용은 `SsulDetailContent` 가 그린다(공유 링크 페이지 `SsulDetailPage` 와 공용).
|
||||
*
|
||||
* `VideoDetailModal` 을 그대로 못 쓰는 이유: `videoId` 로 `/video/{id}` 를
|
||||
* 조회하는데, 썰박스 id 를 넘기면 **id 가 겹치는 다른 ADO2 영상**이 열린다.
|
||||
*/
|
||||
const SsulViewerModal: React.FC<SsulViewerModalProps> = ({ item, onClose }) => {
|
||||
useEffect(() => {
|
||||
if (!item) return;
|
||||
// 모달 열릴 때 배경 스크롤 잠금
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [item, onClose]);
|
||||
|
||||
const overlayCloseHandlers = useOverlayClose(onClose);
|
||||
|
||||
if (!item) return null;
|
||||
|
||||
return (
|
||||
<div className="video-detail-overlay" {...overlayCloseHandlers}>
|
||||
<div className="video-detail-modal-box" onClick={(e) => e.stopPropagation()}>
|
||||
<SsulDetailContent
|
||||
contentId={String(item.video_id)}
|
||||
isModal={true}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SsulViewerModal;
|
||||
20
src/pages/Ssulbox/ssulData.ts
Normal file
20
src/pages/Ssulbox/ssulData.ts
Normal file
@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 썰박스 시나리오 코드와 표시 유틸.
|
||||
*
|
||||
* 원본(o2o-ssulbox/frontend/src/data.ts)에서 이식하되 **문구(name/desc)는 제외**했다.
|
||||
* castad 는 ko/en 다국어라 한글이 코드에 박히면 i18n 밖으로 새기 때문이다.
|
||||
* 표시 문구는 `t('ssulbox.scenario.<key>.name')` / `.desc` 로 가져온다.
|
||||
*
|
||||
* 시나리오별 이모지·대표색·그라디언트는 ADO2 디자인 시스템 통일(2026-08-20)로
|
||||
* 제거했다 — 카드·스피너·진행바 모두 시스템 색(민트/퍼플)을 쓴다.
|
||||
*/
|
||||
|
||||
/** 시나리오 코드. 백엔드 `ssul_content.scenario` 와 값이 일치해야 한다 */
|
||||
export type Scen = 'joseon' | 'samgukji' | 'greek' | 'odyssey';
|
||||
|
||||
/** 시나리오 순서. 선택 그리드(2x2)와 필터 칩이 이 순서를 따른다 */
|
||||
export const SCEN_KEYS: Scen[] = ['joseon', 'samgukji', 'greek', 'odyssey'];
|
||||
|
||||
/** i18n 키 헬퍼 — t(scenName('joseon')) 형태로 쓴다 */
|
||||
export const scenName = (s: Scen): string => `ssulbox.scenario.${s}.name`;
|
||||
export const scenDesc = (s: Scen): string => `ssulbox.scenario.${s}.desc`;
|
||||
111
src/pages/Ssulbox/ssulIcons.tsx
Normal file
111
src/pages/Ssulbox/ssulIcons.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 썰박스 UI 아이콘.
|
||||
*
|
||||
* 원본(o2o-ssulbox/frontend/src/icons.tsx)에서 이식하되 **castad 에 대응물이 있는 것은 제외**했다:
|
||||
* IcHome / IcSearch / IcUser / IcGrid → castad Sidebar 가 대체
|
||||
* IcBack → castad 각 화면의 뒤로가기 버튼이 대체
|
||||
*
|
||||
* 색·굵기는 대부분 CSS(컨텍스트)로 제어된다. 원본이 일부 아이콘에 `stroke="#fff"` 를
|
||||
* 하드코딩했는데, castad 다크 배경에서도 흰색이 맞아 그대로 두되
|
||||
* 재사용 가능성이 있는 것은 `currentColor` 로 바꿨다.
|
||||
*
|
||||
* 모두 장식용이므로 사용하는 쪽에서 `aria-hidden` 을 붙이거나 접근 가능한 라벨을 준다.
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
/** 재생 (카드 오버레이) */
|
||||
export const IcPlay: React.FC = () => (
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** 좋아요 (채움) */
|
||||
export const IcHeart: React.FC = () => (
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M12 21s-8-5.3-8-11a4.5 4.5 0 0 1 8-2.8A4.5 4.5 0 0 1 20 10c0 5.7-8 11-8 11z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** 좋아요 (원본에서 채움과 동일 — 상태는 CSS 로 구분) */
|
||||
export const IcHeartLine = IcHeart;
|
||||
|
||||
/** 댓글 */
|
||||
export const IcComment: React.FC = () => (
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M21 12a8 8 0 0 1-11.6 7.1L4 20l1-5.2A8 8 0 1 1 21 12z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** 공유 */
|
||||
export const IcShare: React.FC = () => (
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M22 3 11 14M22 3l-7 19-4-8-8-4z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** 링크 복사 */
|
||||
export const IcLink: React.FC = () => (
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1m0 8a5 5 0 0 1-7 0 5 5 0 0 1 0-7l2-2a5 5 0 0 1 7 0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** 업로드 */
|
||||
export const IcUpload: React.FC = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<path d="M12 15V4M8 8l4-4 4 4M5 15v3a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** 다운로드 */
|
||||
export const IcDownload: React.FC = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<path d="M12 4v11M8 11l4 4 4-4M5 19h14" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** 복사 (캡션) */
|
||||
export const IcCopy: React.FC = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<rect x="9" y="9" width="11" height="11" rx="2" />
|
||||
<path d="M5 15V5a2 2 0 0 1 2-2h8" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ── SNS 플랫폼 ────────────────────────────────────────────────
|
||||
export const IcYoutube: React.FC = () => (
|
||||
<svg viewBox="0 0 24 24">
|
||||
<rect x="2" y="5" width="20" height="14" rx="4" />
|
||||
<path d="M10 9l5 3-5 3z" fill="#fff" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IcInstagram: React.FC = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<rect x="3" y="3" width="18" height="18" rx="5" />
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<circle cx="17.5" cy="6.5" r="1" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IcTiktok: React.FC = () => (
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M14 3c.3 2.3 1.8 4 4 4.3V10c-1.5 0-2.9-.5-4-1.3V15a5 5 0 1 1-5-5v2.6a2.4 2.4 0 1 0 2.4 2.4V3z" />
|
||||
</svg>
|
||||
);
|
||||
@ -458,27 +458,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 모바일: 하단 탭바에 가리지 않도록 띄운다 */
|
||||
@media (max-width: 767px) {
|
||||
.bottom-button-container {
|
||||
bottom: calc(var(--bottom-nav-height) + 16px);
|
||||
}
|
||||
}
|
||||
|
||||
/* =====================================================
|
||||
Sidebar Components
|
||||
===================================================== */
|
||||
|
||||
/* Sidebar Container */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--color-bg-dark);
|
||||
border-right: 1px solid var(--color-border-white-5);
|
||||
transition: all var(--transition-slow);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
@media (max-width: 767px) {
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
flex-shrink: 0;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@ -486,24 +492,6 @@
|
||||
width: 15rem;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
width: 5rem;
|
||||
}
|
||||
|
||||
.sidebar.mobile-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.sidebar.mobile-closed {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.sidebar.mobile-closed {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Sidebar Header */
|
||||
.sidebar-header {
|
||||
padding: 1.25rem;
|
||||
@ -512,11 +500,6 @@
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.sidebar-header.collapsed {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Sidebar Logo */
|
||||
.sidebar-logo {
|
||||
font-family: 'Playfair Display', serif;
|
||||
@ -555,15 +538,6 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-item.collapsed {
|
||||
justify-content: center;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sidebar-item.active {
|
||||
background-color: var(--color-mint);
|
||||
color: var(--color-bg-dark);
|
||||
@ -677,10 +651,6 @@
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.profile-section.collapsed {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
@ -778,25 +748,6 @@
|
||||
transform: translateX(calc(100% + 2px));
|
||||
}
|
||||
|
||||
/* Collapsed state - simple button */
|
||||
.lang-toggle-collapsed {
|
||||
padding: 4px 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.lang-toggle-collapsed:hover {
|
||||
color: #ffffff;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
/* Sidebar Footer Actions */
|
||||
.sidebar-footer-actions {
|
||||
display: flex;
|
||||
@ -825,11 +776,6 @@
|
||||
color: var(--color-text-white);
|
||||
}
|
||||
|
||||
.sidebar-inquiry-btn.collapsed {
|
||||
justify-content: center;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
/* Logout Button */
|
||||
.logout-btn {
|
||||
width: 100%;
|
||||
@ -848,100 +794,148 @@
|
||||
color: var(--color-text-white);
|
||||
}
|
||||
|
||||
.logout-btn.collapsed {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.logout-btn-label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 모바일 전용 사이드바 튜토리얼 토글 */
|
||||
.sidebar-tutorial-btn {
|
||||
/* Settings Panel (Sidebar 4번째 메뉴 / BottomNav 설정 버튼 공용) */
|
||||
.settings-panel {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border-white-10);
|
||||
border-radius: var(--radius-2xl);
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
z-index: 60;
|
||||
}
|
||||
|
||||
.settings-panel-profile {
|
||||
padding: 0.625rem 0.75rem;
|
||||
}
|
||||
|
||||
.settings-panel-divider {
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
margin: 0.375rem 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-settings-anchor {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.settings-panel--sidebar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(100% + 8px);
|
||||
width: 210px;
|
||||
max-height: calc(100vh - 96px);
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.settings-panel--bottom {
|
||||
position: fixed;
|
||||
/* 설정 버튼이 탭바 오른쪽 끝이므로 오른쪽에 앵커한다 */
|
||||
right: 8px;
|
||||
width: 210px;
|
||||
max-width: calc(100vw - 24px);
|
||||
bottom: calc(var(--bottom-nav-height) - 5px);
|
||||
max-height: calc(100vh - 120px);
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.settings-panel-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--color-text-gray-400);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
transition: color var(--transition-normal), background-color var(--transition-normal);
|
||||
}
|
||||
|
||||
.settings-panel-item:hover {
|
||||
color: var(--color-text-white);
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.settings-panel-item.active {
|
||||
color: var(--color-mint);
|
||||
}
|
||||
|
||||
.settings-panel-item svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.settings-panel-language {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
padding: 0.375rem 0.75rem;
|
||||
}
|
||||
|
||||
.settings-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 55;
|
||||
}
|
||||
|
||||
/* Mobile Bottom Nav */
|
||||
.bottom-nav {
|
||||
display: none;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 50;
|
||||
height: var(--bottom-nav-height);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
background: var(--color-bg-dark);
|
||||
border-top: 1px solid var(--color-border-white-5);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.sidebar-tutorial-btn {
|
||||
.bottom-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--color-text-gray-400);
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
transition: color var(--transition-normal), background-color var(--transition-normal);
|
||||
}
|
||||
|
||||
.sidebar-tutorial-btn:hover {
|
||||
color: var(--color-text-white);
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.sidebar-tutorial-btn.active {
|
||||
color: var(--color-mint);
|
||||
}
|
||||
|
||||
.sidebar-tutorial-label {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.sidebar-tutorial-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sidebar-tutorial-badge.on {
|
||||
color: var(--color-mint);
|
||||
}
|
||||
|
||||
.sidebar-tutorial-badge.off {
|
||||
color: var(--color-text-gray-400);
|
||||
.has-bottom-nav {
|
||||
padding-bottom: var(--bottom-nav-height);
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile Menu Button */
|
||||
.mobile-menu-btn {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 40;
|
||||
padding: 0.625rem;
|
||||
background-color: var(--color-bg-card);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border-white-10);
|
||||
.bottom-nav-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-gray-400);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobile-menu-btn:hover {
|
||||
color: var(--color-text-white);
|
||||
.bottom-nav-item svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.mobile-menu-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile Overlay */
|
||||
.mobile-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.mobile-overlay {
|
||||
display: none;
|
||||
}
|
||||
.bottom-nav-item.active {
|
||||
color: var(--color-mint);
|
||||
}
|
||||
|
||||
|
||||
@ -64,7 +64,7 @@
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.settings-title {
|
||||
font-size: 1.5rem;
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -76,7 +76,7 @@
|
||||
|
||||
.settings-description {
|
||||
color: var(--color-text-gray-400);
|
||||
font-size: 9px;
|
||||
font-size: 10px;
|
||||
font-weight: 300;
|
||||
line-height: 1.625;
|
||||
opacity: 0.8;
|
||||
@ -124,7 +124,7 @@
|
||||
|
||||
.settings-card-title {
|
||||
color: var(--color-mint);
|
||||
font-size: 9px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
letter-spacing: 0.1em;
|
||||
@ -234,7 +234,7 @@
|
||||
|
||||
.social-item-connect {
|
||||
color: var(--color-mint);
|
||||
font-size: 9px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
background: none;
|
||||
border: none;
|
||||
@ -321,7 +321,7 @@
|
||||
}
|
||||
|
||||
.footer-link {
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
color: #379599;
|
||||
text-decoration: none;
|
||||
opacity: 0.8;
|
||||
@ -333,7 +333,7 @@
|
||||
}
|
||||
|
||||
.footer-link-divider {
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
color: #379599;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@ -28,7 +28,7 @@
|
||||
border: 1px solid rgba(155, 202, 204, 0.3);
|
||||
background: transparent;
|
||||
color: #9BCACC;
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
white-space: nowrap;
|
||||
@ -54,7 +54,7 @@
|
||||
}
|
||||
|
||||
.ado2-contents-count {
|
||||
font-size: 17px;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: #9BCACC;
|
||||
}
|
||||
@ -281,7 +281,7 @@
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
color: var(--color-text-white);
|
||||
font-size: 15px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
@ -321,7 +321,7 @@
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
background: rgba(255,255,255,0.05);
|
||||
color: var(--color-text-white);
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@ -453,7 +453,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
font-size: 15px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #9BCACC;
|
||||
white-space: nowrap;
|
||||
@ -646,10 +646,56 @@
|
||||
|
||||
/* ── VideoDetailPage / VideoDetailModal ─────────────────────────── */
|
||||
|
||||
/* 모달 내부 콘텐츠 (풀페이지의 video-detail-page와 동일 스타일, 배경 제외) */
|
||||
/* social-posting-overlay 와 동일한 구조: 배경 + 중앙 정렬 + 내부 스크롤 박스 */
|
||||
.video-detail-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
text-align: left;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* social-posting-modal 과 동일한 박스 스타일: 헤더 고정 + 본문만 스크롤 */
|
||||
.video-detail-modal-box {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
max-height: 90vh;
|
||||
background-color: #01282A;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(166, 255, 234, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
/* 일부 모바일 브라우저는 video 를 별도 하드웨어 컴포지팅 레이어로 그려
|
||||
overflow:hidden + border-radius 클리핑을 무시하고 밖으로 삐져나온다.
|
||||
contain:paint 로 이 박스 밖에는 절대 그려지지 않도록 강제한다. */
|
||||
contain: paint;
|
||||
}
|
||||
|
||||
/* 모달 내부 콘텐츠 (풀페이지의 video-detail-page와 동일 스타일, 배경 제외).
|
||||
헤더는 여기의 일반 흐름 자식(스크롤 없음)이고, .video-detail-body 만 스크롤된다.
|
||||
과거엔 헤더에 position:sticky 를 써서 흉내냈으나, 자동재생 <video> 와 같은
|
||||
스크롤 컨테이너 안에 sticky 요소가 있으면 iOS Safari 에서 video 가 별도
|
||||
레이어로 떨어져나가 모달 밖으로 삐져나오는 버그가 있어 구조 자체를 분리했다. */
|
||||
.video-detail-modal-content {
|
||||
color: #fff;
|
||||
padding: 24px 32px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* social-posting-content 에 대응하는 스크롤 영역 */
|
||||
.video-detail-modal-content > .video-detail-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0 32px 24px;
|
||||
}
|
||||
|
||||
.video-detail-page-content {
|
||||
@ -689,6 +735,21 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.video-detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.video-detail-modal-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #FFFFFF;
|
||||
margin-left: 24px;
|
||||
}
|
||||
|
||||
.video-detail-back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -708,7 +769,7 @@
|
||||
|
||||
.video-detail-content {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
@ -720,77 +781,19 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.video-detail-player-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.video-detail-content.landscape .video-detail-player-wrap {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.video-detail-player {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
padding: 1rem;
|
||||
max-width: 360px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* 영상 종료 직전 공식 홈페이지 링크 오버레이 (유튜브 엔드스크린 스타일) */
|
||||
.video-site-overlay {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: 64px; /* 네이티브 컨트롤 바를 가리지 않도록 */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
backdrop-filter: blur(4px);
|
||||
animation: video-site-overlay-in 0.3s ease-out;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.video-site-overlay:hover {
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
.video-site-overlay svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.video-site-overlay-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.video-site-overlay-text strong {
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@keyframes video-site-overlay-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.video-detail-content.landscape .video-detail-player {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.video-detail-info {
|
||||
@ -798,7 +801,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
padding-top: 1rem;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
@ -864,6 +867,12 @@
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* 버튼 아래 공간이 부족하면(모달 스크롤 영역 하단 근처) 위로 펼친다 */
|
||||
.video-detail-share-menu.upward {
|
||||
top: auto;
|
||||
bottom: calc(100% + 6px);
|
||||
}
|
||||
|
||||
.video-detail-share-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -954,7 +963,7 @@
|
||||
background: #1A8F93;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -970,7 +979,7 @@
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@ -984,7 +993,7 @@
|
||||
|
||||
/* 댓글 닉네임 */
|
||||
.video-detail-comment-nickname {
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #9BCACC;
|
||||
display: block;
|
||||
@ -1000,7 +1009,7 @@
|
||||
}
|
||||
|
||||
.video-detail-comments-title {
|
||||
font-size: 17px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
@ -1217,7 +1226,7 @@
|
||||
.video-detail-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
.video-detail-player-wrap {
|
||||
.video-detail-player {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
@ -1229,6 +1238,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* social-posting-modal 의 Mobile Adjustments 와 동일한 기준(768px) */
|
||||
@media (max-width: 768px) {
|
||||
.video-detail-modal-box {
|
||||
max-height: 95vh;
|
||||
}
|
||||
|
||||
.video-detail-modal-content > .video-detail-header {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.video-detail-modal-content > .video-detail-body {
|
||||
padding: 0 1rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
color: #9BCACC;
|
||||
font-size: 14px;
|
||||
@ -1439,31 +1463,31 @@
|
||||
background-color: rgba(148, 251, 224, 0.15);
|
||||
color: #94FBE0;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.875rem;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.social-connect-title {
|
||||
font-size: 1.5rem;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #FFFFFF;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.social-connect-message {
|
||||
font-size: 1rem;
|
||||
font-size: 16px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.social-connect-detail {
|
||||
font-size: 0.875rem;
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.social-connect-countdown {
|
||||
font-size: 0.875rem;
|
||||
font-size: 14px;
|
||||
color: #94FBE0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@ -1478,7 +1502,7 @@
|
||||
width: 100%;
|
||||
padding: 0.875rem 1.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.875rem;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
@ -1553,8 +1577,20 @@
|
||||
background-color: var(--color-bg-darker);
|
||||
}
|
||||
|
||||
/* 내 콘텐츠/캘린더 탭은 그리드형 화면이라 900px 폭 제한을 해제한다 */
|
||||
.myinfo-page--wide {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* 내 정보 탭에 임베드된 내 콘텐츠: 단독 페이지용 여백을 제거해 이중 여백 방지 */
|
||||
.ado2-contents-page--embedded {
|
||||
padding: 0;
|
||||
min-height: auto;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.myinfo-title {
|
||||
font-size: 1.75rem;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #FFFFFF;
|
||||
margin-bottom: 1.5rem;
|
||||
@ -1563,7 +1599,6 @@
|
||||
/* 탭 네비게이션 */
|
||||
.myinfo-tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
@ -1573,7 +1608,7 @@
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 0.9rem;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
@ -1612,7 +1647,7 @@
|
||||
}
|
||||
|
||||
.myinfo-section-title {
|
||||
font-size: 0.875rem;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
text-transform: uppercase;
|
||||
@ -1621,7 +1656,7 @@
|
||||
|
||||
.myinfo-placeholder {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 0.9rem;
|
||||
font-size: 14px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
@ -1647,21 +1682,21 @@
|
||||
|
||||
.myinfo-credits-label {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 0.95rem;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
|
||||
.myinfo-credits-value {
|
||||
color: #a6ffea;
|
||||
font-size: 1.4rem;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.myinfo-credits-desc {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
margin-right: 5px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@ -1671,7 +1706,7 @@
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: #002224;
|
||||
font-size: 0.875rem;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
@ -1709,7 +1744,7 @@
|
||||
|
||||
.myinfo-popup-title {
|
||||
color: #fff;
|
||||
font-size: 1.05rem;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.5rem;
|
||||
align-self: flex-start;
|
||||
@ -1717,7 +1752,7 @@
|
||||
|
||||
.myinfo-popup-message {
|
||||
color: #fff;
|
||||
font-size: 1.1rem;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
@ -1732,7 +1767,7 @@
|
||||
|
||||
.myinfo-popup-label {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 0.85rem;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.myinfo-popup-counter {
|
||||
@ -1748,7 +1783,7 @@
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 1.25rem;
|
||||
font-size: 20px;
|
||||
width: 48px;
|
||||
min-width: 48px;
|
||||
height: 44px;
|
||||
@ -1766,7 +1801,7 @@
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 0.95rem;
|
||||
font-size: 16px;
|
||||
padding: 0.6rem 0.875rem;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
@ -1798,7 +1833,7 @@
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 0.9rem;
|
||||
font-size: 14px;
|
||||
padding: 0.6rem 0.875rem;
|
||||
outline: none;
|
||||
resize: none;
|
||||
@ -1825,7 +1860,7 @@
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 1.25rem;
|
||||
font-size: 0.9rem;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
@ -1840,7 +1875,7 @@
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
@ -1864,14 +1899,14 @@
|
||||
}
|
||||
|
||||
.myinfo-business-empty-title {
|
||||
font-size: 1.1rem;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #FFFFFF;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.myinfo-business-empty-desc {
|
||||
font-size: 0.85rem;
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
@ -1889,7 +1924,7 @@
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
color: #FFFFFF;
|
||||
font-size: 0.9rem;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.myinfo-business-input::placeholder {
|
||||
@ -1907,7 +1942,7 @@
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: #002224;
|
||||
font-size: 0.875rem;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
@ -1935,7 +1970,7 @@
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 8px;
|
||||
color: #FFFFFF;
|
||||
font-size: 0.875rem;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
@ -2018,13 +2053,13 @@
|
||||
}
|
||||
|
||||
.myinfo-connected-channel {
|
||||
font-size: 0.9rem;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.myinfo-connected-platform {
|
||||
font-size: 0.7rem;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
@ -2040,7 +2075,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #a6ffea;
|
||||
}
|
||||
@ -2051,7 +2086,7 @@
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 0.75rem;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
@ -2063,7 +2098,7 @@
|
||||
}
|
||||
|
||||
.myinfo-loading {
|
||||
font-size: 0.85rem;
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
@ -2077,7 +2112,7 @@
|
||||
}
|
||||
|
||||
.myinfo-title {
|
||||
font-size: 1.5rem;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.myinfo-tabs {
|
||||
@ -2093,7 +2128,7 @@
|
||||
.myinfo-tab {
|
||||
white-space: nowrap;
|
||||
padding: 0.625rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.myinfo-business-input-row {
|
||||
|
||||
@ -232,7 +232,7 @@
|
||||
color: var(--color-bg-dark);
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 9px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 20px 25px -5px rgba(166, 255, 234, 0.2);
|
||||
}
|
||||
@ -269,7 +269,7 @@
|
||||
.chart-xaxis {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0 1rem;
|
||||
font-size: 9px;
|
||||
font-size: 10px;
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
}
|
||||
@ -601,7 +601,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 9px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@ -699,7 +699,7 @@
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.yoy-chart-xaxis {
|
||||
font-size: 9px;
|
||||
font-size: 10px;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
/* Completion Page Title */
|
||||
.completion-title {
|
||||
font-size: 1.75rem;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #E5F1F2;
|
||||
line-height: 1.19;
|
||||
@ -16,7 +16,7 @@
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.completion-title {
|
||||
font-size: 2.5rem;
|
||||
font-size: 40px;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
}
|
||||
@ -185,7 +185,7 @@
|
||||
}
|
||||
|
||||
.video-overlay-title {
|
||||
font-size: 1.25rem;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: #FFFFFF;
|
||||
line-height: 1.3;
|
||||
@ -196,7 +196,7 @@
|
||||
}
|
||||
|
||||
.video-overlay-logo {
|
||||
font-size: 0.375rem;
|
||||
font-size: 6px;
|
||||
font-weight: 400;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
line-height: 1.3;
|
||||
@ -225,7 +225,7 @@
|
||||
}
|
||||
|
||||
.ai-optimization-title {
|
||||
font-size: 0.875rem;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #94FBE0;
|
||||
line-height: 1.19;
|
||||
@ -264,7 +264,7 @@
|
||||
}
|
||||
|
||||
.ai-tag-text {
|
||||
font-size: 0.875rem;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #E5F1F2;
|
||||
line-height: 1.19;
|
||||
@ -336,7 +336,7 @@
|
||||
}
|
||||
|
||||
.completion-social-name {
|
||||
font-size: 0.875rem;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #FFFFFF;
|
||||
line-height: 1.19;
|
||||
@ -344,7 +344,7 @@
|
||||
}
|
||||
|
||||
.completion-social-email {
|
||||
font-size: 0.75rem;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #94FBE0;
|
||||
line-height: 1.19;
|
||||
@ -359,7 +359,7 @@
|
||||
}
|
||||
|
||||
.completion-social-channel {
|
||||
font-size: 0.75rem;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #94FBE0;
|
||||
line-height: 1.19;
|
||||
@ -377,14 +377,14 @@
|
||||
}
|
||||
|
||||
.completion-social-channel-name {
|
||||
font-size: 0.875rem;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #FFFFFF;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.completion-social-platform {
|
||||
font-size: 0.7rem;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
line-height: 1.2;
|
||||
@ -405,7 +405,7 @@
|
||||
|
||||
/* Social Card Status */
|
||||
.completion-social-status {
|
||||
font-size: 0.75rem;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
@ -438,7 +438,7 @@
|
||||
}
|
||||
|
||||
.completion-social-disconnect {
|
||||
font-size: 0.75rem;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #f87171;
|
||||
background: transparent;
|
||||
@ -461,7 +461,7 @@
|
||||
border: none;
|
||||
border-radius: var(--radius-full);
|
||||
color: #FFFFFF;
|
||||
font-size: 0.875rem;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-normal);
|
||||
@ -486,7 +486,7 @@
|
||||
border: 1px solid #AE72F9;
|
||||
border-radius: var(--radius-full);
|
||||
color: #FFFFFF;
|
||||
font-size: 0.875rem;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-normal);
|
||||
@ -531,7 +531,7 @@
|
||||
gap: 4px;
|
||||
background-color: #462E64;
|
||||
color: #CFABFB;
|
||||
font-size: 0.875rem;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
padding: 0 1.25rem 0 0.5rem;
|
||||
height: 36px;
|
||||
@ -1262,7 +1262,7 @@
|
||||
.report-content {
|
||||
color: #CEE5E6;
|
||||
font-family: 'Pretendard', sans-serif;
|
||||
font-size: 15px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.006em;
|
||||
@ -1270,7 +1270,7 @@
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.report-content {
|
||||
font-size: 17px;
|
||||
font-size: 18px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
}
|
||||
@ -1385,7 +1385,7 @@
|
||||
|
||||
.selling-point-content p {
|
||||
font-family: 'Pretendard', sans-serif;
|
||||
font-size: 15px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #CEE5E6;
|
||||
letter-spacing: -0.006em;
|
||||
@ -1395,7 +1395,7 @@
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.selling-point-content p {
|
||||
font-size: 17px;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
@ -1419,7 +1419,7 @@
|
||||
background-color: #034A4D;
|
||||
border-radius: 999px;
|
||||
font-family: 'Pretendard', sans-serif;
|
||||
font-size: 15px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #CEE5E6;
|
||||
letter-spacing: -0.006em;
|
||||
@ -1428,7 +1428,7 @@
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.feature-tag {
|
||||
font-size: 17px;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1665,7 +1665,7 @@
|
||||
}
|
||||
|
||||
.bi-usp-category {
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #6AB0B3;
|
||||
text-transform: uppercase;
|
||||
@ -1678,7 +1678,7 @@
|
||||
}
|
||||
|
||||
.bi-usp-description {
|
||||
font-size: 15px;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
line-height: 1.5;
|
||||
@ -1694,7 +1694,7 @@
|
||||
}
|
||||
|
||||
.bi-usp-english-category {
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #6AB0B3;
|
||||
text-transform: uppercase;
|
||||
@ -1749,7 +1749,7 @@
|
||||
}
|
||||
|
||||
.bi-chart-sublabel {
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
fill: rgba(255, 255, 255, 0.5);
|
||||
font-weight: 500;
|
||||
}
|
||||
@ -1808,10 +1808,6 @@
|
||||
body:has(.sidebar.expanded) .bi2-header {
|
||||
left: 15rem;
|
||||
}
|
||||
|
||||
body:has(.sidebar.collapsed) .bi2-header {
|
||||
left: 5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.bi2-back-btn {
|
||||
@ -1889,7 +1885,7 @@
|
||||
}
|
||||
|
||||
.bi2-subtitle {
|
||||
font-size: 21px;
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
color: #E5F1F2;
|
||||
letter-spacing: -0.006em;
|
||||
@ -2294,9 +2290,12 @@
|
||||
body:has(.sidebar.expanded) .bi2-bottom-button-container {
|
||||
left: 15rem;
|
||||
}
|
||||
}
|
||||
|
||||
body:has(.sidebar.collapsed) .bi2-bottom-button-container {
|
||||
left: 5rem;
|
||||
/* 모바일: 하단 탭바에 가리지 않도록 띄운다 */
|
||||
@media (max-width: 767px) {
|
||||
.bi2-bottom-button-container {
|
||||
bottom: calc(var(--bottom-nav-height) + 16px);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2358,7 +2357,7 @@
|
||||
color: var(--Color-white, #FFF);
|
||||
text-align: center;
|
||||
font-family: Pretendard;
|
||||
font-size: 45px;
|
||||
font-size: 46px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: normal;
|
||||
@ -2388,12 +2387,19 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* `justify-content: center` 를 쓰지 않는다.
|
||||
중앙정렬 상태에서 내용이 컨테이너보다 길어지면 위아래로 똑같이 넘치는데,
|
||||
**위로 넘친 부분은 스크롤로도 닿을 수 없다**(flexbox 중앙정렬의 알려진 함정).
|
||||
실제로 무빙 포스터 탭에서 로고 밴드가 47px 잘려 보였다.
|
||||
대신 자식의 `margin: auto 0` 으로 중앙에 놓는다 — auto 마진은 여백이 있을 때만
|
||||
중앙정렬하고, 넘칠 때는 0 이 되어 위에서부터 정상 스크롤된다. */
|
||||
justify-content: flex-start;
|
||||
background-color: var(--color-bg-darker);
|
||||
color: var(--color-text-white);
|
||||
padding: 80px 1rem 100px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@ -2403,44 +2409,194 @@
|
||||
align-items: center;
|
||||
z-index: 20;
|
||||
position: relative;
|
||||
margin-top: 50px;
|
||||
/* 세로 중앙정렬은 auto 마진으로 한다 (부모의 justify-content 주석 참조).
|
||||
넘칠 때 잘리지 않는 유일한 방법이다 */
|
||||
margin: auto 0;
|
||||
width: 100%;
|
||||
max-width: 375px;
|
||||
}
|
||||
|
||||
/* 진입 화면에 들어가는 폼은 대부분 한 줄 입력이라 375px 로 충분하지만,
|
||||
포스터 스타일링(F2)은 템플릿 그리드·입출력 2단이 한 화면에 있는 **도구**라
|
||||
그 폭에서는 못 쓴다.
|
||||
(세로 넘침 처리는 이제 컨테이너 기본 규칙이 담당한다 — 예외를 두지 않는다) */
|
||||
.url-input-content--wide {
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
/* 375px 화면에서는 타일 4개 + 라벨이 폭을 넘긴다. 타일과 글자를 함께 줄여
|
||||
한 줄을 지킨다 — 라벨이 줄바꿈되면 타일 정렬이 어긋난다 */
|
||||
|
||||
/* =====================================================
|
||||
PipelineTabs — ADO2 / 썰박스 전환 (밑줄 탭)
|
||||
진입 화면에서 로고와 하위 폼 사이에 형제로 얹힌다.
|
||||
|
||||
컨테이너 하단의 옅은 전체 폭 구분선 위로, 활성 탭 아래에만
|
||||
진한 밑줄이 얹힌다. 밑줄 색은 탭에 따라 민트↔앰버로 바뀐다.
|
||||
===================================================== */
|
||||
/* 간격 체계 (8px 그리드 · Material 3 / 수직 리듬 가이드 기준)
|
||||
8~12px 묶음 안 (라벨 ↔ 그 라벨이 설명하는 것, 입력 ↔ 그 입력의 안내)
|
||||
20px 묶음 사이
|
||||
32px 섹션 사이 (주제가 바뀜)
|
||||
이전에는 전부 10~24px 이라 "붙은 것"과 "다른 것"이 구분되지 않아 답답해 보였다.
|
||||
근접성(Gestalt)은 거리 차이로만 전달되므로 값이 고르면 정보 구조가 사라진다. */
|
||||
.pipeline-tabs {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
/* 터치 타겟은 8dp 이상 떨어뜨린다(Material 3). 4px 이면 옆 타일 오탭이 난다 */
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.pipeline-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
/* 타일(52px) + 라벨로 터치 타겟이 이미 충분하다. 좌우 패딩은 라벨이
|
||||
이웃 타일에 붙지 않을 만큼만 */
|
||||
padding: 4px 2px;
|
||||
border: none;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
color: var(--color-text-gray-400);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-normal);
|
||||
}
|
||||
|
||||
/* 아이콘 타일 — 라운드 스퀘어. 아이콘은 currentColor 라 타일이 색을 정한다 */
|
||||
.pipeline-tab__tile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: var(--radius-xl);
|
||||
border: 1px solid var(--color-border-white-10);
|
||||
background: var(--color-bg-card-inner);
|
||||
transition: background-color var(--transition-normal),
|
||||
border-color var(--transition-normal),
|
||||
color var(--transition-normal),
|
||||
box-shadow var(--transition-normal);
|
||||
}
|
||||
|
||||
.pipeline-tab__label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.006em;
|
||||
white-space: nowrap;
|
||||
transition: color var(--transition-normal);
|
||||
}
|
||||
|
||||
.pipeline-tab:hover {
|
||||
color: var(--color-text-white);
|
||||
}
|
||||
|
||||
.pipeline-tab:hover .pipeline-tab__tile {
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
/* 선택 상태는 사이드바 활성 항목(.sidebar-item.active)과 같은 문법 —
|
||||
민트로 채우고 아이콘을 어두운 딥틸로 뒤집는다. 화면에서 가장 강한 표시이므로
|
||||
라벨까지 흰색으로 올려 타일과 한 덩어리로 읽히게 한다 */
|
||||
.pipeline-tab[aria-selected="true"] {
|
||||
color: var(--color-bg-dark);
|
||||
}
|
||||
|
||||
.pipeline-tab[aria-selected="true"] .pipeline-tab__tile {
|
||||
background: var(--color-mint);
|
||||
border-color: var(--color-mint);
|
||||
box-shadow: var(--shadow-mint-glow);
|
||||
}
|
||||
|
||||
.pipeline-tab[aria-selected="true"] .pipeline-tab__label {
|
||||
color: var(--color-text-white);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pipeline-tab,
|
||||
.pipeline-tab__tile,
|
||||
.pipeline-tab__label {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 고른 기능의 한 줄 설명. 탭과는 붙이고(20px), 아래 폼과는 섹션 간격(32px)을 둔다 —
|
||||
설명은 탭에 딸린 것이지 폼의 일부가 아니다 */
|
||||
.pipeline-desc {
|
||||
width: 100%;
|
||||
margin: 0 0 32px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
letter-spacing: -0.006em;
|
||||
text-align: center;
|
||||
color: var(--color-text-gray-300);
|
||||
/* 한글은 기본값(word-break: normal)이면 단어 중간에서 잘린다 — "애니메이/션".
|
||||
keep-all 로 어절 단위 줄바꿈을 강제한다(한국어 조판 표준) */
|
||||
word-break: keep-all;
|
||||
/* 두 줄이 될 때 길이를 고르게 나눠 한쪽만 길게 늘어지지 않게 한다 */
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* 좁은 화면 조정.
|
||||
**이 블록은 반드시 위 기본 규칙들보다 뒤에 있어야 한다** — 미디어 쿼리는 명시도를
|
||||
올리지 않아서, 앞에 두면 뒤에 오는 같은 명시도의 기본 규칙에 그대로 덮인다
|
||||
(실제로 그렇게 두었다가 모바일에서 타일이 48px 로 줄지 않았다). */
|
||||
@media (max-width: 420px) {
|
||||
/* 타일은 48dp 최소 터치 타겟이라 더 줄이지 않는다. 라벨만 줄여 한 줄을 지키고,
|
||||
타일 사이 8px 은 유지한다 — 간격을 없애면 옆 타일 오탭이 난다 */
|
||||
.pipeline-tab__tile {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.pipeline-tab__label {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pipeline-desc {
|
||||
margin-bottom: 28px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 썰박스 탭의 임시 자리 (Phase 7 에서 SsulCreateForm 으로 교체) */
|
||||
.pipeline-placeholder {
|
||||
width: 100%;
|
||||
padding: 40px 16px;
|
||||
border: 1px dashed var(--color-border-white-10);
|
||||
border-radius: 16px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-gray-400);
|
||||
}
|
||||
|
||||
.url-input-icon {
|
||||
color: #AE72F9;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.url-input-logo {
|
||||
margin-bottom: 1rem;
|
||||
/* 로고 → 탭은 "묶음 사이" 간격 (generation-flow.css 상단 간격 체계 참조) */
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* 로고는 **아래 폼 열보다 넓어질 수 없다.**
|
||||
이전에는 화면이 커질수록 350 → 400 → 500 → 554px 로 키웠는데, 정작 폼 열은
|
||||
375px 로 고정이라 데스크톱에서 로고만 좌우로 90px 씩 삐져나왔다.
|
||||
min() 으로 열 폭과 상한을 동시에 걸면 어느 폭에서도 넘지 않는다 —
|
||||
브레이크포인트를 늘려도 다시 어긋날 일이 없다. */
|
||||
.url-input-logo img {
|
||||
width: 350px;
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
width: min(100%, 280px);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.url-input-logo img {
|
||||
width: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.url-input-logo img {
|
||||
width: 500px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.url-input-logo img {
|
||||
width: 554px;
|
||||
}
|
||||
}
|
||||
|
||||
.url-input-title {
|
||||
font-family: 'Pretendard', sans-serif;
|
||||
font-size: 40px;
|
||||
@ -2714,12 +2870,6 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.wizard-stepper {
|
||||
margin: 50px auto 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 노드 셀: 노드 너비와 동일한 2rem 고정. 라벨은 이 밖으로 넘침 */
|
||||
.wizard-step {
|
||||
flex: none;
|
||||
|
||||
@ -10,3 +10,12 @@
|
||||
@import './studio-assets.css';
|
||||
@import './contents-social.css';
|
||||
@import './modals-overlays.css';
|
||||
|
||||
/* 썰박스 UI — 모든 규칙이 .ssul-scope 안에 갇혀 있어 castad 화면에 영향 없음.
|
||||
ssulbox.css 는 스크립트 생성 파일이므로 직접 수정 금지 (파일 상단 주석 참조).
|
||||
castad 안에서 새로 만든 화면의 스타일은 ssulbox-castad.css 에 둔다. */
|
||||
@import './ssulbox.css';
|
||||
@import './ssulbox-castad.css';
|
||||
|
||||
/* Poster to Video (F1 영상 · F2 스타일링) — 모든 규칙이 `.p2v-scope` 안에 갇혀 있다 */
|
||||
@import './poster-p2v.css';
|
||||
|
||||
@ -129,7 +129,7 @@
|
||||
|
||||
/* Hero Form */
|
||||
.hero-form {
|
||||
padding-top: 50px;
|
||||
padding-top: 20px;
|
||||
width: 100%;
|
||||
max-width: 375px;
|
||||
display: flex;
|
||||
@ -384,7 +384,7 @@
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
color: var(--color-text-gray-300);
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.hero-divider::before,
|
||||
@ -581,7 +581,7 @@
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-size: 1.5rem;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
color: #E5F1F2;
|
||||
@ -591,13 +591,13 @@
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.welcome-title {
|
||||
font-size: 2rem;
|
||||
font-size: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.welcome-title {
|
||||
font-size: 2.5rem;
|
||||
font-size: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -617,7 +617,7 @@
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.welcome-subtitle {
|
||||
font-size: 1.5rem;
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -833,7 +833,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.25rem;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #9BCACC;
|
||||
letter-spacing: -0.006em;
|
||||
@ -850,7 +850,7 @@
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.feature-title {
|
||||
font-size: 1.5rem;
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1425,18 +1425,18 @@
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.login-title {
|
||||
font-size: 3rem;
|
||||
font-size: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.login-title {
|
||||
font-size: 3.75rem;
|
||||
font-size: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
font-size: 9px;
|
||||
font-size: 10px;
|
||||
font-weight: 300;
|
||||
margin-bottom: 1.5rem;
|
||||
opacity: 0.8;
|
||||
@ -1509,7 +1509,7 @@
|
||||
}
|
||||
|
||||
.loading-title {
|
||||
font-size: 2rem;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.006em;
|
||||
line-height: 1.19;
|
||||
|
||||
@ -5,6 +5,10 @@
|
||||
.social-posting-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
/* 마운트 위치의 텍스트 정렬을 상속하지 않는다.
|
||||
position:fixed 라도 DOM 상속은 끊기지 않아, 중앙정렬된 부모
|
||||
(.ssul-scope.ssul-result 등) 안에서 열면 폼 라벨까지 가운데로 쏠린다. */
|
||||
text-align: left;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -134,7 +138,7 @@
|
||||
background: rgba(166, 130, 255, 0.15);
|
||||
border: 1px solid rgba(166, 130, 255, 0.3);
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #a682ff;
|
||||
}
|
||||
@ -149,7 +153,7 @@
|
||||
border: 1px dashed rgba(255, 255, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 1rem;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
} */
|
||||
|
||||
@ -277,7 +281,7 @@
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
font-size: 0.7rem;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
@ -286,11 +290,11 @@
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 0.85rem;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.social-posting-no-accounts-hint {
|
||||
font-size: 0.75rem;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
@ -351,7 +355,7 @@
|
||||
}
|
||||
|
||||
.mini-calendar-title {
|
||||
font-size: 0.85rem;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
@ -360,7 +364,7 @@
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 1.2rem;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
@ -381,7 +385,7 @@
|
||||
|
||||
.mini-calendar-day-label {
|
||||
text-align: center;
|
||||
font-size: 0.72rem;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
padding: 4px 0;
|
||||
}
|
||||
@ -390,7 +394,7 @@
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 0.78rem;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 5px 2px;
|
||||
border-radius: 6px;
|
||||
@ -431,7 +435,7 @@
|
||||
}
|
||||
|
||||
.schedule-time-label {
|
||||
font-size: 0.8rem;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-weight: 500;
|
||||
}
|
||||
@ -447,7 +451,7 @@
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 0.85rem;
|
||||
font-size: 14px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
@ -464,7 +468,7 @@
|
||||
}
|
||||
|
||||
.schedule-datetime-preview {
|
||||
font-size: 0.8rem;
|
||||
font-size: 12px;
|
||||
color: #a65eff;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
@ -481,7 +485,7 @@
|
||||
}
|
||||
|
||||
.social-posting-footer-note {
|
||||
font-size: 0.75rem;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin: 0;
|
||||
}
|
||||
@ -499,7 +503,7 @@
|
||||
.social-posting-btn {
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
@ -615,7 +619,7 @@
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
color: #FFFFFF;
|
||||
font-size: 0.875rem;
|
||||
font-size: 14px;
|
||||
font-family: 'Pretendard', sans-serif;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
@ -679,7 +683,7 @@
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #FFFFFF;
|
||||
font-size: 0.875rem;
|
||||
font-size: 14px;
|
||||
font-family: 'Pretendard', sans-serif;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
@ -740,7 +744,7 @@
|
||||
}
|
||||
|
||||
.upload-progress-title {
|
||||
font-size: 1.125rem;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #FFFFFF;
|
||||
margin: 0;
|
||||
@ -831,7 +835,7 @@
|
||||
|
||||
/* Status Text */
|
||||
.upload-progress-status {
|
||||
font-size: 1.25rem;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #FFFFFF;
|
||||
margin: 0;
|
||||
@ -875,7 +879,7 @@
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
@ -901,13 +905,13 @@
|
||||
}
|
||||
|
||||
.upload-progress-label {
|
||||
font-size: 0.8125rem;
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.upload-progress-value {
|
||||
font-size: 0.875rem;
|
||||
font-size: 14px;
|
||||
color: #FFFFFF;
|
||||
text-align: right;
|
||||
word-break: break-word;
|
||||
@ -924,7 +928,7 @@
|
||||
|
||||
.upload-progress-error p {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-size: 14px;
|
||||
color: #f87171;
|
||||
text-align: center;
|
||||
}
|
||||
@ -935,9 +939,9 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #FF0000;
|
||||
background: #f87171;
|
||||
color: #FFFFFF;
|
||||
font-size: 0.875rem;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
@ -964,7 +968,7 @@
|
||||
}
|
||||
|
||||
.upload-progress-calendar-link {
|
||||
font-size: 0.8rem;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
@ -978,7 +982,7 @@
|
||||
.upload-progress-btn {
|
||||
padding: 0.75rem 2rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
@ -997,7 +1001,7 @@
|
||||
|
||||
.upload-progress-note {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
text-align: center;
|
||||
}
|
||||
@ -1024,397 +1028,8 @@
|
||||
.calendar-panel-scroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #067C80 transparent;
|
||||
}
|
||||
|
||||
/* =====================================================
|
||||
Tutorial Overlay
|
||||
===================================================== */
|
||||
|
||||
.tutorial-overlay-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tutorial-overlay-svg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tutorial-overlay-blocker {
|
||||
position: fixed;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
@keyframes tutorial-pulse {
|
||||
0%, 100% {
|
||||
border-color: rgba(166, 255, 234, 0.9);
|
||||
box-shadow: 0 0 0 2px rgba(166, 255, 234, 0.3),
|
||||
0 0 16px 4px rgba(166, 255, 234, 0.35),
|
||||
0 0 40px 8px rgba(166, 255, 234, 0.15);
|
||||
}
|
||||
50% {
|
||||
border-color: rgba(166, 255, 234, 0.5);
|
||||
box-shadow: 0 0 0 5px rgba(166, 255, 234, 0.12),
|
||||
0 0 32px 10px rgba(166, 255, 234, 0.5),
|
||||
0 0 64px 20px rgba(166, 255, 234, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.tutorial-spotlight-ring {
|
||||
position: fixed;
|
||||
border: 4px solid rgba(166, 255, 234, 0.85);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 0 0 1px rgba(166, 255, 234, 0.2), 0 0 12px 2px rgba(166, 255, 234, 0.15);
|
||||
pointer-events: none;
|
||||
z-index: 10001;
|
||||
animation: tutorial-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.tutorial-tooltip {
|
||||
position: fixed;
|
||||
pointer-events: all;
|
||||
z-index: 10002;
|
||||
display: flex;
|
||||
width: 370px;
|
||||
padding: 20px;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--Color-teal-600, #046266);
|
||||
background: var(--Color-teal-800, #002224);
|
||||
}
|
||||
|
||||
.tutorial-tooltip-title {
|
||||
align-self: stretch;
|
||||
color: rgba(255, 255, 255, 0.70);
|
||||
/* 14 */
|
||||
font-family: Pretendard;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 128.571% */
|
||||
}
|
||||
|
||||
.tutorial-tooltip-desc {
|
||||
display: flex;
|
||||
padding-bottom: 16px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
align-self: stretch;
|
||||
line-height: 1.5;
|
||||
white-space: pre-line;
|
||||
align-self: stretch;
|
||||
color: var(--Color-white, #FFF);
|
||||
font-family: Pretendard;
|
||||
font-size: 18px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: 24px; /* 133.333% */
|
||||
letter-spacing: -0.108px;
|
||||
}
|
||||
|
||||
.tutorial-tooltip-note {
|
||||
align-self: stretch;
|
||||
color: rgba(255, 255, 255, 0.60);
|
||||
font-family: Pretendard;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 100%;
|
||||
letter-spacing: -0.072px;
|
||||
margin: -8px 0 14px;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.tutorial-tooltip-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.tutorial-tooltip-footer-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tutorial-tooltip-counter {
|
||||
color: rgba(255, 255, 255, 0.60);
|
||||
font-family: Pretendard;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 100%; /* 12px */
|
||||
letter-spacing: -0.072px;
|
||||
}
|
||||
|
||||
.tutorial-tooltip-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tutorial-btn-skip {
|
||||
color: rgba(255, 255, 255, 0.60);
|
||||
font-family: Pretendard;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 100%; /* 12px */
|
||||
letter-spacing: -0.072px;
|
||||
}
|
||||
|
||||
.tutorial-btn-skip:hover {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.tutorial-btn-prev {
|
||||
display: flex;
|
||||
padding: 8px 12px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-radius: 4px;
|
||||
background: var(--Color-teal-200, #9BCACC);
|
||||
color: var(--Color-teal-800, #002224);
|
||||
font-family: Pretendard;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 100%; /* 13px */
|
||||
letter-spacing: -0.078px;
|
||||
}
|
||||
|
||||
.tutorial-btn-prev:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.tutorial-btn-next {
|
||||
display: flex;
|
||||
padding: 8px 12px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-radius: 4px;
|
||||
background: var(--Color-teal-200, #9BCACC);
|
||||
color: var(--Color-teal-800, #002224);
|
||||
font-family: Pretendard;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 100%; /* 13px */
|
||||
letter-spacing: -0.078px;
|
||||
}
|
||||
|
||||
.tutorial-btn-next:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
/* 말풍선 variant — 색상은 기본 tooltip과 동일, 꼬리만 추가 */
|
||||
.tutorial-tooltip-bubble {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* 꼬리 (화살표) */
|
||||
.tutorial-tooltip-bubble::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border: 10px solid transparent;
|
||||
}
|
||||
|
||||
.tutorial-tooltip-bubble--bottom::before {
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-bottom-color: #1a2630;
|
||||
}
|
||||
|
||||
.tutorial-tooltip-bubble--top::before {
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-top-color: #1a2630;
|
||||
}
|
||||
|
||||
.tutorial-tooltip-bubble--right::before {
|
||||
right: 100%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border-right-color: #1a2630;
|
||||
}
|
||||
|
||||
.tutorial-tooltip-bubble--left::before {
|
||||
left: 100%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border-left-color: #1a2630;
|
||||
}
|
||||
|
||||
.tutorial-tooltip-action-hint {
|
||||
font-size: 11px;
|
||||
color: #a6ffea;
|
||||
margin: 8px 0 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* 튜토리얼 재시작 팝업 */
|
||||
.tutorial-restart-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.tutorial-restart-popup {
|
||||
background: #1a2630;
|
||||
border: 1px solid rgba(166, 255, 234, 0.25);
|
||||
border-radius: 14px;
|
||||
padding: 24px 28px;
|
||||
width: 320px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.tutorial-restart-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #a6ffea;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.tutorial-restart-desc {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
margin: 0 0 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.tutorial-restart-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tutorial-restart-cancel {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
padding: 7px 16px;
|
||||
}
|
||||
|
||||
.tutorial-restart-cancel:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.tutorial-restart-confirm {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #0d1a1f;
|
||||
background: #a6ffea;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
padding: 7px 18px;
|
||||
}
|
||||
|
||||
.tutorial-restart-confirm:hover {
|
||||
background: #c0fff2;
|
||||
}
|
||||
|
||||
/* 사이드바 튜토리얼 버튼 */
|
||||
.tutorial-restart-fab {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 20px;
|
||||
z-index: 900;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 14px;
|
||||
background: rgba(0, 34, 36, 0.85);
|
||||
border: 1px solid rgba(166, 255, 234, 0.25);
|
||||
border-radius: 20px;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
backdrop-filter: blur(6px);
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.tutorial-restart-fab:hover {
|
||||
background: rgba(166, 255, 234, 0.12);
|
||||
border-color: rgba(166, 255, 234, 0.5);
|
||||
color: #a6ffea;
|
||||
}
|
||||
|
||||
/* 튜토리얼 토글 버튼 */
|
||||
.tutorial-toggle-fab {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 20px;
|
||||
z-index: 900;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 14px;
|
||||
background: rgba(0, 34, 36, 0.85);
|
||||
border: 1px solid rgba(166, 255, 234, 0.25);
|
||||
border-radius: 20px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
backdrop-filter: blur(6px);
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.tutorial-toggle-fab:hover {
|
||||
background: rgba(166, 255, 234, 0.12);
|
||||
border-color: rgba(166, 255, 234, 0.5);
|
||||
color: #a6ffea;
|
||||
}
|
||||
|
||||
.tutorial-toggle-fab.active {
|
||||
color: #a6ffea;
|
||||
border-color: rgba(166, 255, 234, 0.4);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.tutorial-toggle-fab {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.tutorial-toggle-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.tutorial-toggle-badge.on {
|
||||
background: #a6ffea;
|
||||
color: #002224;
|
||||
}
|
||||
|
||||
.tutorial-toggle-badge.off {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
/* 패널 끝까지 스크롤해도 뒤의 캘린더/페이지로 스크롤이 전파되지 않게 한다 */
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
/* BusinessNameInputModal */
|
||||
@ -1453,7 +1068,7 @@
|
||||
}
|
||||
|
||||
.manual-modal-subtitle {
|
||||
font-size: 15px;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: rgba(155, 202, 204, 0.7);
|
||||
padding: 15px 20px 0;
|
||||
|
||||
938
src/styles/poster-p2v.css
Normal file
938
src/styles/poster-p2v.css
Normal file
@ -0,0 +1,938 @@
|
||||
/* =====================================================
|
||||
Poster to Video (P2V) — castad 통합 화면 스타일
|
||||
|
||||
F1(포스터→영상) · F2(포스터 스타일링) 두 파이프라인이 함께 쓴다.
|
||||
|
||||
규칙 (썰박스 통합과 동일):
|
||||
- 모든 선택자를 `.p2v-scope` 안에 가둔다 — castad 기존 화면에 새지 않게.
|
||||
- 색·반경·간격은 tokens.css 토큰만 쓴다. 하드코딩 색은 상태색(실패 빨강) 뿐이고
|
||||
그것도 castad 가 이미 쓰는 값(#ff7a7a 계열)을 따른다.
|
||||
- 버튼 문법은 ADO2 프로덕트를 따른다: 퍼플=주 CTA(화면당 하나), 민트=보조·선택,
|
||||
아웃라인=중립. 이 규칙을 어기면 화면이 알록달록해진다.
|
||||
===================================================== */
|
||||
|
||||
.p2v-scope {
|
||||
width: 100%;
|
||||
color: var(--color-text-white);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* ── 공통 요소 ────────────────────────────────────── */
|
||||
|
||||
.p2v-scope .p2v-label {
|
||||
display: block;
|
||||
margin: 0 0 8px;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-white);
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-help {
|
||||
margin: 0 0 12px;
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
color: var(--color-text-gray-400);
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-note {
|
||||
margin: 8px 0 0;
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
color: var(--color-text-gray-400);
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-note.warn {
|
||||
color: #ff9a9a;
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-panel {
|
||||
background: var(--color-bg-card-inner);
|
||||
border: 1px solid var(--color-border-white-5);
|
||||
border-radius: var(--radius-2xl);
|
||||
padding: var(--spacing-page);
|
||||
}
|
||||
|
||||
/* 서버 stderr tail — 개발자용이라 폭이 넘칠 수 있어 스스로 스크롤한다 */
|
||||
.p2v-scope .p2v-errorbox {
|
||||
margin: 12px 0 0;
|
||||
padding: 12px 14px;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
background: var(--color-bg-card-inner);
|
||||
border: 1px solid var(--color-border-white-5);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-gray-400);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-step {
|
||||
align-self: flex-start;
|
||||
margin-bottom: 10px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-mint);
|
||||
}
|
||||
|
||||
/* ── 입력 (프로덕트 흰 pill) ──────────────────────── */
|
||||
|
||||
.p2v-scope .p2v-input {
|
||||
width: 100%;
|
||||
height: 52px;
|
||||
padding: 0 20px;
|
||||
margin-bottom: 8px;
|
||||
border: 1.5px solid transparent;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-text-white);
|
||||
color: var(--color-bg-dark);
|
||||
font-family: inherit;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.006em;
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-input::placeholder {
|
||||
color: #3a8f86;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-mint);
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-input--center,
|
||||
.p2v-scope .p2v-input--center::placeholder {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-field__label {
|
||||
flex: none;
|
||||
width: 52px;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-gray-400);
|
||||
}
|
||||
|
||||
/* ── 버튼 ────────────────────────────────────────── */
|
||||
|
||||
.p2v-scope .p2v-btn-cta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
margin-top: 20px;
|
||||
border: none;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-purple);
|
||||
color: var(--color-text-white);
|
||||
box-shadow: var(--shadow-purple);
|
||||
font-family: inherit;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-normal);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-btn-cta:hover:not(:disabled) {
|
||||
background: var(--color-purple-hover);
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-btn-cta:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* 다운로드처럼 행 안에 들어가는 CTA — 폭을 차지하지 않는다 */
|
||||
.p2v-scope .p2v-btn-cta--compact {
|
||||
width: auto;
|
||||
margin-top: 0;
|
||||
padding: 0 24px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-btn-mint,
|
||||
.p2v-scope .p2v-btn-tonal,
|
||||
.p2v-scope .p2v-btn-outline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 44px;
|
||||
padding: 0 22px;
|
||||
border-radius: var(--radius-full);
|
||||
font-family: inherit;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
transition: background-color var(--transition-normal), border-color var(--transition-normal);
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-btn-mint {
|
||||
border: none;
|
||||
background: var(--color-mint);
|
||||
color: var(--color-bg-dark);
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-btn-mint:hover:not(:disabled) { background: var(--color-mint-hover); }
|
||||
|
||||
.p2v-scope .p2v-btn-tonal {
|
||||
border: 1px solid var(--color-mint-30);
|
||||
background: var(--color-mint-20);
|
||||
color: var(--color-mint);
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-btn-tonal:hover { background: var(--color-mint-30); }
|
||||
|
||||
.p2v-scope .p2v-btn-outline {
|
||||
border: 1px solid var(--color-border-gray-600);
|
||||
background: transparent;
|
||||
color: var(--color-text-white);
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-btn-outline:hover:not(:disabled) { background: rgba(255, 255, 255, 0.05); }
|
||||
|
||||
.p2v-scope .p2v-btn-mint:disabled,
|
||||
.p2v-scope .p2v-btn-outline:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── 접근 키 게이트 ───────────────────────────────── */
|
||||
|
||||
.p2v-scope .p2v-keygate {
|
||||
width: 100%;
|
||||
padding: 22px;
|
||||
border: 1px solid var(--color-mint-30);
|
||||
border-radius: var(--radius-2xl);
|
||||
background: var(--color-mint-10);
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-keygate__title {
|
||||
margin: 0 0 6px;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-keygate__desc {
|
||||
margin: 0 0 16px;
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
color: var(--color-text-gray-300);
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-keygate__row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-keygate__row .p2v-input { margin-bottom: 0; }
|
||||
.p2v-scope .p2v-keygate__row .p2v-btn-mint { height: 52px; flex: none; }
|
||||
|
||||
/* ── 드롭존 ──────────────────────────────────────── */
|
||||
|
||||
.p2v-scope .p2v-dropzone-wrap { width: 100%; }
|
||||
|
||||
.p2v-scope .p2v-dropzone {
|
||||
/* 포스터는 세로물 — 입력창도 세로여야 무엇을 올렸는지 바로 보인다 */
|
||||
aspect-ratio: 4 / 5;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
padding: var(--spacing-page);
|
||||
border: 1px dashed var(--color-border-gray-700);
|
||||
border-radius: var(--radius-2xl);
|
||||
background: var(--color-bg-card-inner);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition-normal), background-color var(--transition-normal);
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-dropzone.has-file { padding: 0; border-style: solid; }
|
||||
|
||||
.p2v-scope .p2v-dropzone.dragging {
|
||||
border-color: var(--color-mint);
|
||||
background: var(--color-mint-10);
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-dropzone img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-dropzone__cta {
|
||||
margin: 0;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-dropzone__hint {
|
||||
margin: 12px 0 0;
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-gray-400);
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
/* ── F1 생성 폼 ───────────────────────────────────── */
|
||||
|
||||
.p2v-scope.p2v-create {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 드롭존과 이름 입력은 "업로드" 한 묶음이라 붙이고(16), 제출과는 섹션 간격을 둔다.
|
||||
간격 체계는 generation-flow.css 상단 참조 */
|
||||
.p2v-scope.p2v-create .p2v-input {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.p2v-scope.p2v-create .p2v-btn-cta { margin-top: 28px; }
|
||||
|
||||
/* ── F1 진행 화면 ────────────────────────────────── */
|
||||
|
||||
.p2v-scope.p2v-making {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
max-width: 520px;
|
||||
/* 부모(.wizard-page-container)가 height:100% flex column 이라
|
||||
margin:auto 가 스텝퍼~바닥 사이 중앙에 놓는다 (썰박스와 같은 처리) */
|
||||
height: auto;
|
||||
margin: auto;
|
||||
padding: 48px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.p2v-making__name {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-mint);
|
||||
}
|
||||
|
||||
.p2v-making__title {
|
||||
margin: 8px 0 24px;
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
.p2v-making__title.warn { color: #ffb4a8; }
|
||||
|
||||
.p2v-making__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text-gray-300);
|
||||
}
|
||||
|
||||
.p2v-making__pct {
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.p2v-making__desc {
|
||||
margin: 22px 0 0;
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
color: var(--color-text-gray-400);
|
||||
}
|
||||
|
||||
.p2v-making__actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-making__narration {
|
||||
width: 100%;
|
||||
margin-top: 26px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-narration-line {
|
||||
margin: 6px 0;
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.6;
|
||||
color: var(--color-text-gray-300);
|
||||
}
|
||||
|
||||
/* 스피너 — castad 전역 `spin` 키프레임을 재사용한다(새로 만들면 중복) */
|
||||
.p2v-scope .p2v-spinner {
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin: 4px 0 22px;
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-spinner--md { width: 48px; height: 48px; margin: 0; }
|
||||
.p2v-scope .p2v-spinner--sm { width: 26px; height: 26px; margin: 0; }
|
||||
|
||||
.p2v-scope .p2v-spinner::before,
|
||||
.p2v-scope .p2v-spinner::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 6px solid transparent;
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-spinner::before { border-color: var(--color-border-white-10); }
|
||||
|
||||
/* 3/4 만 칠해 회전이 보이게 한다. 색은 ADO2 생성 스피너 표준(퍼플) */
|
||||
.p2v-scope .p2v-spinner::after {
|
||||
border-top-color: var(--color-purple);
|
||||
border-right-color: var(--color-purple);
|
||||
border-bottom-color: var(--color-purple);
|
||||
animation: spin 1.1s linear infinite;
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-spinner--md::before,
|
||||
.p2v-scope .p2v-spinner--md::after { border-width: 4px; }
|
||||
.p2v-scope .p2v-spinner--sm::before,
|
||||
.p2v-scope .p2v-spinner--sm::after { border-width: 3px; }
|
||||
|
||||
.p2v-scope .p2v-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
margin-top: 20px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-bg-card-inner);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-bar__fill {
|
||||
height: 100%;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-mint);
|
||||
transition: width 400ms ease;
|
||||
}
|
||||
|
||||
/* ── F1 검수 게이트 ───────────────────────────────── */
|
||||
|
||||
/* 좌우 패딩을 스스로 갖는다. 부모 `.wizard-page-container` 에는 패딩이 없어서
|
||||
0 으로 두면 모바일에서 글자가 화면 가장자리에 그대로 붙는다(실제로 그랬다).
|
||||
16px 은 모바일 화면 가장자리 여백 기준값(Material 3). */
|
||||
.p2v-scope.p2v-review {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
padding: 8px 16px 40px;
|
||||
}
|
||||
|
||||
.p2v-review__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-page-md);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-review__edit .p2v-label { margin-top: 26px; }
|
||||
.p2v-scope .p2v-review__edit > .p2v-label:first-child { margin-top: 0; }
|
||||
|
||||
.p2v-scope .p2v-review__image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: 1px solid var(--color-border-white-5);
|
||||
border-radius: var(--radius-2xl);
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-review__image-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 320px;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-gray-500);
|
||||
}
|
||||
|
||||
/* 모션 칩 — 선택은 민트 테두리+틴트(castad .btn-select.selected 문법) */
|
||||
.p2v-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-chip {
|
||||
padding: 7px 14px;
|
||||
border: 1px solid var(--color-border-white-10);
|
||||
border-radius: var(--radius-full);
|
||||
background: transparent;
|
||||
color: var(--color-text-gray-400);
|
||||
font-family: inherit;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition-normal), background-color var(--transition-normal),
|
||||
color var(--transition-normal);
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-chip:hover { color: var(--color-text-white); }
|
||||
|
||||
.p2v-scope .p2v-chip.active {
|
||||
border-color: var(--color-mint);
|
||||
background: var(--color-mint-20);
|
||||
color: var(--color-mint);
|
||||
}
|
||||
|
||||
/* ── F1 결과 ─────────────────────────────────────── */
|
||||
|
||||
.p2v-scope.p2v-result {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 8px 16px 40px;
|
||||
}
|
||||
|
||||
.p2v-result__title {
|
||||
margin: 0 0 22px;
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
.p2v-result__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-page-md);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-result__player {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-result__player video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 560px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.p2v-result__side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-left: var(--spacing-page);
|
||||
border-left: 2px solid var(--color-mint-20);
|
||||
}
|
||||
|
||||
.p2v-result__filename {
|
||||
margin: 4px 0 16px;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.p2v-result__block {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--color-border-white-10);
|
||||
}
|
||||
|
||||
.p2v-result__row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.p2v-result__row span {
|
||||
flex: none;
|
||||
width: 52px;
|
||||
color: var(--color-text-gray-400);
|
||||
}
|
||||
|
||||
.p2v-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-tag {
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-mint-10);
|
||||
color: var(--color-mint);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.p2v-result__actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: auto;
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-result__new {
|
||||
display: flex;
|
||||
margin: 28px auto 0;
|
||||
}
|
||||
|
||||
/* ── F2 스타일링 ─────────────────────────────────── */
|
||||
|
||||
.p2v-scope.p2v-styling {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 8px 0 40px;
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-styling__scope { margin-bottom: 22px; }
|
||||
|
||||
.p2v-scope .p2v-notice {
|
||||
padding: 12px 18px;
|
||||
border-radius: var(--radius-xl);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
color: var(--color-text-gray-300);
|
||||
}
|
||||
|
||||
/* 내부 전용 — 주황 계열. 민트/퍼플과 겹치지 않아야 "제한"으로 읽힌다 */
|
||||
.p2v-scope .p2v-notice--warn {
|
||||
border: 1px solid rgba(255, 154, 90, 0.35);
|
||||
background: rgba(255, 154, 90, 0.12);
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-notice--warn b { color: #ffc9a3; }
|
||||
|
||||
.p2v-scope .p2v-notice--unknown {
|
||||
margin-top: 12px;
|
||||
border: 1px solid rgba(166, 130, 255, 0.38);
|
||||
background: rgba(166, 130, 255, 0.14);
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-license {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-license--ok {
|
||||
border: 1px solid var(--color-mint-30);
|
||||
background: var(--color-mint-10);
|
||||
color: var(--color-mint);
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-license--warn {
|
||||
border: 1px solid rgba(255, 154, 90, 0.35);
|
||||
background: rgba(255, 154, 90, 0.12);
|
||||
color: #ffc9a3;
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-license--unknown {
|
||||
border: 1px solid rgba(166, 130, 255, 0.38);
|
||||
background: rgba(166, 130, 255, 0.14);
|
||||
color: #c9b6ff;
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-collapse {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-text-white);
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-collapse .p2v-label { margin: 0; }
|
||||
|
||||
.p2v-collapse__picked {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.p2v-collapse__picked img {
|
||||
width: 22px;
|
||||
height: 30px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.p2v-collapse__picked b { color: var(--color-mint); font-weight: 700; }
|
||||
|
||||
.p2v-collapse__empty {
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text-gray-500);
|
||||
}
|
||||
|
||||
.p2v-collapse__caret {
|
||||
margin-left: auto;
|
||||
color: var(--color-text-gray-400);
|
||||
transition: transform var(--transition-normal);
|
||||
}
|
||||
|
||||
.p2v-collapse__caret.open { transform: rotate(180deg); }
|
||||
|
||||
.p2v-tplgroup { margin-top: 20px; }
|
||||
|
||||
.p2v-tplgroup__title {
|
||||
margin: 0 0 10px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-gray-400);
|
||||
}
|
||||
|
||||
.p2v-tplgroup__title span {
|
||||
margin-left: 8px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-gray-500);
|
||||
}
|
||||
|
||||
.p2v-tplgrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.p2v-tplcard { position: relative; }
|
||||
|
||||
.p2v-scope .p2v-tpl {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--color-border-gray-700);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--color-bg-card-inner);
|
||||
color: var(--color-text-gray-400);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition-normal), background-color var(--transition-normal),
|
||||
color var(--transition-normal);
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-tpl:hover { color: var(--color-text-white); }
|
||||
|
||||
.p2v-scope .p2v-tpl.active {
|
||||
border: 1px solid var(--color-mint);
|
||||
background: var(--color-mint-10);
|
||||
color: var(--color-text-white);
|
||||
/* 링을 테두리와 같은 색으로 둔다 — 다르면 두 줄로 갈라져 보인다.
|
||||
테두리 두께를 바꾸지 않아 카드 안 글자도 흔들리지 않는다 */
|
||||
box-shadow: 0 0 0 1px var(--color-mint);
|
||||
}
|
||||
|
||||
/* 명화는 가로 그림도 있다 — cover 로 자르면 무엇인지 알 수 없다 */
|
||||
.p2v-scope .p2v-tpl img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 3;
|
||||
object-fit: contain;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-darker);
|
||||
}
|
||||
|
||||
.p2v-tpl__name {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* 제목이 1줄인 카드와 2줄인 카드가 섞인다 — 아래로 밀어야 배지가 같은 선에 선다 */
|
||||
.p2v-tpl__badge {
|
||||
display: flex;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.p2v-tpl__sub {
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.4;
|
||||
color: var(--color-text-gray-500);
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-tpl--add {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
min-height: 200px;
|
||||
border-style: dashed;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.p2v-tpl__plus { font-size: 26px; font-weight: 300; line-height: 1; }
|
||||
|
||||
.p2v-scope .p2v-tpl__remove {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: 1px solid var(--color-border-white-10);
|
||||
border-radius: var(--radius-full);
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
color: var(--color-text-gray-300);
|
||||
font-family: inherit;
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.p2v-styling__io {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-page-md);
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-styling__out {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
aspect-ratio: 4 / 5;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-styling__out--empty {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: var(--spacing-page);
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text-gray-500);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-styling__out img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.p2v-styling__download {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.p2v-scope.p2v-styling .p2v-label { margin-top: 26px; }
|
||||
.p2v-scope.p2v-styling .p2v-styling__io .p2v-label { margin-top: 0; }
|
||||
|
||||
.p2v-formats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-select {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--color-border-gray-700);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--color-bg-card-inner);
|
||||
color: var(--color-text-gray-400);
|
||||
font-family: inherit;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition-normal), background-color var(--transition-normal),
|
||||
color var(--transition-normal);
|
||||
}
|
||||
|
||||
.p2v-scope .p2v-select:hover { color: var(--color-text-white); }
|
||||
|
||||
.p2v-scope .p2v-select.active {
|
||||
border-color: var(--color-mint);
|
||||
background: var(--color-mint-10);
|
||||
color: var(--color-text-white);
|
||||
box-shadow: 0 0 0 1px var(--color-mint);
|
||||
}
|
||||
|
||||
/* ── 모바일 ──────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.p2v-tplgrid { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.p2v-review__grid,
|
||||
.p2v-result__grid,
|
||||
.p2v-styling__io {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* 세로로 쌓이면 왼쪽 테두리는 위쪽 구분선이 되어야 한다 */
|
||||
.p2v-result__side {
|
||||
padding-left: 0;
|
||||
padding-top: var(--spacing-page);
|
||||
border-left: none;
|
||||
border-top: 2px solid var(--color-mint-20);
|
||||
}
|
||||
|
||||
.p2v-tplgrid { grid-template-columns: repeat(2, 1fr); }
|
||||
.p2v-formats { grid-template-columns: repeat(2, 1fr); }
|
||||
|
||||
/* 검수 화면(모바일) — 2단이 1단으로 접히면서 순서 문제가 생긴다.
|
||||
데스크톱은 좌(편집)·우(포스터)라 나란히 보이지만, 세로로 쌓으면 편집이 먼저 오고
|
||||
**대조할 포스터가 그 아래로 밀린다.** 연도·지명이 맞는지 보려면 위아래로 오가야 한다.
|
||||
포스터를 먼저 올려 읽고 나서 고치는 순서로 만든다. */
|
||||
.p2v-review__preview { order: -1; margin-bottom: 20px; }
|
||||
|
||||
.p2v-scope .p2v-review__image { max-height: 52vh; object-fit: contain; }
|
||||
|
||||
/* 주 액션은 하단 탭바 바로 위에 붙여 둔다.
|
||||
탭바(고정 72px)가 화면 아래를 차지해 CTA 가 문서 흐름 한가운데로 밀리는데,
|
||||
그러면 편집을 마치고도 버튼을 찾아 위로 스크롤해야 한다.
|
||||
castad 가 이미 쓰는 오프셋 패턴(`bottom: nav + 16px`)과 같은 발상이되,
|
||||
여기서는 스크롤 컨테이너 안이라 sticky 로 충분하다.
|
||||
box-shadow 의 두 번째 값은 배경색 링 — 뒤로 지나가는 콘텐츠를 가려 준다. */
|
||||
.p2v-scope.p2v-review .p2v-btn-cta {
|
||||
position: sticky;
|
||||
bottom: 12px;
|
||||
z-index: 5;
|
||||
box-shadow: var(--shadow-purple), 0 0 0 10px var(--color-bg-darker);
|
||||
}
|
||||
}
|
||||
|
||||
/* 진행 애니메이션은 모션 민감 사용자에게 끈다.
|
||||
스피너는 회전을 멈춰도 3/4 호가 남아 정적 표시로 읽힌다 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.p2v-scope .p2v-spinner::after { animation: none; }
|
||||
.p2v-scope .p2v-bar__fill { transition: none; }
|
||||
}
|
||||
493
src/styles/ssulbox-castad.css
Normal file
493
src/styles/ssulbox-castad.css
Normal file
@ -0,0 +1,493 @@
|
||||
/* =====================================================
|
||||
썰박스 — castad 통합용 신규 컴포넌트 스타일
|
||||
|
||||
ssulbox.css 는 원본에서 스크립트로 변환한 파일이라 직접 수정하지 않는다.
|
||||
castad 안에서 새로 만든 화면(SsulCreateForm 등)의 스타일은 여기에 둔다.
|
||||
|
||||
모든 규칙은 원본과 동일하게 `.ssul-scope` 안에 가둔다.
|
||||
===================================================== */
|
||||
|
||||
/* ssulbox.css 의 `.ssul-scope` 는 원본에서 전체 화면 셸(`#app`)이었던 탓에
|
||||
`overflow: hidden` 을 갖는다. castad 안에서는 셸이 아니라 폼·화면 컨테이너로
|
||||
쓰므로 그 클리핑이 **요소 바깥으로 그려지는 것들을 잘라 먹는다** —
|
||||
선택 카드의 링(테두리 바깥 1px)이 좌우에서 잘리고, 버튼 hover 글로우도 잘린다.
|
||||
생성물 파일은 직접 고치지 않으므로 여기서 되돌린다(0,2,0 이라 이긴다). */
|
||||
.ssul-scope.ssul-create,
|
||||
.ssul-scope.ssul-making {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* ── 생성 폼 ─────────────────────────────────────── */
|
||||
.ssul-scope.ssul-create {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--color-text-white);
|
||||
}
|
||||
|
||||
/* 간격은 generation-flow.css 상단의 체계를 따른다 (묶음 안 8~12 / 묶음 사이 20 / 섹션 32) */
|
||||
.ssul-create__step {
|
||||
align-self: flex-start;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-mint);
|
||||
/* 라벨과 그 라벨이 설명하는 것은 붙여야 한 덩어리로 읽힌다 */
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* 2단계 라벨. 강조를 1단계에만 두어 시선 순서를 만든다 */
|
||||
.ssul-create__step.muted {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-gray-400);
|
||||
}
|
||||
|
||||
/* ── 시나리오 2x2 그리드 ─────────────────────────── */
|
||||
.ssul-create__scenarios {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
/* STEP 1 → STEP 2 는 주제가 바뀌는 자리라 섹션 간격 */
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.ssul-scn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
padding: 16px 14px;
|
||||
/* 테두리 두께는 모든 상태에서 1px 로 고정한다. 선택 시 굵기를 바꾸면
|
||||
border-box 안쪽 폭이 줄어 글자가 흔들린다 — 굵기는 box-shadow 링으로 더한다 */
|
||||
border: 1px solid var(--color-border-white-10);
|
||||
border-radius: 14px;
|
||||
background: var(--color-bg-card);
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: border-color var(--transition-normal), transform var(--transition-normal);
|
||||
}
|
||||
|
||||
.ssul-scn:hover {
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 선택 표시 — castad .btn-select.selected 와 동일한 문법(민트 테두리 + 민트 틴트) */
|
||||
.ssul-scn.active {
|
||||
border-color: var(--color-mint);
|
||||
background: var(--color-mint-10);
|
||||
/* 링을 **테두리와 같은 색**으로 둔다. 색이 다르면 안쪽 테두리와 바깥 링이
|
||||
갈라져 두 줄로 보인다. 같은 색이면 1px 테두리 + 1px 링이 이어져
|
||||
2px 한 줄로 읽히고, 테두리 두께를 바꾸지 않아 글자도 흔들리지 않는다. */
|
||||
box-shadow: 0 0 0 1px var(--color-mint);
|
||||
}
|
||||
|
||||
.ssul-scn.active .ssul-scn__desc {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.ssul-scn__name {
|
||||
position: relative;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ssul-scn__desc {
|
||||
position: relative;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--color-text-gray-400);
|
||||
}
|
||||
|
||||
/* ── 업장 검색 (ADO2 식 자동완성) ─────────────────── */
|
||||
/* position: relative 는 드롭다운(.ssul-places)의 기준점이다 */
|
||||
.ssul-create__search {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
/* 입력과 바로 아래 안내는 한 덩어리 — 붙여 둔다 */
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* 이 폼의 가로 박스(입력·안내·선택결과·제출)는 높이를 48px 로 맞춘다.
|
||||
제각각이면(입력 46 / 안내 38 / 버튼 48) 세로로 쌓였을 때 리듬이 깨져
|
||||
정렬이 틀어진 것처럼 보인다. 48px 은 CTA·터치 타겟 기준값이다. */
|
||||
.ssul-create__input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 48px;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: #1f2937;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.006em;
|
||||
}
|
||||
|
||||
.ssul-create__input::placeholder {
|
||||
color: #9ca3af;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ssul-create__search-btn {
|
||||
flex: none;
|
||||
padding: 0 18px;
|
||||
border: 1px solid var(--color-border-white-10);
|
||||
border-radius: 999px;
|
||||
background: var(--color-bg-card-inner);
|
||||
color: var(--color-text-white);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity var(--transition-normal);
|
||||
}
|
||||
|
||||
.ssul-create__search-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── 검색 결과 / 선택 / 안내 ─────────────────────── */
|
||||
/* 입력창 아래로 겹쳐 뜨는 자동완성 드롭다운.
|
||||
문서 흐름에 넣으면 타이핑할 때마다 아래 요소(비용·버튼)가 밀려 흔들린다. */
|
||||
.ssul-places {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
border: 1px solid var(--color-border-white-10);
|
||||
border-radius: 12px;
|
||||
background: var(--color-bg-card);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.ssul-places li + li {
|
||||
border-top: 1px solid var(--color-border-white-10);
|
||||
}
|
||||
|
||||
.ssul-places button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
width: 100%;
|
||||
padding: 11px 14px;
|
||||
border: none;
|
||||
background: var(--color-bg-card);
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ssul-places button:hover {
|
||||
background: var(--color-bg-card-inner);
|
||||
}
|
||||
|
||||
.ssul-places b {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ssul-places span {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-gray-400);
|
||||
}
|
||||
|
||||
/* 안내(.ssul-hint)와 같은 자리에 번갈아 나오므로 아래 간격도 같아야 한다 */
|
||||
.ssul-picked {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
/* 안내(.ssul-hint)와 같은 자리에 번갈아 나오므로 하한을 맞춘다.
|
||||
업체명 + 주소 2줄이라 실제로는 이보다 커지는데, 그건 내용이 늘어난 것이라 괜찮다 */
|
||||
min-height: 48px;
|
||||
padding: 11px 14px;
|
||||
margin-bottom: 28px;
|
||||
border: 1.5px solid var(--color-mint);
|
||||
border-radius: 12px;
|
||||
background: var(--color-mint-10);
|
||||
}
|
||||
|
||||
.ssul-picked__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ssul-picked__body b {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ssul-picked__body span {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-gray-400);
|
||||
}
|
||||
|
||||
.ssul-hint {
|
||||
/* 높이를 입력·제출과 같은 48px 로. 글이 길어 두 줄이 되면 그때만 늘어난다 */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
padding: 10px 14px;
|
||||
/* 안내 다음은 비용·제출 묶음이라 섹션 간격을 준다 */
|
||||
margin-bottom: 28px;
|
||||
border-radius: 12px;
|
||||
background: var(--color-bg-card-inner);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-gray-300);
|
||||
}
|
||||
|
||||
.ssul-hint.ok {
|
||||
color: var(--color-mint);
|
||||
}
|
||||
|
||||
/* 시나리오 확인 안내 — 앞에 점을 찍어 상태 표시임을 드러낸다 */
|
||||
.ssul-hint.dot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--color-text-gray-300);
|
||||
}
|
||||
|
||||
.ssul-hint.dot::before {
|
||||
content: '';
|
||||
flex: none;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-mint);
|
||||
}
|
||||
|
||||
.ssul-hint.warn {
|
||||
color: #ffb4a8;
|
||||
}
|
||||
|
||||
/* ── 비용 고지 + 제출 ────────────────────────────── */
|
||||
/* 비용 고지와 제출 버튼은 한 덩어리 — 누르기 직전에 읽어야 하므로 붙여 둔다 */
|
||||
.ssul-create__cost {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0 4px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-gray-400);
|
||||
}
|
||||
|
||||
.ssul-create__cost-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-weight: 700;
|
||||
color: var(--color-mint);
|
||||
}
|
||||
|
||||
.ssul-create__coin {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* castad .btn-cta 와 같은 문법 — 퍼플은 페이지에서 이 CTA 한 곳만 쓴다 */
|
||||
.ssul-create__submit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: var(--color-purple);
|
||||
color: var(--color-text-white);
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-purple);
|
||||
transition: background-color var(--transition-normal);
|
||||
}
|
||||
|
||||
.ssul-create__submit:hover:not(:disabled) {
|
||||
background: var(--color-purple-hover);
|
||||
}
|
||||
|
||||
.ssul-create__submit:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── 생성 진행 화면 ──────────────────────────────── */
|
||||
.ssul-scope.ssul-making {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
/* ssulbox.css 의 베이스 `.ssul-scope { height:100% }` (원본 전체화면 셸 규칙)가
|
||||
여기까지 상속되어 이 박스 자체가 부모 높이를 꽉 채워버린다. 그러면 아래
|
||||
margin:auto 가 나눠 가질 여유 공간이 0 이 되어 컨텐츠가 위로 쏠린다.
|
||||
height:auto 로 되돌려 컨텐츠 크기만큼만 차지하게 해야 margin:auto 가 뜻대로
|
||||
작동한다(부모 .wizard-page-container 가 height:100% flex column). */
|
||||
height: auto;
|
||||
/* `0 auto` 면 수평만 중앙이라 스텝퍼 바로 아래에 붙고 세로 여백이 전부
|
||||
아래로 몰린다. `auto` 는 상하 여백도 균등 분배해 스텝퍼~바닥 사이
|
||||
중앙에 놓인다. */
|
||||
margin: auto;
|
||||
padding: 48px 20px;
|
||||
text-align: center;
|
||||
gap: 10px;
|
||||
color: var(--color-text-white);
|
||||
}
|
||||
|
||||
.ssul-making__scenario {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--color-mint);
|
||||
}
|
||||
|
||||
.ssul-making__title {
|
||||
margin: 0 0 24px;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
.ssul-making__title.warn {
|
||||
color: #ffb4a8;
|
||||
}
|
||||
|
||||
/* 진행 스피너 — 원본 LoadingSection 의 회전 요소만 가져왔다.
|
||||
색은 ADO2 생성 스피너 표준(퍼플)을 따른다.
|
||||
`spin` 키프레임은 castad 전역에 이미 있어 재사용한다(새로 만들면 3번째 중복). */
|
||||
.ssul-making__spinner {
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
margin: 4px 0 26px;
|
||||
}
|
||||
|
||||
/* 트랙(전체 원) */
|
||||
.ssul-making__spinner::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 7px solid var(--color-border-white-10);
|
||||
}
|
||||
|
||||
/* 회전하는 호 — 3/4 만 칠해 원본 SVG 의 형태를 따른다 */
|
||||
.ssul-making__spinner::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 7px solid transparent;
|
||||
border-top-color: var(--color-purple);
|
||||
border-right-color: var(--color-purple);
|
||||
border-bottom-color: var(--color-purple);
|
||||
animation: spin 1.1s linear infinite;
|
||||
}
|
||||
|
||||
.ssul-making__bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
margin-top: 20px;
|
||||
background: var(--color-bg-card-inner);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ssul-making__fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--color-mint);
|
||||
transition: width 400ms ease;
|
||||
}
|
||||
|
||||
.ssul-making__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
font-size: 16px;
|
||||
color: var(--color-text-gray-300);
|
||||
}
|
||||
|
||||
.ssul-making__pct {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ssul-making__desc {
|
||||
margin: 22px 0 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text-gray-400);
|
||||
}
|
||||
|
||||
.ssul-making__refund {
|
||||
margin: 8px 0 0;
|
||||
font-size: 14px;
|
||||
color: var(--color-mint);
|
||||
}
|
||||
|
||||
.ssul-making__btn {
|
||||
margin-top: 28px;
|
||||
padding: 12px 24px;
|
||||
border: 1px solid var(--color-border-white-10);
|
||||
border-radius: 999px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-white);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition-normal);
|
||||
}
|
||||
|
||||
.ssul-making__btn:hover {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* 진행바·스피너 애니메이션은 모션 민감 사용자에게 끈다.
|
||||
스피너는 회전을 멈추면 3/4 호가 그대로 남아 정적 표시로 읽힌다. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ssul-making__fill {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.ssul-making__spinner::after {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 썰박스 뷰어 전용 스타일은 두지 않는다 —
|
||||
SsulViewerModal 이 castad `video-detail-*` 클래스를 그대로 재사용해
|
||||
통합 목록에서 어떤 카드를 열든 같은 모양이 나오게 했다. */
|
||||
|
||||
/* 결과 화면 스타일은 없다 — SsulResultContent 가 ADO2 완료 화면과 같은
|
||||
`CompletionView`(comp2-* 클래스)를 쓴다(2026-08-10 통일). */
|
||||
571
src/styles/ssulbox.css
Normal file
571
src/styles/ssulbox.css
Normal file
@ -0,0 +1,571 @@
|
||||
/* =====================================================
|
||||
썰박스 UI — 원본(o2o-ssulbox/frontend/src/index.css)에서 스코프 변환
|
||||
|
||||
**이 파일은 스크립트로 생성됐다.** 직접 고치지 말고
|
||||
scratchpad/scope_ssulbox_css.py 를 고쳐 다시 생성할 것.
|
||||
|
||||
변환 내용:
|
||||
1. @import "tailwindcss" 삭제 (castad 는 index.html 의 Play CDN 사용)
|
||||
2. #app → .ssul-scope, 100dvh → 100%
|
||||
3. 모든 최상위 셀렉터에 .ssul-scope 접두
|
||||
4. 전역 리셋(*, html, body, button, img, ul)을 .ssul-scope 하위로
|
||||
← 이게 빠지면 castad 전 화면의 버튼·이미지·폰트가 깨진다
|
||||
5. html,body{height:100%} 삭제
|
||||
6. :root 변수 → .ssul-scope 로 이동, 값은 castad 다크 팔레트로 매핑
|
||||
7. .nav* .topbar .brand .login* 삭제 (castad Sidebar/카카오가 대체)
|
||||
8. --nav-h/--top-h → 0 (셸이 없으므로)
|
||||
9. .grid → .ssul-grid (Tailwind CDN 의 .grid 와 충돌 회피)
|
||||
10. font-family 미지정 → castad Noto Sans KR 상속
|
||||
===================================================== */
|
||||
|
||||
/* castad 다크 팔레트로 매핑한 썰박스 변수.
|
||||
원본은 라이트 테마 기본이었으나 castad 대시보드는 다크 단일이다. */
|
||||
.ssul-scope {
|
||||
--bg: var(--color-bg-darker);
|
||||
--surface: var(--color-bg-card);
|
||||
--surface-2: var(--color-bg-card-inner);
|
||||
--fg: var(--color-text-white);
|
||||
--muted: var(--color-text-gray-400);
|
||||
--faint: var(--color-text-gray-500);
|
||||
--border: var(--color-border-white-10);
|
||||
--elev: 0 2px 16px rgba(0, 0, 0, 0.6);
|
||||
|
||||
/* 시나리오 색 (원본 유지) */
|
||||
--joseon: #3b4cc0;
|
||||
--samgukji: #d63a2f;
|
||||
--greek: #0a8f6b;
|
||||
--greek-2: #e0a91b;
|
||||
--odyssey: #6d4aff;
|
||||
|
||||
--ig: linear-gradient(45deg, #f9a03f 0%, #e6683c 25%, #dc2743 50%, #cc2366 75%, #bc1888 100%);
|
||||
|
||||
/* 셸(하단 네비·상단바)이 castad 에는 없으므로 0 */
|
||||
--nav-h: 0px;
|
||||
--top-h: 0px;
|
||||
--r: 14px;
|
||||
}
|
||||
|
||||
/* 원본의 전역 리셋 — 스코프 안으로 가둔다.
|
||||
|
||||
요소 리셋은 `:where()` 로 감싸 **특정도를 0 으로** 만든다.
|
||||
그러지 않으면 `.ssul-scope button`(0,1,1)이 우리 컴포넌트의 BEM
|
||||
클래스(`.ssul-create__submit` 등 0,1,0)를 임포트 순서와 무관하게 이겨
|
||||
버튼 배경·테두리가 통째로 사라진다(2026-07-29 실제 발생).
|
||||
리셋의 목적(클래스 없는 원본 마크업 정규화)은 그대로 유지된다. */
|
||||
.ssul-scope,
|
||||
.ssul-scope * {
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
:where(.ssul-scope) :where(button) {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
:where(.ssul-scope) :where(img) {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
:where(.ssul-scope) :where(ul) {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
:where(.ssul-scope) :where(button) {font:inherit;color:inherit;background:none;border:none;cursor:pointer}
|
||||
|
||||
:where(.ssul-scope) :where(img) {display:block;max-width:100%}
|
||||
|
||||
:where(.ssul-scope) :where(ul) {list-style:none}
|
||||
|
||||
.ssul-scope {position:relative;
|
||||
width:100%;
|
||||
height:100%;
|
||||
background:var(--bg);
|
||||
display:flex;flex-direction:column;
|
||||
overflow:hidden;}
|
||||
|
||||
.ssul-scope .main {flex:1 1 auto;display:flex;flex-direction:column;min-height:0;min-width:0;position:relative}
|
||||
|
||||
.ssul-scope .screen {flex:1 1 auto;overflow-y:auto;overflow-x:hidden;
|
||||
-webkit-overflow-scrolling:touch;
|
||||
padding-bottom:calc(var(--nav-h) + env(safe-area-inset-bottom) + 8px);}
|
||||
|
||||
.ssul-scope .screen::-webkit-scrollbar {width:0}
|
||||
|
||||
.ssul-scope .ssul-grid {display:grid;grid-template-columns:1fr 1fr;gap:8px;padding:10px}
|
||||
|
||||
.ssul-scope .card {position:relative;aspect-ratio:9/16;border-radius:var(--r);overflow:hidden;
|
||||
background:#222;isolation:isolate;transition:transform .12s ease;}
|
||||
|
||||
.ssul-scope .card:active {transform:scale(.975)}
|
||||
|
||||
.ssul-scope .card .cover {position:absolute;inset:0;display:grid;place-items:center}
|
||||
|
||||
.ssul-scope .card .cov-vid {position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
|
||||
|
||||
.ssul-scope .card .emoji {font-size:58px;filter:drop-shadow(0 3px 8px rgba(0,0,0,.35));opacity:.92}
|
||||
|
||||
.ssul-scope .card .shade {position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,0) 42%,rgba(0,0,0,.72) 100%)}
|
||||
|
||||
.ssul-scope .card .meta {position:absolute;left:9px;right:9px;bottom:8px;z-index:2}
|
||||
|
||||
.ssul-scope .card .title {color:#fff;font-size:12px;font-weight:700;line-height:1.28;
|
||||
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;
|
||||
text-shadow:0 1px 3px rgba(0,0,0,.5);}
|
||||
|
||||
.ssul-scope .card .sub {display:flex;align-items:center;gap:4px;margin-top:5px;color:rgba(255,255,255,.9);font-size:12px;font-variant-numeric:tabular-nums}
|
||||
|
||||
.ssul-scope .card .sub svg {width:12px;height:12px;fill:currentColor}
|
||||
|
||||
.ssul-scope .badge {position:absolute;top:8px;left:8px;z-index:2;
|
||||
display:inline-flex;align-items:center;gap:4px;
|
||||
padding:3px 8px;border-radius:999px;
|
||||
font-size:10px;font-weight:700;color:#fff;
|
||||
background:rgba(0,0,0,.32);backdrop-filter:blur(3px);}
|
||||
|
||||
.ssul-scope .badge i {width:7px;height:7px;border-radius:50%;display:inline-block}
|
||||
|
||||
.ssul-scope .play-fab {position:absolute;top:8px;right:8px;z-index:2;width:24px;height:24px;border-radius:50%;
|
||||
background:rgba(0,0,0,.35);backdrop-filter:blur(3px);display:grid;place-items:center}
|
||||
|
||||
.ssul-scope .play-fab svg {width:12px;height:12px;fill:#fff;margin-left:1px}
|
||||
|
||||
.ssul-scope .sec-h {display:flex;align-items:baseline;justify-content:space-between;padding:16px 14px 4px}
|
||||
|
||||
.ssul-scope .sec-h h3 {font-size:16px;font-weight:800;letter-spacing:-.01em}
|
||||
|
||||
.ssul-scope .sec-h span {font-size:12px;color:var(--muted)}
|
||||
|
||||
.ssul-scope .searchbar {position:sticky;top:0;z-index:4;background:var(--bg);padding:10px 12px;border-bottom:1px solid var(--border)}
|
||||
|
||||
.ssul-scope .searchbox {display:flex;align-items:center;gap:8px;background:var(--surface-2);border-radius:11px;padding:9px 12px}
|
||||
|
||||
.ssul-scope .searchbox svg {width:16px;height:16px;fill:none;stroke:var(--muted);stroke-width:2;flex:0 0 auto}
|
||||
|
||||
.ssul-scope .searchbox input {flex:1;background:none;border:none;outline:none;color:var(--fg);font-size:14px}
|
||||
|
||||
.ssul-scope .searchbox input::placeholder {color:var(--faint)}
|
||||
|
||||
.ssul-scope .chips {display:flex;gap:8px;overflow-x:auto;padding:10px 12px;scrollbar-width:none}
|
||||
|
||||
.ssul-scope .chips::-webkit-scrollbar {display:none}
|
||||
|
||||
.ssul-scope .chip {flex:0 0 auto;padding:7px 13px;border-radius:999px;background:var(--surface-2);
|
||||
font-size:14px;font-weight:600;color:var(--muted);border:1px solid transparent;transition:.15s}
|
||||
|
||||
.ssul-scope .chip[aria-pressed="true"] {background:var(--fg);color:var(--bg)}
|
||||
|
||||
.ssul-scope .empty {padding:60px 24px;text-align:center;color:var(--muted);font-size:14px;line-height:1.6}
|
||||
|
||||
.ssul-scope .profile {padding:20px 16px 6px}
|
||||
|
||||
.ssul-scope .profile .row {display:flex;align-items:center;gap:18px}
|
||||
|
||||
.ssul-scope .avatar {width:74px;height:74px;border-radius:50%;flex:0 0 auto;display:grid;place-items:center;overflow:hidden;
|
||||
color:#fff;font-size:30px;font-weight:800;background:var(--ig)}
|
||||
|
||||
.ssul-scope .avatar img {width:100%;height:100%;object-fit:cover;display:block}
|
||||
|
||||
.ssul-scope .avatar.lg {width:92px;height:92px;font-size:38px}
|
||||
|
||||
.ssul-scope .stats {flex:1;display:flex;justify-content:space-around;text-align:center}
|
||||
|
||||
.ssul-scope .stats b {display:block;font-size:18px;font-weight:800;font-variant-numeric:tabular-nums}
|
||||
|
||||
.ssul-scope .stats span {font-size:12px;color:var(--muted)}
|
||||
|
||||
.ssul-scope .bio {margin-top:12px}
|
||||
|
||||
.ssul-scope .bio .name {font-weight:700;font-size:14px}
|
||||
|
||||
.ssul-scope .bio .bio-txt {font-size:14px;color:var(--fg);margin-top:3px;line-height:1.45;white-space:pre-wrap}
|
||||
|
||||
.ssul-scope .bio .bio-txt.muted {color:var(--faint)}
|
||||
|
||||
.ssul-scope .pe-avatar {display:flex;flex-direction:column;align-items:center;gap:10px;padding:6px 0 14px}
|
||||
|
||||
.ssul-scope .pe-av-btns {display:flex;gap:8px}
|
||||
|
||||
.ssul-scope .pe-av-btns button {font-size:14px;font-weight:700;color:var(--samgukji);padding:6px 12px;border-radius:9px;background:var(--surface-2)}
|
||||
|
||||
.ssul-scope .pe-av-btns button.rm {color:var(--muted)}
|
||||
|
||||
.ssul-scope .pe-bio {width:100%;background:var(--surface-2);border:1px solid var(--border);border-radius:12px;padding:11px 13px;
|
||||
color:var(--fg);outline:none;font-size:14px;resize:none;line-height:1.5}
|
||||
|
||||
.ssul-scope .pe-bio:focus {border-color:var(--samgukji)}
|
||||
|
||||
.ssul-scope .token-card {display:flex;align-items:center;justify-content:space-between;gap:12px;
|
||||
margin:14px 0 2px;padding:14px 16px;border-radius:14px;border:1px solid var(--border);
|
||||
background:linear-gradient(135deg,rgba(220,39,67,.06),rgba(188,24,136,.06))}
|
||||
|
||||
.ssul-scope .token-card .tk-left {display:flex;flex-direction:column;gap:1px;min-width:0}
|
||||
|
||||
.ssul-scope .token-card .tk-left span {font-size:12px;color:var(--muted);font-weight:600}
|
||||
|
||||
.ssul-scope .token-card .tk-left b {font-size:24px;font-weight:800;font-variant-numeric:tabular-nums;line-height:1.15}
|
||||
|
||||
.ssul-scope .token-card .tk-left em {font-size:12px;color:var(--muted);font-style:normal;margin-top:2px}
|
||||
|
||||
.ssul-scope .token-card .tk-btn {flex:0 0 auto;padding:11px 20px;border-radius:11px;color:#fff;font-weight:800;font-size:14px;
|
||||
background:var(--ig);box-shadow:0 4px 12px rgba(220,39,67,.28)}
|
||||
|
||||
.ssul-scope .pf-actions {display:flex;gap:8px;margin:12px 0 4px}
|
||||
|
||||
.ssul-scope .pf-actions button {flex:1;padding:8px;border-radius:9px;background:var(--surface-2);font-size:14px;font-weight:700}
|
||||
|
||||
.ssul-scope .pf-tabs {display:flex;border-top:1px solid var(--border);margin-top:12px}
|
||||
|
||||
.ssul-scope .pf-tabs button {flex:1;padding:11px;display:grid;place-items:center;color:var(--faint);border-bottom:2px solid transparent}
|
||||
|
||||
.ssul-scope .pf-tabs button[aria-selected="true"] {color:var(--fg);border-bottom-color:var(--fg)}
|
||||
|
||||
.ssul-scope .pf-tabs svg {width:22px;height:22px;fill:currentColor}
|
||||
|
||||
.ssul-scope .scrim {position:absolute;inset:0;z-index:40;background:rgba(0,0,0,.5);opacity:0;pointer-events:none;transition:opacity .22s}
|
||||
|
||||
.ssul-scope .scrim.on {opacity:1;pointer-events:auto}
|
||||
|
||||
.ssul-scope .sheet {position:absolute;left:0;right:0;bottom:0;z-index:50;
|
||||
background:var(--surface);border-radius:22px 22px 0 0;
|
||||
transform:translateY(101%);transition:transform .28s cubic-bezier(.22,1,.36,1);
|
||||
max-height:92%;display:flex;flex-direction:column;
|
||||
padding-bottom:env(safe-area-inset-bottom);
|
||||
box-shadow:0 -8px 40px rgba(0,0,0,.35);
|
||||
pointer-events:none;}
|
||||
|
||||
.ssul-scope .sheet.on {transform:translateY(0);pointer-events:auto}
|
||||
|
||||
.ssul-scope .sheet .grab {width:38px;height:4px;border-radius:2px;background:var(--faint);margin:9px auto 4px}
|
||||
|
||||
.ssul-scope .sheet-h {display:flex;align-items:center;justify-content:space-between;padding:6px 16px 10px;border-bottom:1px solid var(--border)}
|
||||
|
||||
.ssul-scope .sheet-h h3 {font-size:18px;font-weight:800}
|
||||
|
||||
.ssul-scope .sheet-h button {color:var(--muted);font-size:22px;line-height:1;padding:4px}
|
||||
|
||||
.ssul-scope .sheet-body {overflow-y:auto;padding:16px}
|
||||
|
||||
.ssul-scope .step-label {font-size:14px;font-weight:700;color:var(--muted);margin:2px 2px 10px}
|
||||
|
||||
.ssul-scope .step-label b {color:var(--fg)}
|
||||
|
||||
.ssul-scope .scn-list {display:flex;flex-direction:column;gap:10px}
|
||||
|
||||
.ssul-scope .scn {display:flex;align-items:center;gap:14px;padding:14px;border-radius:16px;
|
||||
border:1.5px solid var(--border);background:var(--surface);text-align:left;transition:.14s;width:100%;}
|
||||
|
||||
.ssul-scope .scn:active {transform:scale(.99)}
|
||||
|
||||
.ssul-scope .scn[aria-pressed="true"] {border-color:var(--sc);background:color-mix(in srgb,var(--sc) 8%,var(--surface))}
|
||||
|
||||
.ssul-scope .scn .ic {width:48px;height:48px;border-radius:13px;display:grid;place-items:center;font-size:26px;flex:0 0 auto;
|
||||
background:color-mix(in srgb,var(--sc) 15%,var(--surface))}
|
||||
|
||||
.ssul-scope .scn .tx {flex:1}
|
||||
|
||||
.ssul-scope .scn .tx .t {font-weight:800;font-size:16px}
|
||||
|
||||
.ssul-scope .scn .tx .d {font-size:12px;color:var(--muted);margin-top:2px;line-height:1.4}
|
||||
|
||||
.ssul-scope .scn .ck {width:22px;height:22px;border-radius:50%;border:2px solid var(--border);flex:0 0 auto;display:grid;place-items:center}
|
||||
|
||||
.ssul-scope .scn[aria-pressed="true"] .ck {background:var(--sc);border-color:var(--sc)}
|
||||
|
||||
.ssul-scope .scn[aria-pressed="true"] .ck::after {content:"";width:7px;height:11px;border:2.5px solid #fff;border-top:0;border-left:0;transform:rotate(42deg) translateY(-1px)}
|
||||
|
||||
.ssul-scope .field {margin-top:20px}
|
||||
|
||||
.ssul-scope .field > label {display:block;font-size:14px;font-weight:700;margin-bottom:8px}
|
||||
|
||||
.ssul-scope .input {display:flex;align-items:center;gap:8px;background:var(--surface-2);border-radius:12px;padding:12px 14px;border:1.5px solid transparent}
|
||||
|
||||
.ssul-scope .input:focus-within {border-color:var(--fg)}
|
||||
|
||||
.ssul-scope .input svg {width:17px;height:17px;color:var(--muted);flex:0 0 auto}
|
||||
|
||||
.ssul-scope .input input {flex:1;background:none;border:none;outline:none;color:var(--fg);font-size:16px}
|
||||
|
||||
.ssul-scope .input input::placeholder {color:var(--faint)}
|
||||
|
||||
.ssul-scope .hint {font-size:12px;color:var(--muted);margin-top:7px;line-height:1.5;padding:0 2px}
|
||||
|
||||
.ssul-scope .sheet-foot {padding:12px 16px calc(14px + env(safe-area-inset-bottom));border-top:1px solid var(--border);background:var(--surface)}
|
||||
|
||||
.ssul-scope .cta {width:100%;padding:15px;border-radius:14px;font-size:16px;font-weight:800;color:#fff;background:var(--ig);
|
||||
transition:.15s;box-shadow:0 6px 18px rgba(220,39,67,.32)}
|
||||
|
||||
.ssul-scope .cta:disabled {background:var(--surface-2);color:var(--faint);box-shadow:none;cursor:not-allowed}
|
||||
|
||||
.ssul-scope .making {padding:44px 24px;text-align:center}
|
||||
|
||||
@keyframes sp{to{transform:rotate(360deg)}}
|
||||
|
||||
.ssul-scope .making h4 {font-size:18px;font-weight:800}
|
||||
|
||||
.ssul-scope .making p {font-size:14px;color:var(--muted);margin-top:8px;line-height:1.6}
|
||||
|
||||
.ssul-scope .making .prog {margin-top:22px;display:flex;align-items:center;gap:12px}
|
||||
|
||||
.ssul-scope .making .prog-bar {flex:1;height:10px;border-radius:99px;background:var(--surface-2);overflow:hidden;position:relative}
|
||||
|
||||
.ssul-scope .making .prog-fill {height:100%;border-radius:99px;background:var(--sc,#dc2743);
|
||||
transition:width .5s cubic-bezier(.22,1,.36,1);position:relative;overflow:hidden}
|
||||
|
||||
.ssul-scope .making .prog-fill::after {content:"";position:absolute;inset:0;
|
||||
background:linear-gradient(90deg,transparent,rgba(255,255,255,.45),transparent);
|
||||
animation:progsh 1.15s linear infinite}
|
||||
|
||||
@keyframes progsh{from{transform:translateX(-100%)}to{transform:translateX(100%)}}
|
||||
|
||||
.ssul-scope .making .prog-pct {font-size:14px;font-weight:800;color:var(--fg);min-width:40px;text-align:right;font-variant-numeric:tabular-nums}
|
||||
|
||||
.ssul-scope .viewer {position:absolute;inset:0;z-index:60;background:#000;display:none;flex-direction:column}
|
||||
|
||||
.ssul-scope .viewer.on {display:flex}
|
||||
|
||||
.ssul-scope .viewer .vtop {position:absolute;top:0;left:0;right:0;z-index:3;display:flex;align-items:center;gap:14px;
|
||||
padding:14px 16px;padding-top:calc(14px + env(safe-area-inset-top));
|
||||
background:linear-gradient(180deg,rgba(0,0,0,.5),transparent)}
|
||||
|
||||
.ssul-scope .viewer .vtop button {color:#fff;display:flex;align-items:center;justify-content:center;
|
||||
width:38px;height:38px;border-radius:50%;background:rgba(0,0,0,.4);flex:0 0 auto}
|
||||
|
||||
.ssul-scope .viewer .vtop button svg {width:22px;height:22px}
|
||||
|
||||
.ssul-scope .viewer .vtop h2 {color:#fff;font-size:16px;font-weight:700}
|
||||
|
||||
.ssul-scope .vframe {position:absolute;inset:0;display:flex;flex-direction:column;overflow:hidden}
|
||||
|
||||
.ssul-scope .vstage {flex:1;position:relative;display:grid;place-items:center;overflow:hidden}
|
||||
|
||||
.ssul-scope .vstage .cover {position:absolute;inset:0;display:grid;place-items:center}
|
||||
|
||||
.ssul-scope .vstage .cover .emoji {font-size:120px;opacity:.9;filter:drop-shadow(0 6px 20px rgba(0,0,0,.5))}
|
||||
|
||||
.ssul-scope .vstage video {width:100%;height:100%;object-fit:contain;background:#000}
|
||||
|
||||
.ssul-scope .vbig {position:relative;z-index:2;width:74px;height:74px;border-radius:50%;background:rgba(255,255,255,.14);
|
||||
backdrop-filter:blur(4px);display:grid;place-items:center;border:1.5px solid rgba(255,255,255,.5)}
|
||||
|
||||
.ssul-scope .vbig svg {width:30px;height:30px;fill:#fff;margin-left:4px}
|
||||
|
||||
.ssul-scope .vrail {position:absolute;right:10px;bottom:120px;z-index:3;display:flex;flex-direction:column;gap:20px;align-items:center}
|
||||
|
||||
.ssul-scope .vrail button {color:#fff;display:flex;flex-direction:column;align-items:center;gap:4px;font-size:12px;font-weight:600;font-variant-numeric:tabular-nums}
|
||||
|
||||
.ssul-scope .vrail svg {width:29px;height:29px;fill:#fff}
|
||||
|
||||
.ssul-scope .vbottom {position:absolute;left:0;right:64px;bottom:0;z-index:3;padding:16px;pointer-events:none;
|
||||
padding-bottom:calc(56px + env(safe-area-inset-bottom));
|
||||
background:linear-gradient(0deg,rgba(0,0,0,.55),transparent)}
|
||||
|
||||
.ssul-scope .vbottom .vt {color:#fff;font-weight:800;font-size:18px;line-height:1.3}
|
||||
|
||||
.ssul-scope .vbottom .vn {color:rgba(255,255,255,.86);font-size:14px;margin-top:8px;line-height:1.5;
|
||||
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
||||
|
||||
.ssul-scope .vbottom .vbadge {margin-bottom:10px}
|
||||
|
||||
.ssul-scope .note-pill {display:inline-block;margin-top:12px;font-size:12px;color:rgba(255,255,255,.7);
|
||||
background:rgba(255,255,255,.12);padding:5px 10px;border-radius:8px}
|
||||
|
||||
@media (min-width:860px) {
|
||||
.ssul-scope {flex-direction:row}
|
||||
.ssul-scope .screen {padding-bottom:24px}
|
||||
.ssul-scope .ssul-grid {grid-template-columns:repeat(4,1fr);gap:18px;padding:22px 26px;max-width:1240px;margin:0 auto}
|
||||
.ssul-scope .sec-h {padding:24px 26px 2px;max-width:1240px;margin:0 auto}
|
||||
.ssul-scope .sec-h h3 {font-size:22px}
|
||||
.ssul-scope .searchbar {padding:16px 26px;max-width:1240px;margin:0 auto}
|
||||
.ssul-scope .chips {padding:6px 26px 14px;max-width:1240px;margin:0 auto}
|
||||
.ssul-scope .ch-wrap {max-width:1240px;margin:0 auto}
|
||||
.ssul-scope .sheet {left:50%;right:auto;bottom:auto;top:50%;width:min(460px,92vw);max-height:86vh;
|
||||
border-radius:22px;transform:translate(-50%,-46%);opacity:0;
|
||||
transition:transform .22s cubic-bezier(.22,1,.36,1),opacity .22s}
|
||||
.ssul-scope .sheet.on {transform:translate(-50%,-50%);opacity:1}
|
||||
.ssul-scope .sheet .grab {display:none}
|
||||
.ssul-scope .viewer.on {align-items:center;justify-content:center}
|
||||
.ssul-scope .vframe {position:relative;inset:auto;width:min(430px,26vw);height:min(90vh,780px);
|
||||
border-radius:18px;box-shadow:0 24px 70px rgba(0,0,0,.6)}
|
||||
}
|
||||
|
||||
.ssul-scope .vrail button.liked svg {fill:#ff3b5c}
|
||||
|
||||
.ssul-scope .cmts {position:absolute;left:0;right:0;bottom:0;z-index:5;max-height:64%;display:flex;flex-direction:column;
|
||||
background:var(--surface);border-radius:18px 18px 0 0;color:var(--fg);box-shadow:0 -8px 30px rgba(0,0,0,.4)}
|
||||
|
||||
.ssul-scope .cmts-h {display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--border);font-weight:800;font-size:14px}
|
||||
|
||||
.ssul-scope .cmts-h button {color:var(--muted);font-size:20px;line-height:1}
|
||||
|
||||
.ssul-scope .cmts-list {flex:1;overflow-y:auto;padding:6px 16px}
|
||||
|
||||
.ssul-scope .cmts-empty {color:var(--muted);font-size:14px;text-align:center;padding:30px 0}
|
||||
|
||||
.ssul-scope .cmt {padding:9px 0;border-bottom:1px solid var(--border)}
|
||||
|
||||
.ssul-scope .cmt .who {font-size:12px;color:var(--muted);font-weight:700}
|
||||
|
||||
.ssul-scope .cmt .bd {font-size:14px;margin-top:2px;line-height:1.4}
|
||||
|
||||
.ssul-scope .cmt .del {font-size:12px;color:var(--faint);float:right}
|
||||
|
||||
.ssul-scope .cmt .rep {margin:8px 0 0 16px;padding-left:10px;border-left:2px solid var(--border)}
|
||||
|
||||
.ssul-scope .cmts-in {display:flex;gap:8px;padding:10px 12px;border-top:1px solid var(--border);align-items:center}
|
||||
|
||||
.ssul-scope .cmts-in input {flex:1;background:var(--surface-2);border:none;border-radius:20px;padding:10px 14px;color:var(--fg);outline:none;font-size:14px}
|
||||
|
||||
.ssul-scope .cmts-in button {color:var(--samgukji);font-weight:800;padding:0 8px;font-size:14px}
|
||||
|
||||
.ssul-scope .cmts-in button:disabled {color:var(--faint)}
|
||||
|
||||
.ssul-scope .lbl-sub {font-weight:500;color:var(--muted);font-size:12px}
|
||||
|
||||
.ssul-scope .input.search {align-items:center}
|
||||
|
||||
.ssul-scope .srch-btn {flex:0 0 auto;padding:8px 14px;border-radius:9px;background:var(--fg);color:var(--bg);font-size:14px;font-weight:800}
|
||||
|
||||
.ssul-scope .srch-btn:disabled {opacity:.4}
|
||||
|
||||
.ssul-scope .places {margin:8px 0 0;list-style:none;border:1px solid var(--border);border-radius:12px;overflow:hidden;background:var(--surface);max-height:230px;overflow-y:auto}
|
||||
|
||||
.ssul-scope .places li+li {border-top:1px solid var(--border)}
|
||||
|
||||
.ssul-scope .places button {display:flex;flex-direction:column;gap:2px;width:100%;text-align:left;padding:11px 13px;background:none}
|
||||
|
||||
.ssul-scope .places button:hover {background:var(--surface-2)}
|
||||
|
||||
.ssul-scope .places b {font-size:14px;font-weight:700;color:var(--fg)}
|
||||
|
||||
.ssul-scope .places span {font-size:12px;color:var(--muted)}
|
||||
|
||||
.ssul-scope .picked {margin:8px 0 0;display:flex;align-items:center;gap:10px;padding:12px 14px;border-radius:12px;
|
||||
border:1.5px solid color-mix(in srgb,var(--sc,#22c55e) 55%,var(--border));background:color-mix(in srgb,var(--sc,#22c55e) 8%,var(--surface))}
|
||||
|
||||
.ssul-scope .picked .pk-body {flex:1;display:flex;flex-direction:column;gap:2px;min-width:0}
|
||||
|
||||
.ssul-scope .picked .pk-body b {font-size:16px;font-weight:800;color:var(--fg)}
|
||||
|
||||
.ssul-scope .picked .pk-body b::before {content:"✓ ";color:#22c55e}
|
||||
|
||||
.ssul-scope .picked .pk-body span {font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
|
||||
.ssul-scope .pk-change {flex:0 0 auto;font-size:12px;font-weight:800;color:var(--muted);padding:6px 10px;border-radius:8px;background:var(--surface-2)}
|
||||
|
||||
.ssul-scope .place-loading {margin:10px 2px 0;display:flex;align-items:center;gap:9px;font-size:14px;color:var(--muted)}
|
||||
|
||||
.ssul-scope .spin.sm {width:16px;height:16px;border-width:2px;margin:0}
|
||||
|
||||
.ssul-scope .hint.ok {color:#16a34a}
|
||||
|
||||
.ssul-scope .hint.warn {color:#d97706}
|
||||
|
||||
.ssul-scope .sns-card {margin:14px 16px 4px;border:1px solid var(--border);border-radius:16px;background:var(--surface);overflow:hidden}
|
||||
|
||||
.ssul-scope .sns-h {display:flex;align-items:baseline;gap:8px;padding:13px 16px 9px;border-bottom:1px solid var(--border)}
|
||||
|
||||
.ssul-scope .sns-h span {font-size:14px;font-weight:800}
|
||||
|
||||
.ssul-scope .sns-h em {font-style:normal;font-size:12px;color:var(--muted)}
|
||||
|
||||
.ssul-scope .sns-row {display:flex;align-items:center;gap:11px;padding:11px 16px}
|
||||
|
||||
.ssul-scope .sns-row+.sns-row {border-top:1px solid var(--border)}
|
||||
|
||||
.ssul-scope .sns-ic {display:grid;place-items:center;width:34px;height:34px;border-radius:9px;background:color-mix(in srgb,var(--sc) 16%,transparent)}
|
||||
|
||||
.ssul-scope .sns-ic svg {width:20px;height:20px;fill:var(--sc);stroke:var(--sc)}
|
||||
|
||||
.ssul-scope .sns-name {flex:1;font-size:14px;font-weight:600;display:flex;align-items:center;gap:6px}
|
||||
|
||||
.ssul-scope .sns-dot {width:7px;height:7px;border-radius:50%;background:#22c55e;display:inline-block}
|
||||
|
||||
.ssul-scope .sns-soon {font-style:normal;font-size:10px;font-weight:800;color:var(--muted);background:var(--surface-2);padding:2px 7px;border-radius:10px}
|
||||
|
||||
.ssul-scope .sns-btn {padding:7px 15px;border-radius:20px;font-size:14px;font-weight:800;background:var(--fg);color:var(--bg)}
|
||||
|
||||
.ssul-scope .sns-btn.on {background:var(--surface-2);color:var(--muted)}
|
||||
|
||||
.ssul-scope .sns-btn:disabled {opacity:.5}
|
||||
|
||||
.ssul-scope .sns-row.sns-sub {padding-left:61px;border-top:1px solid var(--border)}
|
||||
|
||||
.ssul-scope .sns-row.sns-sub .sns-name {font-weight:500;color:var(--muted)}
|
||||
|
||||
.ssul-scope .cmts.share .share-list {flex:1;overflow-y:auto;padding:8px 12px}
|
||||
|
||||
.ssul-scope .share-row {display:flex;align-items:center;gap:12px;width:100%;padding:13px 8px;background:none}
|
||||
|
||||
.ssul-scope .share-row+.share-row {border-top:1px solid var(--border)}
|
||||
|
||||
.ssul-scope .share-name {flex:1;text-align:left;font-size:16px;font-weight:700;color:var(--fg)}
|
||||
|
||||
.ssul-scope .share-state {font-size:12px;font-weight:800;color:var(--samgukji)}
|
||||
|
||||
.ssul-scope .share-row:disabled .share-state {color:var(--faint)}
|
||||
|
||||
.ssul-scope .share-row.share-sub {padding-left:54px;border-top:1px solid var(--border)}
|
||||
|
||||
.ssul-scope .share-row.share-sub .share-name {font-weight:500}
|
||||
|
||||
.ssul-scope .share-hint {padding:10px 16px 14px;font-size:12px;color:var(--muted);line-height:1.5}
|
||||
|
||||
.ssul-scope .share-hint b {color:var(--fg)}
|
||||
|
||||
.ssul-scope .cmts.cap .cap-body {flex:1;overflow-y:auto;padding:14px 16px;font-size:14px;line-height:1.6;color:var(--fg);white-space:pre-wrap;word-break:break-word}
|
||||
|
||||
.ssul-scope .cap-foot {padding:10px 12px;border-top:1px solid var(--border)}
|
||||
|
||||
.ssul-scope .cap-copy {display:flex;align-items:center;justify-content:center;gap:7px;width:100%;padding:12px;border-radius:12px;background:var(--samgukji);color:#fff;font-size:14px;font-weight:800}
|
||||
|
||||
.ssul-scope .cap-copy svg {width:17px;height:17px;stroke:#fff}
|
||||
|
||||
.ssul-scope .toast {position:fixed;left:50%;bottom:86px;z-index:60;transform:translate(-50%,14px);opacity:0;pointer-events:none;
|
||||
max-width:min(88vw,420px);padding:12px 18px;border-radius:14px;background:rgba(20,18,16,.94);color:#fff;
|
||||
font-size:14px;font-weight:600;line-height:1.45;text-align:center;box-shadow:0 10px 34px rgba(0,0,0,.4);
|
||||
transition:opacity .2s,transform .2s}
|
||||
|
||||
.ssul-scope .toast.on {opacity:1;transform:translate(-50%,0)}
|
||||
|
||||
@media (min-width:860px) {
|
||||
.ssul-scope .toast {bottom:32px}
|
||||
}
|
||||
|
||||
.ssul-scope .job-badge {position:fixed;right:14px;bottom:86px;z-index:55;padding:11px 16px;border-radius:999px;
|
||||
background:rgba(20,18,16,.94);color:#fff;font-size:14px;font-weight:700;box-shadow:0 10px 34px rgba(0,0,0,.4);
|
||||
display:flex;align-items:center;gap:6px;cursor:pointer}
|
||||
|
||||
.ssul-scope .job-badge.err {background:#b3261e}
|
||||
|
||||
@media (min-width:860px) {
|
||||
.ssul-scope .job-badge {bottom:32px}
|
||||
}
|
||||
|
||||
.ssul-scope .prods {display:flex;flex-direction:column;gap:10px;margin-top:6px}
|
||||
|
||||
.ssul-scope .prod {position:relative;display:grid;grid-template-columns:1fr auto;grid-template-areas:"cr price" "bonus price" "name price";
|
||||
align-items:center;gap:2px 12px;text-align:left;padding:14px 16px;border-radius:14px;border:1.5px solid var(--border);background:var(--surface)}
|
||||
|
||||
.ssul-scope .prod:hover {border-color:var(--samgukji)}
|
||||
|
||||
.ssul-scope .prod.best {border-color:var(--samgukji);background:color-mix(in srgb,var(--samgukji) 7%,var(--surface))}
|
||||
|
||||
.ssul-scope .prod:disabled {opacity:.55}
|
||||
|
||||
.ssul-scope .prod-tag {position:absolute;top:-9px;left:14px;background:var(--samgukji);color:#fff;font-size:10px;font-weight:800;padding:2px 8px;border-radius:10px}
|
||||
|
||||
.ssul-scope .prod-cr {grid-area:cr;font-size:16px;color:var(--muted);font-weight:600}
|
||||
|
||||
.ssul-scope .prod-cr b {font-size:22px;color:var(--fg);font-weight:800;margin-right:3px}
|
||||
|
||||
.ssul-scope .prod-bonus {grid-area:bonus;font-size:12px;color:var(--samgukji);font-weight:700}
|
||||
|
||||
.ssul-scope .prod-name {grid-area:name;font-size:12px;color:var(--faint)}
|
||||
|
||||
.ssul-scope .prod-price {grid-area:price;font-size:16px;font-weight:800;color:var(--fg);white-space:nowrap}
|
||||
|
||||
.ssul-scope .tb-login {background:var(--samgukji);color:#fff;font-size:14px;font-weight:800;padding:8px 15px;border-radius:20px}
|
||||
|
||||
.ssul-scope :focus-visible {outline:2px solid var(--samgukji);outline-offset:2px}
|
||||
@ -80,7 +80,7 @@
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 0.8125rem;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-white);
|
||||
}
|
||||
@ -92,14 +92,14 @@
|
||||
}
|
||||
|
||||
.progress-current {
|
||||
font-size: 0.8125rem;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-white);
|
||||
}
|
||||
|
||||
.progress-divider,
|
||||
.progress-total {
|
||||
font-size: 0.8125rem;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #379599;
|
||||
}
|
||||
@ -122,7 +122,7 @@
|
||||
|
||||
/* Sound Studio Title */
|
||||
.sound-studio-title {
|
||||
font-size: 2rem;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
text-align: center;
|
||||
@ -192,7 +192,7 @@
|
||||
|
||||
/* Column Title */
|
||||
.column-title {
|
||||
font-size: 1.125rem;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--color-mint);
|
||||
line-height: 1.19;
|
||||
@ -337,7 +337,7 @@
|
||||
}
|
||||
|
||||
.language-flag {
|
||||
font-size: 1.25rem;
|
||||
font-size: 20px;
|
||||
line-height: 1.19;
|
||||
letter-spacing: -0.006em;
|
||||
}
|
||||
@ -415,7 +415,7 @@
|
||||
}
|
||||
|
||||
.language-dropdown-item .language-flag {
|
||||
font-size: 1.25rem;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.language-dropdown-item .language-name {
|
||||
@ -642,7 +642,7 @@
|
||||
.charge-credits-link {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
color: #AE72F9;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
@ -795,7 +795,7 @@
|
||||
}
|
||||
|
||||
.column-title {
|
||||
font-size: 1.25rem;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.lyrics-display {
|
||||
@ -857,7 +857,7 @@
|
||||
.asset-upload-error-title {
|
||||
margin: 0;
|
||||
color: #94FBE0;
|
||||
font-size: 1.25rem;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
@ -865,7 +865,7 @@
|
||||
.asset-upload-error-message {
|
||||
margin: 0.75rem 0 1.5rem;
|
||||
color: #E5F1F2;
|
||||
font-size: 0.9375rem;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
@ -879,7 +879,7 @@
|
||||
background: #94FBE0;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
font-size: 0.9375rem;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s, transform 0.2s;
|
||||
@ -931,7 +931,7 @@
|
||||
gap: 4px;
|
||||
background-color: #462E64;
|
||||
color: #CFABFB;
|
||||
font-size: 0.875rem;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
padding: 0 1.25rem 0 0.5rem;
|
||||
height: 36px;
|
||||
@ -999,19 +999,18 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
body:has(.sidebar.collapsed) .asset-sticky-footer {
|
||||
left: 5rem;
|
||||
right: 0;
|
||||
width: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
/* 모바일: 하단 탭바에 가리지 않도록 띄운다 */
|
||||
@media (max-width: 767px) {
|
||||
.asset-sticky-footer {
|
||||
bottom: calc(var(--bottom-nav-height) + 16px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Asset Title */
|
||||
.asset-title {
|
||||
font-size: 2rem;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #E5F1F2;
|
||||
padding: 0 0 1.5rem;
|
||||
@ -1091,7 +1090,7 @@
|
||||
}
|
||||
|
||||
.asset-section-subtitle {
|
||||
font-size: 0.875rem;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #6AB0B3;
|
||||
line-height: 1.19;
|
||||
@ -1099,7 +1098,7 @@
|
||||
}
|
||||
|
||||
.asset-section-count {
|
||||
font-size: 0.875rem;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #9BCACC;
|
||||
line-height: 1.19;
|
||||
@ -1114,7 +1113,7 @@
|
||||
|
||||
/* Asset Section Title */
|
||||
.asset-section-title {
|
||||
font-size: 1.5rem;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #94FBE0;
|
||||
line-height: 1.19;
|
||||
@ -1129,7 +1128,7 @@
|
||||
gap: 4px;
|
||||
background-color: #94FBE0;
|
||||
color: #000;
|
||||
font-size: 0.75rem;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 0 10px;
|
||||
height: 24px;
|
||||
@ -1208,7 +1207,7 @@
|
||||
padding: 0.625rem 1rem;
|
||||
background-color: #002224;
|
||||
color: #9BCACC;
|
||||
font-size: 0.875rem;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
border: 1px solid #379599;
|
||||
border-radius: 8px;
|
||||
@ -1260,7 +1259,7 @@
|
||||
|
||||
.asset-image-placeholder-name {
|
||||
width: 100%;
|
||||
font-size: 0.6875rem;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
color: #9BCACC;
|
||||
overflow: hidden;
|
||||
@ -1274,7 +1273,7 @@
|
||||
left: 8px;
|
||||
background-color: #EFE3FE;
|
||||
color: #AE72F9;
|
||||
font-size: 0.75rem;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
@ -1339,7 +1338,7 @@
|
||||
}
|
||||
|
||||
.asset-upload-text {
|
||||
font-size: 0.875rem;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #9BCACC;
|
||||
text-align: center;
|
||||
@ -1383,7 +1382,7 @@
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background-color 0.2s;
|
||||
color: #FFFFFF;
|
||||
font-size: 0.875rem;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.19;
|
||||
letter-spacing: -0.006em;
|
||||
@ -1405,7 +1404,7 @@
|
||||
}
|
||||
|
||||
.asset-ratio-subtitle {
|
||||
font-size: 0.875rem;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #9BCACC;
|
||||
}
|
||||
@ -1440,7 +1439,7 @@
|
||||
.asset-next-button {
|
||||
background-color: #AE72F9;
|
||||
color: #FFFFFF;
|
||||
font-size: 0.875rem;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
padding: 10px 40px;
|
||||
border: none;
|
||||
@ -1463,7 +1462,7 @@
|
||||
/* Responsive Styles */
|
||||
@media (max-width: 639px) {
|
||||
.asset-title {
|
||||
font-size: 1.5rem;
|
||||
font-size: 24px;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
@ -1478,7 +1477,7 @@
|
||||
|
||||
@media (min-width: 640px) and (max-width: 767px) {
|
||||
.asset-title {
|
||||
font-size: 1.75rem;
|
||||
font-size: 28px;
|
||||
padding-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
@ -1490,7 +1489,7 @@
|
||||
/* Height-based responsive adjustments */
|
||||
@media (max-height: 700px) {
|
||||
.asset-title {
|
||||
font-size: 1.75rem;
|
||||
font-size: 28px;
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
|
||||
@ -6,6 +6,10 @@
|
||||
CSS Variables - Design Tokens
|
||||
===================================================== */
|
||||
:root {
|
||||
/* 모바일 하단 탭바 높이 (safe-area 포함).
|
||||
탭바 자신과, 탭바에 가리면 안 되는 fixed 요소(캘린더 바텀시트 등)가 공유한다 */
|
||||
--bottom-nav-height: calc(72px + env(safe-area-inset-bottom));
|
||||
|
||||
/* Primary Colors */
|
||||
--color-mint: #a6ffea;
|
||||
--color-mint-hover: #8affda;
|
||||
@ -57,14 +61,14 @@
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Font Sizes */
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-base: 1rem;
|
||||
--text-lg: 1.125rem;
|
||||
--text-xl: 1.25rem;
|
||||
--text-2xl: 1.5rem;
|
||||
--text-3xl: 1.875rem;
|
||||
--text-4xl: 2.25rem;
|
||||
--text-xs: 12px;
|
||||
--text-sm: 14px;
|
||||
--text-base: 16px;
|
||||
--text-lg: 18px;
|
||||
--text-xl: 20px;
|
||||
--text-2xl: 24px;
|
||||
--text-3xl: 30px;
|
||||
--text-4xl: 36px;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms;
|
||||
|
||||
@ -270,7 +270,18 @@ export interface UserCreditsResponse {
|
||||
}
|
||||
|
||||
// 비디오 목록 아이템 (갤러리용)
|
||||
/** 콘텐츠 종류. ADO2 영상과 썰박스가 한 목록에 섞인다 */
|
||||
export type ContentType = 'video' | 'ssul';
|
||||
|
||||
export interface VideoListItem {
|
||||
/**
|
||||
* ⚠️ `video_id` 는 **type 안에서만 유일하다.**
|
||||
* `video.id` 와 `ssul_content.id` 는 각각 1부터 시작하는 독립 시퀀스라 값이 겹친다.
|
||||
* 삭제·상세 열기·React key 는 반드시 `(type, video_id)` 쌍으로 다룰 것.
|
||||
* 특히 `DELETE /archive/videos/{id}` 는 `Video.id` 로 지우므로,
|
||||
* 썰박스 항목의 id 를 넘기면 **엉뚱한 ADO2 영상이 삭제된다.**
|
||||
*/
|
||||
type: ContentType;
|
||||
video_id: number;
|
||||
store_name: string;
|
||||
region: string;
|
||||
@ -281,7 +292,6 @@ export interface VideoListItem {
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
hashtags?: string[] | null;
|
||||
official_site_url?: string | null;
|
||||
created_at: string;
|
||||
like_count: number;
|
||||
comment_count: number;
|
||||
@ -295,9 +305,6 @@ export interface VideoDetailItem {
|
||||
poster_url?: string | null;
|
||||
store_name: string;
|
||||
region: string;
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
official_site_url?: string | null;
|
||||
created_at: string;
|
||||
like_count: number;
|
||||
is_liked_by_me: boolean;
|
||||
@ -401,7 +408,9 @@ export interface SocialDisconnectResponse {
|
||||
|
||||
// 유튜브 SEO Description 자동완성 요청
|
||||
export interface YTAutoSeoRequest {
|
||||
video_id: number;
|
||||
/** 썰박스면 'ssul' + task_id 자리에 ssul_content.id 문자열. 생략 시 video */
|
||||
content_type?: ContentType;
|
||||
task_id: string; // ADO2 는 task_id, 썰박스는 콘텐츠 id 문자열
|
||||
}
|
||||
|
||||
// 유튜브 SEO Description 자동완성 응답
|
||||
@ -413,7 +422,12 @@ export interface YTAutoSeoResponse {
|
||||
|
||||
// 소셜 업로드 요청
|
||||
export interface SocialUploadRequest {
|
||||
video_id: number; // 아카이브의 비디오 ID
|
||||
/**
|
||||
* 콘텐츠 종류. video.id 와 ssul_content.id 가 겹치므로 생략(=video)한 채
|
||||
* 썰박스 id 를 보내면 **엉뚱한 ADO2 영상이 업로드된다.**
|
||||
*/
|
||||
content_type?: ContentType;
|
||||
video_id: number; // 콘텐츠 ID (content_type 안에서만 유일)
|
||||
social_account_id: number; // 선택된 채널 ID
|
||||
title: string; // 최대 100자
|
||||
description: string; // 최대 5000자
|
||||
|
||||
251
src/utils/api.ts
251
src/utils/api.ts
@ -1,3 +1,4 @@
|
||||
import { clearSessionStorage } from './storageKeys';
|
||||
import {
|
||||
CrawlingResponse,
|
||||
LyricGenerateRequest,
|
||||
@ -33,6 +34,7 @@ import {
|
||||
VideoDetailItem,
|
||||
CommentsResponse,
|
||||
CommentItem,
|
||||
ContentType,
|
||||
LikeToggleResponse,
|
||||
} from '../types/api';
|
||||
import { uploadImagesSequentially } from './imageUpload.ts';
|
||||
@ -574,7 +576,7 @@ export async function getAllVideos(
|
||||
if (region.trim()) params.set('region', region.trim());
|
||||
const response = await authenticatedFetch(`${API_URL}/video/all?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
}, { redirectOnAuthFail: false });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
@ -587,7 +589,7 @@ export async function getAllVideos(
|
||||
export async function getVideoById(videoId: string): Promise<VideoDetailItem> {
|
||||
const response = await authenticatedFetch(`${API_URL}/video/${videoId}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
}, { redirectOnAuthFail: false });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
@ -596,22 +598,74 @@ export async function getVideoById(videoId: string): Promise<VideoDetailItem> {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 댓글 목록 조회
|
||||
export async function getVideoComments(videoId: string, page: number = 1, pageSize: number = 20): Promise<CommentsResponse> {
|
||||
const response = await authenticatedFetch(`${API_URL}/comment/video/${videoId}?page=${page}&page_size=${pageSize}`, {
|
||||
// 썰박스 콘텐츠 공개 상세 (비로그인 허용 — 공유 링크 /ssul/{id} 가 사용)
|
||||
export interface SsulDetailItem {
|
||||
content_id: number;
|
||||
scenario: string;
|
||||
video_url: string;
|
||||
store_name: string | null;
|
||||
region: string | null;
|
||||
created_at: string;
|
||||
like_count: number;
|
||||
is_liked_by_me: boolean;
|
||||
}
|
||||
|
||||
export async function getSsulContentById(contentId: string): Promise<SsulDetailItem> {
|
||||
const response = await authenticatedFetch(`${API_URL}/ssul/${contentId}`, {
|
||||
method: 'GET',
|
||||
}, { redirectOnAuthFail: false });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 썰박스 콘텐츠 소프트 삭제.
|
||||
// ⚠️ deleteVideo(= /archive/videos/{id}, Video.id 기준)와 절대 혼용하지 말 것 —
|
||||
// id 가 겹치므로 엉뚱한 ADO2 영상이 지워진다. 썰박스는 반드시 이 함수로.
|
||||
export async function deleteSsulContent(contentId: number): Promise<void> {
|
||||
const response = await authenticatedFetch(`${API_URL}/ssul/${contentId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 댓글 목록 조회
|
||||
// contentType: video.id 와 ssul_content.id 가 겹치므로 종류를 함께 보낸다 (기본 'video')
|
||||
export async function getVideoComments(
|
||||
videoId: string,
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
contentType: ContentType = 'video'
|
||||
): Promise<CommentsResponse> {
|
||||
const response = await authenticatedFetch(
|
||||
`${API_URL}/comment/video/${videoId}?page=${page}&page_size=${pageSize}&type=${contentType}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
{ redirectOnAuthFail: false }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 댓글 작성 (작성자 닉네임/프로필은 서버가 로그인된 카카오 정보로 채움)
|
||||
export async function postVideoComment(videoId: string, content: string, parentId?: number): Promise<CommentItem> {
|
||||
const response = await authenticatedFetch(`${API_URL}/comment/video/${videoId}`, {
|
||||
export async function postVideoComment(
|
||||
videoId: string,
|
||||
content: string,
|
||||
parentId?: number,
|
||||
contentType: ContentType = 'video'
|
||||
): Promise<CommentItem> {
|
||||
const response = await authenticatedFetch(`${API_URL}/comment/video/${videoId}?type=${contentType}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content, parent_id: parentId ?? null }),
|
||||
@ -636,10 +690,18 @@ export async function deleteComment(commentId: number): Promise<void> {
|
||||
}
|
||||
|
||||
// 좋아요 토글
|
||||
export async function toggleVideoLike(videoId: string): Promise<LikeToggleResponse> {
|
||||
const response = await authenticatedFetch(`${API_URL}/video/${videoId}/like`, {
|
||||
method: 'POST',
|
||||
});
|
||||
export async function toggleVideoLike(
|
||||
videoId: string,
|
||||
// video.id 와 ssul_content.id 가 겹치므로 종류를 함께 보내야 한다.
|
||||
// 기본값 'video' 라 기존 호출부는 수정이 없다.
|
||||
contentType: ContentType = 'video'
|
||||
): Promise<LikeToggleResponse> {
|
||||
const response = await authenticatedFetch(
|
||||
`${API_URL}/video/${videoId}/like?type=${contentType}`,
|
||||
{
|
||||
method: 'POST',
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
@ -771,11 +833,8 @@ let refreshPromise: Promise<TokenRefreshResponse> | null = null;
|
||||
function redirectToLogin() {
|
||||
// 토큰 삭제
|
||||
clearTokens();
|
||||
// localStorage 정리
|
||||
localStorage.removeItem('castad_view_mode');
|
||||
localStorage.removeItem('castad_analysis_data');
|
||||
localStorage.removeItem('castad_wizard_step');
|
||||
localStorage.removeItem('castad_active_item');
|
||||
// localStorage 정리 (생성 중이던 상태까지 전부 — 재로그인 시 잔류 방지)
|
||||
clearSessionStorage();
|
||||
// 홈으로 리다이렉트
|
||||
window.location.href = '/';
|
||||
}
|
||||
@ -783,7 +842,8 @@ function redirectToLogin() {
|
||||
// 401 에러 시 자동으로 토큰 갱신 후 재요청하는 래퍼 함수
|
||||
export async function authenticatedFetch(
|
||||
url: string,
|
||||
options: RequestInit = {}
|
||||
options: RequestInit = {},
|
||||
{ redirectOnAuthFail = true }: { redirectOnAuthFail?: boolean } = {}
|
||||
): Promise<Response> {
|
||||
// 인증 헤더 + 캐시 방지 헤더 추가
|
||||
const headers: HeadersInit = {
|
||||
@ -800,9 +860,14 @@ export async function authenticatedFetch(
|
||||
const errorBody = await response.json().catch(() => null);
|
||||
const errorCode = errorBody?.detail?.code;
|
||||
|
||||
// 애초에 토큰이 없던 익명 요청(공개 화면)까지 튕기면 안 되므로 별도 처리
|
||||
const isMissingToken = errorCode === 'MISSING_TOKEN';
|
||||
|
||||
if (errorCode !== 'TOKEN_EXPIRED') {
|
||||
// INVALID_TOKEN 등 갱신으로 해결 불가한 경우 즉시 로그인 이동
|
||||
redirectToLogin();
|
||||
if (redirectOnAuthFail && !isMissingToken) {
|
||||
redirectToLogin();
|
||||
}
|
||||
throw new Error(errorCode ?? 'Unauthorized');
|
||||
}
|
||||
|
||||
@ -825,7 +890,9 @@ export async function authenticatedFetch(
|
||||
response = await fetch(url, { ...options, headers: newHeaders });
|
||||
} catch (refreshError) {
|
||||
console.error('Token refresh failed:', refreshError);
|
||||
redirectToLogin();
|
||||
if (redirectOnAuthFail) {
|
||||
redirectToLogin();
|
||||
}
|
||||
throw refreshError;
|
||||
}
|
||||
}
|
||||
@ -923,15 +990,7 @@ export async function refreshAccessToken(): Promise<TokenRefreshResponse> {
|
||||
// 로컬 스토리지 전체 정리
|
||||
function clearAllLocalData() {
|
||||
clearTokens();
|
||||
localStorage.removeItem('castad_view_mode');
|
||||
localStorage.removeItem('castad_analysis_data');
|
||||
localStorage.removeItem('castad_wizard_step');
|
||||
localStorage.removeItem('castad_active_item');
|
||||
localStorage.removeItem('castad_song_task_id');
|
||||
localStorage.removeItem('castad_image_task_id');
|
||||
localStorage.removeItem('castad_song_generation');
|
||||
localStorage.removeItem('castad_video_generation');
|
||||
localStorage.removeItem('castad_video_ratio');
|
||||
clearSessionStorage();
|
||||
}
|
||||
|
||||
// 로그아웃
|
||||
@ -1093,7 +1152,7 @@ export async function autocomplete(request: AutocompleteRequest): Promise<Crawli
|
||||
}
|
||||
|
||||
// 업체명·주소 직접 입력으로 마케팅 분석
|
||||
export async function marketingAnalysis(storeName: string, address: string, category = '', officialSiteUrl?: string): Promise<CrawlingResponse> {
|
||||
export async function marketingAnalysis(storeName: string, address: string, category = ''): Promise<CrawlingResponse> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), CRAWL_TIMEOUT);
|
||||
|
||||
@ -1103,12 +1162,7 @@ export async function marketingAnalysis(storeName: string, address: string, cate
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
store_name: storeName,
|
||||
address,
|
||||
category,
|
||||
official_site_url: officialSiteUrl?.trim() || null,
|
||||
}),
|
||||
body: JSON.stringify({ store_name: storeName, address, category }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
@ -1336,3 +1390,130 @@ export async function retryUpload(uploadId: number): Promise<{ success: boolean;
|
||||
if (!response.ok) throw new Error('재시도 실패');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 썰박스 (Ssulbox)
|
||||
// ============================================================================
|
||||
|
||||
export interface SsulCreateRequest {
|
||||
scenario: string;
|
||||
/**
|
||||
* 업장명 또는 네이버 지도 링크.
|
||||
*
|
||||
* 자동완성으로 고른 경우 **업장명**을 보낸다 — place URL 해석은 서버가
|
||||
* ADO2 와 동일한 경로(NvMapPwScraper)로 처리한다. 링크를 직접 붙여넣었으면
|
||||
* 그대로 보낸다.
|
||||
*/
|
||||
input: string;
|
||||
scenes?: number;
|
||||
seconds?: number;
|
||||
/** 자동완성으로 고른 경우에만. 목록 표시·필터·place URL 해석에 쓰인다 */
|
||||
store_name?: string;
|
||||
/** 지역 추출용(도로명). 백엔드는 이 값을 저장하지 않는다 */
|
||||
road_address?: string;
|
||||
/** 지역 추출용(지번). 도로명에서 시/군 추출이 실패할 때의 폴백 */
|
||||
address?: string;
|
||||
}
|
||||
|
||||
export interface SsulCreateResponse {
|
||||
id: number;
|
||||
status: string;
|
||||
poll_interval_seconds?: number;
|
||||
}
|
||||
|
||||
export async function createSsulJob(
|
||||
request: SsulCreateRequest
|
||||
): Promise<SsulCreateResponse> {
|
||||
const response = await authenticatedFetch(`${API_URL}/ssul/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
if (response.status === 402) {
|
||||
throw new InsufficientCreditError();
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/** 크레딧 부족(402). 호출부가 충전 화면으로 유도할 수 있게 별도 타입으로 던진다 */
|
||||
export class InsufficientCreditError extends Error {
|
||||
constructor(message = '크레딧이 부족합니다.') {
|
||||
super(message);
|
||||
this.name = 'InsufficientCreditError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface SsulTaskStatus {
|
||||
id: number;
|
||||
scenario: string;
|
||||
status: 'queued' | 'running' | 'done' | 'error';
|
||||
/** 0~4. 0=준비, 4=영상 합성 완료 */
|
||||
step: number;
|
||||
error: string | null;
|
||||
video_url: string | null;
|
||||
/**
|
||||
* 대상 업장명. `status === 'done'` 이면 서버가 채워 보낸다.
|
||||
* 생성 중에는 비어 있을 수 있다 — 행은 요청 즉시 만들어지고 업장명은 그 뒤에 확정된다.
|
||||
*/
|
||||
store_name: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** 폴링 간격. castad 의 waitForVideoComplete 와 동일하게 맞춘다 */
|
||||
const SSUL_POLL_INTERVAL = 3000;
|
||||
/** 생성이 수 분~십수 분 걸리므로 넉넉히 */
|
||||
const SSUL_POLL_TIMEOUT = 30 * 60 * 1000;
|
||||
|
||||
/** 생성 잡 단건 조회 */
|
||||
export async function getSsulTask(taskId: number): Promise<SsulTaskStatus> {
|
||||
const response = await authenticatedFetch(`${API_URL}/ssul/tasks/${taskId}`);
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 진행 중인 내 잡 조회 (새로고침·새 탭 복구용).
|
||||
* 진행 중인 것이 없으면 null.
|
||||
*/
|
||||
export async function getActiveSsulTask(): Promise<SsulTaskStatus | null> {
|
||||
try {
|
||||
const response = await authenticatedFetch(`${API_URL}/ssul/tasks/active`);
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
return data.items?.[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성 완료까지 폴링한다. castad 의 waitForVideoComplete 와 같은 재귀 패턴.
|
||||
*
|
||||
* SSE 대신 폴링을 쓰는 이유는 계획서 참조 — 요약하면 EventSource 가
|
||||
* `authenticatedFetch` 의 401 자동 갱신을 못 타고, 스트림이 DB 커넥션을
|
||||
* 점유하며, 리버스 프록시 버퍼링에 취약하기 때문이다.
|
||||
*
|
||||
* @param shouldStop 매 틱마다 확인. true 면 조용히 중단한다(언마운트·로그아웃).
|
||||
*/
|
||||
export async function waitForSsulComplete(
|
||||
taskId: number,
|
||||
onProgress?: (task: SsulTaskStatus) => void,
|
||||
shouldStop?: () => boolean,
|
||||
startedAt: number = Date.now()
|
||||
): Promise<SsulTaskStatus> {
|
||||
if (shouldStop?.()) throw new Error('CANCELLED');
|
||||
if (Date.now() - startedAt > SSUL_POLL_TIMEOUT) throw new Error('TIMEOUT');
|
||||
|
||||
const task = await getSsulTask(taskId);
|
||||
onProgress?.(task);
|
||||
|
||||
if (task.status === 'done') return task;
|
||||
if (task.status === 'error') throw new Error(task.error ?? 'SSUL_FAILED');
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, SSUL_POLL_INTERVAL));
|
||||
return waitForSsulComplete(taskId, onProgress, shouldStop, startedAt);
|
||||
}
|
||||
|
||||
@ -1,9 +1,6 @@
|
||||
/**
|
||||
* 기기의 네이티브 공유 시트를 열고 요청을 처리했는지 반환합니다.
|
||||
*
|
||||
* 긴 `text`를 넣으면 카카오 등이 URL을 미리보기가 아니라 본문 텍스트로만 보냅니다.
|
||||
* 제목·설명은 OG 페이지(`og:title`, `og:description`)에 두고, 여기에는 url(과 짧은 title)만 넘깁니다.
|
||||
*
|
||||
* 사용자가 공유 시트를 닫은 경우도 정상적으로 처리된 것으로 간주합니다.
|
||||
*/
|
||||
export async function tryNativeShare(data: ShareData): Promise<boolean> {
|
||||
@ -27,3 +24,16 @@ export async function tryNativeShare(data: ShareData): Promise<boolean> {
|
||||
export function buildVideoShareUrl(apiBaseUrl: string, videoId: number | string): string {
|
||||
return `${apiBaseUrl.replace(/\/$/, '')}/video/share/${encodeURIComponent(String(videoId))}`;
|
||||
}
|
||||
|
||||
/** 콘텐츠 종류별 공개 공유 URL. video.id 와 ssul_content.id 가 겹치므로 경로를 갈라야 한다. */
|
||||
export function buildContentShareUrl(
|
||||
apiBaseUrl: string,
|
||||
contentId: number | string,
|
||||
contentType: 'video' | 'ssul' = 'video',
|
||||
): string {
|
||||
if (contentType === 'ssul') {
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
return `${origin}/ssul/${encodeURIComponent(String(contentId))}`;
|
||||
}
|
||||
return buildVideoShareUrl(apiBaseUrl, contentId);
|
||||
}
|
||||
|
||||
263
src/utils/p2vApi.ts
Normal file
263
src/utils/p2vApi.ts
Normal file
@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Poster to Video (P2V) 클라이언트.
|
||||
*
|
||||
* castad 백엔드가 아니라 **별도 서버(:8010)** 를 직접 호출한다. 다른 파이프라인들
|
||||
* (ADO2·썰박스)이 `utils/api.ts` 로 castad 백엔드에 붙는 것과 대비된다.
|
||||
*
|
||||
* 왜 프록시를 두지 않았나 — P2V 서버는 검증된 `scripts/*.py` 를 subprocess 로 감싼
|
||||
* 단일 워커라 castad 백엔드에 흡수하려면 실행 환경(Higgsfield CLI·ffmpeg·모델 키)을
|
||||
* 통째로 이식해야 한다. **내부 시연 범위**에서는 직접 호출이 맞고, 대외 공개 단계에서
|
||||
* castad 백엔드에 `/p2v/*` 프록시를 세워 카카오 인증·크레딧 차감을 얹는 게 다음 수순이다.
|
||||
* 그때 갈아끼울 지점은 이 파일 하나다.
|
||||
*
|
||||
* 인증은 단일 팀 키(`P2V_ACCESS_KEY`). 서버는 3경로로 받는다:
|
||||
* - `X-P2V-Key` 헤더 → fetch 호출
|
||||
* - `p2v_key` 쿠키 → **여기서는 못 쓴다.** castad 와 P2V 는 다른 오리진이라
|
||||
* document.cookie 로 심어도 P2V 요청에 실리지 않는다.
|
||||
* - `?key=` 쿼리 → 헤더를 못 붙이는 <img>/<video>/<a download> 용 (p2vFile)
|
||||
*/
|
||||
|
||||
/** P2V 서버 오리진. 배포 시 `.env` 의 VITE_P2V_URL 로 덮는다 */
|
||||
export const P2V_URL = (import.meta.env.VITE_P2V_URL || 'http://localhost:8010').replace(/\/$/, '');
|
||||
|
||||
/**
|
||||
* 접근 키 저장소.
|
||||
*
|
||||
* storageKeys.K 에 넣지 않은 것은 의도적이다 — 그쪽 키들은 로그아웃·새 프로젝트에
|
||||
* 일괄 삭제되는데, 이건 서버 자격증명이라 지워지면 시연 중에 매번 다시 물어야 한다.
|
||||
*/
|
||||
const KEY_STORAGE = 'castad_p2v_key';
|
||||
|
||||
export const getP2vKey = (): string => localStorage.getItem(KEY_STORAGE) ?? '';
|
||||
export const setP2vKey = (key: string): void => localStorage.setItem(KEY_STORAGE, key.trim());
|
||||
export const clearP2vKey = (): void => localStorage.removeItem(KEY_STORAGE);
|
||||
|
||||
/** 401 — 화면이 키 입력을 띄워야 하는 상황. 일반 실패와 구분해야 해서 타입을 나눈다 */
|
||||
export class P2vAuthError extends Error {
|
||||
constructor() {
|
||||
super('P2V_AUTH_REQUIRED');
|
||||
this.name = 'P2vAuthError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 404 — 서버에 그 잡이 없다. 화면은 처음으로 되돌려야 한다.
|
||||
*
|
||||
* 상태 코드로 판정하는 이유: 서버 detail 문구("잡 없음")를 문자열로 맞추려다
|
||||
* 실제로 한 번 놓쳤다. 문구는 서버 사정으로 바뀌고 번역도 되지만 코드는 안 바뀐다.
|
||||
*/
|
||||
export class P2vNotFoundError extends Error {
|
||||
constructor(detail: string) {
|
||||
super(detail);
|
||||
this.name = 'P2vNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function p2vFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const key = getP2vKey();
|
||||
const res = await fetch(`${P2V_URL}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init?.headers ?? {}),
|
||||
...(key ? { 'X-P2V-Key': key } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status === 401) throw new P2vAuthError();
|
||||
|
||||
if (!res.ok) {
|
||||
// FastAPI 는 {detail: ...} 로 준다. 본문이 없는 에러(502 등)도 있으므로 감싼다
|
||||
let detail = res.statusText;
|
||||
try {
|
||||
const body = await res.json();
|
||||
detail = typeof body.detail === 'string' ? body.detail : JSON.stringify(body);
|
||||
} catch {
|
||||
/* 본문 없음 — statusText 로 간다 */
|
||||
}
|
||||
if (res.status === 404) throw new P2vNotFoundError(detail);
|
||||
throw new Error(detail);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 정적 산출물(영상·이미지) URL.
|
||||
*
|
||||
* 서버가 주는 artifacts 값은 `/files/render/x.mp4` 같은 **루트 상대 경로**라
|
||||
* 다른 오리진인 castad 에서는 그대로 쓸 수 없다. 오리진을 붙이고, 헤더를 실을 수 없는
|
||||
* 태그(<video src> 등)를 위해 키를 쿼리로 태운다.
|
||||
*/
|
||||
export const p2vFile = (path: string | null | undefined): string => {
|
||||
if (!path) return '';
|
||||
const key = getP2vKey();
|
||||
return `${P2V_URL}${path}${key ? `?key=${encodeURIComponent(key)}` : ''}`;
|
||||
};
|
||||
|
||||
// ── 타입 (서버 jobs.py / routers 와 1:1) ──────────────────────────────
|
||||
|
||||
export type P2vStageStatus = 'idle' | 'running' | 'done' | 'failed';
|
||||
|
||||
export interface P2vStageState {
|
||||
status: P2vStageStatus;
|
||||
started: number | null;
|
||||
ended: number | null;
|
||||
}
|
||||
|
||||
export interface PosterMeta {
|
||||
event_name: string;
|
||||
date_text: string;
|
||||
place: string;
|
||||
category: string;
|
||||
keywords: string[];
|
||||
region_guess?: string;
|
||||
}
|
||||
|
||||
export interface P2vJob {
|
||||
id: string;
|
||||
kind: 'f1' | 'f2';
|
||||
name: string;
|
||||
status: 'queued' | 'running' | 'awaiting_review' | 'failed' | 'done';
|
||||
stage: string | null;
|
||||
stages: Record<string, P2vStageState>;
|
||||
narration: string[] | null;
|
||||
motion_elements?: string[] | null;
|
||||
metadata: PosterMeta | null;
|
||||
error: { stage: string; detail: string } | null;
|
||||
artifacts: Record<string, string>;
|
||||
created_at: number;
|
||||
queue_size?: number;
|
||||
template_id?: string | null;
|
||||
}
|
||||
|
||||
/** 배포 등급. public-domain 만 외부 공개 가능 — 나머지는 내부 시연 한정 */
|
||||
export type P2vLicense = 'public-domain' | 'internal-only' | 'user-uploaded';
|
||||
|
||||
export interface F2Template {
|
||||
id: string;
|
||||
name_ko: string;
|
||||
thumb_url: string;
|
||||
category: string;
|
||||
license: P2vLicense;
|
||||
attribution: string;
|
||||
license_note: string;
|
||||
/** 사용자가 올린 레퍼런스인가 (삭제 가능) */
|
||||
removable: boolean;
|
||||
small_ref: boolean;
|
||||
}
|
||||
|
||||
export interface F2Category { id: string; label: string }
|
||||
export interface F2Format { id: string; label: string }
|
||||
export interface F2UploadHint { enabled: boolean; min_long_edge: number }
|
||||
|
||||
// ── F1 (포스터 → 영상) ────────────────────────────────────────────────
|
||||
|
||||
/** 포스터 업로드 → 잡 생성. 반환된 id 로 폴링한다 */
|
||||
export async function createPosterJob(poster: File, name: string): Promise<{ id: string }> {
|
||||
const fd = new FormData();
|
||||
fd.append('poster', poster);
|
||||
fd.append('name', name);
|
||||
return p2vFetch<{ id: string }>('/api/f1/jobs', { method: 'POST', body: fd });
|
||||
}
|
||||
|
||||
export const getPosterJob = (id: string): Promise<P2vJob> =>
|
||||
p2vFetch<P2vJob>(`/api/f1/jobs/${id}`);
|
||||
|
||||
export const updatePosterNarration = (id: string, narration: string[]): Promise<unknown> =>
|
||||
p2vFetch(`/api/f1/jobs/${id}/narration`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ narration }),
|
||||
});
|
||||
|
||||
export const updatePosterMetadata = (
|
||||
id: string,
|
||||
meta: { event_name: string; date_text: string; place: string },
|
||||
): Promise<unknown> =>
|
||||
p2vFetch(`/api/f1/jobs/${id}/metadata`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(meta),
|
||||
});
|
||||
|
||||
/** 검수 승인 → TTS·BGM·애니메이션·렌더가 이어진다. motions 를 넘기면 모션 선정을 덮어쓴다 */
|
||||
export const approvePosterJob = (id: string, motions?: string[] | null): Promise<unknown> =>
|
||||
p2vFetch(`/api/f1/jobs/${id}/approve`, {
|
||||
method: 'POST',
|
||||
...(motions
|
||||
? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ motions }) }
|
||||
: {}),
|
||||
});
|
||||
|
||||
export const retryPosterJob = (id: string, motions?: string[] | null): Promise<unknown> =>
|
||||
p2vFetch(`/api/f1/jobs/${id}/retry`, {
|
||||
method: 'POST',
|
||||
...(motions
|
||||
? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ motions }) }
|
||||
: {}),
|
||||
});
|
||||
|
||||
export const deletePosterJob = (id: string): Promise<unknown> =>
|
||||
p2vFetch(`/api/f1/jobs/${id}`, { method: 'DELETE' });
|
||||
|
||||
// ── F2 (포스터 스타일링) ──────────────────────────────────────────────
|
||||
|
||||
export const listF2Templates = (): Promise<F2Template[]> => p2vFetch<F2Template[]>('/api/f2/templates');
|
||||
export const listF2Categories = (): Promise<F2Category[]> => p2vFetch<F2Category[]>('/api/f2/categories');
|
||||
export const listF2Formats = (): Promise<F2Format[]> => p2vFetch<F2Format[]>('/api/f2/formats');
|
||||
export const getF2UploadHint = (): Promise<F2UploadHint> => p2vFetch<F2UploadHint>('/api/f2/upload-hint');
|
||||
|
||||
/** 레퍼런스 업로드 — 저장 + 화풍 분석까지 서버가 동기로 끝낸다(10초 안팎) */
|
||||
export async function createF2Template(reference: File, name: string): Promise<F2Template> {
|
||||
const fd = new FormData();
|
||||
fd.append('reference', reference);
|
||||
fd.append('name', name);
|
||||
return p2vFetch<F2Template>('/api/f2/templates', { method: 'POST', body: fd });
|
||||
}
|
||||
|
||||
export const deleteF2Template = (id: string): Promise<unknown> =>
|
||||
p2vFetch(`/api/f2/templates/${id}`, { method: 'DELETE' });
|
||||
|
||||
export async function createStylingJob(
|
||||
poster: File,
|
||||
templateId: string,
|
||||
format: string,
|
||||
): Promise<{ id: string }> {
|
||||
const fd = new FormData();
|
||||
fd.append('poster', poster);
|
||||
fd.append('template_id', templateId);
|
||||
fd.append('format', format);
|
||||
return p2vFetch<{ id: string }>('/api/f2/jobs', { method: 'POST', body: fd });
|
||||
}
|
||||
|
||||
export const getStylingJob = (id: string): Promise<P2vJob> =>
|
||||
p2vFetch<P2vJob>(`/api/f2/jobs/${id}`);
|
||||
|
||||
// ── 진행 계산 ────────────────────────────────────────────────────────
|
||||
|
||||
/** 아직 도는 중인가. 이 두 상태에서만 폴링을 계속한다 */
|
||||
export const isP2vActive = (job: P2vJob | null): boolean =>
|
||||
job !== null && (job.status === 'queued' || job.status === 'running');
|
||||
|
||||
/** 스테이지 진행률(%) — 선형 진행바용. 도는 중인 스테이지는 절반으로 친다 */
|
||||
export function p2vProgress(job: P2vJob | null): number {
|
||||
if (!job) return 0;
|
||||
const states = Object.values(job.stages);
|
||||
if (states.length === 0) return 0;
|
||||
const done = states.filter((s) => s.status === 'done').length;
|
||||
const running = states.some((s) => s.status === 'running') ? 0.5 : 0;
|
||||
return Math.round(((done + running) / states.length) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* 검수 게이트를 이미 지났는가.
|
||||
*
|
||||
* 스텝퍼가 "분석 → 검수 → 생성" 중 어디인지 알아야 하는데 status 만으로는
|
||||
* 검수 전 running 과 승인 후 running 이 구분되지 않는다. tts 는 승인 이후에만
|
||||
* 시작하므로 이게 게이트 통과의 신호다(스테이지 이름을 박는 대신 F1_STAGES 순서에
|
||||
* 의존하지 않도록 tts 하나만 본다 — 게이트가 옮겨가면 이 상수만 고친다).
|
||||
*/
|
||||
const POST_REVIEW_STAGE = 'tts';
|
||||
|
||||
export const isPastReview = (job: P2vJob | null): boolean =>
|
||||
!!job && job.stages[POST_REVIEW_STAGE]?.status !== undefined
|
||||
&& job.stages[POST_REVIEW_STAGE].status !== 'idle';
|
||||
91
src/utils/storageKeys.ts
Normal file
91
src/utils/storageKeys.ts
Normal file
@ -0,0 +1,91 @@
|
||||
/**
|
||||
* localStorage 키 정의와 정리 함수.
|
||||
*
|
||||
* 이 앱에는 라우터도 전역 스토어도 없어서 localStorage 가 사실상의 상태 저장소다.
|
||||
* 그래서 "언제 무엇을 지우는가"가 흩어지면 조용히 어긋난다 — 이 모듈을 만들기 전에는
|
||||
* 정리 코드가 5곳에 있었고 지우는 키 목록이 전부 달랐다:
|
||||
*
|
||||
* App.tsx initializeOnNewSession video_complete 누락
|
||||
* GenerationFlow clearAllProjectStorage (프로젝트 키만, 의도된 범위)
|
||||
* GenerationFlow handleLogout song/video 생성 상태 누락 ← 계정 전환 시 잔류
|
||||
* api.ts redirectToLogin 위와 동일
|
||||
* api.ts clearAllLocalData video_complete 누락
|
||||
*
|
||||
* 키를 추가할 때는 아래 배열에만 넣으면 5곳에 자동 반영된다.
|
||||
*/
|
||||
|
||||
export const K = {
|
||||
// 화면 상태
|
||||
VIEW_MODE: 'castad_view_mode',
|
||||
ACTIVE_ITEM: 'castad_active_item',
|
||||
WIZARD_STEP: 'castad_wizard_step',
|
||||
|
||||
// 분석 결과
|
||||
ANALYSIS_DATA: 'castad_analysis_data',
|
||||
|
||||
// 생성 진행 상태
|
||||
SONG_TASK_ID: 'castad_song_task_id',
|
||||
IMAGE_TASK_ID: 'castad_image_task_id',
|
||||
SONG_GENERATION: 'castad_song_generation',
|
||||
VIDEO_GENERATION: 'castad_video_generation',
|
||||
VIDEO_COMPLETE: 'castad_video_complete',
|
||||
VIDEO_RATIO: 'castad_video_ratio',
|
||||
|
||||
// 썰박스 통합
|
||||
/** 현재 선택된 파이프라인 (PipelineTabs 의 Pipeline 값) */
|
||||
PIPELINE: 'castad_pipeline',
|
||||
/** 진행 중인 썰박스 생성 잡 ID. 권위는 서버(`/ssul/tasks/active`)이고 이건 복구 힌트다 */
|
||||
SSUL_TASK_ID: 'castad_ssul_task_id',
|
||||
|
||||
// Poster to Video 통합
|
||||
/**
|
||||
* 진행 중인 포스터 영상(F1) 잡 ID.
|
||||
*
|
||||
* 썰박스의 `/ssul/tasks/active` 같은 "내 활성 잡" 엔드포인트가 P2V 에는 없다
|
||||
* (단일 팀 키라 사용자 개념이 없다). 그래서 이 값이 새로고침 복구의 유일한 근거다 —
|
||||
* 지우면 진행 중인 잡을 화면에서 잃는다. 서버에서 잡이 사라졌으면 조회가 404 로
|
||||
* 떨어지고 GenerationFlow 가 이 키를 정리한다.
|
||||
*
|
||||
* 접근 키(castad_p2v_key)는 여기 두지 않는다 — 서버 자격증명이라 프로젝트 초기화·
|
||||
* 로그아웃으로 지워지면 시연 중 매번 다시 물어야 한다 (utils/p2vApi.ts 참조).
|
||||
*/
|
||||
POSTER_JOB_ID: 'castad_poster_job_id',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 하나의 프로젝트(생성 1건)에 딸린 키.
|
||||
* '새 프로젝트 만들기'로 초기화할 때 지운다. 로그인 상태와 분석 데이터는 남긴다.
|
||||
*/
|
||||
export const PROJECT_KEYS: readonly string[] = [
|
||||
K.WIZARD_STEP,
|
||||
K.SONG_TASK_ID,
|
||||
K.IMAGE_TASK_ID,
|
||||
K.SONG_GENERATION,
|
||||
K.VIDEO_GENERATION,
|
||||
K.VIDEO_COMPLETE,
|
||||
K.VIDEO_RATIO,
|
||||
K.PIPELINE,
|
||||
K.SSUL_TASK_ID,
|
||||
K.POSTER_JOB_ID,
|
||||
];
|
||||
|
||||
/**
|
||||
* 세션 전체에 걸친 키. 로그아웃·토큰 만료·새 탭 진입 시 전부 지운다.
|
||||
* PROJECT_KEYS 를 포함한다.
|
||||
*/
|
||||
export const SESSION_KEYS: readonly string[] = [
|
||||
...PROJECT_KEYS,
|
||||
K.VIEW_MODE,
|
||||
K.ACTIVE_ITEM,
|
||||
K.ANALYSIS_DATA,
|
||||
];
|
||||
|
||||
/** 진행 중인 프로젝트 상태만 정리 (로그인·분석 데이터 유지) */
|
||||
export const clearProjectStorage = (): void => {
|
||||
PROJECT_KEYS.forEach((key) => localStorage.removeItem(key));
|
||||
};
|
||||
|
||||
/** 세션 상태 전체 정리 (로그아웃·토큰 만료·새 탭) */
|
||||
export const clearSessionStorage = (): void => {
|
||||
SESSION_KEYS.forEach((key) => localStorage.removeItem(key));
|
||||
};
|
||||
16
src/utils/useIsMobile.ts
Normal file
16
src/utils/useIsMobile.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const QUERY = '(max-width: 767px)';
|
||||
|
||||
export function useIsMobile(): boolean {
|
||||
const [isMobile, setIsMobile] = useState(() => window.matchMedia(QUERY).matches);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(QUERY);
|
||||
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
||||
mql.addEventListener('change', handler);
|
||||
return () => mql.removeEventListener('change', handler);
|
||||
}, []);
|
||||
|
||||
return isMobile;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user