Compare commits
No commits in common. "feature-p2v" and "main" have entirely different histories.
feature-p2
...
main
@ -1,157 +0,0 @@
|
|||||||
# 배포 요청서 — 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,28 +51,6 @@ npm run build
|
|||||||
npm run preview
|
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 실행
|
## Docker 실행
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
26
index.html
26
index.html
@ -36,24 +36,24 @@
|
|||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
brand: {
|
brand: {
|
||||||
bg: '#001a1c',
|
bg: '#011c1e',
|
||||||
card: '#003538',
|
card: '#083336',
|
||||||
cardHover: '#004548',
|
cardHover: '#0b3f42',
|
||||||
accent: '#a6ffea',
|
accent: '#94FBE0',
|
||||||
purple: '#a682ff',
|
purple: '#a855f7',
|
||||||
purpleHover: '#9570f0',
|
purpleHover: '#9333ea',
|
||||||
text: '#d1d5db',
|
text: '#e2e8f0',
|
||||||
muted: '#9ca3af'
|
muted: '#94a3b8'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<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.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,700;1,700&display=swap" rel="stylesheet">
|
<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://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" rel="stylesheet">
|
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--app-height: 100%;
|
--app-height: 100%;
|
||||||
@ -63,13 +63,11 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: #001a1c;
|
background-color: #121a1d;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
font-family: 'Pretendard', 'Apple SD Gothic Neo', -apple-system, BlinkMacSystemFont,
|
font-family: 'Noto Sans KR', sans-serif;
|
||||||
'Helvetica Neue', 'Segoe UI', system-ui, sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
letter-spacing: -0.006em;
|
|
||||||
}
|
}
|
||||||
.font-serif-logo {
|
.font-serif-logo {
|
||||||
font-family: 'Playfair Display', serif;
|
font-family: 'Playfair Display', serif;
|
||||||
|
|||||||
43
package-lock.json
generated
43
package-lock.json
generated
@ -16,8 +16,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
"@types/react": "^19.2.0",
|
|
||||||
"@types/react-dom": "^19.2.0",
|
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
"typescript": "~5.8.2",
|
"typescript": "~5.8.2",
|
||||||
"vite": "^6.2.0"
|
"vite": "^6.2.0"
|
||||||
@ -54,7 +52,6 @@
|
|||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
@ -1291,32 +1288,10 @@
|
|||||||
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
|
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"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",
|
|
||||||
"peer": true,
|
|
||||||
"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": {
|
"node_modules/@types/use-sync-external-store": {
|
||||||
"version": "0.0.6",
|
"version": "0.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
@ -1374,7 +1349,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@ -1426,13 +1400,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/d3-array": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
@ -1724,7 +1691,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.28.4"
|
"@babel/runtime": "^7.28.4"
|
||||||
},
|
},
|
||||||
@ -1845,7 +1811,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@ -1887,7 +1852,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@ -1897,7 +1861,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@ -1944,7 +1907,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/use-sync-external-store": "^0.0.6",
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
"use-sync-external-store": "^1.4.0"
|
"use-sync-external-store": "^1.4.0"
|
||||||
@ -2007,8 +1969,7 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/redux-thunk": {
|
"node_modules/redux-thunk": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
@ -2122,7 +2083,6 @@
|
|||||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@ -2206,7 +2166,6 @@
|
|||||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.4.4",
|
||||||
|
|||||||
@ -5,8 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"typecheck": "tsc --noEmit",
|
"build": "vite build",
|
||||||
"build": "tsc --noEmit && vite build",
|
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -18,8 +17,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
"@types/react": "^19.2.0",
|
|
||||||
"@types/react-dom": "^19.2.0",
|
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
"typescript": "~5.8.2",
|
"typescript": "~5.8.2",
|
||||||
"vite": "^6.2.0"
|
"vite": "^6.2.0"
|
||||||
|
|||||||
420
src/App.tsx
420
src/App.tsx
@ -1,16 +1,27 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useRef, useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 GenerationFlow from './pages/Dashboard/GenerationFlow';
|
||||||
import SocialConnectSuccess from './pages/Social/SocialConnectSuccess';
|
import SocialConnectSuccess from './pages/Social/SocialConnectSuccess';
|
||||||
import SocialConnectError from './pages/Social/SocialConnectError';
|
import SocialConnectError from './pages/Social/SocialConnectError';
|
||||||
import YouTubeOAuthCallback from './pages/Social/YouTubeOAuthCallback';
|
import YouTubeOAuthCallback from './pages/Social/YouTubeOAuthCallback';
|
||||||
|
import ADO2ContentsPage from './pages/Dashboard/ADO2ContentsPage';
|
||||||
import VideoDetailPage from './components/VideoDetailPage';
|
import VideoDetailPage from './components/VideoDetailPage';
|
||||||
import SsulDetailPage from './pages/Ssulbox/SsulDetailPage';
|
import { crawlUrl, autocomplete, marketingAnalysis, kakaoCallback, isLoggedIn, saveTokens, getVideosList, AutocompleteRequest, storeUtmFromUrl, trackViewContent, trackCompleteRegistration } from './utils/api';
|
||||||
import { kakaoCallback, saveTokens, storeUtmFromUrl, trackViewContent, trackCompleteRegistration } from './utils/api';
|
import { saveSearchHistory } from './components/SearchHistory/useSearchHistory';
|
||||||
import { clearSessionStorage } from './utils/storageKeys';
|
import { CrawlingResponse } from './types/api';
|
||||||
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';
|
const SESSION_KEY = 'castad_session_active';
|
||||||
|
|
||||||
// 새 탭/새 창에서 접근 시 localStorage 초기화 (sessionStorage로 현재 세션 확인)
|
// 새 탭/새 창에서 접근 시 localStorage 초기화 (sessionStorage로 현재 세션 확인)
|
||||||
@ -19,7 +30,15 @@ const initializeOnNewSession = () => {
|
|||||||
|
|
||||||
if (!isExistingSession) {
|
if (!isExistingSession) {
|
||||||
// 새 세션이면 localStorage 정리하고 세션 표시
|
// 새 세션이면 localStorage 정리하고 세션 표시
|
||||||
clearSessionStorage();
|
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');
|
||||||
sessionStorage.setItem(SESSION_KEY, 'true');
|
sessionStorage.setItem(SESSION_KEY, 'true');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -28,9 +47,79 @@ const initializeOnNewSession = () => {
|
|||||||
initializeOnNewSession();
|
initializeOnNewSession();
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const [initialTab, setInitialTab] = useState<string>(NAV.HOME);
|
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 [isProcessingCallback, setIsProcessingCallback] = useState(false);
|
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 파라미터 확인)
|
// 카카오 로그인 콜백 처리 (URL에서 토큰 또는 code 파라미터 확인)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -57,8 +146,8 @@ const App: React.FC = () => {
|
|||||||
setIsProcessingCallback(true);
|
setIsProcessingCallback(true);
|
||||||
handleKakaoCallback(code);
|
handleKakaoCallback(code);
|
||||||
}
|
}
|
||||||
// 콜백이 아닌 일반 진입 — 광고 유입 추적
|
// 콜백이 아닌 일반 진입이고 랜딩이 첫 화면이면 ViewContent 발화
|
||||||
else {
|
else if (viewMode === 'landing') {
|
||||||
trackViewContent();
|
trackViewContent();
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@ -82,15 +171,21 @@ const App: React.FC = () => {
|
|||||||
await trackCompleteRegistration();
|
await trackCompleteRegistration();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 로그인 전 특정 화면(예: /video/{id})에서 유도된 로그인이면 그 경로로 복귀
|
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');
|
||||||
|
}
|
||||||
const redirectPath = sessionStorage.getItem('castad_login_redirect');
|
const redirectPath = sessionStorage.getItem('castad_login_redirect');
|
||||||
sessionStorage.removeItem('castad_login_redirect');
|
sessionStorage.removeItem('castad_login_redirect');
|
||||||
if (redirectPath && redirectPath !== '/') {
|
if (redirectPath && redirectPath !== '/') {
|
||||||
window.location.href = redirectPath;
|
window.location.href = redirectPath;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 그 외(사이드바 게이트 등)는 GenerationFlow 가 castad_login_next_item 으로 자체 복귀 처리
|
setInitialTab('새 프로젝트 만들기');
|
||||||
setInitialTab(NAV.HOME);
|
setViewMode('generation_flow');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Token callback failed:', err);
|
console.error('Token callback failed:', err);
|
||||||
alert(t('app.loginFailed'));
|
alert(t('app.loginFailed'));
|
||||||
@ -124,6 +219,231 @@ 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 콜백 페이지 처리
|
// Social OAuth 콜백 페이지 처리
|
||||||
const pathname = window.location.pathname;
|
const pathname = window.location.pathname;
|
||||||
|
|
||||||
@ -146,13 +466,6 @@ const App: React.FC = () => {
|
|||||||
return <VideoDetailPage videoId={videoDetailMatch[1]} />;
|
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) {
|
if (isProcessingCallback) {
|
||||||
@ -165,8 +478,71 @@ const App: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 그 외 전 경로는 ADO2 콘텐츠 갤러리를 첫 화면으로 하는 GenerationFlow 로 수렴
|
if (viewMode === 'loading') {
|
||||||
return <GenerationFlow initialActiveItem={initialTab} />;
|
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>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@ -1,67 +0,0 @@
|
|||||||
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,7 +2,6 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import CitySelectModal, { REGIONS } from './CitySelectModal';
|
import CitySelectModal, { REGIONS } from './CitySelectModal';
|
||||||
import { useOverlayClose } from '../hooks/useOverlayClose';
|
|
||||||
|
|
||||||
interface BusinessNameInputModalProps {
|
interface BusinessNameInputModalProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@ -17,7 +16,6 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
|
|||||||
const [category, setCategory] = useState('');
|
const [category, setCategory] = useState('');
|
||||||
const [officialSiteUrl, setOfficialSiteUrl] = useState('');
|
const [officialSiteUrl, setOfficialSiteUrl] = useState('');
|
||||||
const [isCityModalOpen, setIsCityModalOpen] = useState(false);
|
const [isCityModalOpen, setIsCityModalOpen] = useState(false);
|
||||||
const overlayCloseHandlers = useOverlayClose(onClose);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
@ -63,7 +61,7 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
|
|||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<>
|
<>
|
||||||
<div className="manual-modal-backdrop" {...overlayCloseHandlers}>
|
<div className="manual-modal-backdrop" onClick={onClose}>
|
||||||
<div className="manual-modal" onClick={e => e.stopPropagation()}>
|
<div className="manual-modal" onClick={e => e.stopPropagation()}>
|
||||||
<div className="manual-modal-header">
|
<div className="manual-modal-header">
|
||||||
<span className="manual-modal-title">{t('landing.hero.manualModalTitle')}</span>
|
<span className="manual-modal-title">{t('landing.hero.manualModalTitle')}</span>
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useOverlayClose } from '../hooks/useOverlayClose';
|
|
||||||
|
|
||||||
export const REGIONS: { label: string; cities: string[] }[] = [
|
export const REGIONS: { label: string; cities: string[] }[] = [
|
||||||
{
|
{
|
||||||
@ -97,10 +96,8 @@ const CitySelectModal: React.FC<CitySelectModalProps> = ({ selected, onSelect, o
|
|||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const overlayCloseHandlers = useOverlayClose(onClose);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="city-modal-backdrop" {...overlayCloseHandlers}>
|
<div className="city-modal-backdrop" onClick={onClose}>
|
||||||
<div className="city-modal" onClick={e => e.stopPropagation()}>
|
<div className="city-modal" onClick={e => e.stopPropagation()}>
|
||||||
<div className="city-modal-header">
|
<div className="city-modal-header">
|
||||||
<span className="city-modal-title">
|
<span className="city-modal-title">
|
||||||
|
|||||||
@ -1,201 +0,0 @@
|
|||||||
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,22 +1,13 @@
|
|||||||
import React, { useRef, useState } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { API_URL, toggleVideoLike, isLoggedIn } from '../utils/api';
|
import { API_URL, toggleVideoLike } from '../utils/api';
|
||||||
import { buildContentShareUrl, tryNativeShare } from '../utils/nativeShare';
|
import { buildVideoShareUrl, tryNativeShare } from '../utils/nativeShare';
|
||||||
import LoginPromptModal from './LoginPromptModal';
|
|
||||||
|
|
||||||
interface ContentCardSocialActionsProps {
|
interface ContentCardSocialActionsProps {
|
||||||
videoId: number;
|
videoId: number;
|
||||||
/**
|
|
||||||
* 콘텐츠 종류. video.id 와 ssul_content.id 가 겹치므로 좋아요 API 와
|
|
||||||
* 공유 URL(/video/{id} vs /ssul/{id}) 분기에 반드시 필요하다.
|
|
||||||
* ContentType 전체가 아니라 이 둘로 좁힌 이유: 좋아요·공유 축은 video·ssul
|
|
||||||
* 에만 있다 — P2V 항목은 이 컴포넌트를 렌더하지 않는다(MyContentsPage 분기).
|
|
||||||
*/
|
|
||||||
contentType?: 'video' | 'ssul';
|
|
||||||
storeName: string;
|
storeName: string;
|
||||||
region?: string;
|
region?: string;
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
description?: string | null;
|
|
||||||
commentCount: number;
|
commentCount: number;
|
||||||
initialLikeCount: number;
|
initialLikeCount: number;
|
||||||
initialIsLiked?: boolean;
|
initialIsLiked?: boolean;
|
||||||
@ -24,11 +15,8 @@ interface ContentCardSocialActionsProps {
|
|||||||
|
|
||||||
const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
|
const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
|
||||||
videoId,
|
videoId,
|
||||||
contentType = 'video',
|
|
||||||
storeName,
|
storeName,
|
||||||
region,
|
|
||||||
title,
|
title,
|
||||||
description,
|
|
||||||
commentCount,
|
commentCount,
|
||||||
initialLikeCount,
|
initialLikeCount,
|
||||||
initialIsLiked = false,
|
initialIsLiked = false,
|
||||||
@ -37,10 +25,9 @@ const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
|
|||||||
|
|
||||||
const [likeCount, setLikeCount] = useState(initialLikeCount);
|
const [likeCount, setLikeCount] = useState(initialLikeCount);
|
||||||
const [isLiked, setIsLiked] = useState(initialIsLiked);
|
const [isLiked, setIsLiked] = useState(initialIsLiked);
|
||||||
const [showLoginModal, setShowLoginModal] = useState(false);
|
|
||||||
const likeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const likeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const shareUrl = buildContentShareUrl(API_URL, videoId, contentType);
|
const shareUrl = buildVideoShareUrl(API_URL, videoId);
|
||||||
const shareTitle = title || storeName || t('videoDetail.kakaoDefaultTitle');
|
const shareTitle = title || storeName || t('videoDetail.kakaoDefaultTitle');
|
||||||
|
|
||||||
const handleShareBtnClick = async (e: React.MouseEvent) => {
|
const handleShareBtnClick = async (e: React.MouseEvent) => {
|
||||||
@ -64,11 +51,6 @@ const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
|
|||||||
const handleLikeClick = (e: React.MouseEvent) => {
|
const handleLikeClick = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
if (!isLoggedIn()) {
|
|
||||||
setShowLoginModal(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const prevLiked = isLiked;
|
const prevLiked = isLiked;
|
||||||
const prevCount = likeCount;
|
const prevCount = likeCount;
|
||||||
|
|
||||||
@ -78,7 +60,7 @@ const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
|
|||||||
if (likeDebounceRef.current) clearTimeout(likeDebounceRef.current);
|
if (likeDebounceRef.current) clearTimeout(likeDebounceRef.current);
|
||||||
likeDebounceRef.current = setTimeout(async () => {
|
likeDebounceRef.current = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await toggleVideoLike(String(videoId), contentType);
|
const res = await toggleVideoLike(String(videoId));
|
||||||
setIsLiked(res.is_liked);
|
setIsLiked(res.is_liked);
|
||||||
setLikeCount(res.like_count);
|
setLikeCount(res.like_count);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -118,10 +100,6 @@ 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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showLoginModal && (
|
|
||||||
<LoginPromptModal onClose={() => setShowLoginModal(false)} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
64
src/components/Header.tsx
Executable file
64
src/components/Header.tsx
Executable file
@ -0,0 +1,64 @@
|
|||||||
|
|
||||||
|
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;
|
||||||
@ -1,46 +0,0 @@
|
|||||||
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,7 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { getKakaoLoginUrl } from '../utils/api';
|
import { getKakaoLoginUrl } from '../utils/api';
|
||||||
import { useOverlayClose } from '../hooks/useOverlayClose';
|
|
||||||
|
|
||||||
interface LoginPromptModalProps {
|
interface LoginPromptModalProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@ -9,7 +8,6 @@ interface LoginPromptModalProps {
|
|||||||
|
|
||||||
const LoginPromptModal: React.FC<LoginPromptModalProps> = ({ onClose }) => {
|
const LoginPromptModal: React.FC<LoginPromptModalProps> = ({ onClose }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const overlayCloseHandlers = useOverlayClose(onClose);
|
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
try {
|
try {
|
||||||
@ -26,24 +24,22 @@ const LoginPromptModal: React.FC<LoginPromptModalProps> = ({ onClose }) => {
|
|||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
background: 'rgba(0, 0, 0, 0.7)',
|
background: 'rgba(255, 255, 255, 0.25)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
zIndex: 9999,
|
zIndex: 9999,
|
||||||
padding: '16px',
|
|
||||||
}}
|
}}
|
||||||
{...overlayCloseHandlers}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: '#01282A',
|
background: '#01282A',
|
||||||
border: '1px solid rgba(166, 255, 234, 0.15)',
|
border: '1px solid rgba(155, 202, 204, 0.2)',
|
||||||
borderRadius: '20px',
|
borderRadius: '16px',
|
||||||
boxShadow: '0 24px 60px rgba(0, 0, 0, 0.5)',
|
padding: '40px 32px',
|
||||||
padding: '44px 32px 36px',
|
width: '360px',
|
||||||
width: '380px',
|
maxWidth: '90vw',
|
||||||
maxWidth: '100%',
|
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
@ -68,49 +64,27 @@ const LoginPromptModal: React.FC<LoginPromptModalProps> = ({ onClose }) => {
|
|||||||
<line x1="16" y1="4" x2="4" y2="16"/>
|
<line x1="16" y1="4" x2="4" y2="16"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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')}
|
{t('loginPrompt.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<p style={{ color: '#9BCACC', fontSize: '14px', lineHeight: 1.5, marginBottom: '28px', whiteSpace: 'pre-line' }}>
|
|
||||||
{t('loginPrompt.description')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleLogin}
|
onClick={handleLogin}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '50%',
|
||||||
padding: '15px',
|
margin: '0 auto',
|
||||||
|
padding: '14px',
|
||||||
background: '#FEE500',
|
background: '#FEE500',
|
||||||
color: '#3C1E1E',
|
color: '#3C1E1E',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '10px',
|
borderRadius: '8px',
|
||||||
fontSize: '16px',
|
fontSize: '15px',
|
||||||
fontWeight: 700,
|
fontWeight: 600,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
gap: '8px',
|
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">
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|||||||
@ -1,49 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,143 +0,0 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { VideoListItem } from '../types/api';
|
|
||||||
import { useOverlayClose } from '../hooks/useOverlayClose';
|
|
||||||
import { downloadP2vFile } from '../utils/p2vApi';
|
|
||||||
|
|
||||||
interface P2vViewerModalProps {
|
|
||||||
/** null 이면 닫힘. type 이 p2v_video 면 영상, p2v_poster 면 이미지로 그린다 */
|
|
||||||
item: VideoListItem | null;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* P2V 콘텐츠 상세 뷰어 — ADO2/썰박스 상세(video-detail-*)와 같은 화면 구성.
|
|
||||||
*
|
|
||||||
* 다른 점 둘:
|
|
||||||
* - 상세 조회 API 를 부르지 않는다. P2V 항목은 목록 응답(result_movie_url =
|
|
||||||
* Blob 공개 URL)만으로 그릴 수 있고, 부를 상세 엔드포인트도 없다.
|
|
||||||
* - 좋아요·댓글·공유가 없다. comment/video_reaction 축이 video·ssul 전용이라
|
|
||||||
* P2V 지원은 별도 확장(DDL) 없이는 불가 — 화면 구성만 맞춘다(2026-08-26 결정).
|
|
||||||
* 대신 그 자리에 다운로드 버튼을 둔다.
|
|
||||||
*/
|
|
||||||
const P2vViewerModal: React.FC<P2vViewerModalProps> = ({ item, onClose }) => {
|
|
||||||
const { t, i18n } = useTranslation();
|
|
||||||
const [isLandscape, setIsLandscape] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!item) return;
|
|
||||||
setIsLandscape(false);
|
|
||||||
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;
|
|
||||||
|
|
||||||
const isVideo = item.type === 'p2v_video';
|
|
||||||
const fileName = `${item.store_name || 'p2v'}.${isVideo ? 'mp4' : 'png'}`;
|
|
||||||
const formatDate = (iso: string) =>
|
|
||||||
new Date(iso).toLocaleDateString(i18n.language === 'ko' ? 'ko-KR' : 'en-US', {
|
|
||||||
year: 'numeric', month: 'long', day: 'numeric',
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="video-detail-overlay" {...overlayCloseHandlers}>
|
|
||||||
<div className="video-detail-modal-box" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<div className="video-detail-modal-content">
|
|
||||||
{/* 헤더 — 문구는 ADO2/썰박스 상세와 같은 키를 쓴다 (같은 목록에서 열리는 모달) */}
|
|
||||||
<div className="video-detail-header">
|
|
||||||
<h2 className="video-detail-modal-title">{t('sidebar.ado2Contents')}</h2>
|
|
||||||
<button
|
|
||||||
className="video-detail-close-btn"
|
|
||||||
onClick={onClose}
|
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="video-detail-body">
|
|
||||||
<div className={`video-detail-content ${isLandscape ? 'landscape' : ''}`}>
|
|
||||||
<div className="video-detail-player-wrap">
|
|
||||||
{isVideo ? (
|
|
||||||
<video
|
|
||||||
src={item.result_movie_url}
|
|
||||||
controls
|
|
||||||
autoPlay
|
|
||||||
playsInline
|
|
||||||
className="video-detail-player"
|
|
||||||
onLoadedMetadata={(e) => {
|
|
||||||
const v = e.currentTarget;
|
|
||||||
setIsLandscape(v.videoWidth > v.videoHeight);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<img
|
|
||||||
src={item.result_movie_url}
|
|
||||||
alt={item.store_name}
|
|
||||||
className="video-detail-player"
|
|
||||||
style={{ objectFit: 'contain' }}
|
|
||||||
onLoad={(e) => {
|
|
||||||
const im = e.currentTarget;
|
|
||||||
setIsLandscape(im.naturalWidth > im.naturalHeight);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="video-detail-info">
|
|
||||||
<h2 className="video-detail-store">{item.store_name}</h2>
|
|
||||||
<p className="video-detail-date">{formatDate(item.created_at)}</p>
|
|
||||||
|
|
||||||
{/* 좋아요·공유 자리 — P2V 는 그 축이 없어 다운로드를 둔다.
|
|
||||||
교차 출처(Blob) URL 은 <a download> 가 무시되므로 fetch 경유 */}
|
|
||||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
|
||||||
<button
|
|
||||||
className="video-detail-copy-btn"
|
|
||||||
onClick={() => downloadP2vFile(item.result_movie_url, fileName)}
|
|
||||||
>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M12 3v12M12 15l-4-4M12 15l4-4" />
|
|
||||||
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" />
|
|
||||||
</svg>
|
|
||||||
{t('p2vViewer.download')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* F1 키워드 해시태그 (있을 때만) — 검수 화면과 같은 칩 스타일 재사용 */}
|
|
||||||
{item.hashtags && item.hashtags.length > 0 && (
|
|
||||||
<div className="p2v-scope">
|
|
||||||
<div className="p2v-tags">
|
|
||||||
{item.hashtags.map((kw) => (
|
|
||||||
<span key={kw} className="p2v-tag">#{kw}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 스타일링은 사용한 템플릿 이름을 부가 정보로 (title 자리에 실려온다) */}
|
|
||||||
{item.type === 'p2v_poster' && item.title && (
|
|
||||||
<p className="video-detail-date">{item.title}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default P2vViewerModal;
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,106 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,141 +0,0 @@
|
|||||||
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,30 +1,32 @@
|
|||||||
|
|
||||||
import React, { useRef, useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { UserMeResponse } from '../types/api';
|
import { UserMeResponse } from '../types/api';
|
||||||
import { logout } from '../utils/api';
|
import { logout } from '../utils/api';
|
||||||
import { NAV_ITEMS, SETTINGS_ID } from './navItems';
|
import LanguageSwitch from './LanguageSwitch';
|
||||||
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 {
|
interface SidebarItemProps {
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
isCollapsed: boolean;
|
||||||
|
isDisabled?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SidebarItem: React.FC<SidebarItemProps> = ({ icon, label, isActive, onClick }) => {
|
const SidebarItem: React.FC<SidebarItemProps> = ({ icon, label, isActive, isCollapsed, isDisabled, onClick, id }) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
id={id}
|
||||||
className={`sidebar-item ${isActive ? 'active' : ''}`}
|
onClick={isDisabled ? undefined : onClick}
|
||||||
|
className={`sidebar-item ${isActive ? 'active' : ''} ${isCollapsed ? 'collapsed' : ''} ${isDisabled ? 'disabled' : ''}`}
|
||||||
|
title={isCollapsed ? label : ""}
|
||||||
>
|
>
|
||||||
<div className="sidebar-item-icon">
|
<div className="sidebar-item-icon">
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
<span className="sidebar-item-label">{label}</span>
|
{!isCollapsed && <span className="sidebar-item-label">{label}</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -32,20 +34,22 @@ const SidebarItem: React.FC<SidebarItemProps> = ({ icon, label, isActive, onClic
|
|||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
activeItem: string;
|
activeItem: string;
|
||||||
onNavigate: (id: string) => void;
|
onNavigate: (id: string) => void;
|
||||||
onLogoClick?: () => void;
|
onHome?: () => void;
|
||||||
userInfo?: UserMeResponse | null;
|
userInfo?: UserMeResponse | null;
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
credits?: number | null;
|
credits?: number | null;
|
||||||
isGuest: boolean;
|
tutorialAvailable?: boolean;
|
||||||
onLoginClick: () => void;
|
tutorialEnabled?: boolean;
|
||||||
|
onToggleTutorial?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onLogoClick, userInfo, onLogout, credits, isGuest, onLoginClick }) => {
|
const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userInfo, onLogout, credits, tutorialAvailable, tutorialEnabled, onToggleTutorial }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
|
const [isMobileOpen, setIsMobileOpen] = useState(false);
|
||||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
||||||
const settingsAnchorRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
|
// 로그아웃 처리
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
if (isLoggingOut) return;
|
if (isLoggingOut) return;
|
||||||
setIsLoggingOut(true);
|
setIsLoggingOut(true);
|
||||||
@ -54,69 +58,184 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onLogoClick,
|
|||||||
onLogout?.();
|
onLogout?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Logout failed:', error);
|
console.error('Logout failed:', error);
|
||||||
|
// 에러가 나도 로컬 토큰은 이미 삭제됨, 홈으로 이동
|
||||||
onLogout?.();
|
onLogout?.();
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoggingOut(false);
|
setIsLoggingOut(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleItemClick = (id: string) => {
|
useEffect(() => {
|
||||||
if (id === SETTINGS_ID) {
|
const handleResize = () => {
|
||||||
setSettingsOpen(v => !v);
|
if (window.innerWidth < 768) {
|
||||||
return;
|
setIsMobileOpen(false);
|
||||||
}
|
}
|
||||||
onNavigate(id);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleResize();
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleNavigate = (id: string) => {
|
||||||
|
onNavigate(id);
|
||||||
|
if (window.innerWidth < 768) {
|
||||||
|
setIsMobileOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="sidebar expanded">
|
<>
|
||||||
<div className="sidebar-header">
|
{/* 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)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className={`sidebar ${isCollapsed ? 'collapsed' : 'expanded'} ${isMobileOpen ? 'mobile-open' : 'mobile-closed'}`}>
|
||||||
|
<div className={`sidebar-header ${isCollapsed ? 'collapsed' : ''}`}>
|
||||||
|
{!isCollapsed && (
|
||||||
<img
|
<img
|
||||||
onClick={onLogoClick}
|
onClick={onHome}
|
||||||
src="/assets/images/ado2-sidebar-logo.svg"
|
src="/assets/images/ado2-sidebar-logo.svg"
|
||||||
alt="ADO2"
|
alt="ADO2"
|
||||||
className="sidebar-logo"
|
className="sidebar-logo"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
<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>
|
||||||
|
|
||||||
<div className="sidebar-menu no-scrollbar">
|
<div className="sidebar-menu no-scrollbar">
|
||||||
{TOP_NAV_ITEMS.map(item => (
|
{menuItems.map(item => (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
|
id={
|
||||||
|
item.id === '내 정보'
|
||||||
|
? 'sidebar-my-info'
|
||||||
|
: item.id === 'ADO2 콘텐츠'
|
||||||
|
? 'sidebar-ado2-contents'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
icon={item.icon}
|
icon={item.icon}
|
||||||
label={t(item.labelKey)}
|
label={item.label}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
isActive={activeItem === item.id}
|
isActive={activeItem === item.id}
|
||||||
onClick={() => handleItemClick(item.id)}
|
isDisabled={item.disabled}
|
||||||
|
onClick={() => handleNavigate(item.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sidebar-footer">
|
<div className="sidebar-footer">
|
||||||
<div className="sidebar-settings-anchor" ref={settingsAnchorRef}>
|
{/* 모바일 전용 튜토리얼 토글 */}
|
||||||
<SidebarItem
|
{tutorialAvailable && (
|
||||||
icon={SETTINGS_NAV_ITEM.icon}
|
<button
|
||||||
label={t(SETTINGS_NAV_ITEM.labelKey)}
|
className={`sidebar-tutorial-btn ${tutorialEnabled ? 'active' : ''}`}
|
||||||
isActive={settingsOpen}
|
onClick={() => onToggleTutorial?.()}
|
||||||
onClick={() => handleItemClick(SETTINGS_NAV_ITEM.id)}
|
title={tutorialEnabled ? t('sidebar.tutorialOff') : t('sidebar.tutorialOn')}
|
||||||
/>
|
>
|
||||||
{settingsOpen && (
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
<SettingsPanel
|
<circle cx="12" cy="12" r="10"/>
|
||||||
variant="sidebar"
|
<path d="M12 8v4l3 3"/>
|
||||||
onClose={() => setSettingsOpen(false)}
|
</svg>
|
||||||
onNavigate={onNavigate}
|
<span className="sidebar-tutorial-label">{t('sidebar.tutorial')}</span>
|
||||||
activeItem={activeItem}
|
<span className={`sidebar-tutorial-badge ${tutorialEnabled ? 'on' : 'off'}`}>
|
||||||
userInfo={userInfo}
|
{tutorialEnabled ? 'ON' : 'OFF'}
|
||||||
credits={credits}
|
</span>
|
||||||
isGuest={isGuest}
|
</button>
|
||||||
isLoggingOut={isLoggingOut}
|
)}
|
||||||
onLogout={handleLogout}
|
|
||||||
onLoginClick={onLoginClick}
|
<div className="sidebar-language-switch">
|
||||||
anchorRef={settingsAnchorRef}
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,9 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { getSocialAccounts, uploadToSocial, waitForUploadComplete, TokenExpiredError, handleSocialReconnect, getAutoSeoYoutube } from '../utils/api';
|
import { getSocialAccounts, uploadToSocial, waitForUploadComplete, TokenExpiredError, handleSocialReconnect, getAutoSeoYoutube } from '../utils/api';
|
||||||
import { SocialAccount, VideoListItem, SocialUploadStatusResponse } from '../types/api';
|
import { SocialAccount, VideoListItem, SocialUploadStatusResponse } from '../types/api';
|
||||||
import UploadProgressModal, { UploadStatus } from './UploadProgressModal';
|
import UploadProgressModal, { UploadStatus } from './UploadProgressModal';
|
||||||
import { useOverlayClose } from '../hooks/useOverlayClose';
|
import { useTutorial } from './Tutorial/useTutorial';
|
||||||
|
import { TUTORIAL_KEYS } from './Tutorial/tutorialSteps';
|
||||||
|
import TutorialOverlay from './Tutorial/TutorialOverlay';
|
||||||
|
|
||||||
interface SocialPostingModalProps {
|
interface SocialPostingModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@ -119,6 +121,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||||||
onGoToCalendar,
|
onGoToCalendar,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const tutorial = useTutorial();
|
||||||
const [socialAccounts, setSocialAccounts] = useState<SocialAccount[]>([]);
|
const [socialAccounts, setSocialAccounts] = useState<SocialAccount[]>([]);
|
||||||
const [selectedChannel, setSelectedChannel] = useState<string>('');
|
const [selectedChannel, setSelectedChannel] = useState<string>('');
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
@ -138,10 +141,8 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||||||
const [videoMeta, setVideoMeta] = useState<{ width: number; height: number; duration: number } | null>(null);
|
const [videoMeta, setVideoMeta] = useState<{ width: number; height: number; duration: number } | null>(null);
|
||||||
const channelDropdownRef = useRef<HTMLDivElement>(null);
|
const channelDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const privacyDropdownRef = useRef<HTMLDivElement>(null);
|
const privacyDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const loadedForTaskIdRef = useRef<string | null>(null);
|
const hasBeenOpenedRef = useRef(false);
|
||||||
const loadedAtRef = useRef<number>(0);
|
const loadedForVideoIdRef = useRef<number | null>(null);
|
||||||
const seoCache = useRef<Map<string, { title: string; description: string; tags: string }>>(new Map());
|
|
||||||
const SEO_CACHE_TTL = 50 * 60 * 1000;
|
|
||||||
|
|
||||||
// Upload progress modal state
|
// Upload progress modal state
|
||||||
const [showUploadProgress, setShowUploadProgress] = useState(false);
|
const [showUploadProgress, setShowUploadProgress] = useState(false);
|
||||||
@ -180,15 +181,48 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||||||
return () => { document.body.style.overflow = ''; };
|
return () => { document.body.style.overflow = ''; };
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
// 소셜 계정 로드 + SEO 자동 채움
|
// 모달 오픈/닫힘 시 튜토리얼 트리거
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// 소셜 계정 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
loadedForTaskIdRef.current = null;
|
loadedForVideoIdRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadSocialAccounts();
|
loadSocialAccounts();
|
||||||
|
|
||||||
|
const videoId = video?.video_id ?? null;
|
||||||
|
if (!videoId || videoId === loadedForVideoIdRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadedForVideoIdRef.current = videoId;
|
||||||
|
|
||||||
if (video?.title) {
|
if (video?.title) {
|
||||||
setTitle(video.title);
|
setTitle(video.title);
|
||||||
setDescription(video.description || '');
|
setDescription(video.description || '');
|
||||||
@ -197,26 +231,8 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
loadAutocomplete();
|
||||||
const seoKey =
|
}, [isOpen, video?.video_id, video?.title, video?.description, video?.hashtags]);
|
||||||
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 () => {
|
const loadSocialAccounts = async () => {
|
||||||
setIsLoadingAccounts(true);
|
setIsLoadingAccounts(true);
|
||||||
@ -241,34 +257,19 @@ 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);
|
setIsLoadingAutoDescription(true);
|
||||||
try {
|
try {
|
||||||
const requestPayload = {
|
const requestPayload = {
|
||||||
content_type: video.type,
|
video_id: video.video_id,
|
||||||
task_id: isSsul ? String(video.video_id) : video.task_id,
|
|
||||||
};
|
};
|
||||||
const autoSeoResponse = await getAutoSeoYoutube(requestPayload);
|
const autoSeoResponse = await getAutoSeoYoutube(requestPayload);
|
||||||
|
|
||||||
if (autoSeoResponse.title) setTitle(autoSeoResponse.title);
|
if (autoSeoResponse.title) setTitle(autoSeoResponse.title);
|
||||||
if (autoSeoResponse.description) setDescription(autoSeoResponse.description);
|
if (autoSeoResponse.description) setDescription(autoSeoResponse.description);
|
||||||
if (autoSeoResponse.keywords) setTags(autoSeoResponse.keywords.join(','));
|
if (autoSeoResponse.keywords) setTags(autoSeoResponse.keywords.join(','));
|
||||||
seoCache.current.set(seoKey, {
|
|
||||||
title: autoSeoResponse.title || '',
|
|
||||||
description: autoSeoResponse.description || '',
|
|
||||||
tags: autoSeoResponse.keywords?.join(',') || '',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load autocomplete:', error);
|
console.error('Failed to load autocomplete:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@ -340,9 +341,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const requestPayload = {
|
const requestPayload = {
|
||||||
// 종류를 함께 보낸다 — 없으면 백엔드가 video 로 간주해, 썰박스 항목에서는
|
|
||||||
// id 가 겹치는 엉뚱한 ADO2 영상이 업로드된다.
|
|
||||||
content_type: video.type,
|
|
||||||
video_id: video.video_id,
|
video_id: video.video_id,
|
||||||
social_account_id: selectedAcc.id,
|
social_account_id: selectedAcc.id,
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
@ -434,8 +432,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||||||
resetForm();
|
resetForm();
|
||||||
};
|
};
|
||||||
|
|
||||||
const overlayCloseHandlers = useOverlayClose(handleClose);
|
|
||||||
|
|
||||||
const selectedAccount = socialAccounts.find(acc => acc.platform_user_id === selectedChannel);
|
const selectedAccount = socialAccounts.find(acc => acc.platform_user_id === selectedChannel);
|
||||||
|
|
||||||
const privacyOptions = [
|
const privacyOptions = [
|
||||||
@ -467,13 +463,23 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{showUploadProgress && uploadProgressModalElement}
|
{showUploadProgress && uploadProgressModalElement}
|
||||||
|
{tutorial.isActive && (
|
||||||
|
<TutorialOverlay
|
||||||
|
hints={tutorial.hints}
|
||||||
|
currentIndex={tutorial.currentHintIndex}
|
||||||
|
onNext={tutorial.nextHint}
|
||||||
|
onPrev={tutorial.prevHint}
|
||||||
|
onSkip={tutorial.skipTutorial}
|
||||||
|
groupProgress={tutorial.groupProgress}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="social-posting-overlay" {...overlayCloseHandlers}>
|
<div className="social-posting-overlay" onClick={handleClose}>
|
||||||
<div className="social-posting-modal" onClick={(e) => e.stopPropagation()}>
|
<div className="social-posting-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="social-posting-header">
|
<div className="social-posting-header">
|
||||||
@ -843,6 +849,16 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{uploadProgressModalElement}
|
{uploadProgressModalElement}
|
||||||
|
{tutorial.isActive && (
|
||||||
|
<TutorialOverlay
|
||||||
|
hints={tutorial.hints}
|
||||||
|
currentIndex={tutorial.currentHintIndex}
|
||||||
|
onNext={tutorial.nextHint}
|
||||||
|
onPrev={tutorial.prevHint}
|
||||||
|
onSkip={tutorial.skipTutorial}
|
||||||
|
groupProgress={tutorial.groupProgress}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
314
src/components/Tutorial/TutorialOverlay.tsx
Normal file
314
src/components/Tutorial/TutorialOverlay.tsx
Normal file
@ -0,0 +1,314 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
377
src/components/Tutorial/tutorialSteps.ts
Normal file
377
src/components/Tutorial/tutorialSteps.ts
Normal file
@ -0,0 +1,377 @@
|
|||||||
|
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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
220
src/components/Tutorial/useTutorial.ts
Normal file
220
src/components/Tutorial/useTutorial.ts
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -13,7 +13,6 @@ import {
|
|||||||
import { VideoDetailItem, CommentItem, UserMeResponse } from '../types/api';
|
import { VideoDetailItem, CommentItem, UserMeResponse } from '../types/api';
|
||||||
import { buildVideoShareUrl, tryNativeShare } from '../utils/nativeShare';
|
import { buildVideoShareUrl, tryNativeShare } from '../utils/nativeShare';
|
||||||
import LoginPromptModal from './LoginPromptModal';
|
import LoginPromptModal from './LoginPromptModal';
|
||||||
import { isSafeHttpUrl } from '../utils/url';
|
|
||||||
|
|
||||||
interface VideoDetailContentProps {
|
interface VideoDetailContentProps {
|
||||||
videoId: string;
|
videoId: string;
|
||||||
@ -200,14 +199,11 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
|||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="video-detail-header">
|
<div className="video-detail-header">
|
||||||
{isModal ? (
|
{isModal ? (
|
||||||
<>
|
|
||||||
<h2 className="video-detail-modal-title">{t('sidebar.ado2Contents')}</h2>
|
|
||||||
<button className="video-detail-close-btn" onClick={handleHeaderAction} aria-label={t('videoDetail.closeAriaLabel')}>
|
<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">
|
<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"/>
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<button className="video-detail-back-btn" onClick={handleHeaderAction}>
|
<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">
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
@ -218,7 +214,6 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="video-detail-body">
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="ado2-contents-loading">
|
<div className="ado2-contents-loading">
|
||||||
<div className="loading-spinner"></div>
|
<div className="loading-spinner"></div>
|
||||||
@ -233,7 +228,6 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
|||||||
src={video.result_movie_url}
|
src={video.result_movie_url}
|
||||||
controls
|
controls
|
||||||
autoPlay
|
autoPlay
|
||||||
playsInline
|
|
||||||
controlsList="nodownload"
|
controlsList="nodownload"
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
className="video-detail-player"
|
className="video-detail-player"
|
||||||
@ -250,9 +244,9 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{showSiteOverlay && isSafeHttpUrl(video.official_site_url) && (
|
{video.official_site_url && showSiteOverlay && (
|
||||||
<a
|
<a
|
||||||
href={video.official_site_url as string}
|
href={video.official_site_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="video-site-overlay"
|
className="video-site-overlay"
|
||||||
@ -437,7 +431,6 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
|
||||||
|
|
||||||
{showLoginModal && (
|
{showLoginModal && (
|
||||||
<LoginPromptModal onClose={() => setShowLoginModal(false)} />
|
<LoginPromptModal onClose={() => setShowLoginModal(false)} />
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import VideoDetailContent from './VideoDetailContent';
|
import VideoDetailContent from './VideoDetailContent';
|
||||||
import { useOverlayClose } from '../hooks/useOverlayClose';
|
|
||||||
|
|
||||||
interface VideoDetailModalProps {
|
interface VideoDetailModalProps {
|
||||||
videoId: string;
|
videoId: string;
|
||||||
@ -24,11 +23,30 @@ const VideoDetailModal: React.FC<VideoDetailModalProps> = ({ videoId, onClose })
|
|||||||
};
|
};
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
const overlayCloseHandlers = useOverlayClose(onClose);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="video-detail-overlay" {...overlayCloseHandlers}>
|
<div
|
||||||
<div className="video-detail-modal-box" onClick={(e) => e.stopPropagation()}>
|
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()}
|
||||||
|
>
|
||||||
<VideoDetailContent videoId={videoId} isModal={true} onClose={onClose} />
|
<VideoDetailContent videoId={videoId} isModal={true} onClose={onClose} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,18 +3,13 @@ import React from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface WizardStepperProps {
|
interface WizardStepperProps {
|
||||||
currentStep: number; // 0-based. ADO2 는 wizardStep 0~3 과 일치
|
currentStep: number; // 0~3, matching wizardStep in GenerationFlow
|
||||||
/**
|
|
||||||
* 단계 라벨. 생략하면 ADO2 기본 4단계를 쓴다(기존 호출부 무수정).
|
|
||||||
* 썰박스처럼 단계 구성이 다른 파이프라인이 직접 넘긴다.
|
|
||||||
*/
|
|
||||||
steps?: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const WizardStepper: React.FC<WizardStepperProps> = ({ currentStep, steps: stepsProp }) => {
|
const WizardStepper: React.FC<WizardStepperProps> = ({ currentStep }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const steps = stepsProp ?? [
|
const steps = [
|
||||||
t('wizardSteps.brandAnalysis'),
|
t('wizardSteps.brandAnalysis'),
|
||||||
t('wizardSteps.asset'),
|
t('wizardSteps.asset'),
|
||||||
t('wizardSteps.sound'),
|
t('wizardSteps.sound'),
|
||||||
|
|||||||
@ -1,93 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
@ -5,6 +5,17 @@ import './i18n';
|
|||||||
import './styles/index.css';
|
import './styles/index.css';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
Kakao: any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const kakaoKey = import.meta.env.VITE_KAKAO_JS_KEY as string | undefined;
|
||||||
|
if (kakaoKey && window.Kakao && !window.Kakao.isInitialized()) {
|
||||||
|
window.Kakao.init(kakaoKey);
|
||||||
|
}
|
||||||
|
|
||||||
const rootElement = document.getElementById('root');
|
const rootElement = document.getElementById('root');
|
||||||
if (!rootElement) {
|
if (!rootElement) {
|
||||||
throw new Error("Could not find root element to mount to");
|
throw new Error("Could not find root element to mount to");
|
||||||
|
|||||||
@ -21,9 +21,82 @@
|
|||||||
"credits": "Credits left: {{count}}",
|
"credits": "Credits left: {{count}}",
|
||||||
"loggingOut": "Logging out...",
|
"loggingOut": "Logging out...",
|
||||||
"logout": "Log Out",
|
"logout": "Log Out",
|
||||||
"inquiry": "Feedback",
|
"tutorialRestart": "Restart Tutorial",
|
||||||
"login": "Log In",
|
"tutorial": "Tutorial",
|
||||||
"settings": "Settings"
|
"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"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"company": "O2O Inc.",
|
"company": "O2O Inc.",
|
||||||
@ -440,9 +513,7 @@
|
|||||||
"chargeSubmit": "Submit",
|
"chargeSubmit": "Submit",
|
||||||
"chargeSubmitting": "Submitting...",
|
"chargeSubmitting": "Submitting...",
|
||||||
"chargeSuccess": "Your top-up request has been submitted!",
|
"chargeSuccess": "Your top-up request has been submitted!",
|
||||||
"chargeConfirm": "OK",
|
"chargeConfirm": "OK"
|
||||||
"tabContents": "My Contents",
|
|
||||||
"tabCalendar": "Calendar"
|
|
||||||
},
|
},
|
||||||
"ado2Contents": {
|
"ado2Contents": {
|
||||||
"title": "ADO2 Contents",
|
"title": "ADO2 Contents",
|
||||||
@ -526,29 +597,8 @@
|
|||||||
"scheduled": "Planned",
|
"scheduled": "Planned",
|
||||||
"failed": "Failed"
|
"failed": "Failed"
|
||||||
},
|
},
|
||||||
"months": [
|
"months": ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],
|
||||||
"Jan",
|
"days": ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],
|
||||||
"Feb",
|
|
||||||
"Mar",
|
|
||||||
"Apr",
|
|
||||||
"May",
|
|
||||||
"Jun",
|
|
||||||
"Jul",
|
|
||||||
"Aug",
|
|
||||||
"Sep",
|
|
||||||
"Oct",
|
|
||||||
"Nov",
|
|
||||||
"Dec"
|
|
||||||
],
|
|
||||||
"days": [
|
|
||||||
"Sun",
|
|
||||||
"Mon",
|
|
||||||
"Tue",
|
|
||||||
"Wed",
|
|
||||||
"Thu",
|
|
||||||
"Fri",
|
|
||||||
"Sat"
|
|
||||||
],
|
|
||||||
"yearMonth": "{{month}} {{year}}",
|
"yearMonth": "{{month}} {{year}}",
|
||||||
"monthDay": "{{month}} {{day}}",
|
"monthDay": "{{month}} {{day}}",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
@ -566,8 +616,7 @@
|
|||||||
},
|
},
|
||||||
"loginPrompt": {
|
"loginPrompt": {
|
||||||
"title": "Login Required",
|
"title": "Login Required",
|
||||||
"loginBtn": "Login",
|
"loginBtn": "Login"
|
||||||
"description": "Sign in with Kakao in a few seconds\nto keep going."
|
|
||||||
},
|
},
|
||||||
"videoDetail": {
|
"videoDetail": {
|
||||||
"dateFormat": "{{month}}/{{day}}/{{year}}",
|
"dateFormat": "{{month}}/{{day}}/{{year}}",
|
||||||
@ -578,6 +627,10 @@
|
|||||||
"share": "Share",
|
"share": "Share",
|
||||||
"copied": "Copied!",
|
"copied": "Copied!",
|
||||||
"siteOverlayLabel": "View more",
|
"siteOverlayLabel": "View more",
|
||||||
|
"shareKakao": "KakaoTalk",
|
||||||
|
"shareFacebook": "Facebook",
|
||||||
|
"shareTwitter": "X (Twitter)",
|
||||||
|
"copyUrl": "Copy URL",
|
||||||
"commentsTitle": "Comments",
|
"commentsTitle": "Comments",
|
||||||
"commentPlaceholder": "Write a comment...",
|
"commentPlaceholder": "Write a comment...",
|
||||||
"commentLoginRequired": "Please log in to write a comment",
|
"commentLoginRequired": "Please log in to write a comment",
|
||||||
@ -599,115 +652,5 @@
|
|||||||
"autocompleteError": "No results found. Please check your input and try again.",
|
"autocompleteError": "No results found. Please check your input and try again.",
|
||||||
"autocompleteGeneralError": "An error occurred while retrieving business information. Please try again.",
|
"autocompleteGeneralError": "An error occurred while retrieving business information. Please try again.",
|
||||||
"pageComingSoon": "{{page}} page is coming soon."
|
"pageComingSoon": "{{page}} page is coming soon."
|
||||||
},
|
|
||||||
"p2vViewer": {
|
|
||||||
"download": "Download",
|
|
||||||
"close": "Close"
|
|
||||||
},
|
|
||||||
"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",
|
|
||||||
"force": "Ignore and proceed",
|
|
||||||
"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,12 +21,85 @@
|
|||||||
"credits": "보유 크레딧: {{count}}",
|
"credits": "보유 크레딧: {{count}}",
|
||||||
"loggingOut": "로그아웃 중...",
|
"loggingOut": "로그아웃 중...",
|
||||||
"logout": "로그아웃",
|
"logout": "로그아웃",
|
||||||
"inquiry": "고객의견",
|
"tutorialRestart": "튜토리얼 다시 보기",
|
||||||
"login": "로그인",
|
"tutorial": "튜토리얼",
|
||||||
"settings": "설정"
|
"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": "취소"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"company": "㈜에이아이오투오",
|
"company":"㈜에이아이오투오",
|
||||||
"businessNumber": "사업자 등록번호 : 620-87-00810 | 대표 : 안성민",
|
"businessNumber": "사업자 등록번호 : 620-87-00810 | 대표 : 안성민",
|
||||||
"headquarters": "본사 : 대구광역시 북구 옥산로 111, 5층 유니콘랩 대구 A05호",
|
"headquarters": "본사 : 대구광역시 북구 옥산로 111, 5층 유니콘랩 대구 A05호",
|
||||||
"researchCenter": "연구소 : 경기 성남시 수정구 금토로 32 (금토동) (주)KT 판교빌딩 504호~505호 (East)",
|
"researchCenter": "연구소 : 경기 성남시 수정구 금토로 32 (금토동) (주)KT 판교빌딩 504호~505호 (East)",
|
||||||
@ -109,8 +182,7 @@
|
|||||||
"close": "닫기",
|
"close": "닫기",
|
||||||
"doNotClose": "업로드가 진행 중입니다. 창을 닫지 마세요.",
|
"doNotClose": "업로드가 진행 중입니다. 창을 닫지 마세요.",
|
||||||
"goToCalendar": "캘린더에서 확인",
|
"goToCalendar": "캘린더에서 확인",
|
||||||
"scheduleConflict": "예약 시간이 충돌하여 {{time}}으로 조정되었습니다."
|
"scheduleConflict": "예약 시간이 충돌하여 {{time}}으로 조정되었습니다." },
|
||||||
},
|
|
||||||
"landing": {
|
"landing": {
|
||||||
"hero": {
|
"hero": {
|
||||||
"searchTypeLabel": "업체명 | URL",
|
"searchTypeLabel": "업체명 | URL",
|
||||||
@ -440,9 +512,7 @@
|
|||||||
"chargeSubmit": "요청하기",
|
"chargeSubmit": "요청하기",
|
||||||
"chargeSubmitting": "요청 중...",
|
"chargeSubmitting": "요청 중...",
|
||||||
"chargeSuccess": "충전 요청이 완료 되었습니다!",
|
"chargeSuccess": "충전 요청이 완료 되었습니다!",
|
||||||
"chargeConfirm": "확인",
|
"chargeConfirm": "확인"
|
||||||
"tabContents": "내 콘텐츠",
|
|
||||||
"tabCalendar": "콘텐츠 캘린더"
|
|
||||||
},
|
},
|
||||||
"ado2Contents": {
|
"ado2Contents": {
|
||||||
"title": "ADO2 콘텐츠",
|
"title": "ADO2 콘텐츠",
|
||||||
@ -526,29 +596,8 @@
|
|||||||
"scheduled": "예약",
|
"scheduled": "예약",
|
||||||
"failed": "실패"
|
"failed": "실패"
|
||||||
},
|
},
|
||||||
"months": [
|
"months": ["1월","2월","3월","4월","5월","6월","7월","8월","9월","10월","11월","12월"],
|
||||||
"1월",
|
"days": ["일","월","화","수","목","금","토"],
|
||||||
"2월",
|
|
||||||
"3월",
|
|
||||||
"4월",
|
|
||||||
"5월",
|
|
||||||
"6월",
|
|
||||||
"7월",
|
|
||||||
"8월",
|
|
||||||
"9월",
|
|
||||||
"10월",
|
|
||||||
"11월",
|
|
||||||
"12월"
|
|
||||||
],
|
|
||||||
"days": [
|
|
||||||
"일",
|
|
||||||
"월",
|
|
||||||
"화",
|
|
||||||
"수",
|
|
||||||
"목",
|
|
||||||
"금",
|
|
||||||
"토"
|
|
||||||
],
|
|
||||||
"yearMonth": "{{year}}년 {{month}}",
|
"yearMonth": "{{year}}년 {{month}}",
|
||||||
"monthDay": "{{month}} {{day}}일",
|
"monthDay": "{{month}} {{day}}일",
|
||||||
"loading": "불러오는 중...",
|
"loading": "불러오는 중...",
|
||||||
@ -566,8 +615,7 @@
|
|||||||
},
|
},
|
||||||
"loginPrompt": {
|
"loginPrompt": {
|
||||||
"title": "로그인이 필요합니다.",
|
"title": "로그인이 필요합니다.",
|
||||||
"loginBtn": "로그인",
|
"loginBtn": "로그인"
|
||||||
"description": "카카오 계정으로 간편하게 로그인하고\n이어서 진행해보세요."
|
|
||||||
},
|
},
|
||||||
"videoDetail": {
|
"videoDetail": {
|
||||||
"dateFormat": "{{year}}년 {{month}}월 {{day}}일",
|
"dateFormat": "{{year}}년 {{month}}월 {{day}}일",
|
||||||
@ -578,6 +626,10 @@
|
|||||||
"share": "공유하기",
|
"share": "공유하기",
|
||||||
"copied": "복사됨!",
|
"copied": "복사됨!",
|
||||||
"siteOverlayLabel": "자세히 보기",
|
"siteOverlayLabel": "자세히 보기",
|
||||||
|
"shareKakao": "카카오톡",
|
||||||
|
"shareFacebook": "페이스북",
|
||||||
|
"shareTwitter": "X (트위터)",
|
||||||
|
"copyUrl": "URL 복사",
|
||||||
"commentsTitle": "댓글",
|
"commentsTitle": "댓글",
|
||||||
"commentPlaceholder": "댓글을 입력하세요...",
|
"commentPlaceholder": "댓글을 입력하세요...",
|
||||||
"commentLoginRequired": "로그인 후 댓글을 작성할 수 있습니다",
|
"commentLoginRequired": "로그인 후 댓글을 작성할 수 있습니다",
|
||||||
@ -599,213 +651,5 @@
|
|||||||
"autocompleteError": "검색 정보를 찾을 수 없습니다. 입력 정보를 다시 확인해주세요.",
|
"autocompleteError": "검색 정보를 찾을 수 없습니다. 입력 정보를 다시 확인해주세요.",
|
||||||
"autocompleteGeneralError": "업체 정보 조회 중 오류가 발생했습니다. 다시 시도해주세요.",
|
"autocompleteGeneralError": "업체 정보 조회 중 오류가 발생했습니다. 다시 시도해주세요.",
|
||||||
"pageComingSoon": "{{page}} 페이지 준비 중입니다."
|
"pageComingSoon": "{{page}} 페이지 준비 중입니다."
|
||||||
},
|
|
||||||
"p2vViewer": {
|
|
||||||
"download": "다운로드",
|
|
||||||
"close": "닫기"
|
|
||||||
},
|
|
||||||
"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": "다시 시도",
|
|
||||||
"force": "무시하고 진행",
|
|
||||||
"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,7 +1,10 @@
|
|||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { CrawlingResponse, TargetPersona } from '../../types/api';
|
import { CrawlingResponse, TargetPersona } from '../../types/api';
|
||||||
import { GeometricChart } from './GeometricChart';
|
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 {
|
interface AnalysisResultSectionProps {
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
@ -11,8 +14,19 @@ interface AnalysisResultSectionProps {
|
|||||||
|
|
||||||
const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, onGenerate, data }) => {
|
const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, onGenerate, data }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const tutorial = useTutorial();
|
||||||
const { processed_info, marketing_analysis } = data;
|
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 brandIdentity = marketing_analysis?.brand_identity;
|
||||||
const marketPositioning = marketing_analysis?.market_positioning;
|
const marketPositioning = marketing_analysis?.market_positioning;
|
||||||
const targetPersonas = marketing_analysis?.target_persona || [];
|
const targetPersonas = marketing_analysis?.target_persona || [];
|
||||||
@ -23,6 +37,7 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
|
|||||||
const sortedSellingPoints = [...sellingPoints].sort((a, b) => b.score - a.score);
|
const sortedSellingPoints = [...sellingPoints].sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="bi2-page">
|
<div className="bi2-page">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bi2-header">
|
<div className="bi2-header">
|
||||||
@ -174,6 +189,18 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{tutorial.isActive && (
|
||||||
|
<TutorialOverlay
|
||||||
|
hints={tutorial.hints}
|
||||||
|
currentIndex={tutorial.currentHintIndex}
|
||||||
|
onNext={tutorial.nextHint}
|
||||||
|
onPrev={tutorial.prevHint}
|
||||||
|
onSkip={tutorial.skipTutorial}
|
||||||
|
groupProgress={tutorial.groupProgress}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,11 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { getAllVideos } from '../../utils/api';
|
import { getAllVideos, isLoggedIn } from '../../utils/api';
|
||||||
import { VideoListItem } from '../../types/api';
|
import { VideoListItem } from '../../types/api';
|
||||||
|
import LoginPromptModal from '../../components/LoginPromptModal';
|
||||||
import VideoDetailModal from '../../components/VideoDetailModal';
|
import VideoDetailModal from '../../components/VideoDetailModal';
|
||||||
import CitySelectModal from '../../components/CitySelectModal';
|
import CitySelectModal from '../../components/CitySelectModal';
|
||||||
import ContentCardSocialActions from '../../components/ContentCardSocialActions';
|
import ContentCardSocialActions from '../../components/ContentCardSocialActions';
|
||||||
import P2vViewerModal from '../../components/P2vViewerModal';
|
|
||||||
import SsulViewerModal from '../Ssulbox/SsulViewerModal';
|
|
||||||
|
|
||||||
interface ADO2ContentsPageProps {
|
interface ADO2ContentsPageProps {
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
@ -14,15 +13,11 @@ interface ADO2ContentsPageProps {
|
|||||||
|
|
||||||
const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
|
const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const authed = isLoggedIn();
|
||||||
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null);
|
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null);
|
||||||
// 썰박스는 별도 뷰어를 쓴다 — VideoDetailModal 은 video_id 로 조회하므로
|
|
||||||
// 썰박스 id 를 넘기면 id 가 겹치는 다른 영상이 열린다.
|
|
||||||
const [selectedSsul, setSelectedSsul] = useState<VideoListItem | null>(null);
|
|
||||||
// P2V(무빙 포스터·스타일링)도 별도 뷰어 — 상세 API 없이 목록 데이터로 그린다
|
|
||||||
const [selectedP2v, setSelectedP2v] = useState<VideoListItem | null>(null);
|
|
||||||
const [videos, setVideos] = useState<VideoListItem[]>([]);
|
const [videos, setVideos] = useState<VideoListItem[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(authed);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [hasNext, setHasNext] = useState(false);
|
const [hasNext, setHasNext] = useState(false);
|
||||||
@ -38,6 +33,7 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
|
|||||||
const [showCityModal, setShowCityModal] = useState(false);
|
const [showCityModal, setShowCityModal] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!authed) return;
|
||||||
fetchVideos();
|
fetchVideos();
|
||||||
}, [page, sortBy, order, storeName, region]);
|
}, [page, sortBy, order, storeName, region]);
|
||||||
|
|
||||||
@ -145,25 +141,14 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
|
|||||||
<>
|
<>
|
||||||
<div className="ado2-contents-grid">
|
<div className="ado2-contents-grid">
|
||||||
{videos.map((video) => (
|
{videos.map((video) => (
|
||||||
// key·상세 열기 모두 (type, video_id) 로 다뤄야 한다 —
|
|
||||||
// video_id 는 종류별 독립 시퀀스라 값이 겹친다.
|
|
||||||
<div
|
<div
|
||||||
key={`${video.type}-${video.video_id}`}
|
key={video.video_id}
|
||||||
className="ado2-content-card"
|
className="ado2-content-card"
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
onClick={() => {
|
onClick={() => handleCardClick(video.video_id)}
|
||||||
if (video.type === 'ssul') setSelectedSsul(video);
|
|
||||||
else if (video.type === 'p2v_video' || video.type === 'p2v_poster') setSelectedP2v(video);
|
|
||||||
else handleCardClick(video.video_id);
|
|
||||||
}}
|
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => e.key === 'Enter' && handleCardClick(video.video_id)}
|
||||||
if (e.key !== 'Enter') return;
|
|
||||||
if (video.type === 'ssul') setSelectedSsul(video);
|
|
||||||
else if (video.type === 'p2v_video' || video.type === 'p2v_poster') setSelectedP2v(video);
|
|
||||||
else handleCardClick(video.video_id);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="content-card-thumbnail ado2-gallery-thumbnail-wrap">
|
<div className="content-card-thumbnail ado2-gallery-thumbnail-wrap">
|
||||||
{video.poster_url ? (
|
{video.poster_url ? (
|
||||||
@ -203,24 +188,17 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
|
|||||||
<div className="content-card-info">
|
<div className="content-card-info">
|
||||||
<div className="content-card-text">
|
<div className="content-card-text">
|
||||||
<h3 className="content-card-title">{video.store_name}</h3>
|
<h3 className="content-card-title">{video.store_name}</h3>
|
||||||
<div className="content-card-meta">
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<p className="content-card-date">{formatDate(video.created_at)}</p>
|
<p className="content-card-date">{formatDate(video.created_at)}</p>
|
||||||
{/* 좋아요·댓글 API 가 type 을 받으므로 썰박스에도 노출한다.
|
|
||||||
contentType 이 없으면 id 가 겹치는 다른 영상에 반영된다.
|
|
||||||
갤러리(/video/all)에는 video·ssul 만 내려온다 — P2V 는 내 콘텐츠 전용. */}
|
|
||||||
{(video.type === 'video' || video.type === 'ssul') && (
|
|
||||||
<ContentCardSocialActions
|
<ContentCardSocialActions
|
||||||
videoId={video.video_id}
|
videoId={video.video_id}
|
||||||
contentType={video.type}
|
|
||||||
storeName={video.store_name}
|
storeName={video.store_name}
|
||||||
region={video.region}
|
region={video.region}
|
||||||
title={video.title}
|
title={video.title}
|
||||||
description={video.description}
|
|
||||||
commentCount={video.comment_count ?? 0}
|
commentCount={video.comment_count ?? 0}
|
||||||
initialLikeCount={video.like_count ?? 0}
|
initialLikeCount={video.like_count ?? 0}
|
||||||
initialIsLiked={video.is_liked_by_me}
|
initialIsLiked={video.is_liked_by_me}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -248,9 +226,9 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SsulViewerModal item={selectedSsul} onClose={() => setSelectedSsul(null)} />
|
{!authed && (
|
||||||
|
<LoginPromptModal onClose={() => { window.location.href = '/'; }} />
|
||||||
<P2vViewerModal item={selectedP2v} onClose={() => setSelectedP2v(null)} />
|
)}
|
||||||
|
|
||||||
{selectedVideoId !== null && (
|
{selectedVideoId !== null && (
|
||||||
<VideoDetailModal
|
<VideoDetailModal
|
||||||
|
|||||||
@ -3,7 +3,9 @@ import React, { useState, useEffect, useRef } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { generateVideo, waitForVideoComplete, getSubtitleStatus, waitForSubtitleComplete, trackFirstVideoCreated } from '../../utils/api';
|
import { generateVideo, waitForVideoComplete, getSubtitleStatus, waitForSubtitleComplete, trackFirstVideoCreated } from '../../utils/api';
|
||||||
import SocialPostingModal from '../../components/SocialPostingModal';
|
import SocialPostingModal from '../../components/SocialPostingModal';
|
||||||
import CompletionView from '../../components/CompletionView';
|
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
||||||
|
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
||||||
|
import TutorialOverlay from '../../components/Tutorial/TutorialOverlay';
|
||||||
|
|
||||||
interface CompletionContentProps {
|
interface CompletionContentProps {
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
@ -44,6 +46,22 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||||||
const hasStartedGeneration = useRef(false);
|
const hasStartedGeneration = useRef(false);
|
||||||
const displayIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
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 [showSocialModal, setShowSocialModal] = useState(false);
|
||||||
const [videoDbId, setVideoDbId] = useState<number | null>(null);
|
const [videoDbId, setVideoDbId] = useState<number | null>(null);
|
||||||
@ -392,13 +410,92 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||||||
return `${businessName}.mp4`;
|
return `${businessName}.mp4`;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
return (
|
||||||
* 가사 단락 파싱.
|
<main className="comp2-page">
|
||||||
*
|
{/* <div className="comp2-header">
|
||||||
* `[Verse]` 처럼 대괄호만 있는 줄을 섹션 머리로 보고, 뒤따르는 줄들을 그 섹션에
|
<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" />
|
||||||
const renderLyrics = () => {
|
</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) {
|
if (!songCompletionData) {
|
||||||
return <p className="comp2-lyrics-text">{t('completion.sampleLyrics')}</p>;
|
return <p className="comp2-lyrics-text">{t('completion.sampleLyrics')}</p>;
|
||||||
}
|
}
|
||||||
@ -419,9 +516,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<div className="comp2-lyrics-paragraphs">
|
<div className="comp2-lyrics-paragraphs">
|
||||||
{sections
|
{sections.filter(s => s.lines.length > 0).map((section, i) => (
|
||||||
.filter((s) => s.lines.length > 0)
|
|
||||||
.map((section, i) => (
|
|
||||||
<div key={i} className="comp2-lyrics-para-section">
|
<div key={i} className="comp2-lyrics-para-section">
|
||||||
{section.tag && <span className="comp2-lyrics-tag">{section.tag}</span>}
|
{section.tag && <span className="comp2-lyrics-tag">{section.tag}</span>}
|
||||||
<p className="comp2-lyrics-text">{section.lines.join('\n')}</p>
|
<p className="comp2-lyrics-text">{section.lines.join('\n')}</p>
|
||||||
@ -429,47 +524,37 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
})()}
|
||||||
|
</div>
|
||||||
return (
|
</div>
|
||||||
<CompletionView
|
</div>
|
||||||
videoState={
|
{/* 하단 버튼 */}
|
||||||
isLoading
|
<div className="comp2-buttons">
|
||||||
? 'loading'
|
<button
|
||||||
: videoStatus === 'error'
|
onClick={handleDownload}
|
||||||
? 'error'
|
disabled={videoStatus !== 'complete' || !videoUrl || isDownloading}
|
||||||
: videoUrl
|
className="comp2-btn comp2-btn-secondary"
|
||||||
? '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}
|
|
||||||
>
|
>
|
||||||
|
{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 컴포넌트 사용) */}
|
||||||
<SocialPostingModal
|
<SocialPostingModal
|
||||||
isOpen={showSocialModal}
|
isOpen={showSocialModal}
|
||||||
onClose={handleCloseSocialConnect}
|
onClose={handleCloseSocialConnect}
|
||||||
onGoToCalendar={onGoToCalendar}
|
onGoToCalendar={onGoToCalendar}
|
||||||
video={
|
video={videoUrl && videoDbId ? {
|
||||||
videoUrl && videoDbId
|
|
||||||
? {
|
|
||||||
// ADO2 파이프라인 완료 화면이므로 항상 영상이다
|
|
||||||
type: 'video',
|
|
||||||
video_id: videoDbId,
|
video_id: videoDbId,
|
||||||
store_name: songCompletionData?.businessName || '',
|
store_name: songCompletionData?.businessName || '',
|
||||||
region: '',
|
region: '',
|
||||||
@ -479,11 +564,20 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||||||
like_count: 0,
|
like_count: 0,
|
||||||
comment_count: 0,
|
comment_count: 0,
|
||||||
is_liked_by_me: false,
|
is_liked_by_me: false,
|
||||||
}
|
} : null}
|
||||||
: null
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</CompletionView>
|
|
||||||
|
{tutorial.isActive && (
|
||||||
|
<TutorialOverlay
|
||||||
|
hints={tutorial.hints}
|
||||||
|
currentIndex={tutorial.currentHintIndex}
|
||||||
|
onNext={tutorial.nextHint}
|
||||||
|
onPrev={tutorial.prevHint}
|
||||||
|
onSkip={tutorial.skipTutorial}
|
||||||
|
groupProgress={tutorial.groupProgress}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -67,17 +67,16 @@ const statusLabelKey = (status: UploadStatus) => {
|
|||||||
|
|
||||||
interface ContentCalendarContentProps {
|
interface ContentCalendarContentProps {
|
||||||
onNavigate?: (id: string) => void;
|
onNavigate?: (id: string) => void;
|
||||||
/** 내 정보 탭 안에 임베드될 때: 탭 라벨과 중복되는 자체 제목을 숨기고 여백을 줄인다 */
|
|
||||||
embedded?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavigate, embedded }) => {
|
const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavigate }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const [year, setYear] = useState(today.getFullYear());
|
const [year, setYear] = useState(today.getFullYear());
|
||||||
const [month, setMonth] = useState(today.getMonth());
|
const [month, setMonth] = useState(today.getMonth());
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('전체');
|
const [activeTab, setActiveTab] = useState<TabType>('전체');
|
||||||
const [isMobile, setIsMobile] = useState(window.innerWidth < MOBILE_BREAKPOINT);
|
const [isMobile, setIsMobile] = useState(window.innerWidth < MOBILE_BREAKPOINT);
|
||||||
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
// allItems: 캘린더 도트용 (전체)
|
// allItems: 캘린더 도트용 (전체)
|
||||||
// panelItems: 오른쪽 패널용 (탭 필터)
|
// panelItems: 오른쪽 패널용 (탭 필터)
|
||||||
const [allItems, setAllItems] = useState<UploadHistoryItem[]>([]);
|
const [allItems, setAllItems] = useState<UploadHistoryItem[]>([]);
|
||||||
@ -195,16 +194,11 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const el = dateRefs.current[dateKey];
|
const el = dateRefs.current[dateKey];
|
||||||
const panel = panelRef.current;
|
const panel = panelRef.current;
|
||||||
if (!el) return;
|
if (el && panel) {
|
||||||
// 패널이 자체 스크롤을 가진 경우(데스크톱 사이드 패널)만 내부 스크롤.
|
|
||||||
// 모바일은 목록이 캘린더 아래에 그대로 펼쳐지므로 페이지 스크롤로 이동한다.
|
|
||||||
if (panel && panel.scrollHeight > panel.clientHeight) {
|
|
||||||
const panelTop = panel.getBoundingClientRect().top;
|
const panelTop = panel.getBoundingClientRect().top;
|
||||||
const elTop = el.getBoundingClientRect().top;
|
const elTop = el.getBoundingClientRect().top;
|
||||||
const offset = elTop - panelTop + panel.scrollTop - 12; // 12px 여백
|
const offset = elTop - panelTop + panel.scrollTop - 12; // 12px 여백
|
||||||
panel.scrollTo({ top: offset, behavior: 'smooth' });
|
panel.scrollTo({ top: offset, behavior: 'smooth' });
|
||||||
} else {
|
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
}
|
}
|
||||||
}, 50);
|
}, 50);
|
||||||
};
|
};
|
||||||
@ -402,7 +396,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
|||||||
{summary.completed > 0 && (
|
{summary.completed > 0 && (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
<div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: '#1ba64f', flexShrink: 0 }} />
|
<div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: '#1ba64f', flexShrink: 0 }} />
|
||||||
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 12, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}>
|
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 11, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}>
|
||||||
{t('contentCalendar.completedCount', { count: summary.completed })}
|
{t('contentCalendar.completedCount', { count: summary.completed })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -410,7 +404,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
|||||||
{summary.scheduled > 0 && (
|
{summary.scheduled > 0 && (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
<div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: '#2563eb', flexShrink: 0 }} />
|
<div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: '#2563eb', flexShrink: 0 }} />
|
||||||
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 12, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}>
|
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 11, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}>
|
||||||
{t('contentCalendar.scheduledCount', { count: summary.scheduled })}
|
{t('contentCalendar.scheduledCount', { count: summary.scheduled })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -418,7 +412,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
|||||||
{summary.failed > 0 && (
|
{summary.failed > 0 && (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
<div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: '#e15252', flexShrink: 0 }} />
|
<div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: '#e15252', flexShrink: 0 }} />
|
||||||
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 12, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}>
|
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 11, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}>
|
||||||
{t('contentCalendar.failedCount', { count: summary.failed })}
|
{t('contentCalendar.failedCount', { count: summary.failed })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -434,7 +428,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
|||||||
|
|
||||||
// ── 탭 바 ────────────────────────────────────────────────────
|
// ── 탭 바 ────────────────────────────────────────────────────
|
||||||
const renderTabs = () => (
|
const renderTabs = () => (
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'center', width: '100%' }}>
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
{tabs.map(tab => (
|
{tabs.map(tab => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
@ -541,13 +535,13 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
|||||||
backgroundColor: statusColor(item.status as UploadStatus), flexShrink: 0,
|
backgroundColor: statusColor(item.status as UploadStatus), flexShrink: 0,
|
||||||
}} />
|
}} />
|
||||||
<span style={{
|
<span style={{
|
||||||
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 14,
|
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 13,
|
||||||
color: statusColor(item.status as UploadStatus), lineHeight: 1,
|
color: statusColor(item.status as UploadStatus), lineHeight: 1,
|
||||||
}}>
|
}}>
|
||||||
{t(statusLabelKey(item.status as UploadStatus))}
|
{t(statusLabelKey(item.status as UploadStatus))}
|
||||||
</span>
|
</span>
|
||||||
<span style={{
|
<span style={{
|
||||||
fontFamily: 'Pretendard, sans-serif', fontWeight: 400, fontSize: 14,
|
fontFamily: 'Pretendard, sans-serif', fontWeight: 400, fontSize: 13,
|
||||||
color: '#9bcacc', lineHeight: 1, marginLeft: 'auto',
|
color: '#9bcacc', lineHeight: 1, marginLeft: 'auto',
|
||||||
}}>
|
}}>
|
||||||
{formatTime(item)}
|
{formatTime(item)}
|
||||||
@ -595,7 +589,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
|||||||
style={{
|
style={{
|
||||||
height: 30, padding: '0 12px', borderRadius: 8,
|
height: 30, padding: '0 12px', borderRadius: 8,
|
||||||
border: '1px solid #379599', backgroundColor: 'transparent',
|
border: '1px solid #379599', backgroundColor: 'transparent',
|
||||||
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 14,
|
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 13,
|
||||||
color: '#9bcacc', cursor: 'pointer',
|
color: '#9bcacc', cursor: 'pointer',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -608,7 +602,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
|||||||
style={{
|
style={{
|
||||||
height: 30, padding: '0 12px', borderRadius: 8,
|
height: 30, padding: '0 12px', borderRadius: 8,
|
||||||
border: 'none', backgroundColor: '#a65eff',
|
border: 'none', backgroundColor: '#a65eff',
|
||||||
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 14,
|
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 13,
|
||||||
color: '#ffffff', cursor: 'pointer',
|
color: '#ffffff', cursor: 'pointer',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -622,11 +616,10 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── 패널 콘텐츠 ────────────────────────────────────────────
|
// ── 패널 콘텐츠 ────────────────────────────────────────────
|
||||||
// inline=true (모바일): 자체 스크롤 없이 페이지 흐름에 그대로 펼친다
|
const renderPanelContent = () => {
|
||||||
const renderPanelContent = (inline = false) => {
|
|
||||||
if (panelLoading) {
|
if (panelLoading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: inline ? 120 : undefined }}>
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<span style={{ color: '#9bcacc', fontFamily: 'Pretendard, sans-serif', fontSize: 14 }}>{t('contentCalendar.loading')}</span>
|
<span style={{ color: '#9bcacc', fontFamily: 'Pretendard, sans-serif', fontSize: 14 }}>{t('contentCalendar.loading')}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -636,7 +629,6 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
|||||||
<div style={{
|
<div style={{
|
||||||
flex: 1, display: 'flex', flexDirection: 'column',
|
flex: 1, display: 'flex', flexDirection: 'column',
|
||||||
alignItems: 'center', justifyContent: 'center', gap: 24, padding: 16,
|
alignItems: 'center', justifyContent: 'center', gap: 24, padding: 16,
|
||||||
minHeight: inline ? 160 : undefined,
|
|
||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center', textAlign: 'center' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center', textAlign: 'center' }}>
|
||||||
<p style={{
|
<p style={{
|
||||||
@ -670,11 +662,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
|||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
className="calendar-panel-scroll"
|
className="calendar-panel-scroll"
|
||||||
style={{
|
style={{ flex: 1, overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 20, maxHeight: 700 }}
|
||||||
flex: 1, padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 20,
|
|
||||||
overflowY: inline ? 'visible' : 'auto',
|
|
||||||
maxHeight: inline ? undefined : 700,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{sortedDateKeys.map(dateKey => (
|
{sortedDateKeys.map(dateKey => (
|
||||||
<div
|
<div
|
||||||
@ -703,10 +691,9 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
|||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||||
paddingTop: embedded ? 0 : 24, paddingBottom: 20, width: '100%',
|
paddingTop: 24, paddingBottom: 20, width: '100%',
|
||||||
boxSizing: 'border-box', position: 'relative',
|
boxSizing: 'border-box', position: 'relative',
|
||||||
}}>
|
}}>
|
||||||
{!embedded && (
|
|
||||||
<div style={{ width: '100%', paddingBottom: 24, paddingLeft: 20, paddingRight: 20, boxSizing: 'border-box' }}>
|
<div style={{ width: '100%', paddingBottom: 24, paddingLeft: 20, paddingRight: 20, boxSizing: 'border-box' }}>
|
||||||
<p style={{
|
<p style={{
|
||||||
fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 30,
|
fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 30,
|
||||||
@ -715,7 +702,6 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
|||||||
{t('contentCalendar.title')}
|
{t('contentCalendar.title')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex', flexDirection: 'column', gap: 16,
|
display: 'flex', flexDirection: 'column', gap: 16,
|
||||||
@ -730,24 +716,34 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
|||||||
{renderCalendarGrid()}
|
{renderCalendarGrid()}
|
||||||
</div>
|
</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={{
|
<div style={{
|
||||||
backgroundColor: '#01393b',
|
backgroundColor: '#01393b',
|
||||||
border: '1px solid #046266',
|
borderTop: '1px solid #046266',
|
||||||
borderRadius: 20,
|
borderRadius: '20px 20px 0 0',
|
||||||
overflow: 'hidden',
|
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={{
|
<div style={{
|
||||||
borderBottom: '1px solid #046266',
|
borderBottom: sheetOpen ? '1px solid #046266' : 'none',
|
||||||
height: 50, display: 'flex', alignItems: 'center', padding: '0 16px',
|
height: 50, display: 'flex', alignItems: 'center', padding: '0 16px',
|
||||||
}}>
|
}}>
|
||||||
{renderTabs()}
|
{renderTabs()}
|
||||||
</div>
|
</div>
|
||||||
{renderPanelContent(true)}
|
{sheetOpen && (
|
||||||
|
<div style={{ height: 400, overflowY: 'auto', backgroundColor: '#01393b', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{renderPanelContent()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ height: sheetOpen ? 516 : 116 }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -758,12 +754,11 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
|||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||||
padding: embedded ? '0' : '32px',
|
padding: '32px',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
minWidth: 1400,
|
minWidth: 1400,
|
||||||
height: embedded ? 'auto' : '100%', boxSizing: 'border-box',
|
height: '100%', boxSizing: 'border-box',
|
||||||
}}>
|
}}>
|
||||||
{!embedded && (
|
|
||||||
<div style={{ width: '100%', maxWidth: 1440, paddingBottom: 32, flexShrink: 0 }}>
|
<div style={{ width: '100%', maxWidth: 1440, paddingBottom: 32, flexShrink: 0 }}>
|
||||||
<p style={{
|
<p style={{
|
||||||
fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 30,
|
fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 30,
|
||||||
@ -772,14 +767,13 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
|||||||
{t('contentCalendar.title')}
|
{t('contentCalendar.title')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'grid',
|
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',
|
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,
|
gap: 16,
|
||||||
width: '100%', maxWidth: 1440,
|
width: '100%', maxWidth: 1440,
|
||||||
flex: 1, minHeight: embedded ? 'calc(100vh - 220px)' : 0,
|
flex: 1, minHeight: 0,
|
||||||
}}>
|
}}>
|
||||||
{/* 캘린더 영역 */}
|
{/* 캘린더 영역 */}
|
||||||
<div className="calendar-grid-area" style={{
|
<div className="calendar-grid-area" style={{
|
||||||
|
|||||||
@ -740,7 +740,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
|
|||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
color: 'rgba(255,255,255,0.85)',
|
color: 'rgba(255,255,255,0.85)',
|
||||||
padding: '5px 10px',
|
padding: '5px 10px',
|
||||||
fontSize: '14px',
|
fontSize: '13px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
minWidth: '160px',
|
minWidth: '160px',
|
||||||
}}
|
}}
|
||||||
@ -788,7 +788,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
|
|||||||
display: 'flex', alignItems: 'center', gap: '8px',
|
display: 'flex', alignItems: 'center', gap: '8px',
|
||||||
padding: '8px 12px',
|
padding: '8px 12px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: '14px',
|
fontSize: '13px',
|
||||||
background: acc.id === selectedAccountId ? 'rgba(255,255,255,0.08)' : 'transparent',
|
background: acc.id === selectedAccountId ? 'rgba(255,255,255,0.08)' : 'transparent',
|
||||||
color: 'rgba(255,255,255,0.85)',
|
color: 'rgba(255,255,255,0.85)',
|
||||||
}}
|
}}
|
||||||
@ -940,8 +940,8 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
|
|||||||
onClick={() => setShowMockData(prev => !prev)}
|
onClick={() => setShowMockData(prev => !prev)}
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
top: '10px',
|
bottom: '24px',
|
||||||
right: '5px',
|
right: '24px',
|
||||||
zIndex: 9999,
|
zIndex: 9999,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@ -2,24 +2,31 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import Sidebar from '../../components/Sidebar';
|
import Sidebar from '../../components/Sidebar';
|
||||||
import BottomNav from '../../components/BottomNav';
|
|
||||||
import LoginPromptModal from '../../components/LoginPromptModal';
|
|
||||||
import AssetManagementContent from './AssetManagementContent';
|
import AssetManagementContent from './AssetManagementContent';
|
||||||
import SoundStudioContent from './SoundStudioContent';
|
import SoundStudioContent from './SoundStudioContent';
|
||||||
import CompletionContent from './CompletionContent';
|
import CompletionContent from './CompletionContent';
|
||||||
import DashboardContent from './DashboardContent';
|
import DashboardContent from './DashboardContent';
|
||||||
|
import BusinessSettingsContent from './BusinessSettingsContent';
|
||||||
import UrlInputContent from './UrlInputContent';
|
import UrlInputContent from './UrlInputContent';
|
||||||
import ADO2ContentsPage from './ADO2ContentsPage';
|
import ADO2ContentsPage from './ADO2ContentsPage';
|
||||||
|
import MyContentsPage from './MyContentsPage';
|
||||||
import MyInfoContent from './MyInfoContent';
|
import MyInfoContent from './MyInfoContent';
|
||||||
|
import ContentCalendarContent from './ContentCalendarContent';
|
||||||
import LoadingSection from '../Analysis/LoadingSection';
|
import LoadingSection from '../Analysis/LoadingSection';
|
||||||
import AnalysisResultSection from '../Analysis/AnalysisResultSection';
|
import AnalysisResultSection from '../Analysis/AnalysisResultSection';
|
||||||
import { ImageItem, type ImageListItem, CrawlingResponse, UserMeResponse } from '../../types/api';
|
import { ImageItem, type ImageListItem, CrawlingResponse, UserMeResponse } from '../../types/api';
|
||||||
import { crawlUrl, autocomplete, marketingAnalysis, AutocompleteRequest, getUserMe, getUserCredits, clearTokens, getActiveSsulTask, waitForSsulComplete, isLoggedIn } from '../../utils/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 WizardStepper from '../../components/WizardStepper';
|
import WizardStepper from '../../components/WizardStepper';
|
||||||
import { NAV, GATED_ITEMS, sanitizeActiveItem, MyInfoTab } from '../../components/navItems';
|
import { createPreviewThumbnail } from '../../utils/imageCompression.ts';
|
||||||
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';
|
import { saveSearchHistory } from '../../components/SearchHistory/useSearchHistory';
|
||||||
|
|
||||||
/** 파일 이미지의 썸네일 objectURL 을 일괄 해제한다. preview 가 null 인 항목은 건너뛴다. */
|
/** 파일 이미지의 썸네일 objectURL 을 일괄 해제한다. preview 가 null 인 항목은 건너뛴다. */
|
||||||
@ -30,72 +37,22 @@ 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, forcePosterJob, isPastReview, retryPosterJob } from '../../utils/p2vApi';
|
|
||||||
import {
|
|
||||||
K,
|
|
||||||
clearProjectStorage,
|
|
||||||
clearSessionStorage,
|
|
||||||
} from '../../utils/storageKeys';
|
|
||||||
|
|
||||||
const WIZARD_STEP_KEY = K.WIZARD_STEP;
|
// 다른 컴포넌트에서 사용하는 storage key들 (초기화용)
|
||||||
const ACTIVE_ITEM_KEY = K.ACTIVE_ITEM;
|
const SONG_GENERATION_KEY = 'castad_song_generation';
|
||||||
const SONG_TASK_ID_KEY = K.SONG_TASK_ID;
|
const VIDEO_GENERATION_KEY = 'castad_video_generation';
|
||||||
const IMAGE_TASK_ID_KEY = K.IMAGE_TASK_ID;
|
const VIDEO_COMPLETE_KEY = 'castad_video_complete'; // 완료된 영상 정보
|
||||||
const ANALYSIS_DATA_KEY = K.ANALYSIS_DATA;
|
|
||||||
|
|
||||||
/**
|
// 모든 프로젝트 관련 localStorage 초기화
|
||||||
* 랜딩 CTA 에서 썰박스를 고르고 로그인하러 갈 때 남기는 1회성 프리셋.
|
const clearAllProjectStorage = () => {
|
||||||
* 카카오 로그인은 외부 리다이렉트 왕복이고 initializeOnNewSession 이
|
localStorage.removeItem(WIZARD_STEP_KEY);
|
||||||
* localStorage 만 지우므로 sessionStorage 를 쓴다.
|
localStorage.removeItem(SONG_TASK_ID_KEY);
|
||||||
*/
|
localStorage.removeItem(IMAGE_TASK_ID_KEY);
|
||||||
const PENDING_PIPELINE_KEY = 'castad_pending_pipeline';
|
localStorage.removeItem(SONG_GENERATION_KEY);
|
||||||
|
localStorage.removeItem(VIDEO_GENERATION_KEY);
|
||||||
/** 비로그인 게이트 클릭 시 "로그인 후 열어줄 메뉴"를 남기는 1회성 프리셋. 카카오 왕복을 견뎌야 해서 sessionStorage */
|
localStorage.removeItem(VIDEO_COMPLETE_KEY);
|
||||||
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 {
|
interface BusinessInfo {
|
||||||
customer_name: string;
|
customer_name: string;
|
||||||
region: string;
|
region: string;
|
||||||
@ -103,7 +60,7 @@ interface BusinessInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface GenerationFlowProps {
|
interface GenerationFlowProps {
|
||||||
onHome?: () => void;
|
onHome: () => void;
|
||||||
initialActiveItem?: string;
|
initialActiveItem?: string;
|
||||||
initialImageList?: ImageListItem[];
|
initialImageList?: ImageListItem[];
|
||||||
businessInfo?: BusinessInfo;
|
businessInfo?: BusinessInfo;
|
||||||
@ -120,7 +77,7 @@ interface GenerationFlowProps {
|
|||||||
|
|
||||||
const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
onHome,
|
onHome,
|
||||||
initialActiveItem = NAV.HOME,
|
initialActiveItem = '대시보드',
|
||||||
initialImageList = [],
|
initialImageList = [],
|
||||||
businessInfo,
|
businessInfo,
|
||||||
initialAnalysisData
|
initialAnalysisData
|
||||||
@ -144,20 +101,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isGuest = !isLoggedIn();
|
const [activeItem, setActiveItem] = useState(savedActiveItem || initialActiveItem);
|
||||||
|
|
||||||
/** 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 => {
|
const getInitialWizardStep = (): number => {
|
||||||
@ -177,77 +121,6 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const [wizardStep, setWizardStep] = useState(getInitialWizardStep());
|
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 [songTaskId, setSongTaskId] = useState<string | null>(savedSongTaskId);
|
||||||
const [imageTaskId, setImageTaskId] = useState<string | null>(savedImageTaskId);
|
const [imageTaskId, setImageTaskId] = useState<string | null>(savedImageTaskId);
|
||||||
const [videoGenerationStatus, setVideoGenerationStatus] = useState<'idle' | 'generating' | 'complete' | 'error'>('idle');
|
const [videoGenerationStatus, setVideoGenerationStatus] = useState<'idle' | 'generating' | 'complete' | 'error'>('idle');
|
||||||
@ -257,10 +130,10 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||||||
const [isAnalysisComplete, setIsAnalysisComplete] = useState(false);
|
const [isAnalysisComplete, setIsAnalysisComplete] = useState(false);
|
||||||
const [userInfo, setUserInfo] = useState<UserMeResponse | null>(null);
|
const [userInfo, setUserInfo] = useState<UserMeResponse | null>(null);
|
||||||
const [credits, setCredits] = useState<number | null>(null);
|
const [credits, setCredits] = useState<number | null>(null);
|
||||||
const [myInfoInitialTab, setMyInfoInitialTab] = useState<MyInfoTab | undefined>(initialNav.myInfoTab);
|
const [myInfoInitialTab, setMyInfoInitialTab] = useState<'basic' | 'payment' | 'business' | undefined>(undefined);
|
||||||
|
const tutorial = useTutorial();
|
||||||
|
|
||||||
const refreshCredits = useCallback(async () => {
|
const refreshCredits = useCallback(async () => {
|
||||||
if (!isLoggedIn()) return;
|
|
||||||
try {
|
try {
|
||||||
const { credits } = await getUserCredits();
|
const { credits } = await getUserCredits();
|
||||||
setCredits(credits);
|
setCredits(credits);
|
||||||
@ -276,9 +149,8 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||||||
}
|
}
|
||||||
}, [wizardStep]);
|
}, [wizardStep]);
|
||||||
|
|
||||||
// 로그인 직후 사용자 정보 + 크레딧 조회 (비로그인이면 요청 자체를 하지 않는다 — 401 → 강제 리다이렉트 방지)
|
// 로그인 직후 사용자 정보 + 크레딧 조회
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoggedIn()) return;
|
|
||||||
const fetchUserInfo = async () => {
|
const fetchUserInfo = async () => {
|
||||||
try {
|
try {
|
||||||
const [data, creditsData] = await Promise.all([getUserMe(), getUserCredits()]);
|
const [data, creditsData] = await Promise.all([getUserMe(), getUserCredits()]);
|
||||||
@ -294,9 +166,10 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||||||
// 로그아웃 핸들러
|
// 로그아웃 핸들러
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
clearTokens();
|
clearTokens();
|
||||||
// 세션 키를 전부 지운다. 이전에는 view_mode/analysis/wizard/active_item 만 지워
|
localStorage.removeItem('castad_view_mode');
|
||||||
// 생성 중이던 song·video 상태가 남았고, 다른 계정으로 로그인하면 그대로 복원됐다.
|
localStorage.removeItem('castad_analysis_data');
|
||||||
clearSessionStorage();
|
localStorage.removeItem(WIZARD_STEP_KEY);
|
||||||
|
localStorage.removeItem(ACTIVE_ITEM_KEY);
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -372,9 +245,9 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||||||
setImageList(prev => [...newImages, ...prev]);
|
setImageList(prev => [...newImages, ...prev]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 홈 버튼(로고) 클릭 시 프로젝트 상태를 비우고 ADO2 콘텐츠로 돌아간다.
|
// 홈 버튼(로고) 클릭 시 모든 상태 초기화 후 홈으로 이동
|
||||||
const handleHome = () => {
|
const handleHome = () => {
|
||||||
clearProjectStorage();
|
clearAllProjectStorage();
|
||||||
localStorage.removeItem(ANALYSIS_DATA_KEY);
|
localStorage.removeItem(ANALYSIS_DATA_KEY);
|
||||||
setWizardStep(-2);
|
setWizardStep(-2);
|
||||||
setSongTaskId(null);
|
setSongTaskId(null);
|
||||||
@ -382,11 +255,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||||||
setAnalysisData(null);
|
setAnalysisData(null);
|
||||||
revokeAllPreviews(imageListRef.current);
|
revokeAllPreviews(imageListRef.current);
|
||||||
setImageList([]);
|
setImageList([]);
|
||||||
if (onHome) {
|
|
||||||
onHome();
|
onHome();
|
||||||
return;
|
|
||||||
}
|
|
||||||
setActiveItem(NAV.HOME);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 위저드 단계 이동
|
// 위저드 단계 이동
|
||||||
@ -527,216 +396,51 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||||||
localStorage.setItem(ACTIVE_ITEM_KEY, activeItem);
|
localStorage.setItem(ACTIVE_ITEM_KEY, activeItem);
|
||||||
}, [activeItem]);
|
}, [activeItem]);
|
||||||
|
|
||||||
const [loginGateOpen, setLoginGateOpen] = useState(false);
|
// wizardStep 변경 시 튜토리얼 트리거 (사이드바 메뉴 화면에서는 제외)
|
||||||
|
const SIDEBAR_ITEMS = ['대시보드', 'ADO2 콘텐츠', '내 정보', '콘텐츠 캘린더'];
|
||||||
/** 내 정보의 특정 탭으로 딥링크 이동. 비로그인이면 로그인 관문을 띄운다 */
|
useEffect(() => {
|
||||||
const goToMyInfoTab = (tab: MyInfoTab) => {
|
if (tutorial.isActive) tutorial.skipTutorial();
|
||||||
if (!isLoggedIn()) {
|
if (SIDEBAR_ITEMS.includes(activeItem)) return;
|
||||||
sessionStorage.setItem(LOGIN_NEXT_ITEM_KEY, NAV.MY);
|
const timer = setTimeout(() => {
|
||||||
setLoginGateOpen(true);
|
if (wizardStep === 1 && !tutorial.hasSeen(TUTORIAL_KEYS.ASSET)) {
|
||||||
return;
|
tutorial.startTutorial(TUTORIAL_KEYS.ASSET);
|
||||||
|
} else if (wizardStep === 2 && !tutorial.hasSeen(TUTORIAL_KEYS.SOUND)) {
|
||||||
|
tutorial.startTutorial(TUTORIAL_KEYS.SOUND);
|
||||||
}
|
}
|
||||||
setMyInfoInitialTab(tab);
|
}, 600);
|
||||||
setActiveItem(NAV.MY);
|
return () => clearTimeout(timer);
|
||||||
};
|
}, [wizardStep, activeItem]);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}, [activeItem]);
|
||||||
|
|
||||||
// 결제 탭으로 바로 이동하는 핸들러 (크레딧 충전하기 버튼용)
|
// 결제 탭으로 바로 이동하는 핸들러 (크레딧 충전하기 버튼용)
|
||||||
const handleGoToPayment = () => {
|
const handleGoToPayment = () => {
|
||||||
goToMyInfoTab('payment');
|
setMyInfoInitialTab('payment');
|
||||||
|
setActiveItem('내 정보');
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* 썰박스 잡 폴링을 시작한다.
|
|
||||||
*
|
|
||||||
* 폴링 소유가 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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 실패를 무시하고 진행 — i2v 게이트 실패에서 이미 생성된 클립으로 렌더를 잇는다.
|
|
||||||
* 재시도와 달리 Higgsfield 재생성 비용이 없다. 무시 불가한 실패는 서버가 409 를 준다.
|
|
||||||
*/
|
|
||||||
const handlePosterForce = async () => {
|
|
||||||
if (!posterJobId || posterBusy) return;
|
|
||||||
setPosterBusy(true);
|
|
||||||
try {
|
|
||||||
await forcePosterJob(posterJobId);
|
|
||||||
refreshPosterJob();
|
|
||||||
} catch (e) {
|
|
||||||
// 409 = 무시할 수 없는 실패(클립 미생성 — 저해상·모션 없음 등).
|
|
||||||
// 조용히 삼키면 버튼이 아무 반응 없어 보여 재클릭만 유발한다 — 사유를 보여준다.
|
|
||||||
alert((e as Error).message);
|
|
||||||
} 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) => {
|
const handleNavigate = (item: string) => {
|
||||||
if (!isLoggedIn() && GATED_ITEMS.includes(item)) {
|
|
||||||
sessionStorage.setItem(LOGIN_NEXT_ITEM_KEY, item);
|
|
||||||
setLoginGateOpen(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setMyInfoInitialTab(undefined);
|
setMyInfoInitialTab(undefined);
|
||||||
if (item === NAV.NEW) {
|
if (item === '새 프로젝트 만들기') {
|
||||||
// 기존 프로젝트 데이터 초기화
|
// 기존 프로젝트 데이터 초기화
|
||||||
clearProjectStorage();
|
clearAllProjectStorage();
|
||||||
localStorage.removeItem(ANALYSIS_DATA_KEY);
|
localStorage.removeItem(ANALYSIS_DATA_KEY);
|
||||||
// URL 입력 단계로 명시적 저장 (다음 진입 시 올바른 단계 복원)
|
// URL 입력 단계로 명시적 저장 (다음 진입 시 올바른 단계 복원)
|
||||||
localStorage.setItem(WIZARD_STEP_KEY, '-2');
|
localStorage.setItem(WIZARD_STEP_KEY, '-2');
|
||||||
setWizardStep(-2);
|
setWizardStep(-2);
|
||||||
// 파이프라인도 기본값으로 되돌린다. clearProjectStorage() 가 K.PIPELINE 을
|
|
||||||
// 지우므로 state 만 남으면 저장소와 어긋난다.
|
|
||||||
setPipeline('ado2');
|
|
||||||
setSsulStep('entry');
|
|
||||||
// clearProjectStorage() 가 POSTER_JOB_ID 를 지우므로 state 도 함께 비운다
|
|
||||||
setPosterJobId(null);
|
|
||||||
setSongTaskId(null);
|
setSongTaskId(null);
|
||||||
setImageTaskId(null);
|
setImageTaskId(null);
|
||||||
setAnalysisData(null);
|
setAnalysisData(null);
|
||||||
@ -751,65 +455,17 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||||||
|
|
||||||
// 새 프로젝트 만들기 - 단계별 컨텐츠 렌더링
|
// 새 프로젝트 만들기 - 단계별 컨텐츠 렌더링
|
||||||
const renderWizardContent = () => {
|
const renderWizardContent = () => {
|
||||||
// 진입 화면은 두 파이프라인이 공유하므로 switch 밖에서 먼저 처리한다.
|
switch (wizardStep) {
|
||||||
// ADO2 는 wizardStep -2, 썰박스는 ssulStep 'entry' 가 여기 해당한다.
|
case -2:
|
||||||
if (isEntry) {
|
// URL 입력 단계
|
||||||
return (
|
return (
|
||||||
<UrlInputContent
|
<UrlInputContent
|
||||||
pipeline={pipeline}
|
|
||||||
onPipelineChange={changePipeline}
|
|
||||||
onAnalyze={handleStartAnalysis}
|
onAnalyze={handleStartAnalysis}
|
||||||
onAutocomplete={handleAutocomplete}
|
onAutocomplete={handleAutocomplete}
|
||||||
onManualInput={handleManualInput}
|
onManualInput={handleManualInput}
|
||||||
error={analysisError}
|
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}
|
|
||||||
onForce={handlePosterForce}
|
|
||||||
onDiscard={handlePosterDiscard}
|
|
||||||
busy={posterBusy}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (wizardStep) {
|
|
||||||
case -1:
|
case -1:
|
||||||
// 로딩 단계
|
// 로딩 단계
|
||||||
return (
|
return (
|
||||||
@ -837,8 +493,8 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||||||
onBack={() => goToWizardStep(0)}
|
onBack={() => goToWizardStep(0)}
|
||||||
onNext={(taskId: string) => {
|
onNext={(taskId: string) => {
|
||||||
// Clear video generation state to start fresh
|
// Clear video generation state to start fresh
|
||||||
localStorage.removeItem(K.VIDEO_GENERATION);
|
localStorage.removeItem(VIDEO_GENERATION_KEY);
|
||||||
localStorage.removeItem(K.VIDEO_COMPLETE);
|
localStorage.removeItem(VIDEO_COMPLETE_KEY);
|
||||||
setVideoGenerationStatus('idle');
|
setVideoGenerationStatus('idle');
|
||||||
setVideoGenerationProgress(0);
|
setVideoGenerationProgress(0);
|
||||||
|
|
||||||
@ -867,6 +523,13 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||||||
videoGenerationStatus={videoGenerationStatus}
|
videoGenerationStatus={videoGenerationStatus}
|
||||||
videoGenerationProgress={videoGenerationProgress}
|
videoGenerationProgress={videoGenerationProgress}
|
||||||
onGoToPayment={handleGoToPayment}
|
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:
|
case 3:
|
||||||
@ -875,8 +538,8 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||||||
onBack={() => {
|
onBack={() => {
|
||||||
// 뒤로가기 시 비디오 생성 상태 초기화
|
// 뒤로가기 시 비디오 생성 상태 초기화
|
||||||
// 새 노래 생성 후 다시 영상 생성할 수 있도록
|
// 새 노래 생성 후 다시 영상 생성할 수 있도록
|
||||||
localStorage.removeItem(K.VIDEO_GENERATION);
|
localStorage.removeItem(VIDEO_GENERATION_KEY);
|
||||||
localStorage.removeItem(K.VIDEO_COMPLETE);
|
localStorage.removeItem(VIDEO_COMPLETE_KEY);
|
||||||
setVideoGenerationStatus('idle');
|
setVideoGenerationStatus('idle');
|
||||||
setVideoGenerationProgress(0);
|
setVideoGenerationProgress(0);
|
||||||
goToWizardStep(2);
|
goToWizardStep(2);
|
||||||
@ -884,7 +547,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||||||
songTaskId={songTaskId}
|
songTaskId={songTaskId}
|
||||||
onVideoStatusChange={setVideoGenerationStatus}
|
onVideoStatusChange={setVideoGenerationStatus}
|
||||||
onVideoProgressChange={setVideoGenerationProgress}
|
onVideoProgressChange={setVideoGenerationProgress}
|
||||||
onGoToCalendar={() => goToMyInfoTab('calendar')}
|
onGoToCalendar={() => handleNavigate('콘텐츠 캘린더')}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
@ -894,45 +557,28 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
switch (activeItem) {
|
switch (activeItem) {
|
||||||
case NAV.DASHBOARD:
|
case '대시보드':
|
||||||
return (
|
return <DashboardContent onNavigate={handleNavigate} />;
|
||||||
<DashboardContent
|
case '비즈니스 설정':
|
||||||
onNavigate={(id) => (id === NAV.MY ? goToMyInfoTab('business') : handleNavigate(id))}
|
return <BusinessSettingsContent />;
|
||||||
/>
|
case 'ADO2 콘텐츠':
|
||||||
);
|
|
||||||
case NAV.HOME:
|
|
||||||
return <ADO2ContentsPage />;
|
return <ADO2ContentsPage />;
|
||||||
case NAV.MY:
|
case '내 콘텐츠':
|
||||||
return <MyInfoContent initialTab={myInfoInitialTab} onNavigate={handleNavigate} />;
|
return (
|
||||||
case NAV.NEW:
|
<MyContentsPage
|
||||||
// 진입 화면(탭)과 ADO2 로딩(-1)은 스텝퍼 없이 전체 화면으로 표시
|
onBack={() => setActiveItem('새 프로젝트 만들기')}
|
||||||
if (isEntry || (pipeline === 'ado2' && wizardStep === -1)) {
|
onNavigate={handleNavigate}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case '콘텐츠 캘린더':
|
||||||
|
return <ContentCalendarContent onNavigate={handleNavigate} />;
|
||||||
|
case '내 정보':
|
||||||
|
return <MyInfoContent initialTab={myInfoInitialTab} />;
|
||||||
|
case '새 프로젝트 만들기':
|
||||||
|
// 로딩(-1), URL 입력(-2)은 스텝퍼 없이 전체 화면으로 표시
|
||||||
|
if (wizardStep === -1 || wizardStep === -2) {
|
||||||
return renderWizardContent();
|
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)은 전체 화면 스크롤이지만 스텝퍼는 표시
|
// 브랜드 분석(0)은 전체 화면 스크롤이지만 스텝퍼는 표시
|
||||||
if (wizardStep === 0) {
|
if (wizardStep === 0) {
|
||||||
return (
|
return (
|
||||||
@ -957,75 +603,88 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 로딩 화면에서만 Sidebar/BottomNav 숨김
|
// 로딩 화면에서만 Sidebar 숨김
|
||||||
const showSidebar = wizardStep !== -1;
|
const showSidebar = wizardStep !== -1;
|
||||||
|
|
||||||
// 브랜드 분석(0)일 때는 전체 페이지 스크롤
|
// 브랜드 분석(0)일 때는 전체 페이지 스크롤
|
||||||
const isBrandAnalysis = activeItem === NAV.NEW && wizardStep === 0;
|
const isBrandAnalysis = activeItem === '새 프로젝트 만들기' && wizardStep === 0;
|
||||||
// 위저드 진행 중에도 탭바를 노출한다. 각 단계의 고정 CTA 는 모바일에서
|
// 스크롤이 필요한 페이지: 대시보드, 비즈니스 설정, 브랜드 분석(0), ADO2 콘텐츠, 내 정보
|
||||||
// 탭바 높이만큼 띄우도록 CSS(@media max-width:767px)에서 처리한다.
|
const needsScroll = activeItem === '대시보드' || activeItem === '비즈니스 설정' || activeItem === 'ADO2 콘텐츠' || activeItem === '내 콘텐츠' || activeItem === '내 정보' || activeItem === '콘텐츠 캘린더' || isBrandAnalysis;
|
||||||
const showBottomNav = showSidebar;
|
|
||||||
// 스크롤이 필요한 페이지: 새 프로젝트 만들기(위저드) 외 전부 + 브랜드 분석(0)
|
|
||||||
const needsScroll = activeItem !== NAV.NEW || isBrandAnalysis;
|
|
||||||
|
|
||||||
const isMobile = useIsMobile();
|
// 현재 화면에 맞는 튜토리얼 키 반환 (없으면 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 nav = isMobile
|
const tutorialUI = (
|
||||||
? (showBottomNav && (
|
<>
|
||||||
<BottomNav
|
{getCurrentTutorialKey() && (
|
||||||
activeItem={activeItem}
|
<button
|
||||||
onNavigate={handleNavigate}
|
className={`tutorial-toggle-fab ${tutorial.isEnabled ? 'active' : ''}`}
|
||||||
userInfo={userInfo}
|
onClick={() => tutorial.toggleTutorial(getCurrentTutorialKey())}
|
||||||
onLogout={handleLogout}
|
title={tutorial.isEnabled ? t('sidebar.tutorialOff') : t('sidebar.tutorialOn')}
|
||||||
credits={credits}
|
>
|
||||||
isGuest={isGuest}
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
onLoginClick={() => setLoginGateOpen(true)}
|
<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}
|
||||||
/>
|
/>
|
||||||
))
|
)}
|
||||||
: (showSidebar && (
|
{tutorial.isRestartPopupVisible && (
|
||||||
<Sidebar
|
<TutorialRestartPopup
|
||||||
activeItem={activeItem}
|
onConfirm={tutorial.confirmRestart}
|
||||||
onNavigate={handleNavigate}
|
onCancel={tutorial.cancelRestart}
|
||||||
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) {
|
if (isBrandAnalysis) {
|
||||||
return (
|
return (
|
||||||
<div className="analysis-page-wrapper">
|
<div className="analysis-page-wrapper">
|
||||||
{!isMobile && nav}
|
{showSidebar && (
|
||||||
<main className={`analysis-page-main ${showBottomNav ? 'has-bottom-nav' : ''}`}>
|
<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">
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
</main>
|
</main>
|
||||||
{isMobile && nav}
|
{tutorialUI}
|
||||||
{loginGateModal}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex w-full bg-[#002224] text-white ${needsScroll ? 'min-h-[100dvh]' : 'h-[100dvh] overflow-hidden'}`}>
|
<div className={`flex w-full bg-[#002224] text-white ${needsScroll ? 'min-h-[100dvh]' : 'h-[100dvh] overflow-hidden'}`}>
|
||||||
{!isMobile && nav}
|
{showSidebar && (
|
||||||
<div className={`flex-1 relative pl-0 md:pl-0 bg-[#001a1c] ${needsScroll ? 'overflow-auto' : 'h-full overflow-hidden'} ${showBottomNav ? 'has-bottom-nav' : ''}`}>
|
<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'}`}>
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
</div>
|
</div>
|
||||||
{isMobile && nav}
|
|
||||||
{loginGateModal}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,15 +1,13 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { getVideosList, deleteVideo, deleteSsulContent } from '../../utils/api';
|
import { getVideosList, deleteVideo } from '../../utils/api';
|
||||||
import { deletePosterJob, deleteStylingJob } from '../../utils/p2vApi';
|
|
||||||
import { VideoListItem } from '../../types/api';
|
import { VideoListItem } from '../../types/api';
|
||||||
import SocialPostingModal from '../../components/SocialPostingModal';
|
import SocialPostingModal from '../../components/SocialPostingModal';
|
||||||
import VideoDetailModal from '../../components/VideoDetailModal';
|
import VideoDetailModal from '../../components/VideoDetailModal';
|
||||||
import ContentCardSocialActions from '../../components/ContentCardSocialActions';
|
import ContentCardSocialActions from '../../components/ContentCardSocialActions';
|
||||||
import P2vViewerModal from '../../components/P2vViewerModal';
|
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
||||||
import SsulViewerModal from '../Ssulbox/SsulViewerModal';
|
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
||||||
import { useOverlayClose } from '../../hooks/useOverlayClose';
|
|
||||||
|
|
||||||
const VideoPreviewCard: React.FC<{ src: string; className?: string }> = ({ src, className }) => {
|
const VideoPreviewCard: React.FC<{ src: string; className?: string }> = ({ src, className }) => {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
@ -91,12 +89,11 @@ const VideoPreviewCard: React.FC<{ src: string; className?: string }> = ({ src,
|
|||||||
interface MyContentsPageProps {
|
interface MyContentsPageProps {
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
onNavigate?: (item: string) => void;
|
onNavigate?: (item: string) => void;
|
||||||
/** 내 정보 탭 안에 임베드될 때: 탭 라벨과 중복되는 자체 제목을 숨긴다 */
|
|
||||||
embedded?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate, embedded }) => {
|
const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const tutorial = useTutorial();
|
||||||
const [videos, setVideos] = useState<VideoListItem[]>([]);
|
const [videos, setVideos] = useState<VideoListItem[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -106,16 +103,11 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate, embedded })
|
|||||||
const [hasPrev, setHasPrev] = useState(false);
|
const [hasPrev, setHasPrev] = useState(false);
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<VideoListItem | null>(null);
|
const [deleteTargetId, setDeleteTargetId] = useState<number | null>(null);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
const [uploadModalOpen, setUploadModalOpen] = useState(false);
|
const [uploadModalOpen, setUploadModalOpen] = useState(false);
|
||||||
const [uploadTargetVideo, setUploadTargetVideo] = useState<VideoListItem | null>(null);
|
const [uploadTargetVideo, setUploadTargetVideo] = useState<VideoListItem | null>(null);
|
||||||
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null);
|
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null);
|
||||||
// 썰박스는 별도 뷰어를 쓴다 — VideoDetailModal 은 video_id 로 조회하므로
|
|
||||||
// 썰박스 id 를 넘기면 id 가 겹치는 다른 영상이 열린다.
|
|
||||||
const [selectedSsul, setSelectedSsul] = useState<VideoListItem | null>(null);
|
|
||||||
// P2V(무빙 포스터·스타일링)도 별도 뷰어 — 상세 API 없이 목록 데이터로 그린다
|
|
||||||
const [selectedP2v, setSelectedP2v] = useState<VideoListItem | null>(null);
|
|
||||||
|
|
||||||
const pageSize = 12;
|
const pageSize = 12;
|
||||||
|
|
||||||
@ -123,6 +115,12 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate, embedded })
|
|||||||
fetchVideos();
|
fetchVideos();
|
||||||
}, [page]);
|
}, [page]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tutorial.hasSeen(TUTORIAL_KEYS.ADO2_CONTENTS)) {
|
||||||
|
tutorial.startTutorial(TUTORIAL_KEYS.ADO2_CONTENTS);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const fetchVideos = async () => {
|
const fetchVideos = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@ -147,7 +145,9 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate, embedded })
|
|||||||
const year = date.getFullYear();
|
const year = date.getFullYear();
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
return `${year}.${month}.${day}`;
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
return `${year}.${month}.${day}・${hours}:${minutes}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTitle = (storeName: string, dateString: string) => {
|
const formatTitle = (storeName: string, dateString: string) => {
|
||||||
@ -177,10 +177,8 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate, embedded })
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 아이템 전체를 저장한다 — id 만 들면 종류를 잃어 어느 삭제 API 를 부를지 알 수 없다
|
const handleDeleteClick = (videoId: number) => {
|
||||||
// (video.id 와 ssul_content.id 는 값이 겹치는 독립 시퀀스).
|
setDeleteTargetId(videoId);
|
||||||
const handleDeleteClick = (video: VideoListItem) => {
|
|
||||||
setDeleteTarget(video);
|
|
||||||
setDeleteModalOpen(true);
|
setDeleteModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -196,34 +194,18 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate, embedded })
|
|||||||
|
|
||||||
const handleDeleteCancel = () => {
|
const handleDeleteCancel = () => {
|
||||||
setDeleteModalOpen(false);
|
setDeleteModalOpen(false);
|
||||||
setDeleteTarget(null);
|
setDeleteTargetId(null);
|
||||||
};
|
};
|
||||||
const deleteModalOverlayHandlers = useOverlayClose(handleDeleteCancel);
|
|
||||||
|
|
||||||
const handleDeleteConfirm = async () => {
|
const handleDeleteConfirm = async () => {
|
||||||
if (!deleteTarget) return;
|
if (!deleteTargetId) return;
|
||||||
setIsDeleting(true);
|
setIsDeleting(true);
|
||||||
try {
|
try {
|
||||||
// 종류별 삭제 API 가 다르다. deleteVideo 는 Video.id 로 지우므로
|
await deleteVideo(deleteTargetId);
|
||||||
// 썰박스·P2V id 를 넘기면 id 가 겹치는 엉뚱한 ADO2 영상이 삭제된다.
|
setVideos(prev => prev.filter(video => video.video_id !== deleteTargetId));
|
||||||
if (deleteTarget.type === 'ssul') {
|
|
||||||
await deleteSsulContent(deleteTarget.video_id);
|
|
||||||
} else if (deleteTarget.type === 'p2v_video') {
|
|
||||||
await deletePosterJob(deleteTarget.video_id);
|
|
||||||
} else if (deleteTarget.type === 'p2v_poster') {
|
|
||||||
await deleteStylingJob(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));
|
setTotal(prev => Math.max(0, prev - 1));
|
||||||
setDeleteModalOpen(false);
|
setDeleteModalOpen(false);
|
||||||
setDeleteTarget(null);
|
setDeleteTargetId(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Delete failed:', err);
|
console.error('Delete failed:', err);
|
||||||
alert(t('ado2Contents.deleteFailed'));
|
alert(t('ado2Contents.deleteFailed'));
|
||||||
@ -233,10 +215,10 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate, embedded })
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`ado2-contents-page ${embedded ? 'ado2-contents-page--embedded' : ''}`}>
|
<div className="ado2-contents-page">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="ado2-contents-header">
|
<div className="ado2-contents-header">
|
||||||
{!embedded && <h1 className="ado2-contents-title">{t('sidebar.myContents')}</h1>}
|
<h1 className="ado2-contents-title">{t('sidebar.myContents')}</h1>
|
||||||
<span className="ado2-contents-count">{t('ado2Contents.totalCount', { count: total })}</span>
|
<span className="ado2-contents-count">{t('ado2Contents.totalCount', { count: total })}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -259,18 +241,12 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate, embedded })
|
|||||||
<>
|
<>
|
||||||
<div className="ado2-contents-grid">
|
<div className="ado2-contents-grid">
|
||||||
{videos.map((video) => (
|
{videos.map((video) => (
|
||||||
// key 는 (type, video_id) 쌍이어야 한다 — task_id 는 썰박스에 없고(빈 문자열)
|
<div key={video.task_id} className="ado2-content-card">
|
||||||
// video_id 는 종류별 독립 시퀀스라 값이 겹친다.
|
|
||||||
<div key={`${video.type}-${video.video_id}`} className="ado2-content-card">
|
|
||||||
{/* Video Thumbnail */}
|
{/* Video Thumbnail */}
|
||||||
<div
|
<div
|
||||||
className="content-card-thumbnail"
|
className="content-card-thumbnail"
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
onClick={() => {
|
onClick={() => setSelectedVideoId(video.video_id)}
|
||||||
if (video.type === 'ssul') setSelectedSsul(video);
|
|
||||||
else if (video.type === 'p2v_video' || video.type === 'p2v_poster') setSelectedP2v(video);
|
|
||||||
else setSelectedVideoId(video.video_id);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{video.poster_url ? (
|
{video.poster_url ? (
|
||||||
<img
|
<img
|
||||||
@ -307,39 +283,28 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate, embedded })
|
|||||||
<h3 className="content-card-title">
|
<h3 className="content-card-title">
|
||||||
{formatTitle(video.store_name, video.created_at)}
|
{formatTitle(video.store_name, video.created_at)}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="content-card-meta">
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<p className="content-card-date">
|
<p className="content-card-date">
|
||||||
{formatDate(video.created_at)}
|
{formatDate(video.created_at)}
|
||||||
</p>
|
</p>
|
||||||
{/* 좋아요·댓글 API 가 type 을 받으므로 썰박스에도 노출한다.
|
|
||||||
contentType 이 없으면 id 가 겹치는 다른 영상에 반영된다.
|
|
||||||
P2V 는 좋아요·댓글 축이 없어(내 콘텐츠 전용) 숨긴다. */}
|
|
||||||
{(video.type === 'video' || video.type === 'ssul') && (
|
|
||||||
<ContentCardSocialActions
|
<ContentCardSocialActions
|
||||||
videoId={video.video_id}
|
videoId={video.video_id}
|
||||||
contentType={video.type}
|
|
||||||
storeName={video.store_name}
|
storeName={video.store_name}
|
||||||
region={video.region}
|
region={video.region}
|
||||||
title={video.title}
|
title={video.title}
|
||||||
description={video.description}
|
|
||||||
commentCount={video.comment_count ?? 0}
|
commentCount={video.comment_count ?? 0}
|
||||||
initialLikeCount={video.like_count ?? 0}
|
initialLikeCount={video.like_count ?? 0}
|
||||||
initialIsLiked={video.is_liked_by_me}
|
initialIsLiked={video.is_liked_by_me}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="content-card-actions">
|
<div className="content-card-actions">
|
||||||
{/* SocialPostingModal 이 content_type 을 함께 보내므로
|
|
||||||
썰박스 항목도 같은 모달로 업로드한다 (social_upload 병합).
|
|
||||||
P2V 는 업로드 파이프라인이 video·ssul 전용이라 SNS 버튼을 숨긴다. */}
|
|
||||||
<button
|
<button
|
||||||
className="content-download-btn"
|
className="content-download-btn"
|
||||||
onClick={() => handleUploadClick(video)}
|
onClick={() => handleUploadClick(video)}
|
||||||
disabled={!video.result_movie_url}
|
disabled={!video.result_movie_url}
|
||||||
style={video.type === 'p2v_video' || video.type === 'p2v_poster' ? { display: 'none' } : undefined}
|
|
||||||
>
|
>
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
<path d="M10 13V3M10 3l-4 4M10 3l4 4"/>
|
<path d="M10 13V3M10 3l-4 4M10 3l4 4"/>
|
||||||
@ -358,11 +323,9 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate, embedded })
|
|||||||
<path d="M3 15v2h14v-2"/>
|
<path d="M3 15v2h14v-2"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/* 삭제는 종류별 API 로 분기한다(handleDeleteConfirm).
|
|
||||||
아이템 전체를 넘겨야 종류를 잃지 않는다. */}
|
|
||||||
<button
|
<button
|
||||||
className="content-delete-btn"
|
className="content-delete-btn"
|
||||||
onClick={() => handleDeleteClick(video)}
|
onClick={() => handleDeleteClick(video.video_id)}
|
||||||
>
|
>
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
<path d="M3 5h14M8 5V3h4v2M6 5v12h8V5"/>
|
<path d="M3 5h14M8 5V3h4v2M6 5v12h8V5"/>
|
||||||
@ -399,7 +362,7 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate, embedded })
|
|||||||
|
|
||||||
{/* 삭제 확인 모달 */}
|
{/* 삭제 확인 모달 */}
|
||||||
{deleteModalOpen && (
|
{deleteModalOpen && (
|
||||||
<div className="delete-modal-overlay" {...deleteModalOverlayHandlers}>
|
<div className="delete-modal-overlay" onClick={handleDeleteCancel}>
|
||||||
<div className="delete-modal" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
<div className="delete-modal" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||||
<h2 className="delete-modal-title">{t('ado2Contents.deleteConfirmTitle')}</h2>
|
<h2 className="delete-modal-title">{t('ado2Contents.deleteConfirmTitle')}</h2>
|
||||||
<p className="delete-modal-description">{t('ado2Contents.deleteConfirmDesc')}</p>
|
<p className="delete-modal-description">{t('ado2Contents.deleteConfirmDesc')}</p>
|
||||||
@ -430,10 +393,6 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate, embedded })
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SsulViewerModal item={selectedSsul} onClose={() => setSelectedSsul(null)} />
|
|
||||||
|
|
||||||
<P2vViewerModal item={selectedP2v} onClose={() => setSelectedP2v(null)} />
|
|
||||||
|
|
||||||
{/* 소셜 미디어 업로드 모달 */}
|
{/* 소셜 미디어 업로드 모달 */}
|
||||||
<SocialPostingModal
|
<SocialPostingModal
|
||||||
isOpen={uploadModalOpen}
|
isOpen={uploadModalOpen}
|
||||||
|
|||||||
@ -3,21 +3,16 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { getSocialAccounts, getYouTubeConnectUrl, disconnectSocialAccount, TokenExpiredError, handleSocialReconnect, getUserCredits, requestCreditCharge } from '../../utils/api';
|
import { getSocialAccounts, getYouTubeConnectUrl, disconnectSocialAccount, TokenExpiredError, handleSocialReconnect, getUserCredits, requestCreditCharge } from '../../utils/api';
|
||||||
import { SocialAccount } from '../../types/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 = MyInfoTab;
|
type TabType = 'basic' | 'payment' | 'business';
|
||||||
|
|
||||||
interface MyInfoContentProps {
|
interface MyInfoContentProps {
|
||||||
initialTab?: TabType;
|
initialTab?: TabType;
|
||||||
onNavigate?: (id: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab, onNavigate }) => {
|
const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [activeTab, setActiveTab] = useState<TabType>(initialTab || 'contents');
|
const [activeTab, setActiveTab] = useState<TabType>(initialTab || 'business');
|
||||||
const [businessUrl, setBusinessUrl] = useState('');
|
const [businessUrl, setBusinessUrl] = useState('');
|
||||||
const [socialAccounts, setSocialAccounts] = useState<SocialAccount[]>([]);
|
const [socialAccounts, setSocialAccounts] = useState<SocialAccount[]>([]);
|
||||||
const [isLoadingAccounts, setIsLoadingAccounts] = useState(false);
|
const [isLoadingAccounts, setIsLoadingAccounts] = useState(false);
|
||||||
@ -118,33 +113,20 @@ const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab, onNavigate })
|
|||||||
const hasConnectedAccounts = youtubeAccounts.length > 0 || instagramAccounts.length > 0;
|
const hasConnectedAccounts = youtubeAccounts.length > 0 || instagramAccounts.length > 0;
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'contents' as TabType, label: t('myInfo.tabContents') },
|
// { id: 'basic' as TabType, label: t('myInfo.tabBasic') },
|
||||||
{ id: 'calendar' as TabType, label: t('myInfo.tabCalendar') },
|
|
||||||
{ id: 'payment' as TabType, label: t('myInfo.tabPayment') },
|
{ id: 'payment' as TabType, label: t('myInfo.tabPayment') },
|
||||||
{ id: 'business' as TabType, label: t('myInfo.tabBusiness') },
|
{ 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{showChargePopup && (
|
{showChargePopup && (
|
||||||
<div className="myinfo-popup-overlay" {...chargePopupOverlayHandlers}>
|
<div className="myinfo-popup-overlay" onClick={() => { setShowChargePopup(false); setChargeSuccess(false); setChargeAmount(''); setChargeNote(''); }}>
|
||||||
<div className="myinfo-popup" onClick={e => e.stopPropagation()}>
|
<div className="myinfo-popup" onClick={e => e.stopPropagation()}>
|
||||||
{chargeSuccess ? (
|
{chargeSuccess ? (
|
||||||
<>
|
<>
|
||||||
<p className="myinfo-popup-message">{t('myInfo.chargeSuccess')}</p>
|
<p className="myinfo-popup-message">{t('myInfo.chargeSuccess')}</p>
|
||||||
<button className="myinfo-popup-close" onClick={closeChargePopup}>{t('myInfo.chargeConfirm')}</button>
|
<button className="myinfo-popup-close" onClick={() => { setShowChargePopup(false); setChargeSuccess(false); setChargeAmount(''); setChargeNote(''); }}>{t('myInfo.chargeConfirm')}</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@ -205,7 +187,7 @@ const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab, onNavigate })
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<main className={`myinfo-page ${isWideTab ? 'myinfo-page--wide' : ''}`}>
|
<main className="myinfo-page">
|
||||||
<h1 className="myinfo-title">{t('myInfo.title')}</h1>
|
<h1 className="myinfo-title">{t('myInfo.title')}</h1>
|
||||||
|
|
||||||
{/* 탭 네비게이션 */}
|
{/* 탭 네비게이션 */}
|
||||||
@ -223,13 +205,11 @@ const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab, onNavigate })
|
|||||||
|
|
||||||
{/* 탭 컨텐츠 */}
|
{/* 탭 컨텐츠 */}
|
||||||
<div className="myinfo-content">
|
<div className="myinfo-content">
|
||||||
{activeTab === 'contents' && (
|
{/* {activeTab === 'basic' && (
|
||||||
<MyContentsPage onNavigate={handleChildNavigate} embedded />
|
<div className="myinfo-section">
|
||||||
)}
|
<p className="myinfo-placeholder">{t('myInfo.basicPlaceholder')}</p>
|
||||||
|
</div>
|
||||||
{activeTab === 'calendar' && (
|
)} */}
|
||||||
<ContentCalendarContent onNavigate={handleChildNavigate} embedded />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'payment' && (
|
{activeTab === 'payment' && (
|
||||||
<div className="myinfo-section">
|
<div className="myinfo-section">
|
||||||
@ -298,13 +278,13 @@ const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab, onNavigate })
|
|||||||
<span>{isConnecting === 'youtube' ? t('myInfo.youtubeConnecting') : t('myInfo.youtubeConnect')}</span>
|
<span>{isConnecting === 'youtube' ? t('myInfo.youtubeConnecting') : t('myInfo.youtubeConnect')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* <button
|
<button
|
||||||
disabled
|
disabled
|
||||||
className="myinfo-social-btn disabled"
|
className="myinfo-social-btn disabled"
|
||||||
>
|
>
|
||||||
<img src="/assets/images/social-instagram.png" alt="Instagram" className="myinfo-social-btn-icon" />
|
<img src="/assets/images/social-instagram.png" alt="Instagram" className="myinfo-social-btn-icon" />
|
||||||
<span>{t('myInfo.instagramConnect')}</span>
|
<span>{t('myInfo.instagramConnect')}</span>
|
||||||
</button> */}
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 연결된 계정 목록 */}
|
{/* 연결된 계정 목록 */}
|
||||||
|
|||||||
@ -1,87 +1,28 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { AutocompleteRequest } from '../../utils/api';
|
import { AutocompleteRequest } from '../../utils/api';
|
||||||
import SearchInputForm, { SearchType } from '../../components/SearchInputForm';
|
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 {
|
interface UrlInputContentProps {
|
||||||
/** 현재 선택된 파이프라인. 소유자는 GenerationFlow (분기·리셋 때문) */
|
|
||||||
pipeline: Pipeline;
|
|
||||||
onPipelineChange: (pipeline: Pipeline) => void;
|
|
||||||
onAnalyze: (value: string, type?: SearchType) => void;
|
onAnalyze: (value: string, type?: SearchType) => void;
|
||||||
onAutocomplete?: (data: AutocompleteRequest) => void;
|
onAutocomplete?: (data: AutocompleteRequest) => void;
|
||||||
onManualInput?: (businessName: string, address: string, category: string, officialSiteUrl?: string) => void;
|
onManualInput?: (businessName: string, address: string, category: string, officialSiteUrl?: string) => void;
|
||||||
error: string | null;
|
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 (
|
return (
|
||||||
|
<div className="url-input-container">
|
||||||
|
<div className="url-input-content">
|
||||||
|
<div className="url-input-logo">
|
||||||
|
<img src="/assets/images/ADO2_with Slogan_white.svg" alt="ADO2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<SearchInputForm
|
<SearchInputForm
|
||||||
onAnalyze={onAnalyze}
|
onAnalyze={onAnalyze}
|
||||||
onAutocomplete={onAutocomplete}
|
onAutocomplete={onAutocomplete}
|
||||||
onManualInput={onManualInput}
|
onManualInput={onManualInput}
|
||||||
error={error}
|
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${wide ? ' url-input-content--wide' : ''}`}>
|
|
||||||
<div className="url-input-logo">
|
|
||||||
<img src="/assets/images/ADO2_with Slogan_white.svg" alt="ADO2" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PipelineTabs value={pipeline} onChange={onPipelineChange} />
|
|
||||||
|
|
||||||
{/* 고른 기능이 무엇을 해주는지 한 줄. 탭 라벨(2~6자)만으로는 네 기능의 차이가
|
|
||||||
전달되지 않아 처음 온 사람이 아무거나 눌러본다. STEP 1 위에 둔다. */}
|
|
||||||
<p className="pipeline-desc">{t(`pipelineTabs.desc.${pipeline}`)}</p>
|
|
||||||
|
|
||||||
{renderForm()}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
51
src/pages/Landing/DisplaySection.tsx
Executable file
51
src/pages/Landing/DisplaySection.tsx
Executable file
@ -0,0 +1,51 @@
|
|||||||
|
|
||||||
|
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;
|
||||||
202
src/pages/Landing/HeroSection.tsx
Executable file
202
src/pages/Landing/HeroSection.tsx
Executable file
@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
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;
|
||||||
70
src/pages/Landing/WelcomeSection.tsx
Executable file
70
src/pages/Landing/WelcomeSection.tsx
Executable file
@ -0,0 +1,70 @@
|
|||||||
|
|
||||||
|
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;
|
||||||
109
src/pages/Login/LoginSection.tsx
Executable file
109
src/pages/Login/LoginSection.tsx
Executable file
@ -0,0 +1,109 @@
|
|||||||
|
|
||||||
|
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;
|
||||||
@ -1,79 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,117 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { P2vJob, isPastReview, p2vProgress } from '../../utils/p2vApi';
|
|
||||||
|
|
||||||
interface PosterMakingContentProps {
|
|
||||||
job: P2vJob | null;
|
|
||||||
/** 실패 시 같은 잡을 다시 태운다 (업로드부터 다시 하지 않는다) */
|
|
||||||
onRetry: () => void;
|
|
||||||
/** 실패를 무시하고 이미 생성된 클립으로 렌더를 잇는다 (i2v 게이트 실패 전용) */
|
|
||||||
onForce: () => void;
|
|
||||||
/** 실패한 잡을 버리고 처음부터 */
|
|
||||||
onDiscard: () => void;
|
|
||||||
busy: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 포스터 영상 생성 진행 화면. 검수 전(분석)과 검수 후(생성) 양쪽에 쓰인다.
|
|
||||||
*
|
|
||||||
* 두 구간을 한 컴포넌트로 둔 이유: 화면 구성이 같고 문구만 다르다. 나누면
|
|
||||||
* 스피너·진행바·에러 처리가 두 벌이 되어 한쪽만 고치는 사고가 난다.
|
|
||||||
*
|
|
||||||
* 진행바는 스테이지 완료 수 기반이라 구간 사이에서 멈춰 보인다 —
|
|
||||||
* 스피너가 "살아 있다"를 따로 알린다(썰박스 SsulMakingContent 와 같은 판단).
|
|
||||||
*/
|
|
||||||
const PosterMakingContent: React.FC<PosterMakingContentProps> = ({
|
|
||||||
job, onRetry, onForce, onDiscard, busy,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
if (!job) return null;
|
|
||||||
|
|
||||||
const failed = job.status === 'failed';
|
|
||||||
// "무시하고 진행"은 i2v 게이트 실패(클립은 이미 생성됨)에서만 의미가 있다.
|
|
||||||
// 다른 스테이지 실패는 이어갈 산출물이 없어 서버도 409 로 거절한다.
|
|
||||||
// 개발자 모드(vite dev) 전용 — 품질 게이트를 일반 사용자가 우회하면 훼손된
|
|
||||||
// 제목의 영상이 그대로 나가므로, 프로덕션 빌드에서는 버튼 자체를 뺀다.
|
|
||||||
const canForce = import.meta.env.DEV && failed && job.error?.stage === 'i2v';
|
|
||||||
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>
|
|
||||||
{canForce && (
|
|
||||||
<button type="button" className="p2v-btn-tonal" disabled={busy} onClick={onForce}>
|
|
||||||
{t('poster.making.force')}
|
|
||||||
</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;
|
|
||||||
@ -1,104 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { P2vJob, downloadP2vFile, 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">
|
|
||||||
{/* 교차 출처(Blob) URL 은 <a download> 가 무시된다 — fetch 경유 다운로드 */}
|
|
||||||
{thumbnail && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="p2v-btn-tonal"
|
|
||||||
onClick={() => downloadP2vFile(thumbnail, `${job.name || 'poster'}_thumb.jpg`)}
|
|
||||||
>
|
|
||||||
{t('poster.result.downloadThumb')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{video && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="p2v-btn-cta p2v-btn-cta--compact"
|
|
||||||
onClick={() => downloadP2vFile(video, `${job.name || 'poster'}.mp4`)}
|
|
||||||
>
|
|
||||||
{t('poster.result.downloadVideo')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="button" className="p2v-btn-outline p2v-result__new" onClick={onNew}>
|
|
||||||
{t('poster.result.new')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PosterResultContent;
|
|
||||||
@ -1,189 +0,0 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import {
|
|
||||||
P2vJob,
|
|
||||||
approvePosterJob,
|
|
||||||
fetchP2vImage,
|
|
||||||
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);
|
|
||||||
const [checkImageUrl, setCheckImageUrl] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// 검수 이미지는 인증이 필요한 프록시 경로(regions/)라 <img src> 로 직접 못 넣는다.
|
|
||||||
// authenticatedFetch → blob URL 로 받아오고, 잡이 바뀌면 이전 URL 을 회수한다.
|
|
||||||
useEffect(() => {
|
|
||||||
const path = job.artifacts.check_jpg;
|
|
||||||
if (!path) {
|
|
||||||
setCheckImageUrl(null);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
let revoked: string | null = null;
|
|
||||||
fetchP2vImage(path)
|
|
||||||
.then((url) => { revoked = url; setCheckImageUrl(url); })
|
|
||||||
.catch(() => setCheckImageUrl(null));
|
|
||||||
return () => { if (revoked) URL.revokeObjectURL(revoked); };
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [job.artifacts.check_jpg]);
|
|
||||||
|
|
||||||
// 서버 값으로 초안을 채우되, 사용자가 고치기 시작한 뒤에는 폴링 응답이 덮지 않아야 한다.
|
|
||||||
// 잡 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>
|
|
||||||
{checkImageUrl ? (
|
|
||||||
<img
|
|
||||||
className="p2v-review__image"
|
|
||||||
src={checkImageUrl}
|
|
||||||
alt={t('poster.review.analysisAlt')}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="p2v-panel p2v-review__image-empty">
|
|
||||||
{t('poster.review.analysisMissing')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PosterReviewContent;
|
|
||||||
@ -1,341 +0,0 @@
|
|||||||
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,
|
|
||||||
downloadP2vFile,
|
|
||||||
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">
|
|
||||||
{/* 교차 출처(Blob) URL 은 <a download> 가 무시된다 — fetch 경유 다운로드 */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="p2v-btn-tonal"
|
|
||||||
onClick={() => downloadP2vFile(
|
|
||||||
p2vFile(job.artifacts.image),
|
|
||||||
`p2v_poster_${job.id}.png`,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{t('poster.styling.downloadPng')}
|
|
||||||
</button>
|
|
||||||
</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;
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
/**
|
|
||||||
* 포스터 파이프라인의 코드 목록.
|
|
||||||
*
|
|
||||||
* 썰박스 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];
|
|
||||||
@ -1,250 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,488 +0,0 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import {
|
|
||||||
API_URL,
|
|
||||||
deleteComment,
|
|
||||||
getSsulContentById,
|
|
||||||
getUserMe,
|
|
||||||
getVideoComments,
|
|
||||||
isLoggedIn,
|
|
||||||
postVideoComment,
|
|
||||||
toggleVideoLike,
|
|
||||||
} from '../../utils/api';
|
|
||||||
import type { SsulDetailItem } from '../../utils/api';
|
|
||||||
import { CommentItem, UserMeResponse } from '../../types/api';
|
|
||||||
import { buildContentShareUrl, tryNativeShare } from '../../utils/nativeShare';
|
|
||||||
import LoginPromptModal from '../../components/LoginPromptModal';
|
|
||||||
import { isSafeHttpUrl } from '../../utils/url';
|
|
||||||
|
|
||||||
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/share/{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 [isLandscape, setIsLandscape] = useState(false);
|
|
||||||
const [showSiteOverlay, setShowSiteOverlay] = 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 [currentUser, setCurrentUser] = useState<UserMeResponse | null>(null);
|
|
||||||
|
|
||||||
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);
|
|
||||||
setShowSiteOverlay(false);
|
|
||||||
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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!authed) return;
|
|
||||||
getUserMe().then(setCurrentUser).catch((err) => {
|
|
||||||
console.error('Failed to fetch current user:', err);
|
|
||||||
});
|
|
||||||
}, [authed]);
|
|
||||||
|
|
||||||
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 = buildContentShareUrl(API_URL, contentId, 'ssul');
|
|
||||||
|
|
||||||
const handleCopyLink = async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(shareUrl);
|
|
||||||
} catch {
|
|
||||||
// clipboard API 미지원 환경에서는 무시
|
|
||||||
}
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleShareButtonClick = async () => {
|
|
||||||
const handled = await tryNativeShare({
|
|
||||||
title: content?.store_name ?? t('videoDetail.kakaoDefaultTitle'),
|
|
||||||
url: shareUrl,
|
|
||||||
});
|
|
||||||
if (handled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await handleCopyLink();
|
|
||||||
};
|
|
||||||
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
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' : ''}`}>
|
|
||||||
<div className="video-detail-player-wrap">
|
|
||||||
<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);
|
|
||||||
}}
|
|
||||||
onTimeUpdate={(e) => {
|
|
||||||
if (!content.official_site_url) return;
|
|
||||||
const v = e.currentTarget;
|
|
||||||
// 영상 종료 3초 전부터 공식 홈페이지 오버레이 노출
|
|
||||||
setShowSiteOverlay(
|
|
||||||
Number.isFinite(v.duration) && v.duration - v.currentTime <= 3
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{showSiteOverlay && isSafeHttpUrl(content.official_site_url) && (
|
|
||||||
<a
|
|
||||||
href={content.official_site_url as string}
|
|
||||||
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>{content.store_name}</strong>
|
|
||||||
{t('videoDetail.siteOverlayLabel')}
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* 댓글 섹션 */}
|
|
||||||
<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 && currentUser && (
|
|
||||||
<div className="video-detail-comment-profile">
|
|
||||||
{currentUser.profile_image_url && (
|
|
||||||
<img
|
|
||||||
src={currentUser.profile_image_url}
|
|
||||||
alt={currentUser.nickname}
|
|
||||||
className="video-detail-comment-avatar"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span className="video-detail-comment-nickname">{currentUser.nickname}</span>
|
|
||||||
</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">
|
|
||||||
{c.profile_image_url && (
|
|
||||||
<img
|
|
||||||
src={c.profile_image_url}
|
|
||||||
alt={c.nickname || t('videoDetail.anonymous')}
|
|
||||||
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">
|
|
||||||
{r.profile_image_url && (
|
|
||||||
<img
|
|
||||||
src={r.profile_image_url}
|
|
||||||
alt={r.nickname || t('videoDetail.anonymous')}
|
|
||||||
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;
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,100 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,147 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
/**
|
|
||||||
* 썰박스 시나리오 코드와 표시 유틸.
|
|
||||||
*
|
|
||||||
* 원본(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`;
|
|
||||||
@ -1,111 +0,0 @@
|
|||||||
/**
|
|
||||||
* 썰박스 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,33 +458,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 모바일: 하단 탭바에 가리지 않도록 띄운다 */
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.bottom-button-container {
|
|
||||||
bottom: calc(var(--bottom-nav-height) + 16px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================================================
|
/* =====================================================
|
||||||
Sidebar Components
|
Sidebar Components
|
||||||
===================================================== */
|
===================================================== */
|
||||||
|
|
||||||
/* Sidebar Container */
|
/* Sidebar Container */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
position: sticky;
|
position: fixed;
|
||||||
top: 0;
|
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background-color: var(--color-bg-dark);
|
background-color: var(--color-bg-dark);
|
||||||
border-right: 1px solid var(--color-border-white-5);
|
border-right: 1px solid var(--color-border-white-5);
|
||||||
|
transition: all var(--transition-slow);
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (min-width: 768px) {
|
||||||
.sidebar {
|
.sidebar {
|
||||||
display: none;
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -492,6 +486,24 @@
|
|||||||
width: 15rem;
|
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 */
|
||||||
.sidebar-header {
|
.sidebar-header {
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
@ -500,6 +512,11 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-header.collapsed {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Sidebar Logo */
|
/* Sidebar Logo */
|
||||||
.sidebar-logo {
|
.sidebar-logo {
|
||||||
font-family: 'Playfair Display', serif;
|
font-family: 'Playfair Display', serif;
|
||||||
@ -538,6 +555,15 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-item.collapsed {
|
||||||
|
justify-content: center;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-item.active {
|
.sidebar-item.active {
|
||||||
background-color: var(--color-mint);
|
background-color: var(--color-mint);
|
||||||
color: var(--color-bg-dark);
|
color: var(--color-bg-dark);
|
||||||
@ -651,6 +677,10 @@
|
|||||||
padding: 0 0.5rem;
|
padding: 0 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-section.collapsed {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-avatar {
|
.profile-avatar {
|
||||||
width: 2.5rem;
|
width: 2.5rem;
|
||||||
height: 2.5rem;
|
height: 2.5rem;
|
||||||
@ -748,6 +778,25 @@
|
|||||||
transform: translateX(calc(100% + 2px));
|
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 */
|
||||||
.sidebar-footer-actions {
|
.sidebar-footer-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -776,6 +825,11 @@
|
|||||||
color: var(--color-text-white);
|
color: var(--color-text-white);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-inquiry-btn.collapsed {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Logout Button */
|
/* Logout Button */
|
||||||
.logout-btn {
|
.logout-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -794,148 +848,100 @@
|
|||||||
color: var(--color-text-white);
|
color: var(--color-text-white);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logout-btn.collapsed {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.logout-btn-label {
|
.logout-btn-label {
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Settings Panel (Sidebar 4번째 메뉴 / BottomNav 설정 버튼 공용) */
|
/* 모바일 전용 사이드바 튜토리얼 토글 */
|
||||||
.settings-panel {
|
.sidebar-tutorial-btn {
|
||||||
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;
|
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) {
|
@media (max-width: 767px) {
|
||||||
.bottom-nav {
|
.sidebar-tutorial-btn {
|
||||||
display: flex;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
.has-bottom-nav {
|
.sidebar-tutorial-btn:hover {
|
||||||
padding-bottom: var(--bottom-nav-height);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom-nav-item {
|
/* Mobile Menu Button */
|
||||||
flex: 1;
|
.mobile-menu-btn {
|
||||||
display: flex;
|
position: fixed;
|
||||||
flex-direction: column;
|
top: 1rem;
|
||||||
align-items: center;
|
right: 1rem;
|
||||||
justify-content: center;
|
z-index: 40;
|
||||||
gap: 6px;
|
padding: 0.625rem;
|
||||||
font-size: 12px;
|
background-color: var(--color-bg-card);
|
||||||
font-weight: 700;
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-border-white-10);
|
||||||
color: var(--color-text-gray-400);
|
color: var(--color-text-gray-400);
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom-nav-item svg {
|
.mobile-menu-btn:hover {
|
||||||
width: 24px;
|
color: var(--color-text-white);
|
||||||
height: 24px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom-nav-item.active {
|
@media (min-width: 768px) {
|
||||||
color: var(--color-mint);
|
.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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -64,7 +64,7 @@
|
|||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.settings-title {
|
.settings-title {
|
||||||
font-size: 24px;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -76,7 +76,7 @@
|
|||||||
|
|
||||||
.settings-description {
|
.settings-description {
|
||||||
color: var(--color-text-gray-400);
|
color: var(--color-text-gray-400);
|
||||||
font-size: 10px;
|
font-size: 9px;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
line-height: 1.625;
|
line-height: 1.625;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
@ -124,7 +124,7 @@
|
|||||||
|
|
||||||
.settings-card-title {
|
.settings-card-title {
|
||||||
color: var(--color-mint);
|
color: var(--color-mint);
|
||||||
font-size: 10px;
|
font-size: 9px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
@ -234,7 +234,7 @@
|
|||||||
|
|
||||||
.social-item-connect {
|
.social-item-connect {
|
||||||
color: var(--color-mint);
|
color: var(--color-mint);
|
||||||
font-size: 10px;
|
font-size: 9px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@ -321,7 +321,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.footer-link {
|
.footer-link {
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
color: #379599;
|
color: #379599;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
@ -333,7 +333,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.footer-link-divider {
|
.footer-link-divider {
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
color: #379599;
|
color: #379599;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,7 +28,7 @@
|
|||||||
border: 1px solid rgba(155, 202, 204, 0.3);
|
border: 1px solid rgba(155, 202, 204, 0.3);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #9BCACC;
|
color: #9BCACC;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@ -54,7 +54,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ado2-contents-count {
|
.ado2-contents-count {
|
||||||
font-size: 18px;
|
font-size: 17px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #9BCACC;
|
color: #9BCACC;
|
||||||
}
|
}
|
||||||
@ -281,7 +281,7 @@
|
|||||||
border: none;
|
border: none;
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||||
color: var(--color-text-white);
|
color: var(--color-text-white);
|
||||||
font-size: 16px;
|
font-size: 15px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
@ -321,7 +321,7 @@
|
|||||||
border: 1px solid rgba(255,255,255,0.15);
|
border: 1px solid rgba(255,255,255,0.15);
|
||||||
background: rgba(255,255,255,0.05);
|
background: rgba(255,255,255,0.05);
|
||||||
color: var(--color-text-white);
|
color: var(--color-text-white);
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@ -359,7 +359,6 @@
|
|||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background-color: #034A4D;
|
background-color: #034A4D;
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-card-thumbnail {
|
.content-card-thumbnail {
|
||||||
@ -436,16 +435,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-card-meta {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px 8px;
|
|
||||||
min-width: 0;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-card-title {
|
.content-card-title {
|
||||||
@ -464,12 +453,12 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
font-size: 16px;
|
font-size: 15px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #9BCACC;
|
color: #9BCACC;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-left: auto;
|
margin-left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-card-like-btn {
|
.content-card-like-btn {
|
||||||
@ -657,76 +646,10 @@
|
|||||||
|
|
||||||
/* ── VideoDetailPage / VideoDetailModal ─────────────────────────── */
|
/* ── VideoDetailPage / VideoDetailModal ─────────────────────────── */
|
||||||
|
|
||||||
/* social-posting-overlay 와 동일한 구조: 배경 + 중앙 정렬 + 내부 스크롤 박스 */
|
/* 모달 내부 콘텐츠 (풀페이지의 video-detail-page와 동일 스타일, 배경 제외) */
|
||||||
.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 {
|
.video-detail-modal-content {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
flex: 1;
|
padding: 24px 32px;
|
||||||
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;
|
|
||||||
color-scheme: dark;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: #046266 transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-detail-modal-content > .video-detail-body::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-detail-modal-content > .video-detail-body::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-detail-modal-content > .video-detail-body::-webkit-scrollbar-thumb {
|
|
||||||
background: #046266;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-detail-modal-content > .video-detail-body::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: #067C80;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-detail-page-content {
|
.video-detail-page-content {
|
||||||
@ -766,21 +689,6 @@
|
|||||||
flex-direction: column;
|
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 {
|
.video-detail-back-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -800,7 +708,7 @@
|
|||||||
|
|
||||||
.video-detail-content {
|
.video-detail-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 24px;
|
gap: 32px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@ -890,7 +798,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding-top: 1rem;
|
padding-top: 8px;
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -956,12 +864,6 @@
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 버튼 아래 공간이 부족하면(모달 스크롤 영역 하단 근처) 위로 펼친다 */
|
|
||||||
.video-detail-share-menu.upward {
|
|
||||||
top: auto;
|
|
||||||
bottom: calc(100% + 6px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-detail-share-item {
|
.video-detail-share-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -1052,7 +954,7 @@
|
|||||||
background: #1A8F93;
|
background: #1A8F93;
|
||||||
border: none;
|
border: none;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -1068,7 +970,7 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1082,7 +984,7 @@
|
|||||||
|
|
||||||
/* 댓글 닉네임 */
|
/* 댓글 닉네임 */
|
||||||
.video-detail-comment-nickname {
|
.video-detail-comment-nickname {
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #9BCACC;
|
color: #9BCACC;
|
||||||
display: block;
|
display: block;
|
||||||
@ -1098,7 +1000,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.video-detail-comments-title {
|
.video-detail-comments-title {
|
||||||
font-size: 18px;
|
font-size: 17px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@ -1327,21 +1229,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 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 {
|
.pagination-info {
|
||||||
color: #9BCACC;
|
color: #9BCACC;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@ -1552,31 +1439,31 @@
|
|||||||
background-color: rgba(148, 251, 224, 0.15);
|
background-color: rgba(148, 251, 224, 0.15);
|
||||||
color: #94FBE0;
|
color: #94FBE0;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.social-connect-title {
|
.social-connect-title {
|
||||||
font-size: 24px;
|
font-size: 1.5rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.social-connect-message {
|
.social-connect-message {
|
||||||
font-size: 16px;
|
font-size: 1rem;
|
||||||
color: rgba(255, 255, 255, 0.7);
|
color: rgba(255, 255, 255, 0.7);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.social-connect-detail {
|
.social-connect-detail {
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: rgba(255, 255, 255, 0.5);
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.social-connect-countdown {
|
.social-connect-countdown {
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
color: #94FBE0;
|
color: #94FBE0;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
@ -1591,7 +1478,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.875rem 1.5rem;
|
padding: 0.875rem 1.5rem;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
@ -1666,20 +1553,8 @@
|
|||||||
background-color: var(--color-bg-darker);
|
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 {
|
.myinfo-title {
|
||||||
font-size: 28px;
|
font-size: 1.75rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
@ -1688,6 +1563,7 @@
|
|||||||
/* 탭 네비게이션 */
|
/* 탭 네비게이션 */
|
||||||
.myinfo-tabs {
|
.myinfo-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
@ -1697,7 +1573,7 @@
|
|||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: rgba(255, 255, 255, 0.5);
|
||||||
font-size: 16px;
|
font-size: 0.9rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
position: relative;
|
||||||
@ -1736,7 +1612,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.myinfo-section-title {
|
.myinfo-section-title {
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: rgba(255, 255, 255, 0.6);
|
color: rgba(255, 255, 255, 0.6);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@ -1745,7 +1621,7 @@
|
|||||||
|
|
||||||
.myinfo-placeholder {
|
.myinfo-placeholder {
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: rgba(255, 255, 255, 0.5);
|
||||||
font-size: 14px;
|
font-size: 0.9rem;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: rgba(255, 255, 255, 0.03);
|
||||||
@ -1771,21 +1647,21 @@
|
|||||||
|
|
||||||
.myinfo-credits-label {
|
.myinfo-credits-label {
|
||||||
color: rgba(255, 255, 255, 0.6);
|
color: rgba(255, 255, 255, 0.6);
|
||||||
font-size: 16px;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.myinfo-credits-value {
|
.myinfo-credits-value {
|
||||||
color: #a6ffea;
|
color: #a6ffea;
|
||||||
font-size: 22px;
|
font-size: 1.4rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.myinfo-credits-desc {
|
.myinfo-credits-desc {
|
||||||
color: rgba(255, 255, 255, 0.4);
|
color: rgba(255, 255, 255, 0.4);
|
||||||
font-size: 14px;
|
font-size: 0.85rem;
|
||||||
margin-right: 5px;
|
margin: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1795,7 +1671,7 @@
|
|||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: #002224;
|
color: #002224;
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@ -1833,7 +1709,7 @@
|
|||||||
|
|
||||||
.myinfo-popup-title {
|
.myinfo-popup-title {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 16px;
|
font-size: 1.05rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
@ -1841,7 +1717,7 @@
|
|||||||
|
|
||||||
.myinfo-popup-message {
|
.myinfo-popup-message {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 18px;
|
font-size: 1.1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -1856,7 +1732,7 @@
|
|||||||
|
|
||||||
.myinfo-popup-label {
|
.myinfo-popup-label {
|
||||||
color: rgba(255, 255, 255, 0.6);
|
color: rgba(255, 255, 255, 0.6);
|
||||||
font-size: 14px;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.myinfo-popup-counter {
|
.myinfo-popup-counter {
|
||||||
@ -1872,7 +1748,7 @@
|
|||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
border: none;
|
border: none;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 20px;
|
font-size: 1.25rem;
|
||||||
width: 48px;
|
width: 48px;
|
||||||
min-width: 48px;
|
min-width: 48px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
@ -1890,7 +1766,7 @@
|
|||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 16px;
|
font-size: 0.95rem;
|
||||||
padding: 0.6rem 0.875rem;
|
padding: 0.6rem 0.875rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -1922,7 +1798,7 @@
|
|||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 14px;
|
font-size: 0.9rem;
|
||||||
padding: 0.6rem 0.875rem;
|
padding: 0.6rem 0.875rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
resize: none;
|
resize: none;
|
||||||
@ -1949,7 +1825,7 @@
|
|||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 0.6rem 1.25rem;
|
padding: 0.6rem 1.25rem;
|
||||||
font-size: 14px;
|
font-size: 0.9rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
}
|
}
|
||||||
@ -1964,7 +1840,7 @@
|
|||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 0.6rem 1.5rem;
|
padding: 0.6rem 1.5rem;
|
||||||
font-size: 14px;
|
font-size: 0.9rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
@ -1988,14 +1864,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.myinfo-business-empty-title {
|
.myinfo-business-empty-title {
|
||||||
font-size: 18px;
|
font-size: 1.1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.myinfo-business-empty-desc {
|
.myinfo-business-empty-desc {
|
||||||
font-size: 14px;
|
font-size: 0.85rem;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: rgba(255, 255, 255, 0.5);
|
||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
}
|
}
|
||||||
@ -2013,7 +1889,7 @@
|
|||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
font-size: 14px;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.myinfo-business-input::placeholder {
|
.myinfo-business-input::placeholder {
|
||||||
@ -2031,7 +1907,7 @@
|
|||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: #002224;
|
color: #002224;
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@ -2059,7 +1935,7 @@
|
|||||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
@ -2142,13 +2018,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.myinfo-connected-channel {
|
.myinfo-connected-channel {
|
||||||
font-size: 14px;
|
font-size: 0.9rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
.myinfo-connected-platform {
|
.myinfo-connected-platform {
|
||||||
font-size: 12px;
|
font-size: 0.7rem;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: rgba(255, 255, 255, 0.5);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
@ -2164,7 +2040,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.375rem;
|
gap: 0.375rem;
|
||||||
font-size: 12px;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #a6ffea;
|
color: #a6ffea;
|
||||||
}
|
}
|
||||||
@ -2175,7 +2051,7 @@
|
|||||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: rgba(255, 255, 255, 0.6);
|
color: rgba(255, 255, 255, 0.6);
|
||||||
font-size: 12px;
|
font-size: 0.75rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
@ -2187,7 +2063,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.myinfo-loading {
|
.myinfo-loading {
|
||||||
font-size: 14px;
|
font-size: 0.85rem;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: rgba(255, 255, 255, 0.5);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
@ -2201,7 +2077,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.myinfo-title {
|
.myinfo-title {
|
||||||
font-size: 24px;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.myinfo-tabs {
|
.myinfo-tabs {
|
||||||
@ -2217,7 +2093,7 @@
|
|||||||
.myinfo-tab {
|
.myinfo-tab {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
padding: 0.625rem 0.75rem;
|
padding: 0.625rem 0.75rem;
|
||||||
font-size: 14px;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.myinfo-business-input-row {
|
.myinfo-business-input-row {
|
||||||
|
|||||||
@ -232,7 +232,7 @@
|
|||||||
color: var(--color-bg-dark);
|
color: var(--color-bg-dark);
|
||||||
padding: 0.125rem 0.5rem;
|
padding: 0.125rem 0.5rem;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
font-size: 10px;
|
font-size: 9px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
box-shadow: 0 20px 25px -5px rgba(166, 255, 234, 0.2);
|
box-shadow: 0 20px 25px -5px rgba(166, 255, 234, 0.2);
|
||||||
}
|
}
|
||||||
@ -269,7 +269,7 @@
|
|||||||
.chart-xaxis {
|
.chart-xaxis {
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
font-size: 10px;
|
font-size: 9px;
|
||||||
padding-top: 0.75rem;
|
padding-top: 0.75rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -601,7 +601,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
font-size: 10px;
|
font-size: 9px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -699,7 +699,7 @@
|
|||||||
|
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
.yoy-chart-xaxis {
|
.yoy-chart-xaxis {
|
||||||
font-size: 10px;
|
font-size: 9px;
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
/* Completion Page Title */
|
/* Completion Page Title */
|
||||||
.completion-title {
|
.completion-title {
|
||||||
font-size: 28px;
|
font-size: 1.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #E5F1F2;
|
color: #E5F1F2;
|
||||||
line-height: 1.19;
|
line-height: 1.19;
|
||||||
@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.completion-title {
|
.completion-title {
|
||||||
font-size: 40px;
|
font-size: 2.5rem;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -185,7 +185,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.video-overlay-title {
|
.video-overlay-title {
|
||||||
font-size: 20px;
|
font-size: 1.25rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
@ -196,7 +196,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.video-overlay-logo {
|
.video-overlay-logo {
|
||||||
font-size: 6px;
|
font-size: 0.375rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: rgba(255, 255, 255, 0.6);
|
color: rgba(255, 255, 255, 0.6);
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
@ -225,7 +225,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ai-optimization-title {
|
.ai-optimization-title {
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #94FBE0;
|
color: #94FBE0;
|
||||||
line-height: 1.19;
|
line-height: 1.19;
|
||||||
@ -264,7 +264,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ai-tag-text {
|
.ai-tag-text {
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: #E5F1F2;
|
color: #E5F1F2;
|
||||||
line-height: 1.19;
|
line-height: 1.19;
|
||||||
@ -336,7 +336,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.completion-social-name {
|
.completion-social-name {
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
line-height: 1.19;
|
line-height: 1.19;
|
||||||
@ -344,7 +344,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.completion-social-email {
|
.completion-social-email {
|
||||||
font-size: 12px;
|
font-size: 0.75rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: #94FBE0;
|
color: #94FBE0;
|
||||||
line-height: 1.19;
|
line-height: 1.19;
|
||||||
@ -359,7 +359,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.completion-social-channel {
|
.completion-social-channel {
|
||||||
font-size: 12px;
|
font-size: 0.75rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: #94FBE0;
|
color: #94FBE0;
|
||||||
line-height: 1.19;
|
line-height: 1.19;
|
||||||
@ -377,14 +377,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.completion-social-channel-name {
|
.completion-social-channel-name {
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.completion-social-platform {
|
.completion-social-platform {
|
||||||
font-size: 12px;
|
font-size: 0.7rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: rgba(255, 255, 255, 0.5);
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
@ -405,7 +405,7 @@
|
|||||||
|
|
||||||
/* Social Card Status */
|
/* Social Card Status */
|
||||||
.completion-social-status {
|
.completion-social-status {
|
||||||
font-size: 12px;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding: 0.25rem 0.75rem;
|
padding: 0.25rem 0.75rem;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
@ -438,7 +438,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.completion-social-disconnect {
|
.completion-social-disconnect {
|
||||||
font-size: 12px;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #f87171;
|
color: #f87171;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@ -461,7 +461,7 @@
|
|||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all var(--transition-normal);
|
transition: all var(--transition-normal);
|
||||||
@ -486,7 +486,7 @@
|
|||||||
border: 1px solid #AE72F9;
|
border: 1px solid #AE72F9;
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all var(--transition-normal);
|
transition: all var(--transition-normal);
|
||||||
@ -531,7 +531,7 @@
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
background-color: #462E64;
|
background-color: #462E64;
|
||||||
color: #CFABFB;
|
color: #CFABFB;
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 0 1.25rem 0 0.5rem;
|
padding: 0 1.25rem 0 0.5rem;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
@ -1262,7 +1262,7 @@
|
|||||||
.report-content {
|
.report-content {
|
||||||
color: #CEE5E6;
|
color: #CEE5E6;
|
||||||
font-family: 'Pretendard', sans-serif;
|
font-family: 'Pretendard', sans-serif;
|
||||||
font-size: 16px;
|
font-size: 15px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
letter-spacing: -0.006em;
|
letter-spacing: -0.006em;
|
||||||
@ -1270,7 +1270,7 @@
|
|||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.report-content {
|
.report-content {
|
||||||
font-size: 18px;
|
font-size: 17px;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1385,7 +1385,7 @@
|
|||||||
|
|
||||||
.selling-point-content p {
|
.selling-point-content p {
|
||||||
font-family: 'Pretendard', sans-serif;
|
font-family: 'Pretendard', sans-serif;
|
||||||
font-size: 16px;
|
font-size: 15px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #CEE5E6;
|
color: #CEE5E6;
|
||||||
letter-spacing: -0.006em;
|
letter-spacing: -0.006em;
|
||||||
@ -1395,7 +1395,7 @@
|
|||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.selling-point-content p {
|
.selling-point-content p {
|
||||||
font-size: 18px;
|
font-size: 17px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1419,7 +1419,7 @@
|
|||||||
background-color: #034A4D;
|
background-color: #034A4D;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-family: 'Pretendard', sans-serif;
|
font-family: 'Pretendard', sans-serif;
|
||||||
font-size: 16px;
|
font-size: 15px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #CEE5E6;
|
color: #CEE5E6;
|
||||||
letter-spacing: -0.006em;
|
letter-spacing: -0.006em;
|
||||||
@ -1428,7 +1428,7 @@
|
|||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.feature-tag {
|
.feature-tag {
|
||||||
font-size: 18px;
|
font-size: 17px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1665,7 +1665,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bi-usp-category {
|
.bi-usp-category {
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #6AB0B3;
|
color: #6AB0B3;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@ -1678,7 +1678,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bi-usp-description {
|
.bi-usp-description {
|
||||||
font-size: 16px;
|
font-size: 15px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: rgba(255, 255, 255, 0.7);
|
color: rgba(255, 255, 255, 0.7);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@ -1694,7 +1694,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bi-usp-english-category {
|
.bi-usp-english-category {
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #6AB0B3;
|
color: #6AB0B3;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@ -1749,7 +1749,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bi-chart-sublabel {
|
.bi-chart-sublabel {
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
fill: rgba(255, 255, 255, 0.5);
|
fill: rgba(255, 255, 255, 0.5);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
@ -1808,6 +1808,10 @@
|
|||||||
body:has(.sidebar.expanded) .bi2-header {
|
body:has(.sidebar.expanded) .bi2-header {
|
||||||
left: 15rem;
|
left: 15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body:has(.sidebar.collapsed) .bi2-header {
|
||||||
|
left: 5rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.bi2-back-btn {
|
.bi2-back-btn {
|
||||||
@ -1885,7 +1889,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bi2-subtitle {
|
.bi2-subtitle {
|
||||||
font-size: 22px;
|
font-size: 21px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: #E5F1F2;
|
color: #E5F1F2;
|
||||||
letter-spacing: -0.006em;
|
letter-spacing: -0.006em;
|
||||||
@ -2290,12 +2294,9 @@
|
|||||||
body:has(.sidebar.expanded) .bi2-bottom-button-container {
|
body:has(.sidebar.expanded) .bi2-bottom-button-container {
|
||||||
left: 15rem;
|
left: 15rem;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/* 모바일: 하단 탭바에 가리지 않도록 띄운다 */
|
body:has(.sidebar.collapsed) .bi2-bottom-button-container {
|
||||||
@media (max-width: 767px) {
|
left: 5rem;
|
||||||
.bi2-bottom-button-container {
|
|
||||||
bottom: calc(var(--bottom-nav-height) + 16px);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2357,7 +2358,7 @@
|
|||||||
color: var(--Color-white, #FFF);
|
color: var(--Color-white, #FFF);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-family: Pretendard;
|
font-family: Pretendard;
|
||||||
font-size: 46px;
|
font-size: 45px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: normal;
|
line-height: normal;
|
||||||
@ -2387,19 +2388,12 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
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);
|
background-color: var(--color-bg-darker);
|
||||||
color: var(--color-text-white);
|
color: var(--color-text-white);
|
||||||
padding: 80px 1rem 100px;
|
padding: 80px 1rem 100px;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow-y: auto;
|
overflow: hidden;
|
||||||
overflow-x: hidden;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2409,194 +2403,44 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
position: relative;
|
position: relative;
|
||||||
/* 세로 중앙정렬은 auto 마진으로 한다 (부모의 justify-content 주석 참조).
|
margin-top: 50px;
|
||||||
넘칠 때 잘리지 않는 유일한 방법이다 */
|
|
||||||
margin: auto 0;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 375px;
|
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 {
|
.url-input-icon {
|
||||||
color: #AE72F9;
|
color: #AE72F9;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.url-input-logo {
|
.url-input-logo {
|
||||||
/* 로고 → 탭은 "묶음 사이" 간격 (generation-flow.css 상단 간격 체계 참조) */
|
margin-bottom: 1rem;
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 로고는 **아래 폼 열보다 넓어질 수 없다.**
|
|
||||||
이전에는 화면이 커질수록 350 → 400 → 500 → 554px 로 키웠는데, 정작 폼 열은
|
|
||||||
375px 로 고정이라 데스크톱에서 로고만 좌우로 90px 씩 삐져나왔다.
|
|
||||||
min() 으로 열 폭과 상한을 동시에 걸면 어느 폭에서도 넘지 않는다 —
|
|
||||||
브레이크포인트를 늘려도 다시 어긋날 일이 없다. */
|
|
||||||
.url-input-logo img {
|
.url-input-logo img {
|
||||||
display: block;
|
width: 350px;
|
||||||
width: min(100%, 280px);
|
max-width: 100%;
|
||||||
height: auto;
|
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 {
|
.url-input-title {
|
||||||
font-family: 'Pretendard', sans-serif;
|
font-family: 'Pretendard', sans-serif;
|
||||||
font-size: 40px;
|
font-size: 40px;
|
||||||
@ -2870,6 +2714,12 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.wizard-stepper {
|
||||||
|
margin: 50px auto 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* 노드 셀: 노드 너비와 동일한 2rem 고정. 라벨은 이 밖으로 넘침 */
|
/* 노드 셀: 노드 너비와 동일한 2rem 고정. 라벨은 이 밖으로 넘침 */
|
||||||
.wizard-step {
|
.wizard-step {
|
||||||
flex: none;
|
flex: none;
|
||||||
|
|||||||
@ -10,12 +10,3 @@
|
|||||||
@import './studio-assets.css';
|
@import './studio-assets.css';
|
||||||
@import './contents-social.css';
|
@import './contents-social.css';
|
||||||
@import './modals-overlays.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 */
|
||||||
.hero-form {
|
.hero-form {
|
||||||
padding-top: 20px;
|
padding-top: 50px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 375px;
|
max-width: 375px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -384,7 +384,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
color: var(--color-text-gray-300);
|
color: var(--color-text-gray-300);
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-divider::before,
|
.hero-divider::before,
|
||||||
@ -581,7 +581,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.welcome-title {
|
.welcome-title {
|
||||||
font-size: 24px;
|
font-size: 1.5rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
color: #E5F1F2;
|
color: #E5F1F2;
|
||||||
@ -591,13 +591,13 @@
|
|||||||
|
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
.welcome-title {
|
.welcome-title {
|
||||||
font-size: 32px;
|
font-size: 2rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.welcome-title {
|
.welcome-title {
|
||||||
font-size: 40px;
|
font-size: 2.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -617,7 +617,7 @@
|
|||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.welcome-subtitle {
|
.welcome-subtitle {
|
||||||
font-size: 24px;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -833,7 +833,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 20px;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #9BCACC;
|
color: #9BCACC;
|
||||||
letter-spacing: -0.006em;
|
letter-spacing: -0.006em;
|
||||||
@ -850,7 +850,7 @@
|
|||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.feature-title {
|
.feature-title {
|
||||||
font-size: 24px;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1425,18 +1425,18 @@
|
|||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.login-title {
|
.login-title {
|
||||||
font-size: 48px;
|
font-size: 3rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.login-title {
|
.login-title {
|
||||||
font-size: 60px;
|
font-size: 3.75rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-subtitle {
|
.login-subtitle {
|
||||||
font-size: 10px;
|
font-size: 9px;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
@ -1509,7 +1509,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.loading-title {
|
.loading-title {
|
||||||
font-size: 32px;
|
font-size: 2rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: -0.006em;
|
letter-spacing: -0.006em;
|
||||||
line-height: 1.19;
|
line-height: 1.19;
|
||||||
|
|||||||
@ -5,10 +5,6 @@
|
|||||||
.social-posting-overlay {
|
.social-posting-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
/* 마운트 위치의 텍스트 정렬을 상속하지 않는다.
|
|
||||||
position:fixed 라도 DOM 상속은 끊기지 않아, 중앙정렬된 부모
|
|
||||||
(.ssul-scope.ssul-result 등) 안에서 열면 폼 라벨까지 가운데로 쏠린다. */
|
|
||||||
text-align: left;
|
|
||||||
background-color: rgba(0, 0, 0, 0.7);
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -138,7 +134,7 @@
|
|||||||
background: rgba(166, 130, 255, 0.15);
|
background: rgba(166, 130, 255, 0.15);
|
||||||
border: 1px solid rgba(166, 130, 255, 0.3);
|
border: 1px solid rgba(166, 130, 255, 0.3);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 12px;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #a682ff;
|
color: #a682ff;
|
||||||
}
|
}
|
||||||
@ -153,7 +149,7 @@
|
|||||||
border: 1px dashed rgba(255, 255, 255, 0.2);
|
border: 1px dashed rgba(255, 255, 255, 0.2);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: rgba(255, 255, 255, 0.4);
|
color: rgba(255, 255, 255, 0.4);
|
||||||
font-size: 16px;
|
font-size: 1rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
} */
|
} */
|
||||||
|
|
||||||
@ -281,7 +277,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
font-size: 12px;
|
font-size: 0.7rem;
|
||||||
color: rgba(255, 255, 255, 0.4);
|
color: rgba(255, 255, 255, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -290,11 +286,11 @@
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: rgba(255, 255, 255, 0.5);
|
||||||
font-size: 14px;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.social-posting-no-accounts-hint {
|
.social-posting-no-accounts-hint {
|
||||||
font-size: 12px;
|
font-size: 0.75rem;
|
||||||
color: rgba(255, 255, 255, 0.4);
|
color: rgba(255, 255, 255, 0.4);
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
@ -355,7 +351,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mini-calendar-title {
|
.mini-calendar-title {
|
||||||
font-size: 14px;
|
font-size: 0.85rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: rgba(255, 255, 255, 0.9);
|
color: rgba(255, 255, 255, 0.9);
|
||||||
}
|
}
|
||||||
@ -364,7 +360,7 @@
|
|||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: rgba(255, 255, 255, 0.6);
|
color: rgba(255, 255, 255, 0.6);
|
||||||
font-size: 20px;
|
font-size: 1.2rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@ -385,7 +381,7 @@
|
|||||||
|
|
||||||
.mini-calendar-day-label {
|
.mini-calendar-day-label {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 12px;
|
font-size: 0.72rem;
|
||||||
color: rgba(255, 255, 255, 0.4);
|
color: rgba(255, 255, 255, 0.4);
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
}
|
}
|
||||||
@ -394,7 +390,7 @@
|
|||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: rgba(255, 255, 255, 0.85);
|
color: rgba(255, 255, 255, 0.85);
|
||||||
font-size: 12px;
|
font-size: 0.78rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 5px 2px;
|
padding: 5px 2px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@ -435,7 +431,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.schedule-time-label {
|
.schedule-time-label {
|
||||||
font-size: 12px;
|
font-size: 0.8rem;
|
||||||
color: rgba(255, 255, 255, 0.6);
|
color: rgba(255, 255, 255, 0.6);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
@ -451,7 +447,7 @@
|
|||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: rgba(255, 255, 255, 0.9);
|
color: rgba(255, 255, 255, 0.9);
|
||||||
font-size: 14px;
|
font-size: 0.85rem;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
outline: none;
|
outline: none;
|
||||||
@ -468,7 +464,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.schedule-datetime-preview {
|
.schedule-datetime-preview {
|
||||||
font-size: 12px;
|
font-size: 0.8rem;
|
||||||
color: #a65eff;
|
color: #a65eff;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -485,7 +481,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.social-posting-footer-note {
|
.social-posting-footer-note {
|
||||||
font-size: 12px;
|
font-size: 0.75rem;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: rgba(255, 255, 255, 0.5);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@ -503,7 +499,7 @@
|
|||||||
.social-posting-btn {
|
.social-posting-btn {
|
||||||
padding: 0.625rem 1.25rem;
|
padding: 0.625rem 1.25rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
@ -619,7 +615,7 @@
|
|||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
font-family: 'Pretendard', sans-serif;
|
font-family: 'Pretendard', sans-serif;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -683,7 +679,7 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
font-family: 'Pretendard', sans-serif;
|
font-family: 'Pretendard', sans-serif;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -744,7 +740,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.upload-progress-title {
|
.upload-progress-title {
|
||||||
font-size: 18px;
|
font-size: 1.125rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -835,7 +831,7 @@
|
|||||||
|
|
||||||
/* Status Text */
|
/* Status Text */
|
||||||
.upload-progress-status {
|
.upload-progress-status {
|
||||||
font-size: 20px;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -879,7 +875,7 @@
|
|||||||
display: block;
|
display: block;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
color: rgba(255, 255, 255, 0.7);
|
color: rgba(255, 255, 255, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -905,13 +901,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.upload-progress-label {
|
.upload-progress-label {
|
||||||
font-size: 14px;
|
font-size: 0.8125rem;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: rgba(255, 255, 255, 0.5);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-progress-value {
|
.upload-progress-value {
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
@ -928,7 +924,7 @@
|
|||||||
|
|
||||||
.upload-progress-error p {
|
.upload-progress-error p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
color: #f87171;
|
color: #f87171;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@ -939,9 +935,9 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
background: #f87171;
|
background: #FF0000;
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@ -968,7 +964,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.upload-progress-calendar-link {
|
.upload-progress-calendar-link {
|
||||||
font-size: 12px;
|
font-size: 0.8rem;
|
||||||
color: rgba(255, 255, 255, 0.45);
|
color: rgba(255, 255, 255, 0.45);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
@ -982,7 +978,7 @@
|
|||||||
.upload-progress-btn {
|
.upload-progress-btn {
|
||||||
padding: 0.75rem 2rem;
|
padding: 0.75rem 2rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
@ -1001,7 +997,7 @@
|
|||||||
|
|
||||||
.upload-progress-note {
|
.upload-progress-note {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 14px;
|
font-size: 0.8125rem;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: rgba(255, 255, 255, 0.5);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@ -1028,8 +1024,397 @@
|
|||||||
.calendar-panel-scroll {
|
.calendar-panel-scroll {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: #067C80 transparent;
|
scrollbar-color: #067C80 transparent;
|
||||||
/* 패널 끝까지 스크롤해도 뒤의 캘린더/페이지로 스크롤이 전파되지 않게 한다 */
|
}
|
||||||
overscroll-behavior: contain;
|
|
||||||
|
/* =====================================================
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* BusinessNameInputModal */
|
/* BusinessNameInputModal */
|
||||||
@ -1068,7 +1453,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.manual-modal-subtitle {
|
.manual-modal-subtitle {
|
||||||
font-size: 16px;
|
font-size: 15px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: rgba(155, 202, 204, 0.7);
|
color: rgba(155, 202, 204, 0.7);
|
||||||
padding: 15px 20px 0;
|
padding: 15px 20px 0;
|
||||||
|
|||||||
@ -1,939 +0,0 @@
|
|||||||
/* =====================================================
|
|
||||||
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; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,493 +0,0 @@
|
|||||||
/* =====================================================
|
|
||||||
썰박스 — 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 통일). */
|
|
||||||
@ -1,571 +0,0 @@
|
|||||||
/* =====================================================
|
|
||||||
썰박스 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 {
|
.progress-text {
|
||||||
font-size: 14px;
|
font-size: 0.8125rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-text-white);
|
color: var(--color-text-white);
|
||||||
}
|
}
|
||||||
@ -92,14 +92,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.progress-current {
|
.progress-current {
|
||||||
font-size: 14px;
|
font-size: 0.8125rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-text-white);
|
color: var(--color-text-white);
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-divider,
|
.progress-divider,
|
||||||
.progress-total {
|
.progress-total {
|
||||||
font-size: 14px;
|
font-size: 0.8125rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: #379599;
|
color: #379599;
|
||||||
}
|
}
|
||||||
@ -122,7 +122,7 @@
|
|||||||
|
|
||||||
/* Sound Studio Title */
|
/* Sound Studio Title */
|
||||||
.sound-studio-title {
|
.sound-studio-title {
|
||||||
font-size: 32px;
|
font-size: 2rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@ -192,7 +192,7 @@
|
|||||||
|
|
||||||
/* Column Title */
|
/* Column Title */
|
||||||
.column-title {
|
.column-title {
|
||||||
font-size: 18px;
|
font-size: 1.125rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-mint);
|
color: var(--color-mint);
|
||||||
line-height: 1.19;
|
line-height: 1.19;
|
||||||
@ -337,7 +337,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.language-flag {
|
.language-flag {
|
||||||
font-size: 20px;
|
font-size: 1.25rem;
|
||||||
line-height: 1.19;
|
line-height: 1.19;
|
||||||
letter-spacing: -0.006em;
|
letter-spacing: -0.006em;
|
||||||
}
|
}
|
||||||
@ -415,7 +415,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.language-dropdown-item .language-flag {
|
.language-dropdown-item .language-flag {
|
||||||
font-size: 20px;
|
font-size: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.language-dropdown-item .language-name {
|
.language-dropdown-item .language-name {
|
||||||
@ -642,7 +642,7 @@
|
|||||||
.charge-credits-link {
|
.charge-credits-link {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
color: #AE72F9;
|
color: #AE72F9;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@ -795,7 +795,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.column-title {
|
.column-title {
|
||||||
font-size: 20px;
|
font-size: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lyrics-display {
|
.lyrics-display {
|
||||||
@ -857,7 +857,7 @@
|
|||||||
.asset-upload-error-title {
|
.asset-upload-error-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #94FBE0;
|
color: #94FBE0;
|
||||||
font-size: 20px;
|
font-size: 1.25rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
@ -865,7 +865,7 @@
|
|||||||
.asset-upload-error-message {
|
.asset-upload-error-message {
|
||||||
margin: 0.75rem 0 1.5rem;
|
margin: 0.75rem 0 1.5rem;
|
||||||
color: #E5F1F2;
|
color: #E5F1F2;
|
||||||
font-size: 16px;
|
font-size: 0.9375rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
@ -879,7 +879,7 @@
|
|||||||
background: #94FBE0;
|
background: #94FBE0;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 16px;
|
font-size: 0.9375rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.2s, transform 0.2s;
|
transition: background-color 0.2s, transform 0.2s;
|
||||||
@ -931,7 +931,7 @@
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
background-color: #462E64;
|
background-color: #462E64;
|
||||||
color: #CFABFB;
|
color: #CFABFB;
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 0 1.25rem 0 0.5rem;
|
padding: 0 1.25rem 0 0.5rem;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
@ -999,18 +999,19 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/* 모바일: 하단 탭바에 가리지 않도록 띄운다 */
|
body:has(.sidebar.collapsed) .asset-sticky-footer {
|
||||||
@media (max-width: 767px) {
|
left: 5rem;
|
||||||
.asset-sticky-footer {
|
right: 0;
|
||||||
bottom: calc(var(--bottom-nav-height) + 16px);
|
width: auto;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Asset Title */
|
/* Asset Title */
|
||||||
.asset-title {
|
.asset-title {
|
||||||
font-size: 32px;
|
font-size: 2rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #E5F1F2;
|
color: #E5F1F2;
|
||||||
padding: 0 0 1.5rem;
|
padding: 0 0 1.5rem;
|
||||||
@ -1090,7 +1091,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.asset-section-subtitle {
|
.asset-section-subtitle {
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: #6AB0B3;
|
color: #6AB0B3;
|
||||||
line-height: 1.19;
|
line-height: 1.19;
|
||||||
@ -1098,7 +1099,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.asset-section-count {
|
.asset-section-count {
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #9BCACC;
|
color: #9BCACC;
|
||||||
line-height: 1.19;
|
line-height: 1.19;
|
||||||
@ -1113,7 +1114,7 @@
|
|||||||
|
|
||||||
/* Asset Section Title */
|
/* Asset Section Title */
|
||||||
.asset-section-title {
|
.asset-section-title {
|
||||||
font-size: 24px;
|
font-size: 1.5rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #94FBE0;
|
color: #94FBE0;
|
||||||
line-height: 1.19;
|
line-height: 1.19;
|
||||||
@ -1128,7 +1129,7 @@
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
background-color: #94FBE0;
|
background-color: #94FBE0;
|
||||||
color: #000;
|
color: #000;
|
||||||
font-size: 12px;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
@ -1207,7 +1208,7 @@
|
|||||||
padding: 0.625rem 1rem;
|
padding: 0.625rem 1rem;
|
||||||
background-color: #002224;
|
background-color: #002224;
|
||||||
color: #9BCACC;
|
color: #9BCACC;
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
border: 1px solid #379599;
|
border: 1px solid #379599;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@ -1259,7 +1260,7 @@
|
|||||||
|
|
||||||
.asset-image-placeholder-name {
|
.asset-image-placeholder-name {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-size: 12px;
|
font-size: 0.6875rem;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
color: #9BCACC;
|
color: #9BCACC;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@ -1273,7 +1274,7 @@
|
|||||||
left: 8px;
|
left: 8px;
|
||||||
background-color: #EFE3FE;
|
background-color: #EFE3FE;
|
||||||
color: #AE72F9;
|
color: #AE72F9;
|
||||||
font-size: 12px;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@ -1338,7 +1339,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.asset-upload-text {
|
.asset-upload-text {
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #9BCACC;
|
color: #9BCACC;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@ -1382,7 +1383,7 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.2s, background-color 0.2s;
|
transition: border-color 0.2s, background-color 0.2s;
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.19;
|
line-height: 1.19;
|
||||||
letter-spacing: -0.006em;
|
letter-spacing: -0.006em;
|
||||||
@ -1404,7 +1405,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.asset-ratio-subtitle {
|
.asset-ratio-subtitle {
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: #9BCACC;
|
color: #9BCACC;
|
||||||
}
|
}
|
||||||
@ -1439,7 +1440,7 @@
|
|||||||
.asset-next-button {
|
.asset-next-button {
|
||||||
background-color: #AE72F9;
|
background-color: #AE72F9;
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 10px 40px;
|
padding: 10px 40px;
|
||||||
border: none;
|
border: none;
|
||||||
@ -1462,7 +1463,7 @@
|
|||||||
/* Responsive Styles */
|
/* Responsive Styles */
|
||||||
@media (max-width: 639px) {
|
@media (max-width: 639px) {
|
||||||
.asset-title {
|
.asset-title {
|
||||||
font-size: 24px;
|
font-size: 1.5rem;
|
||||||
padding-bottom: 1rem;
|
padding-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1477,7 +1478,7 @@
|
|||||||
|
|
||||||
@media (min-width: 640px) and (max-width: 767px) {
|
@media (min-width: 640px) and (max-width: 767px) {
|
||||||
.asset-title {
|
.asset-title {
|
||||||
font-size: 28px;
|
font-size: 1.75rem;
|
||||||
padding-bottom: 1.25rem;
|
padding-bottom: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1489,7 +1490,7 @@
|
|||||||
/* Height-based responsive adjustments */
|
/* Height-based responsive adjustments */
|
||||||
@media (max-height: 700px) {
|
@media (max-height: 700px) {
|
||||||
.asset-title {
|
.asset-title {
|
||||||
font-size: 28px;
|
font-size: 1.75rem;
|
||||||
padding-bottom: 0.75rem;
|
padding-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,10 +6,6 @@
|
|||||||
CSS Variables - Design Tokens
|
CSS Variables - Design Tokens
|
||||||
===================================================== */
|
===================================================== */
|
||||||
:root {
|
:root {
|
||||||
/* 모바일 하단 탭바 높이 (safe-area 포함).
|
|
||||||
탭바 자신과, 탭바에 가리면 안 되는 fixed 요소(캘린더 바텀시트 등)가 공유한다 */
|
|
||||||
--bottom-nav-height: calc(72px + env(safe-area-inset-bottom));
|
|
||||||
|
|
||||||
/* Primary Colors */
|
/* Primary Colors */
|
||||||
--color-mint: #a6ffea;
|
--color-mint: #a6ffea;
|
||||||
--color-mint-hover: #8affda;
|
--color-mint-hover: #8affda;
|
||||||
@ -61,14 +57,14 @@
|
|||||||
--radius-full: 9999px;
|
--radius-full: 9999px;
|
||||||
|
|
||||||
/* Font Sizes */
|
/* Font Sizes */
|
||||||
--text-xs: 12px;
|
--text-xs: 0.75rem;
|
||||||
--text-sm: 14px;
|
--text-sm: 0.875rem;
|
||||||
--text-base: 16px;
|
--text-base: 1rem;
|
||||||
--text-lg: 18px;
|
--text-lg: 1.125rem;
|
||||||
--text-xl: 20px;
|
--text-xl: 1.25rem;
|
||||||
--text-2xl: 24px;
|
--text-2xl: 1.5rem;
|
||||||
--text-3xl: 30px;
|
--text-3xl: 1.875rem;
|
||||||
--text-4xl: 36px;
|
--text-4xl: 2.25rem;
|
||||||
|
|
||||||
/* Transitions */
|
/* Transitions */
|
||||||
--transition-fast: 150ms;
|
--transition-fast: 150ms;
|
||||||
|
|||||||
@ -270,22 +270,7 @@ export interface UserCreditsResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 비디오 목록 아이템 (갤러리용)
|
// 비디오 목록 아이템 (갤러리용)
|
||||||
/**
|
|
||||||
* 콘텐츠 종류. ADO2 영상·썰박스·P2V 가 한 목록에 섞인다.
|
|
||||||
* p2v_poster 는 영상이 아니라 **이미지**다 — result_movie_url 에 이미지 URL 이 실리므로
|
|
||||||
* <video> 가 아니라 <img> 로 그려야 한다. p2v_* 는 내 콘텐츠 목록에만 나온다.
|
|
||||||
*/
|
|
||||||
export type ContentType = 'video' | 'ssul' | 'p2v_video' | 'p2v_poster';
|
|
||||||
|
|
||||||
export interface VideoListItem {
|
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;
|
video_id: number;
|
||||||
store_name: string;
|
store_name: string;
|
||||||
region: string;
|
region: string;
|
||||||
@ -416,9 +401,7 @@ export interface SocialDisconnectResponse {
|
|||||||
|
|
||||||
// 유튜브 SEO Description 자동완성 요청
|
// 유튜브 SEO Description 자동완성 요청
|
||||||
export interface YTAutoSeoRequest {
|
export interface YTAutoSeoRequest {
|
||||||
/** 썰박스면 'ssul' + task_id 자리에 ssul_content.id 문자열. 생략 시 video */
|
video_id: number;
|
||||||
content_type?: ContentType;
|
|
||||||
task_id: string; // ADO2 는 task_id, 썰박스는 콘텐츠 id 문자열
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 유튜브 SEO Description 자동완성 응답
|
// 유튜브 SEO Description 자동완성 응답
|
||||||
@ -430,12 +413,7 @@ export interface YTAutoSeoResponse {
|
|||||||
|
|
||||||
// 소셜 업로드 요청
|
// 소셜 업로드 요청
|
||||||
export interface SocialUploadRequest {
|
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
|
social_account_id: number; // 선택된 채널 ID
|
||||||
title: string; // 최대 100자
|
title: string; // 최대 100자
|
||||||
description: string; // 최대 5000자
|
description: string; // 최대 5000자
|
||||||
|
|||||||
240
src/utils/api.ts
240
src/utils/api.ts
@ -1,4 +1,3 @@
|
|||||||
import { clearSessionStorage } from './storageKeys';
|
|
||||||
import {
|
import {
|
||||||
CrawlingResponse,
|
CrawlingResponse,
|
||||||
LyricGenerateRequest,
|
LyricGenerateRequest,
|
||||||
@ -34,7 +33,6 @@ import {
|
|||||||
VideoDetailItem,
|
VideoDetailItem,
|
||||||
CommentsResponse,
|
CommentsResponse,
|
||||||
CommentItem,
|
CommentItem,
|
||||||
ContentType,
|
|
||||||
LikeToggleResponse,
|
LikeToggleResponse,
|
||||||
} from '../types/api';
|
} from '../types/api';
|
||||||
import { uploadImagesSequentially } from './imageUpload.ts';
|
import { uploadImagesSequentially } from './imageUpload.ts';
|
||||||
@ -576,7 +574,7 @@ export async function getAllVideos(
|
|||||||
if (region.trim()) params.set('region', region.trim());
|
if (region.trim()) params.set('region', region.trim());
|
||||||
const response = await authenticatedFetch(`${API_URL}/video/all?${params.toString()}`, {
|
const response = await authenticatedFetch(`${API_URL}/video/all?${params.toString()}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
}, { redirectOnAuthFail: false });
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
@ -589,69 +587,20 @@ export async function getAllVideos(
|
|||||||
export async function getVideoById(videoId: string): Promise<VideoDetailItem> {
|
export async function getVideoById(videoId: string): Promise<VideoDetailItem> {
|
||||||
const response = await authenticatedFetch(`${API_URL}/video/${videoId}`, {
|
const response = await authenticatedFetch(`${API_URL}/video/${videoId}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
}, { redirectOnAuthFail: false });
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 썰박스 콘텐츠 공개 상세 (비로그인 허용 — 공유 링크 /ssul/{id} 가 사용)
|
|
||||||
export interface SsulDetailItem {
|
|
||||||
content_id: number;
|
|
||||||
scenario: string;
|
|
||||||
video_url: string;
|
|
||||||
store_name: string | null;
|
|
||||||
region: string | null;
|
|
||||||
/** 업체 공식 링크. 영상 종료 3초 전 오버레이에 쓰며, 미확보 시 null */
|
|
||||||
official_site_url?: 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) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 댓글 목록 조회
|
// 댓글 목록 조회
|
||||||
// contentType: video.id 와 ssul_content.id 가 겹치므로 종류를 함께 보낸다 (기본 'video')
|
export async function getVideoComments(videoId: string, page: number = 1, pageSize: number = 20): Promise<CommentsResponse> {
|
||||||
export async function getVideoComments(
|
const response = await authenticatedFetch(`${API_URL}/comment/video/${videoId}?page=${page}&page_size=${pageSize}`, {
|
||||||
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',
|
method: 'GET',
|
||||||
},
|
});
|
||||||
{ redirectOnAuthFail: false }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
@ -661,13 +610,8 @@ export async function getVideoComments(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 댓글 작성 (작성자 닉네임/프로필은 서버가 로그인된 카카오 정보로 채움)
|
// 댓글 작성 (작성자 닉네임/프로필은 서버가 로그인된 카카오 정보로 채움)
|
||||||
export async function postVideoComment(
|
export async function postVideoComment(videoId: string, content: string, parentId?: number): Promise<CommentItem> {
|
||||||
videoId: string,
|
const response = await authenticatedFetch(`${API_URL}/comment/video/${videoId}`, {
|
||||||
content: string,
|
|
||||||
parentId?: number,
|
|
||||||
contentType: ContentType = 'video'
|
|
||||||
): Promise<CommentItem> {
|
|
||||||
const response = await authenticatedFetch(`${API_URL}/comment/video/${videoId}?type=${contentType}`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content, parent_id: parentId ?? null }),
|
body: JSON.stringify({ content, parent_id: parentId ?? null }),
|
||||||
@ -692,18 +636,10 @@ export async function deleteComment(commentId: number): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 좋아요 토글
|
// 좋아요 토글
|
||||||
export async function toggleVideoLike(
|
export async function toggleVideoLike(videoId: string): Promise<LikeToggleResponse> {
|
||||||
videoId: string,
|
const response = await authenticatedFetch(`${API_URL}/video/${videoId}/like`, {
|
||||||
// video.id 와 ssul_content.id 가 겹치므로 종류를 함께 보내야 한다.
|
|
||||||
// 기본값 'video' 라 기존 호출부는 수정이 없다.
|
|
||||||
contentType: ContentType = 'video'
|
|
||||||
): Promise<LikeToggleResponse> {
|
|
||||||
const response = await authenticatedFetch(
|
|
||||||
`${API_URL}/video/${videoId}/like?type=${contentType}`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
@ -835,8 +771,11 @@ let refreshPromise: Promise<TokenRefreshResponse> | null = null;
|
|||||||
function redirectToLogin() {
|
function redirectToLogin() {
|
||||||
// 토큰 삭제
|
// 토큰 삭제
|
||||||
clearTokens();
|
clearTokens();
|
||||||
// localStorage 정리 (생성 중이던 상태까지 전부 — 재로그인 시 잔류 방지)
|
// localStorage 정리
|
||||||
clearSessionStorage();
|
localStorage.removeItem('castad_view_mode');
|
||||||
|
localStorage.removeItem('castad_analysis_data');
|
||||||
|
localStorage.removeItem('castad_wizard_step');
|
||||||
|
localStorage.removeItem('castad_active_item');
|
||||||
// 홈으로 리다이렉트
|
// 홈으로 리다이렉트
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
}
|
}
|
||||||
@ -844,8 +783,7 @@ function redirectToLogin() {
|
|||||||
// 401 에러 시 자동으로 토큰 갱신 후 재요청하는 래퍼 함수
|
// 401 에러 시 자동으로 토큰 갱신 후 재요청하는 래퍼 함수
|
||||||
export async function authenticatedFetch(
|
export async function authenticatedFetch(
|
||||||
url: string,
|
url: string,
|
||||||
options: RequestInit = {},
|
options: RequestInit = {}
|
||||||
{ redirectOnAuthFail = true }: { redirectOnAuthFail?: boolean } = {}
|
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
// 인증 헤더 + 캐시 방지 헤더 추가
|
// 인증 헤더 + 캐시 방지 헤더 추가
|
||||||
const headers: HeadersInit = {
|
const headers: HeadersInit = {
|
||||||
@ -862,14 +800,9 @@ export async function authenticatedFetch(
|
|||||||
const errorBody = await response.json().catch(() => null);
|
const errorBody = await response.json().catch(() => null);
|
||||||
const errorCode = errorBody?.detail?.code;
|
const errorCode = errorBody?.detail?.code;
|
||||||
|
|
||||||
// 애초에 토큰이 없던 익명 요청(공개 화면)까지 튕기면 안 되므로 별도 처리
|
|
||||||
const isMissingToken = errorCode === 'MISSING_TOKEN';
|
|
||||||
|
|
||||||
if (errorCode !== 'TOKEN_EXPIRED') {
|
if (errorCode !== 'TOKEN_EXPIRED') {
|
||||||
// INVALID_TOKEN 등 갱신으로 해결 불가한 경우 즉시 로그인 이동
|
// INVALID_TOKEN 등 갱신으로 해결 불가한 경우 즉시 로그인 이동
|
||||||
if (redirectOnAuthFail && !isMissingToken) {
|
|
||||||
redirectToLogin();
|
redirectToLogin();
|
||||||
}
|
|
||||||
throw new Error(errorCode ?? 'Unauthorized');
|
throw new Error(errorCode ?? 'Unauthorized');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -892,9 +825,7 @@ export async function authenticatedFetch(
|
|||||||
response = await fetch(url, { ...options, headers: newHeaders });
|
response = await fetch(url, { ...options, headers: newHeaders });
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
console.error('Token refresh failed:', refreshError);
|
console.error('Token refresh failed:', refreshError);
|
||||||
if (redirectOnAuthFail) {
|
|
||||||
redirectToLogin();
|
redirectToLogin();
|
||||||
}
|
|
||||||
throw refreshError;
|
throw refreshError;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -992,7 +923,15 @@ export async function refreshAccessToken(): Promise<TokenRefreshResponse> {
|
|||||||
// 로컬 스토리지 전체 정리
|
// 로컬 스토리지 전체 정리
|
||||||
function clearAllLocalData() {
|
function clearAllLocalData() {
|
||||||
clearTokens();
|
clearTokens();
|
||||||
clearSessionStorage();
|
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');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 로그아웃
|
// 로그아웃
|
||||||
@ -1397,130 +1336,3 @@ export async function retryUpload(uploadId: number): Promise<{ success: boolean;
|
|||||||
if (!response.ok) throw new Error('재시도 실패');
|
if (!response.ok) throw new Error('재시도 실패');
|
||||||
return response.json();
|
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);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -23,20 +23,7 @@ export async function tryNativeShare(data: ShareData): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** API 서버의 영상 Open Graph 공유 페이지 URL을 만듭니다. */
|
/** API 서버가 제공하는 영상별 Open Graph 공유 페이지 URL을 만듭니다. */
|
||||||
export function buildVideoShareUrl(apiBaseUrl: string, videoId: number | string): string {
|
export function buildVideoShareUrl(apiBaseUrl: string, videoId: number | string): string {
|
||||||
return `${apiBaseUrl.replace(/\/$/, '')}/video/share/${encodeURIComponent(String(videoId))}`;
|
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 {
|
|
||||||
const base = apiBaseUrl.replace(/\/$/, '');
|
|
||||||
if (contentType === 'ssul') {
|
|
||||||
return `${base}/ssul/share/${encodeURIComponent(String(contentId))}`;
|
|
||||||
}
|
|
||||||
return buildVideoShareUrl(base, contentId);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,320 +0,0 @@
|
|||||||
/**
|
|
||||||
* Poster to Video (P2V) 클라이언트.
|
|
||||||
*
|
|
||||||
* **castad 백엔드의 `/p2v/*` 프록시**를 호출한다 (2026-08-25 전환). 이전에는
|
|
||||||
* P2V 서버(:8010)를 팀 키로 직접 호출했지만, 이제 castad 가 카카오 인증(JWT)·
|
|
||||||
* 크레딧 선차감·Blob 아카이빙을 얹는다. 다른 파이프라인(ADO2·썰박스)과 같은
|
|
||||||
* 인증 축이 됐으므로 `authenticatedFetch` 를 그대로 쓴다 (토큰 갱신·재시도 포함).
|
|
||||||
*
|
|
||||||
* 산출물 URL 두 종류:
|
|
||||||
* - 완성 결과물(영상·결과 이미지) → **Azure Blob 공개 URL** (절대 URL 그대로 사용)
|
|
||||||
* - 템플릿 썸네일·검수 이미지 → castad 프록시 경로(`/p2v/files/...`).
|
|
||||||
* 썸네일은 공개라 <img src> 로 바로 되고, 검수 이미지(regions/)는 인증이
|
|
||||||
* 필요해 fetchP2vImage() 로 blob URL 을 만들어 넣는다.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { API_URL, InsufficientCreditError, authenticatedFetch } from './api';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* (구) P2V 접근 키 저장소 — 프록시 전환으로 더 이상 쓰지 않는다.
|
|
||||||
* P2vKeyGate 가 아직 import 하고 있어 심볼만 유지한다. 게이트가 뜰 일은 없다
|
|
||||||
* (401 은 authenticatedFetch 가 토큰 갱신/로그인 이동으로 처리한다).
|
|
||||||
*/
|
|
||||||
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 — P2V 키 입력이 필요하던 상황. 프록시 전환 후에는 castad 로그인 문제라
|
|
||||||
* authenticatedFetch 가 갱신/리다이렉트로 처리하므로 이 예외는 던져지지 않는다.
|
|
||||||
* 컴포넌트들의 instanceof 분기가 남아 있어 타입만 유지한다.
|
|
||||||
*/
|
|
||||||
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 res = await authenticatedFetch(`${API_URL}/p2v${path}`, init);
|
|
||||||
|
|
||||||
if (res.status === 402) throw new InsufficientCreditError();
|
|
||||||
|
|
||||||
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 해석.
|
|
||||||
*
|
|
||||||
* - Azure Blob 공개 URL(절대 URL) → 그대로 (완성 영상·썸네일·결과 이미지)
|
|
||||||
* - `/p2v/files/...` 프록시 경로 → castad 오리진을 붙인다 (템플릿 썸네일 등 공개 파일)
|
|
||||||
*/
|
|
||||||
export const p2vFile = (path: string | null | undefined): string => {
|
|
||||||
if (!path) return '';
|
|
||||||
if (/^https?:\/\//.test(path)) return path;
|
|
||||||
return `${API_URL}${path}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 인증이 필요한 프록시 이미지(검수용 regions/)를 <img> 에 넣을 blob URL 로 받는다.
|
|
||||||
* <img src> 는 Authorization 헤더를 못 실으므로 fetch 를 경유한다.
|
|
||||||
* 반환된 URL 은 쓰는 쪽이 URL.revokeObjectURL 로 정리한다.
|
|
||||||
*/
|
|
||||||
export async function fetchP2vImage(path: string): Promise<string> {
|
|
||||||
const res = await authenticatedFetch(`${API_URL}${path}`);
|
|
||||||
if (!res.ok) throw new Error(`이미지 로드 실패 (${res.status})`);
|
|
||||||
return URL.createObjectURL(await res.blob());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 결과물 다운로드.
|
|
||||||
*
|
|
||||||
* **Blob URL 을 `<a download>` 에 그대로 걸면 다운로드가 아니라 열람 화면으로
|
|
||||||
* 이동한다** — `download` 속성은 교차 출처 URL 에서 무시되고, 결과물은 Azure Blob
|
|
||||||
* 도메인에 있어 앱과 출처가 다르기 때문이다. 썰박스(SsulResultContent)와 동일하게
|
|
||||||
* fetch → Blob → 동일 출처 objectURL 로 바꿔 내려받는다.
|
|
||||||
*
|
|
||||||
* fetch 가 실패하면(CORS 등) 새 탭으로라도 열어준다 — 사용자가 거기서
|
|
||||||
* 직접 저장할 수 있으므로 아무 일도 안 일어나는 것보다 낫다.
|
|
||||||
*/
|
|
||||||
export async function downloadP2vFile(url: string, fileName: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(url);
|
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
||||||
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 = url;
|
|
||||||
link.download = fileName;
|
|
||||||
link.target = '_blank';
|
|
||||||
link.rel = 'noopener';
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 타입 (서버 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 {
|
|
||||||
/** castad 잡 id (정수). 이전 P2V 직접 호출 시절엔 문자열 슬러그였다 */
|
|
||||||
id: number | string;
|
|
||||||
kind?: 'f1' | 'f2';
|
|
||||||
name?: string | null;
|
|
||||||
/** 'archiving' = 완성본을 Blob 으로 옮기는 중 — 진행 중으로 취급한다 */
|
|
||||||
status: 'queued' | 'running' | 'awaiting_review' | 'archiving' | 'failed' | 'done';
|
|
||||||
stage?: string | null;
|
|
||||||
/** 진행 중에만 내려온다 (아카이브 후 조회는 DB 미러라 없음) */
|
|
||||||
stages?: Record<string, P2vStageState> | null;
|
|
||||||
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 }>('/f1/jobs', { method: 'POST', body: fd });
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getPosterJob = (id: string | number): Promise<P2vJob> =>
|
|
||||||
p2vFetch<P2vJob>(`/f1/jobs/${id}`);
|
|
||||||
|
|
||||||
export const updatePosterNarration = (id: string | number, narration: string[]): Promise<unknown> =>
|
|
||||||
p2vFetch(`/f1/jobs/${id}/narration`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ narration }),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const updatePosterMetadata = (
|
|
||||||
id: string | number,
|
|
||||||
meta: { event_name: string; date_text: string; place: string },
|
|
||||||
): Promise<unknown> =>
|
|
||||||
p2vFetch(`/f1/jobs/${id}/metadata`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(meta),
|
|
||||||
});
|
|
||||||
|
|
||||||
/** 검수 승인 → TTS·BGM·애니메이션·렌더가 이어진다. motions 를 넘기면 모션 선정을 덮어쓴다 */
|
|
||||||
export const approvePosterJob = (id: string | number, motions?: string[] | null): Promise<unknown> =>
|
|
||||||
p2vFetch(`/f1/jobs/${id}/approve`, {
|
|
||||||
method: 'POST',
|
|
||||||
...(motions
|
|
||||||
? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ motions }) }
|
|
||||||
: {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 실패 무시하고 진행 — i2v 게이트(제목 훼손 등) 실패 전용.
|
|
||||||
* 이미 생성된 클립으로 렌더만 이으므로 재시도와 달리 Higgsfield 재생성 비용이 없다.
|
|
||||||
* 클립 없는 실패는 서버가 409 로 거절한다.
|
|
||||||
*/
|
|
||||||
export const forcePosterJob = (id: string | number): Promise<unknown> =>
|
|
||||||
p2vFetch(`/f1/jobs/${id}/force`, { method: 'POST' });
|
|
||||||
|
|
||||||
export const retryPosterJob = (id: string | number, motions?: string[] | null): Promise<unknown> =>
|
|
||||||
p2vFetch(`/f1/jobs/${id}/retry`, {
|
|
||||||
method: 'POST',
|
|
||||||
...(motions
|
|
||||||
? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ motions }) }
|
|
||||||
: {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const deletePosterJob = (id: string | number): Promise<unknown> =>
|
|
||||||
p2vFetch(`/f1/jobs/${id}`, { method: 'DELETE' });
|
|
||||||
|
|
||||||
// ── F2 (포스터 스타일링) ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const listF2Templates = (): Promise<F2Template[]> => p2vFetch<F2Template[]>('/f2/templates');
|
|
||||||
export const listF2Categories = (): Promise<F2Category[]> => p2vFetch<F2Category[]>('/f2/categories');
|
|
||||||
export const listF2Formats = (): Promise<F2Format[]> => p2vFetch<F2Format[]>('/f2/formats');
|
|
||||||
export const getF2UploadHint = (): Promise<F2UploadHint> => p2vFetch<F2UploadHint>('/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>('/f2/templates', { method: 'POST', body: fd });
|
|
||||||
}
|
|
||||||
|
|
||||||
export const deleteF2Template = (id: string): Promise<unknown> =>
|
|
||||||
p2vFetch(`/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 }>('/f2/jobs', { method: 'POST', body: fd });
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getStylingJob = (id: string | number): Promise<P2vJob> =>
|
|
||||||
p2vFetch<P2vJob>(`/f2/jobs/${id}`);
|
|
||||||
|
|
||||||
/** 스타일링 잡 삭제 — 내 콘텐츠 목록에서 제거. 미완성 잡은 서버가 크레딧을 환불한다 */
|
|
||||||
export const deleteStylingJob = (id: string | number): Promise<unknown> =>
|
|
||||||
p2vFetch(`/f2/jobs/${id}`, { method: 'DELETE' });
|
|
||||||
|
|
||||||
// ── 진행 계산 ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** 아직 도는 중인가. 이 상태들에서만 폴링을 계속한다 (archiving = Blob 업로드 중) */
|
|
||||||
export const isP2vActive = (job: P2vJob | null): boolean =>
|
|
||||||
job !== null
|
|
||||||
&& (job.status === 'queued' || job.status === 'running' || job.status === 'archiving');
|
|
||||||
|
|
||||||
/** 스테이지 진행률(%) — 선형 진행바용. 도는 중인 스테이지는 절반으로 친다 */
|
|
||||||
export function p2vProgress(job: P2vJob | null): number {
|
|
||||||
if (!job) return 0;
|
|
||||||
if (job.status === 'archiving') return 99; // 렌더 끝, 업로드만 남음
|
|
||||||
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 => {
|
|
||||||
if (!job) return false;
|
|
||||||
// 아카이브 이후 조회는 stages 가 없다 — done/archiving 이면 당연히 검수 뒤다
|
|
||||||
if (job.status === 'done' || job.status === 'archiving') return true;
|
|
||||||
const st = job.stages?.[POST_REVIEW_STAGE]?.status;
|
|
||||||
return st !== undefined && st !== 'idle';
|
|
||||||
};
|
|
||||||
@ -1,91 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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));
|
|
||||||
};
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
/**
|
|
||||||
* 사용자·크롤링에서 온 URL을 링크로 그리기 전에 검증한다.
|
|
||||||
*
|
|
||||||
* `official_site_url`은 업체가 직접 입력하거나 네이버 플레이스에서 긁어온 값이라
|
|
||||||
* `javascript:` 같은 스킴이 섞이면 href에 그대로 실려 XSS가 된다. http/https만 통과시킨다.
|
|
||||||
*/
|
|
||||||
export const isSafeHttpUrl = (url?: string | null): boolean => {
|
|
||||||
if (!url) return false;
|
|
||||||
try {
|
|
||||||
const parsed = new URL(url);
|
|
||||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -10,13 +10,6 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
// 도커 컨테이너가 Windows 바인드 마운트를 감시할 때 inotify 이벤트가 오지 않아
|
|
||||||
// HMR 이 전혀 동작하지 않는다(호스트에서 파일을 고쳐도 옛 모듈을 계속 서빙).
|
|
||||||
// 2026-08-26 실측: 1시간 편집에 HMR 이벤트 0건 → 폴링으로 전환.
|
|
||||||
watch: {
|
|
||||||
usePolling: true,
|
|
||||||
interval: 500,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
resolve: {
|
resolve: {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user