Compare commits

..

27 Commits

Author SHA1 Message Date
3b68845e2d feat: p2v 연동 2026-08-26 13:53:10 +09:00
487d0f6d6a fix(poster): P2V 서버 미연결 상태에서 무빙포스터·스타일링 상호작용 임시 비활성화 2026-08-24 14:35:16 +09:00
8ba5546f85 Merge branch 'feature-ssulbox-ado2-ds' into feature-ssulbox 2026-08-24 14:12:19 +09:00
94b39a44bd feat(ssulbox): 썰박스 상세에 공식 링크 오버레이 추가 2026-08-24 13:45:16 +09:00
3bcca23e97 Merge branch 'main': 공식 링크 오버레이·업체 직접입력 링크 반영, 랜딩 제거 유지 2026-08-24 11:20:57 +09:00
Haewon Kam
efc6e45070 fix: 진입 화면 로고가 폼 열 밖으로 넘치지 않게 한다
화면이 커질수록 로고를 350 → 400 → 500 → 554px 로 키우고 있었는데,
정작 아래 폼 열은 375px 고정이라 데스크톱에서 로고만 좌우로 90px 씩
삐져나왔다. 로고가 탭·입력과 축이 맞지 않아 화면이 불안정해 보인다.

브레이크포인트 4단을 지우고 `width: min(100%, 280px)` 하나로 바꾼다.
열 폭과 상한을 동시에 걸어 어느 화면 폭에서도 열을 넘지 않는다 —
나중에 브레이크포인트를 손대도 다시 어긋나지 않는다.

로고 아래 여백도 16 → 20px 으로 올려 "묶음 사이" 간격 체계에 맞춘다.

측정: 1280px 화면 로고 280 / 열 375, 375px 화면 로고 280 / 열 343 — 양쪽 다 포함.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 15:01:29 +09:00
8be95778f3 fix: 네이티브 공유 시 설명 텍스트 제거 2026-08-20 13:21:46 +09:00
Haewon Kam
40895207ee docs: 배포 요청서를 브랜치에 남긴다
이 브랜치를 dev-ssul 에 올릴 때 필요한 것을 한 곳에 모았다.
A(DS·네비게이션)와 B(포스터 파이프라인)를 나눠 배포할 수 있다는 점,
B 의 선행조건 5개, P2V 환경변수, 검증 절차를 담았다.

특히 두 가지는 문서 없이는 배포에서 반드시 걸린다:
- P2V 를 HTTPS 로 서빙해야 한다 — dev-ssul 이 HTTPS 라 HTTP 면 mixed content 로 전부 차단된다
- P2V 저장소가 templates/*.jpg 를 gitignore 해서, 서버에서 클론하면 F2 썸네일이 전부 깨진다

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 13:07:43 +09:00
Haewon Kam
0ca2140b98 feat: 썰박스 DS 통일과 Poster to Video 통합, 진입 화면 네비게이션 개편
썰박스를 ADO2 디자인 시스템으로 맞추고, 포스터 파이프라인 두 종을 파이프라인
탭에 추가한다. 탭이 4개가 되면서 진입 화면 네비게이션도 함께 손봤다.

## 썰박스 DS 통일
시나리오별 팔레트(조선=파랑·삼국지=빨강·그리스=녹·오디세이=청)와 이모지, 앰버
강조색(--color-ssul-accent)을 걷어냈다. 선택 표시는 castad 표준 문법(민트 테두리
+ 민트 틴트), 스피너는 퍼플, CTA는 퍼플 단색으로 통일했다. 색으로 구분하던 것을
없앤 대신 상태(선택)만 색으로 남긴다.

## Poster to Video 통합 (내부 시연 범위)
- 무빙 포스터(F1): 업로드 → 분석 → **검수 게이트** → 생성 → 완성 5단계 위저드.
  검수에서 나레이션·모션·메타태그를 포스터와 대조해 고치고 승인해야 렌더가 이어진다.
- 포스터 스타일링(F2): 위저드가 아닌 한 화면 도구. 여러 스타일을 갈아 끼우며
  비교하는 작업이라 단계를 나누면 왕복만 늘어난다.

P2V 서버(:8010)를 castad 백엔드가 아니라 **직접 호출**한다. 검증된 scripts를
subprocess로 감싼 단일 워커라 castad 백엔드에 흡수하려면 실행 환경(Higgsfield
CLI·ffmpeg·모델 키)을 통째로 이식해야 하기 때문이다. 호출은 utils/p2vApi.ts 한
파일에 격리했다 — 대외 공개 시 castad 백엔드에 /p2v/* 프록시를 세우고 카카오 인증·
크레딧 차감을 얹을 때 갈아끼울 지점이 여기 하나다.

인증은 단일 팀 키(P2V_ACCESS_KEY)다. 다른 오리진이라 쿠키가 실리지 않아 정적
산출물(영상·이미지·다운로드)은 p2vFile()이 ?key= 로 태운다.

F2는 영화 포스터 레퍼런스가 저작권 자산이라 **결과물의 외부 공개·상업 사용을
금한다**(퍼블릭 도메인 명화 제외). 화면 상단 고지 + 템플릿별 배포 등급 배지 +
내부 전용을 고른 순간에만 뜨는 경고로 표시한다.

## 진입 화면 네비게이션
- 밑줄 탭 → 아이콘 타일 + 기능명. 탭이 4개가 되자 "무빙 포스터"와 "포스터 스타일링"이
  글자로는 갈라지지 않아, 형태로 구분되게 했다. 아이콘은 ADO2 Design System 규칙
  (24×24 · fill none · stroke currentColor 2 · round)을 따르고 색은 넣지 않는다.
- 고른 기능의 한 줄 설명을 탭 아래에 붙였다.
- 간격 체계를 8px 그리드로 다시 잡았다(묶음 안 8~12 / 묶음 사이 20 / 섹션 28~32).
  이전에는 전부 10~24px이라 근접성으로 구조가 읽히지 않아 답답했다. 타일 간격은
  터치 타겟 최소 8dp를 지킨다.

## 버그 수정
- 진입 화면 세로 넘침 잘림: justify-content:center + overflow:hidden 조합이라
  콘텐츠가 길어지면 위로 넘친 부분에 스크롤로도 닿을 수 없었다(무빙 포스터에서
  로고 밴드 47px 잘림). 자식의 margin:auto 0 중앙정렬로 바꿔 해소.
- 사라진 P2V 잡: 404를 문구가 아니라 상태 코드로 판정한다(P2vNotFoundError).
  서버에서 지워진 잡 ID가 남으면 빈 진행 화면에서 멈췄다.
- 검수 화면 모바일: 좌우 여백 0(부모 위저드 컨테이너에 패딩이 없다) → 16px 부여.
  대조할 포스터를 편집 위로 올리고, 승인 CTA를 하단 탭바 위에 sticky로 고정.
- 한글 단어 중간 줄바꿈("애니메이/션") → word-break: keep-all.
- 입력·안내·제출 박스 높이를 48px로 통일.

## 카피
가게→업체, 크롤링→수집으로 어휘 통일.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 12:55:06 +09:00
17319e950e fix: 공유하기 및 UI 수정 2026-08-20 10:34:14 +09:00
4dff60c1ba refactor: main 머지로 복구된 랜딩을 다시 제거한다.
ADO2 콘텐츠를 첫 화면으로 되돌린다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 16:31:36 +09:00
82b9fef3a1 chore: 튜토리얼 오버레이와 관련 문구를 제거
랜딩·업로드에서 자동 안내가 더 이상 뜨지 않게 한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 15:31:44 +09:00
4fe065378f Merge branch 'main' into feature-ssulbox
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 15:15:53 +09:00
3b8298bba2 fix(ssulbox): 업장 선택 후 변경 버튼 제거 및 사이드바 라벨 단순화 2026-08-13 11:29:50 +09:00
fde7c8f6e1 style: 최소 폰트 크기를 14px 로 통일하고 하단 탭바 높이 상향 2026-08-12 17:26:05 +09:00
1f6921c587 fix: 썰박스 생성 소요 시간 안내를 약 5~6분으로 구체화 2026-08-12 17:26:00 +09:00
cdddb913a0 Merge branch 'main' into feature-ssulbox 2026-08-12 15:42:15 +09:00
18b5271110 fix: 로그인 유도 모달 개편과 공유 메뉴 방향·다운로드 파일명·정렬 수정 2026-08-11 14:57:35 +09:00
1c01dab748 fix: 영상 상세 모달을 social-posting-modal 구조로 통일하고 video 삐져나옴 버그 수정 2026-08-11 14:57:35 +09:00
40ebe2cca0 fix: 브랜드 분석 화면의 사이드바 collapsed 잔재 정리 2026-08-11 14:57:34 +09:00
b9fd77fb51 refactor: 생성 완료 화면을 CompletionView 로 통일 2026-08-11 14:57:34 +09:00
3701490728 feat(ssulbox): 업장 검색을 ADO2 자동완성 방식으로 전환 2026-08-11 14:57:34 +09:00
bda4c267dd refactor: 랜딩·튜토리얼·헤더 제거 2026-08-11 14:57:34 +09:00
741539a023 feat(ssulbox): 상세·공유 화면과 좋아요·댓글·업로드 연동 2026-08-11 14:57:34 +09:00
7b1e691715 refactor: 오버레이 닫기 동작을 useOverlayClose 로 통일 2026-08-11 14:57:33 +09:00
54e5d14127 feat: 모바일 하단 내비게이션·설정 패널 도입 2026-08-11 14:57:33 +09:00
bb0ab91bcc feat(ssulbox): 썰박스 진입 탭·생성 플로우와 통합 콘텐츠 목록 분기 2026-08-11 14:57:33 +09:00
82 changed files with 8239 additions and 3576 deletions

157
DEPLOY_REQUEST.md Normal file
View File

@ -0,0 +1,157 @@
# 배포 요청서 — castad 프론트엔드 `feature-ssulbox-ado2-ds`
| | |
|---|---|
| **요청일** | 2026-08-20 |
| **대상 저장소** | `gitea.o2o.kr/castad/o2o-castad-frontend` |
| **브랜치** | `feature-ssulbox-ado2-ds` (브랜치 최신 커밋을 그대로 배포) |
| **코드 커밋** | `0ca2140` — 이 문서는 그 위에 문서만 얹은 커밋입니다 |
| **대상 환경** | dev-ssul.castad.net |
| **연관 저장소** | `gitea.o2o.kr/castad/o2o-ado2-poster-to-video` (신규 연동 대상) |
---
## 0. 한 장 요약
이번 브랜치는 **두 덩어리**입니다. 서로 독립적이라 **나눠서 배포할 수 있습니다.**
| 덩어리 | 내용 | 추가 인프라 | 배포 난이도 |
|---|---|---|---|
| **A. DS 통일 + 네비게이션** | 썰박스 디자인 시스템 통일, 진입 화면 탭 개편, 레이아웃 버그 수정 | **없음** | 기존과 동일 |
| **B. 포스터 파이프라인** | 무빙 포스터(F1), 포스터 스타일링(F2) 탭 | **P2V 서버 신규 배포 필요** | 선행조건 5개 |
**권장: A를 먼저 배포하고, B는 P2V 서버가 준비된 뒤 여는 2단계 진행.**
A만 배포해도 화면은 깨지지 않습니다. 포스터 탭 2개만 "접근 키 입력" 화면이나 연결 실패로
떨어지고, ADO2·썰박스 탭은 평소대로 동작합니다.
---
## 1. A안 — DS·네비게이션만 배포 (지금 바로 가능)
### 필요 작업
```bash
git fetch origin
git checkout feature-ssulbox-ado2-ds # 0ca2140
npm ci
npm run build
```
### 환경변수
| 변수 | 값 | 비고 |
|---|---|---|
| `VITE_API_URL` | 기존 값 유지 | castad 백엔드 |
| `VITE_P2V_URL` | **설정하지 않음** | 미설정 시 포스터 탭만 비활성 상태로 남음 |
### 검증
1. 진입 화면에 탭 4개(ADO2 / 썰박스 / 무빙 포스터 / 포스터 스타일링)가 아이콘 타일로 표시
2. 썰박스 탭 — 시나리오 카드에 이모지·시나리오별 색이 없고 선택 시 민트 테두리
3. **모바일(375px)에서 상단 ADO2 로고가 잘리지 않을 것** ← 이번에 고친 버그
4. ADO2 탭 기존 플로우(URL 입력 → 브랜드 분석) 정상
---
## 2. B안 — 포스터 파이프라인까지 배포
### 2-1. 선행조건 (P2V 서버 측)
P2V 저장소의 `product/DEPLOY.md`가 원본 문서입니다. 아래는 **castad 연동에 필요한 것만** 추린 것입니다.
| # | 항목 | 상태 | 담당 |
|---|---|---|---|
| 1 | P2V 서버(`:8010`) 배포 | **미배포** | Dev |
| 2 | `SUNO_CALLBACK_URL` — Suno가 결과를 POST할 **공개 주소** | 도메인 확정 필요 | Dev |
| 3 | Higgsfield CLI 토큰 — 머신에서 **사람이 1회 로그인** | 자동화 불가 | 담당자 |
| 4 | **P2V를 HTTPS로 서빙** | 미확인 | Dev |
| 5 | F2 템플릿 이미지 파일 별도 전달 | 저장소에 없음 | 요청자 |
**4번이 이번에 새로 확인된 항목입니다.** dev-ssul.castad.net은 HTTPS인데 P2V가 HTTP면
브라우저가 mixed content로 **전부 차단**합니다. P2V 앞에 리버스 프록시로 TLS를 붙여야 합니다.
**5번 주의:** P2V 저장소는 `templates/*.jpg`를 gitignore합니다(영화 포스터 = 저작권).
`templates.json`(메타데이터)만 커밋돼 있어 **클론 직후 F2는 썸네일이 전부 깨집니다.**
compose가 `./server/templates`를 호스트 바인드 마운트하므로, 이미지 21개를 서버에 직접
넣어야 합니다. F2를 이관 범위에서 빼는 것도 선택지입니다(F1은 영향 없음).
### 2-2. P2V 서버 환경변수
`product/.env.example``.env`로 복사해 채웁니다.
| 변수 | 필수 | 설명 |
|---|---|---|
| `CHATGPT_API_KEY` | ✅ | 영역검출·나레이션·모션선정·TTS·F2 |
| `SUNO_API_KEY` | ✅ | BGM 생성 |
| `SUNO_CALLBACK_URL` | ✅ | Suno 결과 수신용 **공개** URL. 없으면 BGM 단계에서 죽음 |
| `P2V_ACCESS_KEY` | ✅ | 팀 공용 키 1개. **비우면 무인증** — 배포에서는 반드시 설정 |
| `P2V_ALLOWED_ORIGINS` | ✅ | **`https://dev-ssul.castad.net` 추가 필수.** 없으면 CORS 차단 |
| `P2V_QUEUE_PATH` | compose가 지정 | 큐를 볼륨 위로. 이미지 레이어에 두면 재배포 때 소실 |
Higgsfield는 환경변수가 아니라 **CLI 토큰 인증**입니다:
```bash
higgsfield auth login && higgsfield workspace set
# 생성된 ~/.config/higgsfield/{credentials,config}.json 을 p2v-higgsfield 볼륨에 주입
# CLI가 토큰을 자동 갱신하며 파일을 다시 쓰므로 :ro 마운트 금지
```
### 2-3. castad 프론트엔드 빌드
```bash
VITE_P2V_URL="https://<P2V 공개 도메인>" npm run build
```
Vite는 **빌드 시점에** 값을 산출물에 박습니다(런타임 주입 아님). 검증했습니다 —
값을 넣으면 번들에 그대로 들어가고 기본값 `localhost:8010`은 남지 않습니다.
### 2-4. 알아둘 제약
- **api를 스케일아웃하면 안 됩니다.** 인메모리 큐 + 파일 상태(`data/jobs.json`) 구조라
인스턴스가 늘면 큐가 깨집니다. compose에도 명시돼 있습니다.
- **처리 시간 1건 4~6분** (Suno ~2분 + veo ~3분).
- **렌더 비용** veo 22크레딧/편(≈$1.12) + OpenAI·Suno 소액.
- **인증이 단일 팀 키**라 사용자별 크레딧 차감이 없습니다. 현재는 내부 시연 범위이고,
대외 공개 시 castad 백엔드에 `/p2v/*` 프록시를 세워 카카오 인증·크레딧을 얹는 것이
다음 수순입니다. 프론트에서 갈아끼울 지점은 `src/utils/p2vApi.ts` 한 파일입니다.
### 2-5. 검증 절차
```bash
# 1) 서버 헬스체크 (인증 없이 열려 있는 유일한 경로)
curl -i https://<P2V 도메인>/api/health # → 200
# 2) 인증 게이트 동작
curl -i https://<P2V 도메인>/api/f2/templates # → 401
curl -i -H "X-P2V-Key: <>" https://<P2V 도메인>/api/f2/templates # → 200 + JSON
```
브라우저에서:
| # | 확인 항목 | 기대 결과 |
|---|---|---|
| 1 | 무빙 포스터 탭 첫 진입 | 접근 키 입력창 → 키 입력 후 업로드 화면 |
| 2 | 포스터 업로드 | 5단계 스텝퍼가 "분석"으로 진행, 진행바·스테이지 라벨 표시 |
| 3 | 1~2분 후 | **검수 화면** — 나레이션 3문장, 모션 칩, 메타태그, 우측 분석 이미지 |
| 4 | 승인 | "생성" 단계로 넘어가고 4~6분 뒤 영상 재생 + MP4 다운로드 |
| 5 | 포스터 스타일링 탭 | 템플릿 썸네일 표시 (**깨지면 5번 선행조건 미이행**) |
| 6 | 모바일(375px) 검수 화면 | 포스터가 위, 승인 버튼이 하단 탭바 위에 고정 |
**F1은 검수 게이트에서 멈춥니다.** 승인 전까지 TTS·BGM·렌더가 시작되지 않으므로,
3번까지만 확인하면 유료 API를 거의 쓰지 않고 연동을 검증할 수 있습니다.
---
## 3. 롤백
프론트엔드는 이전 커밋으로 되돌려 재빌드하면 끝입니다. P2V는 castad와 별도 서비스라
내려도 castad 본체에 영향이 없습니다 — 포스터 탭만 연결 실패 상태가 됩니다.
---
## 4. 이번 브랜치에서 고친 기존 버그
배포 후 확인해 두면 좋은 항목입니다. 모두 포스터 기능과 무관하게 A안에 포함됩니다.
1. **진입 화면 세로 잘림**`justify-content: center` + `overflow: hidden` 조합이라
콘텐츠가 뷰포트보다 길면 위로 넘친 부분에 스크롤로도 닿을 수 없었습니다.
자식의 `margin: auto 0` 중앙정렬로 교체했습니다.
2. **한글 단어 중간 줄바꿈**`word-break: keep-all` 적용.
3. **입력·안내·제출 박스 높이 불일치**(46/38/48) — 48px로 통일.
4. **카피 어휘** — 가게→업체, 크롤링→수집.

View File

@ -51,6 +51,28 @@ npm run build
npm run preview
```
## 환경 변수
| 변수 | 기본값 | 설명 |
|---|---|---|
| `VITE_API_URL` | `http://40.82.133.44` | castad 백엔드 (ADO2·썰박스) |
| `VITE_P2V_URL` | `http://localhost:8010` | Poster to Video 서버 (포스터 영상·스타일링) |
### 포스터 파이프라인 (Poster to Video)
'포스터 영상'(F1)·'포스터 스타일'(F2) 탭은 castad 백엔드가 아니라 **별도 P2V 서버**를 직접
호출한다(`src/utils/p2vApi.ts`). 붙이려면 세 가지가 필요하다:
1. P2V 서버 기동 — 저장소는 `o2o-ado2-poster-to-video`, 기본 포트 8010
2. 서버의 `P2V_ALLOWED_ORIGINS` 에 이 프론트 오리진 추가 (예: `http://localhost:3000`)
3. 첫 진입 시 화면에 뜨는 입력창에 서버의 `P2V_ACCESS_KEY` 입력 (브라우저에 저장된다)
인증이 단일 팀 키라 **내부 시연 범위**다. 대외 공개 시에는 castad 백엔드에 `/p2v/*` 프록시를
세워 카카오 인증·크레딧 차감을 얹고, 프론트는 `p2vApi.ts` 한 파일만 그쪽으로 돌리면 된다.
포스터 스타일링(F2)은 영화 포스터 레퍼런스가 저작권 자산이라 **결과물의 외부 공개·상업 사용을
금한다**(퍼블릭 도메인 명화 제외). 화면 상단과 템플릿 배지로 등급을 표시한다.
## Docker 실행
```bash

View File

@ -36,24 +36,24 @@
extend: {
colors: {
brand: {
bg: '#011c1e',
card: '#083336',
cardHover: '#0b3f42',
accent: '#94FBE0',
purple: '#a855f7',
purpleHover: '#9333ea',
text: '#e2e8f0',
muted: '#94a3b8'
bg: '#001a1c',
card: '#003538',
cardHover: '#004548',
accent: '#a6ffea',
purple: '#a682ff',
purpleHover: '#9570f0',
text: '#d1d5db',
muted: '#9ca3af'
}
}
}
}
}
</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.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&family=Playfair+Display:ital,wght@0,700;1,700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,700;1,700&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" rel="stylesheet">
<style>
:root {
--app-height: 100%;
@ -63,11 +63,13 @@
padding: 0;
width: 100%;
height: 100%;
background-color: #121a1d;
background-color: #001a1c;
}
body {
font-family: 'Noto Sans KR', sans-serif;
font-family: 'Pretendard', 'Apple SD Gothic Neo', -apple-system, BlinkMacSystemFont,
'Helvetica Neue', 'Segoe UI', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
letter-spacing: -0.006em;
}
.font-serif-logo {
font-family: 'Playfair Display', serif;

43
package-lock.json generated
View File

@ -16,6 +16,8 @@
},
"devDependencies": {
"@types/node": "^22.14.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
@ -52,6 +54,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@ -1288,10 +1291,32 @@
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"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": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
@ -1349,6 +1374,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -1400,6 +1426,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
@ -1691,6 +1724,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4"
},
@ -1811,6 +1845,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -1852,6 +1887,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -1861,6 +1897,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@ -1907,6 +1944,7 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@ -1969,7 +2007,8 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/redux-thunk": {
"version": "3.1.0",
@ -2083,6 +2122,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -2166,6 +2206,7 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",

View File

@ -5,7 +5,8 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "tsc --noEmit",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
@ -17,6 +18,8 @@
},
"devDependencies": {
"@types/node": "^22.14.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"

View File

@ -1,27 +1,16 @@
import React, { useRef, useState, useEffect } from 'react';
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import Header from './components/Header';
import HeroSection from './pages/Landing/HeroSection';
import WelcomeSection from './pages/Landing/WelcomeSection';
import DisplaySection from './pages/Landing/DisplaySection';
import LoadingSection from './pages/Analysis/LoadingSection';
import AnalysisResultSection from './pages/Analysis/AnalysisResultSection';
import LoginSection from './pages/Login/LoginSection';
import GenerationFlow from './pages/Dashboard/GenerationFlow';
import SocialConnectSuccess from './pages/Social/SocialConnectSuccess';
import SocialConnectError from './pages/Social/SocialConnectError';
import YouTubeOAuthCallback from './pages/Social/YouTubeOAuthCallback';
import ADO2ContentsPage from './pages/Dashboard/ADO2ContentsPage';
import VideoDetailPage from './components/VideoDetailPage';
import { crawlUrl, autocomplete, marketingAnalysis, kakaoCallback, isLoggedIn, saveTokens, getVideosList, AutocompleteRequest, storeUtmFromUrl, trackViewContent, trackCompleteRegistration } from './utils/api';
import { saveSearchHistory } from './components/SearchHistory/useSearchHistory';
import { CrawlingResponse } from './types/api';
import SsulDetailPage from './pages/Ssulbox/SsulDetailPage';
import { kakaoCallback, saveTokens, storeUtmFromUrl, trackViewContent, trackCompleteRegistration } from './utils/api';
import { clearSessionStorage } from './utils/storageKeys';
import { NAV } from './components/navItems';
type ViewMode = 'landing' | 'loading' | 'analysis' | 'login' | 'generation_flow';
const VIEW_MODE_KEY = 'castad_view_mode';
const ANALYSIS_DATA_KEY = 'castad_analysis_data';
const SESSION_KEY = 'castad_session_active';
// 새 탭/새 창에서 접근 시 localStorage 초기화 (sessionStorage로 현재 세션 확인)
@ -30,15 +19,7 @@ const initializeOnNewSession = () => {
if (!isExistingSession) {
// 새 세션이면 localStorage 정리하고 세션 표시
localStorage.removeItem(VIEW_MODE_KEY);
localStorage.removeItem(ANALYSIS_DATA_KEY);
localStorage.removeItem('castad_wizard_step');
localStorage.removeItem('castad_active_item');
localStorage.removeItem('castad_song_task_id');
localStorage.removeItem('castad_image_task_id');
localStorage.removeItem('castad_song_generation');
localStorage.removeItem('castad_video_generation');
localStorage.removeItem('castad_video_ratio');
clearSessionStorage();
sessionStorage.setItem(SESSION_KEY, 'true');
}
};
@ -47,79 +28,9 @@ const initializeOnNewSession = () => {
initializeOnNewSession();
const App: React.FC = () => {
const { t, i18n } = useTranslation();
const containerRef = useRef<HTMLElement>(null);
// localStorage에서 저장된 상태 복원 (새 세션이면 이미 초기화됨)
const savedViewMode = localStorage.getItem(VIEW_MODE_KEY) as ViewMode | null;
const savedAnalysisData = localStorage.getItem(ANALYSIS_DATA_KEY);
// 저장된 분석 데이터 파싱 및 유효성 검사
const parseSavedAnalysisData = (): CrawlingResponse | null => {
if (!savedAnalysisData) return null;
try {
const data = JSON.parse(savedAnalysisData) as CrawlingResponse;
// 기본값 보장
if (data.marketing_analysis) {
data.marketing_analysis.brand_identity = data.marketing_analysis.brand_identity || {
location_feature_analysis: '',
concept_scalability: ''
};
data.marketing_analysis.market_positioning = data.marketing_analysis.market_positioning || {
category_definition: '',
core_value: ''
};
data.marketing_analysis.target_persona = data.marketing_analysis.target_persona || [];
data.marketing_analysis.selling_points = data.marketing_analysis.selling_points || [];
data.marketing_analysis.target_keywords = data.marketing_analysis.target_keywords || [];
}
if (data.processed_info) {
data.processed_info.customer_name = data.processed_info.customer_name || '알 수 없음';
data.processed_info.region = data.processed_info.region || '';
data.processed_info.detail_region_info = data.processed_info.detail_region_info || '';
}
data.image_list = data.image_list || [];
return data;
} catch {
localStorage.removeItem(ANALYSIS_DATA_KEY);
return null;
}
};
// 초기 viewMode 결정: 로그인 상태면 바로 generation_flow로
const getInitialViewMode = (): ViewMode => {
if (savedViewMode === 'generation_flow') return 'generation_flow';
if (isLoggedIn()) return 'generation_flow';
return 'landing';
};
const [viewMode, setViewMode] = useState<ViewMode>(getInitialViewMode());
const [initialTab, setInitialTab] = useState('새 프로젝트 만들기');
const [analysisData, setAnalysisData] = useState<CrawlingResponse | null>(
parseSavedAnalysisData()
);
const [error, setError] = useState<string | null>(null);
const [isAnalysisComplete, setIsAnalysisComplete] = useState(false);
const [afterLoadTarget, setAfterLoadTarget] = useState<ViewMode>('analysis');
const [scrollProgress, setScrollProgress] = useState(0);
const { t } = useTranslation();
const [initialTab, setInitialTab] = useState<string>(NAV.HOME);
const [isProcessingCallback, setIsProcessingCallback] = useState(false);
const tutorialVideoCheckedRef = useRef(false);
// generation_flow 진입 시 영상 보유 여부 확인 → 있으면 튜토리얼 자동 off
useEffect(() => {
if (viewMode !== 'generation_flow') return;
if (tutorialVideoCheckedRef.current) return;
tutorialVideoCheckedRef.current = true;
const ENABLED_KEY = 'ado2_tutorial_enabled';
if (localStorage.getItem(ENABLED_KEY) !== null) return;
getVideosList(1, 1).then(response => {
const hasVideos = response.items.some(v => v.result_movie_url?.trim());
if (hasVideos) {
localStorage.setItem(ENABLED_KEY, 'false');
window.dispatchEvent(new Event('ado2-tutorial-auto-disable'));
}
}).catch(() => {});
}, [viewMode]);
// 카카오 로그인 콜백 처리 (URL에서 토큰 또는 code 파라미터 확인)
useEffect(() => {
@ -146,8 +57,8 @@ const App: React.FC = () => {
setIsProcessingCallback(true);
handleKakaoCallback(code);
}
// 콜백이 아닌 일반 진입이고 랜딩이 첫 화면이면 ViewContent 발화
else if (viewMode === 'landing') {
// 콜백이 아닌 일반 진입 — 광고 유입 추적
else {
trackViewContent();
}
}, []);
@ -171,21 +82,15 @@ const App: React.FC = () => {
await trackCompleteRegistration();
}
const savedData = localStorage.getItem(ANALYSIS_DATA_KEY);
if (savedData) {
// 분석 데이터가 있으면 에셋 관리(step 1)부터 시작
// 이전에 저장된 wizard step이 URL 입력(-2) 등으로 남아있을 수 있으므로 초기화
localStorage.removeItem('castad_wizard_step');
localStorage.removeItem('castad_active_item');
}
// 로그인 전 특정 화면(예: /video/{id})에서 유도된 로그인이면 그 경로로 복귀
const redirectPath = sessionStorage.getItem('castad_login_redirect');
sessionStorage.removeItem('castad_login_redirect');
if (redirectPath && redirectPath !== '/') {
window.location.href = redirectPath;
return;
}
setInitialTab('새 프로젝트 만들기');
setViewMode('generation_flow');
// 그 외(사이드바 게이트 등)는 GenerationFlow 가 castad_login_next_item 으로 자체 복귀 처리
setInitialTab(NAV.HOME);
} catch (err) {
console.error('Token callback failed:', err);
alert(t('app.loginFailed'));
@ -219,231 +124,6 @@ const App: React.FC = () => {
}
};
// viewMode 변경 시 localStorage에 저장
useEffect(() => {
localStorage.setItem(VIEW_MODE_KEY, viewMode);
}, [viewMode]);
// 스크롤 이벤트 핸들러 - 첫 번째 섹션에서 두 번째 섹션으로 넘어갈 때 0~1 값 계산
useEffect(() => {
const container = containerRef.current;
if (!container || viewMode !== 'landing') return;
const handleScroll = () => {
const scrollTop = container.scrollTop;
const sectionHeight = container.clientHeight;
// 첫 번째 섹션 스크롤 진행률 (0 ~ 1)
const progress = Math.min(1, Math.max(0, scrollTop / sectionHeight));
setScrollProgress(progress);
};
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}, [viewMode]);
const scrollToSection = (index: number) => {
if (containerRef.current) {
const h = containerRef.current.clientHeight;
containerRef.current.scrollTo({
top: h * index,
behavior: 'smooth'
});
}
};
// 크롤링 응답 유효성 검사
const validateCrawlingResponse = (data: CrawlingResponse): boolean => {
// 필수 필드 존재 여부 확인
if (!data) return false;
if (!data.processed_info) return false;
if (!data.marketing_analysis) return false;
// marketing_analysis 내부 필드 기본값 보장
if (!data.marketing_analysis.brand_identity) {
data.marketing_analysis.brand_identity = {
location_feature_analysis: '',
concept_scalability: ''
};
}
if (!data.marketing_analysis.market_positioning) {
data.marketing_analysis.market_positioning = {
category_definition: '',
core_value: ''
};
}
if (!data.marketing_analysis.target_persona) {
data.marketing_analysis.target_persona = [];
}
if (!data.marketing_analysis.selling_points) {
data.marketing_analysis.selling_points = [];
}
if (!data.marketing_analysis.target_keywords) {
data.marketing_analysis.target_keywords = [];
}
// processed_info 내부 필드 기본값 보장
if (!data.processed_info.customer_name) {
data.processed_info.customer_name = '알 수 없음';
}
if (!data.processed_info.region) {
data.processed_info.region = '';
}
if (!data.processed_info.detail_region_info) {
data.processed_info.detail_region_info = '';
}
// image_list 기본값 보장
if (!data.image_list) {
data.image_list = [];
}
return true;
};
const handleStartAnalysis = async (url: string) => {
if (!url.trim()) return;
setAfterLoadTarget('analysis');
setViewMode('loading');
setIsAnalysisComplete(false);
setError(null);
try {
const data = await crawlUrl(url);
// 응답 유효성 검사
if (!validateCrawlingResponse(data)) {
throw new Error(t('app.invalidUrl'));
}
setAnalysisData(data);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
saveSearchHistory({ type: 'url', value: url });
setIsAnalysisComplete(true);
} catch (err) {
console.error('Crawling failed:', err);
setError(t('app.analysisError'));
setViewMode('landing');
}
};
// 업체명 자동완성으로 분석 시작
const handleAutocomplete = async (request: AutocompleteRequest) => {
setAfterLoadTarget('analysis');
setViewMode('loading');
setIsAnalysisComplete(false);
setError(null);
try {
const data = await autocomplete(request);
// 응답 유효성 검사
if (!validateCrawlingResponse(data)) {
throw new Error(t('app.autocompleteError'));
}
setAnalysisData(data);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
saveSearchHistory({ type: 'name', value: request.title.replace(/<[^>]*>/g, ''), address: request.address, roadAddress: request.roadAddress });
setIsAnalysisComplete(true);
} catch (err) {
console.error('Autocomplete failed:', err);
setError(t('app.autocompleteError'));
setViewMode('landing');
}
};
// 업체명·주소 수동 입력으로 마케팅 분석 API 호출
const handleManualInput = async (businessName: string, address: string, category: string, officialSiteUrl?: string) => {
setAfterLoadTarget('generation_flow');
setViewMode('loading');
setIsAnalysisComplete(false);
setError(null);
try {
const data = await marketingAnalysis(businessName, address, category, officialSiteUrl);
if (!validateCrawlingResponse(data)) {
throw new Error(t('app.autocompleteError'));
}
setAnalysisData(data);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
saveSearchHistory({ type: 'name', value: businessName, address, roadAddress: address });
setIsAnalysisComplete(true);
} catch (err) {
console.error('Marketing analysis failed:', err);
setError(t('app.autocompleteError'));
setViewMode('landing');
}
};
// 테스트 데이터로 브랜드 분석 페이지 이동
const handleTestData = (data: CrawlingResponse) => {
const tagged = { ...data, _isTestData: true };
setAnalysisData(tagged);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged));
setViewMode('analysis');
};
// 언어 변경 시 테스트 데이터 다시 로드
useEffect(() => {
const saved = localStorage.getItem(ANALYSIS_DATA_KEY);
if (!saved) return;
try {
const parsed = JSON.parse(saved);
if (!parsed._isTestData) return;
const jsonFile = i18n.language === 'en' ? '/example_analysis_en.json' : '/example_analysis.json';
fetch(jsonFile)
.then(res => res.json())
.then((data: CrawlingResponse) => {
const tagged = { ...data, _isTestData: true };
setAnalysisData(tagged);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged));
})
.catch(err => console.error('Failed to reload test data:', err));
} catch { /* ignore */ }
}, [i18n.language]);
const handleToLogin = async () => {
// 이미 로그인된 상태면 바로 generation_flow로 이동
if (isLoggedIn()) {
// 분석 데이터가 있으면 이전 wizard step 초기화 (에셋 관리부터 시작하도록)
const savedData = localStorage.getItem(ANALYSIS_DATA_KEY);
if (savedData) {
localStorage.removeItem('castad_wizard_step');
localStorage.removeItem('castad_active_item');
}
setInitialTab('새 프로젝트 만들기');
setViewMode('generation_flow');
return;
}
// 로그인 안 된 상태면 카카오 로그인 페이지로 리다이렉션
try {
const { getKakaoLoginUrl } = await import('./utils/api');
const response = await getKakaoLoginUrl();
window.location.href = response.auth_url;
} catch (err) {
console.error('Failed to get Kakao login URL:', err);
alert(t('app.loginUrlFailed'));
}
};
const handleLoginSuccess = () => {
setInitialTab('새 프로젝트 만들기');
setViewMode('generation_flow');
};
const handleGoBack = () => {
// localStorage 정리
localStorage.removeItem(VIEW_MODE_KEY);
localStorage.removeItem(ANALYSIS_DATA_KEY);
localStorage.removeItem('castad_wizard_step');
localStorage.removeItem('castad_active_item');
setViewMode('landing');
};
// Social OAuth 콜백 페이지 처리
const pathname = window.location.pathname;
@ -466,6 +146,13 @@ const App: React.FC = () => {
return <VideoDetailPage videoId={videoDetailMatch[1]} />;
}
// 썰박스 공유 페이지 (/ssul/{id}) — 위와 동일한 패턴, 비로그인 열람 가능.
// /video/{id} 와 경로를 나눈 이유: video.id 와 ssul_content.id 가 독립 시퀀스라
// 값이 겹치므로, 한 경로로 합치면 다른 종류의 콘텐츠가 열린다.
const ssulDetailMatch = pathname.match(/^\/ssul\/(\d+)$/);
if (ssulDetailMatch) {
return <SsulDetailPage contentId={ssulDetailMatch[1]} />;
}
// 카카오 콜백 처리 중 로딩 화면 표시
if (isProcessingCallback) {
@ -478,71 +165,8 @@ const App: React.FC = () => {
);
}
if (viewMode === 'loading') {
return (
<LoadingSection
isComplete={isAnalysisComplete}
onComplete={() => setViewMode(afterLoadTarget)}
/>
);
}
if (viewMode === 'analysis' && analysisData) {
return (
<AnalysisResultSection
onBack={handleGoBack}
onGenerate={handleToLogin}
data={analysisData}
/>
);
}
if (viewMode === 'login') {
return <LoginSection onBack={() => setViewMode('analysis')} onLogin={handleLoginSuccess} />;
}
if (viewMode === 'generation_flow') {
return (
<GenerationFlow
onHome={handleGoBack}
initialActiveItem={initialTab}
initialImageList={analysisData?.image_list || []}
businessInfo={analysisData?.processed_info}
initialAnalysisData={analysisData}
/>
);
}
// 로그인된 상태에서 "시작하기" 버튼 클릭
const handleHeaderStart = () => {
setInitialTab('새 프로젝트 만들기');
setViewMode('generation_flow');
};
return (
<main className="landing-container" ref={containerRef}>
<Header onStartClick={handleHeaderStart} />
<section className="landing-section">
<HeroSection
onAnalyze={handleStartAnalysis}
onAutocomplete={handleAutocomplete}
onManualInput={handleManualInput}
onNext={() => scrollToSection(1)}
error={error}
scrollProgress={scrollProgress}
/>
</section>
<section className="landing-section">
<WelcomeSection
onStartClick={() => scrollToSection(0)}
onNext={() => scrollToSection(0)}
/>
</section>
<section className="landing-section">
<DisplaySection onStartClick={() => scrollToSection(0)} />
</section>
</main>
);
// 그 외 전 경로는 ADO2 콘텐츠 갤러리를 첫 화면으로 하는 GenerationFlow 로 수렴
return <GenerationFlow initialActiveItem={initialTab} />;
};
export default App;

View File

@ -0,0 +1,67 @@
import React, { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { UserMeResponse } from '../types/api';
import { NAV_ITEMS, SETTINGS_ID } from './navItems';
import SettingsPanel from './SettingsPanel';
interface BottomNavProps {
activeItem: string;
onNavigate: (id: string) => void;
userInfo?: UserMeResponse | null;
onLogout?: () => void;
credits?: number | null;
isGuest: boolean;
onLoginClick: () => void;
}
const BottomNav: React.FC<BottomNavProps> = ({ activeItem, onNavigate, userInfo, onLogout, credits, isGuest, onLoginClick }) => {
const { t } = useTranslation();
const [settingsOpen, setSettingsOpen] = useState(false);
const settingsAnchorRef = useRef<HTMLButtonElement>(null);
const handleItemClick = (id: string) => {
if (id === SETTINGS_ID) {
setSettingsOpen(v => !v);
return;
}
onNavigate(id);
};
return (
<>
{settingsOpen && <div className="settings-backdrop" onClick={() => setSettingsOpen(false)} />}
{settingsOpen && (
<SettingsPanel
variant="bottom"
onClose={() => setSettingsOpen(false)}
onNavigate={onNavigate}
activeItem={activeItem}
userInfo={userInfo}
credits={credits}
isGuest={isGuest}
onLogout={onLogout}
onLoginClick={onLoginClick}
anchorRef={settingsAnchorRef}
/>
)}
<nav className="bottom-nav">
{NAV_ITEMS.map(item => {
const isActive = item.id === SETTINGS_ID ? settingsOpen : activeItem === item.id;
return (
<button
key={item.id}
ref={item.id === SETTINGS_ID ? settingsAnchorRef : undefined}
className={`bottom-nav-item ${isActive ? 'active' : ''}`}
onClick={() => handleItemClick(item.id)}
>
{item.icon}
<span>{t(item.labelKey)}</span>
</button>
);
})}
</nav>
</>
);
};
export default BottomNav;

View File

@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import CitySelectModal, { REGIONS } from './CitySelectModal';
import { useOverlayClose } from '../hooks/useOverlayClose';
interface BusinessNameInputModalProps {
onClose: () => void;
@ -16,6 +17,7 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
const [category, setCategory] = useState('');
const [officialSiteUrl, setOfficialSiteUrl] = useState('');
const [isCityModalOpen, setIsCityModalOpen] = useState(false);
const overlayCloseHandlers = useOverlayClose(onClose);
useEffect(() => {
document.body.style.overflow = 'hidden';
@ -61,7 +63,7 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
return createPortal(
<>
<div className="manual-modal-backdrop" onClick={onClose}>
<div className="manual-modal-backdrop" {...overlayCloseHandlers}>
<div className="manual-modal" onClick={e => e.stopPropagation()}>
<div className="manual-modal-header">
<span className="manual-modal-title">{t('landing.hero.manualModalTitle')}</span>

View File

@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
import { useOverlayClose } from '../hooks/useOverlayClose';
export const REGIONS: { label: string; cities: string[] }[] = [
{
@ -96,8 +97,10 @@ const CitySelectModal: React.FC<CitySelectModalProps> = ({ selected, onSelect, o
onClose();
};
const overlayCloseHandlers = useOverlayClose(onClose);
return (
<div className="city-modal-backdrop" onClick={onClose}>
<div className="city-modal-backdrop" {...overlayCloseHandlers}>
<div className="city-modal" onClick={e => e.stopPropagation()}>
<div className="city-modal-header">
<span className="city-modal-title">

View File

@ -0,0 +1,201 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
/**
* (ADO2 · ).
*
* ** ** ·· .
* `CompletionContent`(ADO2)
* , .
*
* :
* - ADO2 : 장르 ·
* - 썰박스 : 시나리오 ·
* `metaLines`/`detail` .
*
* (`comp2-*`) ADO2 .
* [generation-flow.css](../styles/generation-flow.css) ·
* .
*/
/** 영상 영역이 무엇을 그릴지 */
export type CompletionVideoState =
/** 생성/렌더 진행 중 — 스피너 + 진행률 */
| 'loading'
/** 실패 — 메시지 + (있으면) 재시도 버튼 */
| 'error'
/** 재생 가능 */
| 'ready'
/** URL 을 아직 못 받았지만 진행 중도 실패도 아님 — 빈 자리만 유지 */
| 'empty';
interface CompletionViewProps {
videoState: CompletionVideoState;
videoUrl?: string | null;
/** `videoState === 'loading'` 일 때만 쓰인다 */
loadingText?: string;
/** 0~100. 표시용 값이며 크리핑 보간은 호출부 책임이다 */
progress?: number;
/** `videoState === 'error'` 일 때만 쓰인다 */
errorMessage?: string | null;
/** 없으면 재시도 버튼을 그리지 않는다 (썰박스는 이 화면에서 재시도가 없다) */
onRetry?: () => void;
/** 정보 패널 상단 파일명 */
fileName: string;
/** 메타 줄. 줄 사이에 구분선이 들어간다 */
metaLines: React.ReactNode[];
/** 메타 아래 상세 영역의 제목 */
detailLabel: string;
/** 상세 본문 (ADO2=가사 단락, 썰박스=시나리오 설명) */
detail: React.ReactNode;
onDownload: () => void;
downloadDisabled?: boolean;
/** 진행 중에는 '다운로드 중...' 처럼 바뀌므로 호출부가 문구를 정한다 */
downloadLabel: string;
onUpload: () => void;
uploadDisabled?: boolean;
/**
* (SNS ).
*
* `.comp2-page`
* `.social-posting-overlay` `text-align` .
*/
children?: React.ReactNode;
}
const CompletionView: React.FC<CompletionViewProps> = ({
videoState,
videoUrl,
loadingText,
progress = 0,
errorMessage,
onRetry,
fileName,
metaLines,
detailLabel,
detail,
onDownload,
downloadDisabled,
downloadLabel,
onUpload,
uploadDisabled,
children,
}) => {
const { t } = useTranslation();
return (
<main className="comp2-page">
<div className="comp2-title-row">
<h1 className="comp2-page-title">{t('completion.contentComplete')}</h1>
<p className="comp2-page-subtitle">{t('completion.contentCompleteDesc')}</p>
</div>
<div className="comp2-container">
<div className="comp2-grid">
{/* 왼쪽: 영상 */}
<div className="comp2-video-section">
<div className="comp2-video-wrapper">
{videoState === 'loading' ? (
<div className="comp2-video-loading">
<div className="loading-spinner">
<div className="loading-ring"></div>
<div className="loading-dot">
<div className="loading-dot-inner"></div>
</div>
</div>
<p className="comp2-loading-text">{loadingText}</p>
<div className="loading-progress-wrapper">
<div className="loading-progress-bar">
<div
className="loading-progress-fill"
style={{ width: `${progress}%` }}
/>
</div>
<span className="loading-progress-text">{Math.floor(progress)}%</span>
</div>
</div>
) : videoState === 'error' ? (
<div className="comp2-video-error">
<svg
className="comp2-error-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</svg>
<p className="comp2-error-text">{errorMessage}</p>
{onRetry && (
<button onClick={onRetry} className="comp2-retry-btn">
{t('completion.retry')}
</button>
)}
</div>
) : videoState === 'ready' && videoUrl ? (
<video src={videoUrl} className="comp2-video-player" controls playsInline />
) : (
<div className="comp2-video-placeholder"></div>
)}
</div>
</div>
{/* 오른쪽: 콘텐츠 정보 */}
<div className="comp2-info-section">
<div className="comp2-info-header">
<span className="comp2-info-label">{t('completion.contentInfo')}</span>
</div>
<div className="comp2-file-info">
<h3 className="comp2-filename">{fileName}</h3>
</div>
<div className="comp2-info-content">
<div className="comp2-meta-grid">
{metaLines.map((line, i) => (
<React.Fragment key={i}>
<div className="comp2-meta-item">
<span className="comp2-meta-label">{line}</span>
</div>
<div className="comp2-meta-divider"></div>
</React.Fragment>
))}
<div className="comp2-lyrics-section">
<span className="comp2-meta-label">{detailLabel}</span>
{detail}
</div>
</div>
</div>
{/* 하단 버튼 */}
<div className="comp2-buttons">
<button
onClick={onDownload}
disabled={downloadDisabled}
className="comp2-btn comp2-btn-secondary"
>
{downloadLabel}
</button>
<button
onClick={onUpload}
disabled={uploadDisabled}
className="comp2-btn comp2-btn-primary"
>
{t('completion.uploadToSocial')}
</button>
</div>
</div>
</div>
</div>
{children}
</main>
);
};
export default CompletionView;

View File

@ -1,13 +1,22 @@
import React, { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { API_URL, toggleVideoLike } from '../utils/api';
import { buildVideoShareUrl, tryNativeShare } from '../utils/nativeShare';
import { API_URL, toggleVideoLike, isLoggedIn } from '../utils/api';
import { buildContentShareUrl, tryNativeShare } from '../utils/nativeShare';
import LoginPromptModal from './LoginPromptModal';
interface ContentCardSocialActionsProps {
videoId: number;
/**
* . video.id ssul_content.id API
* URL(/video/{id} vs /ssul/{id}) .
* ContentType 이유: 좋아요· video·ssul
* P2V (MyContentsPage ).
*/
contentType?: 'video' | 'ssul';
storeName: string;
region?: string;
title?: string | null;
description?: string | null;
commentCount: number;
initialLikeCount: number;
initialIsLiked?: boolean;
@ -15,8 +24,11 @@ interface ContentCardSocialActionsProps {
const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
videoId,
contentType = 'video',
storeName,
region,
title,
description,
commentCount,
initialLikeCount,
initialIsLiked = false,
@ -25,9 +37,10 @@ const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
const [likeCount, setLikeCount] = useState(initialLikeCount);
const [isLiked, setIsLiked] = useState(initialIsLiked);
const [showLoginModal, setShowLoginModal] = useState(false);
const likeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const shareUrl = buildVideoShareUrl(API_URL, videoId);
const shareUrl = buildContentShareUrl(API_URL, videoId, contentType);
const shareTitle = title || storeName || t('videoDetail.kakaoDefaultTitle');
const handleShareBtnClick = async (e: React.MouseEvent) => {
@ -51,6 +64,11 @@ const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
const handleLikeClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (!isLoggedIn()) {
setShowLoginModal(true);
return;
}
const prevLiked = isLiked;
const prevCount = likeCount;
@ -60,7 +78,7 @@ const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
if (likeDebounceRef.current) clearTimeout(likeDebounceRef.current);
likeDebounceRef.current = setTimeout(async () => {
try {
const res = await toggleVideoLike(String(videoId));
const res = await toggleVideoLike(String(videoId), contentType);
setIsLiked(res.is_liked);
setLikeCount(res.like_count);
} catch (err) {
@ -100,6 +118,10 @@ const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" /><line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
</svg>
</button>
{showLoginModal && (
<LoginPromptModal onClose={() => setShowLoginModal(false)} />
)}
</div>
);
};

View File

@ -1,64 +0,0 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { getKakaoLoginUrl, isLoggedIn } from '../utils/api';
import LanguageSwitch from './LanguageSwitch';
interface HeaderProps {
onStartClick?: () => void;
}
const Header: React.FC<HeaderProps> = ({ onStartClick }) => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const loggedIn = isLoggedIn();
const handleLogin = async () => {
if (isLoading) return;
setIsLoading(true);
try {
const response = await getKakaoLoginUrl();
window.location.href = response.auth_url;
} catch (err) {
console.error('Failed to get Kakao login URL:', err);
alert(t('header.loginFailedAlert'));
setIsLoading(false);
}
};
const handleStart = () => {
if (onStartClick) {
onStartClick();
}
};
return (
<header className="landing-header">
<div className="header-logo">
<img src="/assets/images/ado2-header-logo.svg" alt="ADO2" />
</div>
<div className="header-actions">
<LanguageSwitch />
{loggedIn ? (
<button
className="header-start-btn"
onClick={handleStart}
>
{t('header.start')}
</button>
) : (
<button
className="header-login-btn"
onClick={handleLogin}
disabled={isLoading}
>
{isLoading ? t('header.loading') : t('header.login')}
</button>
)}
</div>
</header>
);
};
export default Header;

View File

@ -0,0 +1,46 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { P2vLicense } from '../utils/p2vApi';
/**
* 릿 .
*
*
* ** ** .
* ( ).
*/
const CLASS: Record<P2vLicense, string> = {
'public-domain': 'ok',
'internal-only': 'warn',
// 내부 전용과 색을 나눈다 — 해제 조건도, 책임 주체도 다르다
'user-uploaded': 'unknown',
};
export const LicenseBadge: React.FC<{ license: P2vLicense }> = ({ license }) => {
const { t } = useTranslation();
const variant = CLASS[license] ?? CLASS['internal-only'];
return (
<span className={`p2v-license p2v-license--${variant}`}>
{t(`poster.styling.license.${license}`)}
</span>
);
};
/** 내부 전용 템플릿을 고른 순간의 경고 */
export const InternalOnlyWarning: React.FC<{ note?: string }> = ({ note }) => {
const { t } = useTranslation();
return (
<div className="p2v-notice p2v-notice--warn">
<b>{t('poster.styling.internalOnlyTitle')}</b>
{' — '}
{note || t('poster.styling.internalOnlyBody')}
</div>
);
};
/** 사용자가 올린 레퍼런스 고지 — 남의 저작물일 수 있다 */
export const UserReferenceNotice: React.FC = () => {
const { t } = useTranslation();
return <div className="p2v-notice p2v-notice--unknown">{t('poster.styling.userRefNotice')}</div>;
};

View File

@ -1,6 +1,7 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { getKakaoLoginUrl } from '../utils/api';
import { useOverlayClose } from '../hooks/useOverlayClose';
interface LoginPromptModalProps {
onClose: () => void;
@ -8,6 +9,7 @@ interface LoginPromptModalProps {
const LoginPromptModal: React.FC<LoginPromptModalProps> = ({ onClose }) => {
const { t } = useTranslation();
const overlayCloseHandlers = useOverlayClose(onClose);
const handleLogin = async () => {
try {
@ -24,22 +26,24 @@ const LoginPromptModal: React.FC<LoginPromptModalProps> = ({ onClose }) => {
style={{
position: 'fixed',
inset: 0,
background: 'rgba(255, 255, 255, 0.25)',
background: 'rgba(0, 0, 0, 0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999,
padding: '16px',
}}
onClick={onClose}
{...overlayCloseHandlers}
>
<div
style={{
background: '#01282A',
border: '1px solid rgba(155, 202, 204, 0.2)',
borderRadius: '16px',
padding: '40px 32px',
width: '360px',
maxWidth: '90vw',
border: '1px solid rgba(166, 255, 234, 0.15)',
borderRadius: '20px',
boxShadow: '0 24px 60px rgba(0, 0, 0, 0.5)',
padding: '44px 32px 36px',
width: '380px',
maxWidth: '100%',
textAlign: 'center',
position: 'relative',
}}
@ -64,27 +68,49 @@ const LoginPromptModal: React.FC<LoginPromptModalProps> = ({ onClose }) => {
<line x1="16" y1="4" x2="4" y2="16"/>
</svg>
</button>
<h2 style={{ color: '#FFFFFF', fontSize: '20px', fontWeight: 600, marginBottom: '12px' }}>
<div
style={{
width: '56px',
height: '56px',
margin: '0 auto 20px',
borderRadius: '50%',
background: 'rgba(166, 255, 234, 0.1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#A6FFEA" strokeWidth="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</div>
<h2 style={{ color: '#FFFFFF', fontSize: '22px', fontWeight: 700, marginBottom: '10px', lineHeight: 1.4 }}>
{t('loginPrompt.title')}
</h2>
<p style={{ color: '#9BCACC', fontSize: '14px', lineHeight: 1.5, marginBottom: '28px', whiteSpace: 'pre-line' }}>
{t('loginPrompt.description')}
</p>
<button
onClick={handleLogin}
style={{
width: '50%',
margin: '0 auto',
padding: '14px',
width: '100%',
padding: '15px',
background: '#FEE500',
color: '#3C1E1E',
border: 'none',
borderRadius: '8px',
fontSize: '15px',
fontWeight: 600,
borderRadius: '10px',
fontSize: '16px',
fontWeight: 700,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
boxShadow: '0 4px 14px rgba(254, 229, 0, 0.25)',
}}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">

View File

@ -0,0 +1,49 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { getP2vKey, setP2vKey } from '../utils/p2vApi';
interface P2vKeyGateProps {
/** 키를 저장한 뒤 호출 — 호출부가 실패했던 요청을 다시 태운다 */
onSaved: () => void;
}
/**
* P2V .
*
* P2V 401 `window.prompt` ( ),
* castad .
* ( + pill ) .
*/
const P2vKeyGate: React.FC<P2vKeyGateProps> = ({ onSaved }) => {
const { t } = useTranslation();
const [value, setValue] = useState(getP2vKey());
const save = () => {
if (!value.trim()) return;
setP2vKey(value);
onSaved();
};
return (
<div className="p2v-keygate">
<p className="p2v-keygate__title">{t('poster.key.title')}</p>
<p className="p2v-keygate__desc">{t('poster.key.desc')}</p>
<div className="p2v-keygate__row">
<input
type="password"
className="p2v-input"
placeholder={t('poster.key.placeholder')}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && save()}
autoComplete="off"
/>
<button type="button" className="p2v-btn-mint" disabled={!value.trim()} onClick={save}>
{t('poster.key.save')}
</button>
</div>
</div>
);
};
export default P2vKeyGate;

View File

@ -0,0 +1,143 @@
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;

View File

@ -0,0 +1,80 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
BookIcon,
PaletteIcon,
PosterVideoIcon,
StorefrontIcon,
} from './pipelineIcons';
/**
* . .
*
* poster·styling castad **P2V (:8010)** (utils/p2vApi.ts).
*/
export type Pipeline = 'ado2' | 'ssul' | 'poster' | 'styling';
/** 유효한 파이프라인 값인가 — localStorage 복원 때 쓴다 */
export const PIPELINES: readonly Pipeline[] = ['ado2', 'ssul', 'poster', 'styling'];
export const isPipeline = (value: unknown): value is Pipeline =>
typeof value === 'string' && (PIPELINES as readonly string[]).includes(value);
interface PipelineTabsProps {
value: Pipeline;
onChange: (value: Pipeline) => void;
}
const TABS: { id: Pipeline; labelKey: string; Icon: React.FC<{ size?: number }> }[] = [
{ id: 'ado2', labelKey: 'pipelineTabs.ado2', Icon: StorefrontIcon },
{ id: 'ssul', labelKey: 'pipelineTabs.ssul', Icon: BookIcon },
{ id: 'poster', labelKey: 'pipelineTabs.poster', Icon: PosterVideoIcon },
{ id: 'styling', labelKey: 'pipelineTabs.styling', Icon: PaletteIcon },
];
/**
* ( + ).
*
* 이유: 파이프라인이 2
* 4 "포스터 영상" "포스터 스타일" ** **
* . () .
*
* ,
* ADO2 + CTA 4 .
* , () .
*
* generation-flow.css `.pipeline-tabs*` .
*
* 접근성: castad (`.myinfo-tab` ) role/aria ,
* tablist .
*/
const PipelineTabs: React.FC<PipelineTabsProps> = ({ value, onChange }) => {
const { t } = useTranslation();
return (
<div
className="pipeline-tabs"
data-active={value}
role="tablist"
aria-label={t('pipelineTabs.ariaLabel')}
>
{TABS.map(({ id, labelKey, Icon }) => (
<button
key={id}
type="button"
role="tab"
aria-selected={value === id}
className="pipeline-tab"
onClick={() => onChange(id)}
>
<span className="pipeline-tab__tile">
<Icon size={24} />
</span>
<span className="pipeline-tab__label">{t(labelKey)}</span>
</button>
))}
</div>
);
};
export default PipelineTabs;

View File

@ -0,0 +1,106 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
interface PosterDropzoneProps {
file: File | null;
onFile: (file: File | null) => void;
}
/**
* . ** **
* ·
* .
*/
const SOFT_LONG_EDGE = 1000;
const ACCEPTED = /^image\/(jpeg|png|webp|gif)$/;
/**
* . F1()·F2() .
*
* 4:5
* .
*/
const PosterDropzone: React.FC<PosterDropzoneProps> = ({ file, onFile }) => {
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const [preview, setPreview] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [dragging, setDragging] = useState(false);
// objectURL 은 명시적으로 놓아줘야 한다. 시연 중 포스터를 여러 장 갈아 끼우면
// 해제 안 된 URL 이 그대로 쌓인다(castad 의 revokeAllPreviews 와 같은 이유).
useEffect(() => {
return () => {
if (preview) URL.revokeObjectURL(preview);
};
}, [preview]);
const accept = useCallback((f: File) => {
if (!ACCEPTED.test(f.type)) {
setError(t('poster.dropzone.badType'));
return;
}
setError(null);
const url = URL.createObjectURL(f);
const img = new Image();
img.onload = () => {
const edge = Math.max(img.width, img.height);
setNotice(edge < SOFT_LONG_EDGE ? t('poster.dropzone.lowRes', { edge }) : null);
setPreview((prev) => {
if (prev) URL.revokeObjectURL(prev);
return url;
});
onFile(f);
};
img.onerror = () => {
URL.revokeObjectURL(url);
setError(t('poster.dropzone.badType'));
};
img.src = url;
}, [onFile, t]);
return (
<div className="p2v-dropzone-wrap">
<div
className={`p2v-dropzone${preview ? ' has-file' : ''}${dragging ? ' dragging' : ''}`}
onClick={() => inputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
const f = e.dataTransfer.files?.[0];
if (f) accept(f);
}}
>
{preview ? (
<img src={preview} alt={t('poster.dropzone.previewAlt')} />
) : (
<div className="p2v-dropzone__empty">
<p className="p2v-dropzone__cta">{t('poster.dropzone.cta')}</p>
<p className="p2v-dropzone__hint">{t('poster.dropzone.formats')}</p>
</div>
)}
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
hidden
onChange={(e) => { const f = e.target.files?.[0]; if (f) accept(f); }}
/>
</div>
{error && <p className="p2v-note warn">{error}</p>}
{notice && <p className="p2v-note">{notice}</p>}
{file && (
<p className="p2v-note">
{file.name} · {(file.size / 1024 / 1024).toFixed(1)}MB
</p>
)}
</div>
);
};
export default PosterDropzone;

View File

@ -0,0 +1,141 @@
import React, { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { UserMeResponse } from '../types/api';
import LanguageSwitch from './LanguageSwitch';
import { NAV } from './navItems';
interface SettingsPanelProps {
variant: 'sidebar' | 'bottom';
onClose: () => void;
onNavigate: (id: string) => void;
activeItem: string;
userInfo?: UserMeResponse | null;
credits?: number | null;
isGuest: boolean;
isLoggingOut?: boolean;
onLogout?: () => void;
onLoginClick: () => void;
/** 팝업을 여는 설정 버튼 영역. 외부 클릭 닫기에서 제외해 재클릭 시 토글이 정상 동작하게 한다 */
anchorRef?: React.RefObject<HTMLElement | null>;
}
const SettingsPanel: React.FC<SettingsPanelProps> = ({
variant,
onClose,
onNavigate,
activeItem,
userInfo,
credits,
isGuest,
isLoggingOut,
onLogout,
onLoginClick,
anchorRef,
}) => {
const { t } = useTranslation();
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node;
if (anchorRef?.current?.contains(target)) return;
if (panelRef.current && !panelRef.current.contains(target)) {
onClose();
}
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
};
}, [onClose, anchorRef]);
return (
<div ref={panelRef} className={`settings-panel settings-panel--${variant}`}>
{/* 1) 프로필 — 패널 헤더 역할 */}
{!isGuest && (
<>
<div className="profile-section settings-panel-profile">
{userInfo?.profile_image_url || userInfo?.thumbnail_image_url ? (
<img
src={userInfo.thumbnail_image_url || userInfo.profile_image_url || ''}
alt="Profile"
className="profile-avatar"
/>
) : (
<div className="profile-avatar profile-avatar-default">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</div>
)}
<div className="flex-1 min-w-0">
<p className="profile-name">{userInfo?.nickname || t('sidebar.defaultUser')}</p>
{credits !== null && credits !== undefined && (
<p className="profile-credits">{t('sidebar.credits', { count: credits })}</p>
)}
</div>
</div>
</>
)}
{/* 2) 메뉴 그룹 — 대시보드 */}
<button
className={`settings-panel-item ${activeItem === NAV.DASHBOARD ? 'active' : ''}`}
onClick={() => {
onNavigate(NAV.DASHBOARD);
onClose();
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<rect x="3" y="3" width="7" height="9" /><rect x="14" y="3" width="7" height="5" />
<rect x="14" y="12" width="7" height="9" /><rect x="3" y="16" width="7" height="5" />
</svg>
<span>{t('sidebar.dashboard')}</span>
</button>
<div className="settings-panel-divider" />
{/* 3) 하단 그룹 — 언어 전환 / 로그아웃(로그인) / 고객의견 */}
<div className="settings-panel-language">
<LanguageSwitch isCollapsed={false} />
</div>
{isGuest ? (
<button className="settings-panel-item" onClick={onLoginClick}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" /><polyline points="10 17 15 12 10 7" /><line x1="15" y1="12" x2="3" y2="12" />
</svg>
<span>{t('sidebar.login')}</span>
</button>
) : (
<button className="settings-panel-item" onClick={onLogout} disabled={isLoggingOut}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><polyline points="16 17 21 12 16 7" /><line x1="21" y1="12" x2="9" y2="12" />
</svg>
<span>{isLoggingOut ? t('sidebar.loggingOut') : t('sidebar.logout')}</span>
</button>
)}
<a
href="https://forms.gle/4a8mGebBYtdesvby9"
target="_blank"
rel="noopener noreferrer"
className="settings-panel-item"
title={t('sidebar.inquiry')}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
<span>{t('sidebar.inquiry')}</span>
</a>
</div>
);
};
export default SettingsPanel;

View File

@ -1,32 +1,30 @@
import React, { useState, useEffect } from 'react';
import React, { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { UserMeResponse } from '../types/api';
import { logout } from '../utils/api';
import LanguageSwitch from './LanguageSwitch';
import { NAV_ITEMS, SETTINGS_ID } from './navItems';
const TOP_NAV_ITEMS = NAV_ITEMS.filter(item => item.id !== SETTINGS_ID);
const SETTINGS_NAV_ITEM = NAV_ITEMS.find(item => item.id === SETTINGS_ID)!;
import SettingsPanel from './SettingsPanel';
interface SidebarItemProps {
icon: React.ReactNode;
label: string;
isActive?: boolean;
isCollapsed: boolean;
isDisabled?: boolean;
onClick?: () => void;
id?: string;
}
const SidebarItem: React.FC<SidebarItemProps> = ({ icon, label, isActive, isCollapsed, isDisabled, onClick, id }) => {
const SidebarItem: React.FC<SidebarItemProps> = ({ icon, label, isActive, onClick }) => {
return (
<div
id={id}
onClick={isDisabled ? undefined : onClick}
className={`sidebar-item ${isActive ? 'active' : ''} ${isCollapsed ? 'collapsed' : ''} ${isDisabled ? 'disabled' : ''}`}
title={isCollapsed ? label : ""}
onClick={onClick}
className={`sidebar-item ${isActive ? 'active' : ''}`}
>
<div className="sidebar-item-icon">
{icon}
</div>
{!isCollapsed && <span className="sidebar-item-label">{label}</span>}
<span className="sidebar-item-label">{label}</span>
</div>
);
};
@ -34,22 +32,20 @@ const SidebarItem: React.FC<SidebarItemProps> = ({ icon, label, isActive, isColl
interface SidebarProps {
activeItem: string;
onNavigate: (id: string) => void;
onHome?: () => void;
onLogoClick?: () => void;
userInfo?: UserMeResponse | null;
onLogout?: () => void;
credits?: number | null;
tutorialAvailable?: boolean;
tutorialEnabled?: boolean;
onToggleTutorial?: () => void;
isGuest: boolean;
onLoginClick: () => void;
}
const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userInfo, onLogout, credits, tutorialAvailable, tutorialEnabled, onToggleTutorial }) => {
const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onLogoClick, userInfo, onLogout, credits, isGuest, onLoginClick }) => {
const { t } = useTranslation();
const [isCollapsed, setIsCollapsed] = useState(false);
const [isMobileOpen, setIsMobileOpen] = useState(false);
const [isLoggingOut, setIsLoggingOut] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const settingsAnchorRef = useRef<HTMLDivElement>(null);
// 로그아웃 처리
const handleLogout = async () => {
if (isLoggingOut) return;
setIsLoggingOut(true);
@ -58,184 +54,69 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userI
onLogout?.();
} catch (error) {
console.error('Logout failed:', error);
// 에러가 나도 로컬 토큰은 이미 삭제됨, 홈으로 이동
onLogout?.();
} finally {
setIsLoggingOut(false);
}
};
useEffect(() => {
const handleResize = () => {
if (window.innerWidth < 768) {
setIsMobileOpen(false);
const handleItemClick = (id: string) => {
if (id === SETTINGS_ID) {
setSettingsOpen(v => !v);
return;
}
};
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 (
<>
{/* 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 && (
<div className="sidebar expanded">
<div className="sidebar-header">
<img
onClick={onHome}
onClick={onLogoClick}
src="/assets/images/ado2-sidebar-logo.svg"
alt="ADO2"
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 className="sidebar-menu no-scrollbar">
{menuItems.map(item => (
{TOP_NAV_ITEMS.map(item => (
<SidebarItem
key={item.id}
id={
item.id === '내 정보'
? 'sidebar-my-info'
: item.id === 'ADO2 콘텐츠'
? 'sidebar-ado2-contents'
: undefined
}
icon={item.icon}
label={item.label}
isCollapsed={isCollapsed}
label={t(item.labelKey)}
isActive={activeItem === item.id}
isDisabled={item.disabled}
onClick={() => handleNavigate(item.id)}
onClick={() => handleItemClick(item.id)}
/>
))}
</div>
<div className="sidebar-footer">
{/* 모바일 전용 튜토리얼 토글 */}
{tutorialAvailable && (
<button
className={`sidebar-tutorial-btn ${tutorialEnabled ? 'active' : ''}`}
onClick={() => onToggleTutorial?.()}
title={tutorialEnabled ? t('sidebar.tutorialOff') : t('sidebar.tutorialOn')}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<circle cx="12" cy="12" r="10"/>
<path d="M12 8v4l3 3"/>
</svg>
<span className="sidebar-tutorial-label">{t('sidebar.tutorial')}</span>
<span className={`sidebar-tutorial-badge ${tutorialEnabled ? 'on' : 'off'}`}>
{tutorialEnabled ? 'ON' : 'OFF'}
</span>
</button>
)}
<div className="sidebar-language-switch">
<LanguageSwitch isCollapsed={isCollapsed} />
</div>
<div className={`profile-section ${isCollapsed ? 'collapsed' : ''}`}>
{userInfo?.profile_image_url || userInfo?.thumbnail_image_url ? (
<img
src={userInfo.thumbnail_image_url || userInfo.profile_image_url || ''}
alt="Profile"
className="profile-avatar"
<div className="sidebar-settings-anchor" ref={settingsAnchorRef}>
<SidebarItem
icon={SETTINGS_NAV_ITEM.icon}
label={t(SETTINGS_NAV_ITEM.labelKey)}
isActive={settingsOpen}
onClick={() => handleItemClick(SETTINGS_NAV_ITEM.id)}
/>
{settingsOpen && (
<SettingsPanel
variant="sidebar"
onClose={() => setSettingsOpen(false)}
onNavigate={onNavigate}
activeItem={activeItem}
userInfo={userInfo}
credits={credits}
isGuest={isGuest}
isLoggingOut={isLoggingOut}
onLogout={handleLogout}
onLoginClick={onLoginClick}
anchorRef={settingsAnchorRef}
/>
) : (
<div className="profile-avatar profile-avatar-default">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
</div>
)}
{!isCollapsed && (
<div className="flex-1 min-w-0">
<p className="profile-name">{userInfo?.nickname || t('sidebar.defaultUser')}</p>
{credits !== null && credits !== undefined && (
<p className="profile-credits">{t('sidebar.credits', { count: credits })}</p>
)}
</div>
)}
</div>
<div className="sidebar-footer-actions">
<button
className={`logout-btn ${isCollapsed ? 'collapsed' : ''}`}
onClick={handleLogout}
disabled={isLoggingOut}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>
</svg>
{!isCollapsed && <span className="logout-btn-label">{isLoggingOut ? t('sidebar.loggingOut') : t('sidebar.logout')}</span>}
</button>
<a
href="https://forms.gle/4a8mGebBYtdesvby9"
target="_blank"
rel="noopener noreferrer"
className={`sidebar-inquiry-btn ${isCollapsed ? 'collapsed' : ''}`}
title={t('sidebar.inquiry')}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
{!isCollapsed && <span>{t('sidebar.inquiry')}</span>}
</a>
</div>
</div>
</div>
</>
);
};

View File

@ -4,9 +4,7 @@ import { useTranslation } from 'react-i18next';
import { getSocialAccounts, uploadToSocial, waitForUploadComplete, TokenExpiredError, handleSocialReconnect, getAutoSeoYoutube } from '../utils/api';
import { SocialAccount, VideoListItem, SocialUploadStatusResponse } from '../types/api';
import UploadProgressModal, { UploadStatus } from './UploadProgressModal';
import { useTutorial } from './Tutorial/useTutorial';
import { TUTORIAL_KEYS } from './Tutorial/tutorialSteps';
import TutorialOverlay from './Tutorial/TutorialOverlay';
import { useOverlayClose } from '../hooks/useOverlayClose';
interface SocialPostingModalProps {
isOpen: boolean;
@ -121,7 +119,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
onGoToCalendar,
}) => {
const { t } = useTranslation();
const tutorial = useTutorial();
const [socialAccounts, setSocialAccounts] = useState<SocialAccount[]>([]);
const [selectedChannel, setSelectedChannel] = useState<string>('');
const [title, setTitle] = useState('');
@ -141,8 +138,10 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
const [videoMeta, setVideoMeta] = useState<{ width: number; height: number; duration: number } | null>(null);
const channelDropdownRef = useRef<HTMLDivElement>(null);
const privacyDropdownRef = useRef<HTMLDivElement>(null);
const hasBeenOpenedRef = useRef(false);
const loadedForVideoIdRef = useRef<number | null>(null);
const loadedForTaskIdRef = useRef<string | null>(null);
const loadedAtRef = useRef<number>(0);
const seoCache = useRef<Map<string, { title: string; description: string; tags: string }>>(new Map());
const SEO_CACHE_TTL = 50 * 60 * 1000;
// Upload progress modal state
const [showUploadProgress, setShowUploadProgress] = useState(false);
@ -181,48 +180,15 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
return () => { document.body.style.overflow = ''; };
}, [isOpen]);
// 모달 오픈/닫힘 시 튜토리얼 트리거
useEffect(() => {
if (isOpen) {
hasBeenOpenedRef.current = true;
if (!tutorial.hasSeen(TUTORIAL_KEYS.UPLOAD_MODAL)) {
const timer = setTimeout(() => {
tutorial.startTutorial(TUTORIAL_KEYS.UPLOAD_MODAL);
}, 400);
return () => clearTimeout(timer);
}
} else if (hasBeenOpenedRef.current && !tutorial.hasSeen(TUTORIAL_KEYS.FEEDBACK)) {
hasBeenOpenedRef.current = false;
tutorial.startTutorial(TUTORIAL_KEYS.FEEDBACK);
}
}, [isOpen]);
// SEO 생성 완료 시 UPLOAD_FORM 튜토리얼 트리거
useEffect(() => {
if (!isLoadingAutoDescription && isOpen && !tutorial.hasSeen(TUTORIAL_KEYS.UPLOAD_FORM)) {
const timer = setTimeout(() => {
tutorial.startTutorial(TUTORIAL_KEYS.UPLOAD_FORM);
}, 400);
return () => clearTimeout(timer);
}
}, [isLoadingAutoDescription]);
// 소셜 계정 로드
// 소셜 계정 로드 + SEO 자동 채움
useEffect(() => {
if (!isOpen) {
loadedForVideoIdRef.current = null;
loadedForTaskIdRef.current = null;
return;
}
loadSocialAccounts();
const videoId = video?.video_id ?? null;
if (!videoId || videoId === loadedForVideoIdRef.current) {
return;
}
loadedForVideoIdRef.current = videoId;
if (video?.title) {
setTitle(video.title);
setDescription(video.description || '');
@ -231,8 +197,26 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
return;
}
loadAutocomplete();
}, [isOpen, video?.video_id, video?.title, video?.description, video?.hashtags]);
const now = Date.now();
const seoKey =
video?.type === 'ssul'
? `ssul:${video.video_id}`
: video?.task_id || null;
const expired = now - loadedAtRef.current > SEO_CACHE_TTL;
if (seoKey && (seoKey !== loadedForTaskIdRef.current || expired)) {
loadedForTaskIdRef.current = seoKey;
loadedAtRef.current = now;
loadAutocomplete(seoKey);
} else if (seoKey) {
const cached = seoCache.current.get(seoKey);
if (cached) {
setTitle(cached.title);
setDescription(cached.description);
setTags(cached.tags);
}
}
}, [isOpen, video?.task_id, video?.type, video?.video_id, video?.title, video?.description, video?.hashtags]);
const loadSocialAccounts = async () => {
setIsLoadingAccounts(true);
@ -257,19 +241,34 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
}
};
const loadAutocomplete = async () => {
if (!video?.video_id) return;
/**
* ·· .
*
* `seoKey` (ADO2=task_id, =`ssul:{id}`).
* ADO2 task_id(UUID),
* ssul_content.id , content_type .
*/
const loadAutocomplete = async (seoKey: string) => {
if (!video) return;
const isSsul = video.type === 'ssul';
if (!isSsul && !video.task_id) return;
setIsLoadingAutoDescription(true);
try {
const requestPayload = {
video_id: video.video_id,
content_type: video.type,
task_id: isSsul ? String(video.video_id) : video.task_id,
};
const autoSeoResponse = await getAutoSeoYoutube(requestPayload);
if (autoSeoResponse.title) setTitle(autoSeoResponse.title);
if (autoSeoResponse.description) setDescription(autoSeoResponse.description);
if (autoSeoResponse.keywords) setTags(autoSeoResponse.keywords.join(','));
seoCache.current.set(seoKey, {
title: autoSeoResponse.title || '',
description: autoSeoResponse.description || '',
tags: autoSeoResponse.keywords?.join(',') || '',
});
} catch (error) {
console.error('Failed to load autocomplete:', error);
} finally {
@ -341,6 +340,9 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
}
const requestPayload = {
// 종류를 함께 보낸다 — 없으면 백엔드가 video 로 간주해, 썰박스 항목에서는
// id 가 겹치는 엉뚱한 ADO2 영상이 업로드된다.
content_type: video.type,
video_id: video.video_id,
social_account_id: selectedAcc.id,
title: title.trim(),
@ -432,6 +434,8 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
resetForm();
};
const overlayCloseHandlers = useOverlayClose(handleClose);
const selectedAccount = socialAccounts.find(acc => acc.platform_user_id === selectedChannel);
const privacyOptions = [
@ -463,23 +467,13 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
return (
<>
{showUploadProgress && uploadProgressModalElement}
{tutorial.isActive && (
<TutorialOverlay
hints={tutorial.hints}
currentIndex={tutorial.currentHintIndex}
onNext={tutorial.nextHint}
onPrev={tutorial.prevHint}
onSkip={tutorial.skipTutorial}
groupProgress={tutorial.groupProgress}
/>
)}
</>
);
}
return (
<>
<div className="social-posting-overlay" onClick={handleClose}>
<div className="social-posting-overlay" {...overlayCloseHandlers}>
<div className="social-posting-modal" onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div className="social-posting-header">
@ -849,16 +843,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
</div>
</div>
{uploadProgressModalElement}
{tutorial.isActive && (
<TutorialOverlay
hints={tutorial.hints}
currentIndex={tutorial.currentHintIndex}
onNext={tutorial.nextHint}
onPrev={tutorial.prevHint}
onSkip={tutorial.skipTutorial}
groupProgress={tutorial.groupProgress}
/>
)}
</>
);
};

View File

@ -1,314 +0,0 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { TutorialHint } from './tutorialSteps';
interface Rect {
top: number;
left: number;
width: number;
height: number;
bottom: number;
}
interface TooltipPos {
top: number;
left: number;
}
interface TutorialOverlayProps {
hints: TutorialHint[];
currentIndex: number;
onNext: () => void;
onPrev: () => void;
onSkip: () => void;
groupProgress?: { groupTotal: number; groupOffset: number; isLastKeyInGroup: boolean } | null;
}
const PADDING = 8;
function getTargetRect(selector: string): Rect | null {
const els = Array.from(document.querySelectorAll(selector));
const el = els.find(e => {
const r = (e as HTMLElement).getBoundingClientRect();
return r.width > 0 && r.height > 0;
}) ?? els[0];
if (!el) return null;
const r = el.getBoundingClientRect();
return { top: r.top, left: r.left, width: r.width, height: r.height, bottom: r.bottom };
}
function getSpotlightRect(
rect: Rect,
padding: number,
override?: { top?: number; right?: number; bottom?: number; left?: number }
): Rect {
const pTop = override?.top ?? padding;
const pRight = override?.right ?? padding;
const pBottom = override?.bottom ?? padding;
const pLeft = override?.left ?? padding;
const left = Math.max(0, Math.floor(rect.left - pLeft));
const top = Math.max(0, Math.floor(rect.top - pTop));
const right = Math.min(window.innerWidth, Math.ceil(rect.left + rect.width + pRight));
const bottom = Math.min(window.innerHeight, Math.ceil(rect.top + rect.height + pBottom));
return {
top,
bottom,
left,
width: Math.max(0, right - left),
height: Math.max(0, bottom - top),
};
}
function calcTooltipPos(rect: Rect, position: TutorialHint['position'], tooltipW = 300, tooltipH = 160): TooltipPos {
switch (position) {
case 'bottom':
return {
top: Math.min(rect.top + rect.height + PADDING, window.innerHeight - tooltipH - 8),
left: Math.min(Math.max(rect.left + rect.width / 2 - tooltipW / 2, 8), window.innerWidth - tooltipW - 8),
};
case 'top':
return {
top: Math.max(rect.top - tooltipH - PADDING, 8),
left: Math.min(Math.max(rect.left + rect.width / 2 - tooltipW / 2, 8), window.innerWidth - tooltipW - 8),
};
case 'right':
return {
top: Math.min(Math.max(rect.top + rect.height / 2 - tooltipH / 2, 8), window.innerHeight - tooltipH - 8),
left: Math.min(rect.left + rect.width + PADDING, window.innerWidth - tooltipW - 8),
};
case 'left':
return {
top: Math.min(Math.max(rect.top + rect.height / 2 - tooltipH / 2, 8), window.innerHeight - tooltipH - 8),
left: Math.max(rect.left - tooltipW - PADDING, 8),
};
}
}
const TutorialOverlay: React.FC<TutorialOverlayProps> = ({
hints,
currentIndex,
onNext,
onPrev,
onSkip,
groupProgress,
}) => {
const { t } = useTranslation();
const [targetRect, setTargetRect] = useState<Rect | null>(null);
const tooltipRef = React.useRef<HTMLDivElement>(null);
const [tooltipSize, setTooltipSize] = useState({ w: 300, h: 160 });
const hint = hints[currentIndex];
const isLast = currentIndex === hints.length - 1;
// 그룹이 있으면 그룹의 마지막 키 + 마지막 힌트일 때만 완료, 그룹 없으면 기존대로
const isFinish = isLast && (groupProgress ? groupProgress.isLastKeyInGroup : true);
const updateRect = useCallback(() => {
if (!hint) return;
setTargetRect(getTargetRect(hint.targetSelector));
}, [hint]);
useEffect(() => {
updateRect();
window.addEventListener('resize', updateRect);
window.addEventListener('scroll', updateRect, true);
return () => {
window.removeEventListener('resize', updateRect);
window.removeEventListener('scroll', updateRect, true);
};
}, [updateRect]);
useEffect(() => {
if (!hint) return;
let retryTimer: number | undefined;
let rectTimer: number | undefined;
let cleanupTarget: (() => void) | undefined;
const bindToTarget = (el: HTMLElement) => {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
rectTimer = window.setTimeout(updateRect, 200);
const shouldClickAdvance = hint.clickToAdvance !== false;
if (shouldClickAdvance) {
el.style.cursor = 'pointer';
el.addEventListener('click', onNext);
cleanupTarget = () => {
el.style.cursor = '';
el.removeEventListener('click', onNext);
};
} else {
cleanupTarget = () => {};
}
};
const tryBind = (): boolean => {
const els = Array.from(document.querySelectorAll(hint.targetSelector));
const el = (els.find(e => {
const r = (e as HTMLElement).getBoundingClientRect();
return r.width > 0 && r.height > 0;
}) ?? els[0]) as HTMLElement | null;
if (!el) return false;
bindToTarget(el);
return true;
};
if (!tryBind()) {
retryTimer = window.setInterval(() => {
if (tryBind() && retryTimer) {
window.clearInterval(retryTimer);
retryTimer = undefined;
}
}, 80);
}
return () => {
if (retryTimer) window.clearInterval(retryTimer);
if (rectTimer) window.clearTimeout(rectTimer);
cleanupTarget?.();
};
}, [hint, updateRect, onNext]);
// 툴팁 DOM 크기 측정 — 힌트가 바뀔 때만 재측정
useEffect(() => {
const el = tooltipRef.current;
if (!el) return;
const { offsetWidth, offsetHeight } = el;
if (offsetWidth && offsetHeight) {
setTooltipSize(prev =>
prev.w === offsetWidth && prev.h === offsetHeight
? prev
: { w: offsetWidth, h: offsetHeight }
);
}
}, [currentIndex, hints]);
if (!hint) return null;
const isTargetVisible = targetRect
? targetRect.top + 60 < window.innerHeight && targetRect.bottom > 60
: true;
if (!isTargetVisible) return null;
const spotlightPadding = hint.spotlightPadding ?? PADDING;
const spotlightRect = (targetRect && !hint.noSpotlight) ? getSpotlightRect(targetRect, spotlightPadding, hint.spotlightPaddingOverride) : null;
// 툴팁은 spotlightRect 기준으로 배치해야 스포트라이트와 겹치지 않음
const tooltipPos: TooltipPos = (spotlightRect ?? targetRect)
? calcTooltipPos((spotlightRect ?? targetRect)!, hint.position, tooltipSize.w, tooltipSize.h)
: {
top: window.innerHeight / 2 - tooltipSize.h / 2,
left: window.innerWidth / 2 - tooltipSize.w / 2,
};
return (
<div className="tutorial-overlay-root">
{spotlightRect ? (
<>
<div
className="tutorial-overlay-blocker"
style={{ top: 0, left: 0, right: 0, height: spotlightRect.top }}
/>
<div
className="tutorial-overlay-blocker"
style={{
top: spotlightRect.top,
left: 0,
width: spotlightRect.left,
height: spotlightRect.height,
}}
/>
<div
className="tutorial-overlay-blocker"
style={{
top: spotlightRect.top,
left: spotlightRect.left + spotlightRect.width,
right: 0,
height: spotlightRect.height,
}}
/>
<div
className="tutorial-overlay-blocker"
style={{
top: spotlightRect.top + spotlightRect.height,
left: 0,
right: 0,
bottom: 0,
}}
/>
<div
className="tutorial-spotlight-ring"
style={{
top: spotlightRect.top,
left: spotlightRect.left,
width: spotlightRect.width,
height: spotlightRect.height,
}}
/>
</>
) : null}
<div
ref={tooltipRef}
className={`tutorial-tooltip${hint.variant === 'bubble' ? ` tutorial-tooltip-bubble tutorial-tooltip-bubble--${hint.position}` : ''}`}
style={{ top: tooltipPos.top, left: tooltipPos.left }}
onClick={(e) => e.stopPropagation()}
>
<p className="tutorial-tooltip-title">{t(hint.titleKey)}</p>
<p className="tutorial-tooltip-desc">{t(hint.descriptionKey)}</p>
{hint.noteKey && (
<p className="tutorial-tooltip-note">{t(hint.noteKey)}</p>
)}
<div className="tutorial-tooltip-footer">
<span className="tutorial-tooltip-counter">
{groupProgress ? groupProgress.groupOffset + 1 : currentIndex + 1} / {groupProgress ? groupProgress.groupTotal : hints.length}
</span>
<div className="tutorial-tooltip-actions">
<button className="tutorial-btn-skip" onClick={onSkip}>
{t('tutorial.skip')}
</button>
{currentIndex > 0 && (
<button className="tutorial-btn-prev" onClick={onPrev}>
{t('tutorial.prev')}
</button>
)}
{(hint.clickToAdvance !== true || isFinish) && (
<button className="tutorial-btn-next" onClick={onNext}>
{isFinish ? t('tutorial.finish') : t('tutorial.next')}
</button>
)}
</div>
</div>
</div>
</div>
);
};
export default TutorialOverlay;
interface TutorialRestartPopupProps {
onConfirm: () => void;
onCancel: () => void;
}
export const TutorialRestartPopup: React.FC<TutorialRestartPopupProps> = ({ onConfirm, onCancel }) => {
const { t } = useTranslation();
return (
<div className="tutorial-restart-backdrop">
<div className="tutorial-restart-popup" onClick={(e) => e.stopPropagation()}>
<p className="tutorial-restart-title">{t('tutorial.restart.title')}</p>
<p className="tutorial-restart-desc">{t('tutorial.restart.desc')}</p>
<div className="tutorial-restart-actions">
<button className="tutorial-restart-cancel" onClick={onCancel}>
{t('tutorial.restart.cancel')}
</button>
<button className="tutorial-restart-confirm" onClick={onConfirm}>
{t('tutorial.restart.confirm')}
</button>
</div>
</div>
</div>
);
};

View File

@ -1,377 +0,0 @@
export interface TutorialHint {
targetSelector: string;
titleKey: string;
descriptionKey: string;
position: 'top' | 'bottom' | 'left' | 'right';
noteKey?: string;
variant?: 'bubble';
clickToAdvance?: boolean;
advanceSelector?: string;
noSpotlight?: boolean;
spotlightPadding?: number;
spotlightPaddingOverride?: { top?: number; right?: number; bottom?: number; left?: number };
}
export interface TutorialStepDef {
key: string;
hints: TutorialHint[];
}
export const TUTORIAL_KEYS = {
LANDING: 'landing',
ANALYSIS: 'analysis',
ASSET: 'asset',
SOUND: 'sound',
SOUND_LYRICS: 'soundLyrics',
SOUND_AUDIO: 'soundAudio',
GENERATING:'generating',
COMPLETION: 'completion',
MY_INFO: 'myInfo',
ADO2_CONTENTS: 'ado2Contents',
UPLOAD_MODAL: 'uploadModal',
UPLOAD_FORM: 'uploadForm',
DASHBOARD: 'dashboard',
CONTENT_CALENDAR: 'contentCalendar',
FEEDBACK: 'feedback',
} as const;
// 같은 페이지에 속하는 튜토리얼 키 그룹 — 진행 카운터를 합산해서 표시
export const TUTORIAL_PAGE_GROUPS: string[][] = [
[TUTORIAL_KEYS.SOUND, TUTORIAL_KEYS.SOUND_LYRICS, TUTORIAL_KEYS.SOUND_AUDIO],
[TUTORIAL_KEYS.GENERATING, TUTORIAL_KEYS.COMPLETION],
[TUTORIAL_KEYS.UPLOAD_MODAL, TUTORIAL_KEYS.UPLOAD_FORM],
];
export const tutorialSteps: TutorialStepDef[] = [
{
key: TUTORIAL_KEYS.LANDING,
hints: [
{
targetSelector: '.hero-input-wrapper',
titleKey: 'tutorial.landing.field.title',
descriptionKey: 'tutorial.landing.field.desc',
position: 'top',
clickToAdvance: false,
noSpotlight: true,
variant: 'bubble',
},
{
targetSelector: '.hero-manual-card-title',
titleKey: 'tutorial.landing.manual.title',
descriptionKey: 'tutorial.landing.manual.desc',
position: 'top',
clickToAdvance: false,
noSpotlight: true,
variant: 'bubble',
},
{
targetSelector: '.hero-button',
titleKey: 'tutorial.landing.button.title',
descriptionKey: 'tutorial.landing.button.desc',
position: 'bottom',
clickToAdvance: true,
noSpotlight: true,
variant: 'bubble',
},
],
},
{
key: TUTORIAL_KEYS.ASSET,
hints: [
{
targetSelector: '.asset-column.asset-column-left',
titleKey: 'tutorial.asset.image.title',
descriptionKey: 'tutorial.asset.image.desc',
position: 'left',
clickToAdvance: false,
},
{
targetSelector: '.asset-upload-zone, .asset-mobile-upload-btn',
titleKey: 'tutorial.asset.upload.title',
descriptionKey: 'tutorial.asset.upload.desc',
position: 'bottom',
clickToAdvance: false,
},
{
targetSelector: '.asset-ratio-section',
titleKey: 'tutorial.asset.ratio.title',
descriptionKey: 'tutorial.asset.ratio.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '.asset-next-button',
titleKey: 'tutorial.asset.next.title',
descriptionKey: 'tutorial.asset.next.desc',
position: 'top',
clickToAdvance: true,
},
],
},
{
key: TUTORIAL_KEYS.SOUND,
hints: [
{
targetSelector: '.genre-grid',
titleKey: 'tutorial.sound.genre.title',
descriptionKey: 'tutorial.sound.genre.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '.language-grid',
titleKey: 'tutorial.sound.language.title',
descriptionKey: 'tutorial.sound.language.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '.btn-generate-sound',
titleKey: 'tutorial.sound.generate.title',
descriptionKey: 'tutorial.sound.generate.desc',
position: 'top',
clickToAdvance: true,
},
],
},
{
key: TUTORIAL_KEYS.SOUND_LYRICS,
hints: [
{
targetSelector: '.lyrics-display',
titleKey: 'tutorial.sound.lyrics.title',
descriptionKey: 'tutorial.sound.lyrics.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '.status-message-new',
titleKey: 'tutorial.sound.lyricsWait.title',
descriptionKey: 'tutorial.sound.lyricsWait.desc',
position: 'top',
clickToAdvance: false,
},
],
},
{
key: TUTORIAL_KEYS.SOUND_AUDIO,
hints: [
{
targetSelector: '.audio-player',
titleKey: 'tutorial.sound.audioPlayer.title',
descriptionKey: 'tutorial.sound.audioPlayer.desc',
position: 'bottom',
clickToAdvance: false,
},
{
targetSelector: '.btn-video-generate',
titleKey: 'tutorial.sound.video.title',
descriptionKey: 'tutorial.sound.video.desc',
position: 'top',
clickToAdvance: true,
},
],
},
{
key: TUTORIAL_KEYS.MY_INFO,
hints: [
{
targetSelector: '.youtube-connect-section',
titleKey: 'tutorial.myInfo.myInfo.title',
descriptionKey: 'tutorial.myInfo.myInfo.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '.myinfo-social-btn',
titleKey: 'tutorial.myInfo.connect.title',
descriptionKey: 'tutorial.myInfo.connect.desc',
noteKey: 'tutorial.myInfo.connect.note',
position: 'top',
clickToAdvance: true,
},
{
targetSelector: '.myinfo-connected-accounts',
titleKey: 'tutorial.myInfo.connected.title',
descriptionKey: 'tutorial.myInfo.connected.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '#sidebar-ado2-contents',
titleKey: 'tutorial.myInfo.ado2.title',
descriptionKey: 'tutorial.myInfo.ado2.desc',
position: 'right',
clickToAdvance: true,
},
],
},
{
key: TUTORIAL_KEYS.ADO2_CONTENTS,
hints: [
{
targetSelector: '.ado2-content-card',
titleKey: 'tutorial.ado2.list.title',
descriptionKey: 'tutorial.ado2.list.desc',
position: 'right',
clickToAdvance: false,
},
{
targetSelector: '.content-upload-btn',
titleKey: 'tutorial.ado2.download.title',
descriptionKey: 'tutorial.ado2.download.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '.content-delete-btn',
titleKey: 'tutorial.ado2.delete.title',
descriptionKey: 'tutorial.ado2.delete.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '.content-download-btn',
titleKey: 'tutorial.ado2.upload.title',
descriptionKey: 'tutorial.ado2.upload.desc',
position: 'top',
clickToAdvance: true,
},
],
},
{
key: TUTORIAL_KEYS.GENERATING,
hints:[
{
targetSelector: '.comp2-info-section',
titleKey: 'tutorial.completion.contentInfo.title',
descriptionKey: 'tutorial.completion.contentInfo.desc',
position: 'left',
},
{
targetSelector: '.comp2-video-section',
titleKey: 'tutorial.completion.generating.title',
descriptionKey: 'tutorial.completion.generating.desc',
position: 'top',
}
]
},
{
key: TUTORIAL_KEYS.COMPLETION,
hints: [
{
targetSelector: '.comp2-video-section',
titleKey: 'tutorial.completion.completion.title',
descriptionKey: 'tutorial.completion.completion.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '#sidebar-my-info',
titleKey: 'tutorial.completion.myInfo.title',
descriptionKey: 'tutorial.completion.myInfo.desc',
position: 'right',
clickToAdvance: true,
},
],
},
{
key: TUTORIAL_KEYS.UPLOAD_MODAL,
hints: [
{
targetSelector: '.social-posting-content',
titleKey: 'tutorial.upload.seo.title',
descriptionKey: 'tutorial.upload.seo.desc',
position: 'right',
clickToAdvance: false,
},
],
},
{
key: TUTORIAL_KEYS.UPLOAD_FORM,
hints: [
{
targetSelector: '.social-posting-form',
titleKey: 'tutorial.upload.required.title',
descriptionKey: 'tutorial.upload.required.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '.social-posting-radio-group',
titleKey: 'tutorial.upload.schedule.title',
descriptionKey: 'tutorial.upload.schedule.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '.social-posting-btn:not(.cancel)',
titleKey: 'tutorial.upload.submit.title',
descriptionKey: 'tutorial.upload.submit.desc',
position: 'top',
clickToAdvance: true,
},
],
},
{
key: TUTORIAL_KEYS.DASHBOARD,
hints: [
{
targetSelector: '.stats-grid-8',
titleKey: 'tutorial.dashboard.metrics.title',
descriptionKey: 'tutorial.dashboard.metrics.desc',
position: 'bottom',
},
{
targetSelector: '.yoy-chart-card',
titleKey: 'tutorial.dashboard.chart.title',
descriptionKey: 'tutorial.dashboard.chart.desc',
position: 'top',
},
{
targetSelector: '.tutorial-center-anchor',
titleKey: 'tutorial.dashboard.more.title',
descriptionKey: 'tutorial.dashboard.more.desc',
position: 'bottom',
clickToAdvance: false,
},
],
},
{
key: TUTORIAL_KEYS.CONTENT_CALENDAR,
hints: [
{
targetSelector: '.calendar-grid-area',
titleKey: 'tutorial.contentCalendar.grid.title',
descriptionKey: 'tutorial.contentCalendar.grid.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '.calendar-side-panel',
titleKey: 'tutorial.contentCalendar.panel.title',
descriptionKey: 'tutorial.contentCalendar.panel.desc',
position: 'left',
clickToAdvance: false,
},
],
},
{
key: TUTORIAL_KEYS.FEEDBACK,
hints: [
{
targetSelector: '.tutorial-center-anchor',
titleKey: 'tutorial.feedback.complete.title',
descriptionKey: 'tutorial.feedback.complete.desc',
position: 'bottom',
clickToAdvance: false,
},
{
targetSelector: '.sidebar-inquiry-btn',
titleKey: 'tutorial.feedback.title',
descriptionKey: 'tutorial.feedback.desc',
position: 'right',
},
],
},
];

View File

@ -1,220 +0,0 @@
import React, { useState, useCallback, useEffect } from 'react';
import { tutorialSteps, TutorialHint, TUTORIAL_PAGE_GROUPS } from './tutorialSteps';
// 현재 키가 속한 그룹의 전체 힌트 수와 현재까지의 offset 반환
function getGroupProgress(key: string, currentIndex: number): { groupTotal: number; groupOffset: number; isLastKeyInGroup: boolean } | null {
const group = TUTORIAL_PAGE_GROUPS.find(g => g.includes(key));
if (!group) return null;
let offset = 0;
let total = 0;
for (const k of group) {
const step = tutorialSteps.find(s => s.key === k);
const count = step?.hints.length ?? 0;
if (k === key) offset = total;
total += count;
}
const isLastKeyInGroup = group[group.length - 1] === key;
return { groupTotal: total, groupOffset: offset + currentIndex, isLastKeyInGroup };
}
const SEEN_KEY = 'ado2_tutorial_seen';
const PROGRESS_KEY = 'ado2_tutorial_progress';
const ENABLED_KEY = 'ado2_tutorial_enabled';
// 전역 단일 활성 튜토리얼 관리 — 새 튜토리얼 시작 시 이전 것을 skip 처리
let globalSkip: (() => void) | null = null;
function getSeenKeys(): string[] {
try {
return JSON.parse(localStorage.getItem(SEEN_KEY) || '[]');
} catch {
return [];
}
}
function markSeen(key: string) {
const seen = getSeenKeys();
if (!seen.includes(key)) {
localStorage.setItem(SEEN_KEY, JSON.stringify([...seen, key]));
}
clearProgress(key);
}
function saveProgress(key: string, index: number) {
try {
const progress = JSON.parse(localStorage.getItem(PROGRESS_KEY) || '{}');
progress[key] = index;
localStorage.setItem(PROGRESS_KEY, JSON.stringify(progress));
} catch {}
}
function loadProgress(key: string): number {
try {
const progress = JSON.parse(localStorage.getItem(PROGRESS_KEY) || '{}');
return progress[key] ?? 0;
} catch {
return 0;
}
}
function clearProgress(key: string) {
try {
const progress = JSON.parse(localStorage.getItem(PROGRESS_KEY) || '{}');
delete progress[key];
localStorage.setItem(PROGRESS_KEY, JSON.stringify(progress));
} catch {}
}
interface UseTutorialReturn {
isActive: boolean;
isEnabled: boolean;
isRestartPopupVisible: boolean;
currentHintIndex: number;
hints: TutorialHint[];
tutorialKey: string | null;
groupProgress: { groupTotal: number; groupOffset: number; isLastKeyInGroup: boolean } | null;
startTutorial: (key: string, onComplete?: () => void, forceFromStart?: boolean) => void;
nextHint: () => void;
prevHint: () => void;
skipTutorial: () => void;
toggleTutorial: (currentKey: string | null) => void;
showRestartPopup: (key: string) => void;
confirmRestart: () => void;
cancelRestart: () => void;
hasSeen: (key: string) => boolean;
}
export function useTutorial(): UseTutorialReturn {
const [isActive, setIsActive] = useState(false);
const [isEnabled, setIsEnabled] = useState(() => localStorage.getItem(ENABLED_KEY) !== 'false');
const [isRestartPopupVisible, setIsRestartPopupVisible] = useState(false);
const [pendingRestartKey, setPendingRestartKey] = useState<string | null>(null);
const [currentHintIndex, setCurrentHintIndex] = useState(0);
const [hints, setHints] = useState<TutorialHint[]>([]);
const [tutorialKey, setTutorialKey] = useState<string | null>(null);
const onCompleteRef = React.useRef<(() => void) | undefined>(undefined);
const startTutorial = useCallback((key: string, onComplete?: () => void, forceFromStart?: boolean) => {
if (localStorage.getItem(ENABLED_KEY) === 'false') return;
const step = tutorialSteps.find(s => s.key === key);
if (!step || step.hints.length === 0) return;
// 다른 인스턴스에서 활성화된 튜토리얼이 있으면 skip 처리
globalSkip?.();
const savedIndex = forceFromStart ? 0 : loadProgress(key);
const resumeIndex = savedIndex < step.hints.length ? savedIndex : 0;
onCompleteRef.current = onComplete;
setHints(step.hints);
setTutorialKey(key);
setCurrentHintIndex(resumeIndex);
setIsActive(true);
// 이 인스턴스의 skip을 전역에 등록
globalSkip = () => {
if (key) saveProgress(key, resumeIndex);
setIsActive(false);
setCurrentHintIndex(0);
globalSkip = null;
};
}, []);
const nextHint = useCallback(() => {
setCurrentHintIndex(prev => {
if (prev < hints.length - 1) {
const next = prev + 1;
if (tutorialKey) saveProgress(tutorialKey, next);
return next;
}
// 마지막 힌트 완료 → seen 기록 + 진행 상태 삭제
setIsActive(false);
if (tutorialKey) markSeen(tutorialKey);
globalSkip = null; // 완료된 튜토리얼은 globalSkip 해제
onCompleteRef.current?.();
onCompleteRef.current = undefined;
return 0;
});
}, [hints.length, tutorialKey]);
const prevHint = useCallback(() => {
setCurrentHintIndex(prev => Math.max(0, prev - 1));
}, []);
// 건너뛰기: 현재 진행 인덱스 저장 후 오버레이 닫기 → 다음 방문 시 이어서 표시
const skipTutorial = useCallback(() => {
if (tutorialKey) saveProgress(tutorialKey, currentHintIndex);
setIsActive(false);
setCurrentHintIndex(0);
}, [tutorialKey, currentHintIndex]);
const toggleTutorial = useCallback((currentKey: string | null) => {
if (isEnabled) {
// off: 튜토리얼 중단 + 비활성화
setIsActive(false);
setIsEnabled(false);
localStorage.setItem(ENABLED_KEY, 'false');
} else {
// on: seen/progress 초기화 + 현재 화면 튜토리얼 시작
localStorage.removeItem(SEEN_KEY);
localStorage.removeItem(PROGRESS_KEY);
localStorage.setItem(ENABLED_KEY, 'true');
setIsEnabled(true);
if (currentKey) {
startTutorial(currentKey, undefined, true);
}
}
}, [isEnabled, startTutorial]);
// 튜토리얼 다시 보기: 팝업 표시만
const showRestartPopup = useCallback((key: string) => {
setPendingRestartKey(key);
setIsRestartPopupVisible(true);
}, []);
// 팝업에서 확인 → seen/progress 초기화 후 튜토리얼 시작
const confirmRestart = useCallback(() => {
setIsRestartPopupVisible(false);
if (pendingRestartKey) {
localStorage.removeItem(SEEN_KEY);
localStorage.removeItem(PROGRESS_KEY);
startTutorial(pendingRestartKey, undefined, true);
}
setPendingRestartKey(null);
}, [pendingRestartKey, startTutorial]);
// 팝업에서 취소
const cancelRestart = useCallback(() => {
setIsRestartPopupVisible(false);
setPendingRestartKey(null);
}, []);
const hasSeen = useCallback((key: string) => {
return getSeenKeys().includes(key);
}, []);
// 영상 보유 사용자 자동 off 이벤트 수신
useEffect(() => {
const handler = () => {
setIsActive(false);
setIsEnabled(false);
};
window.addEventListener('ado2-tutorial-auto-disable', handler);
return () => window.removeEventListener('ado2-tutorial-auto-disable', handler);
}, []);
return {
isActive,
isEnabled,
isRestartPopupVisible,
currentHintIndex,
hints,
tutorialKey,
groupProgress: tutorialKey ? getGroupProgress(tutorialKey, currentHintIndex) : null,
startTutorial,
nextHint,
prevHint,
skipTutorial,
toggleTutorial,
showRestartPopup,
confirmRestart,
cancelRestart,
hasSeen,
};
}

View File

@ -13,6 +13,7 @@ import {
import { VideoDetailItem, CommentItem, UserMeResponse } from '../types/api';
import { buildVideoShareUrl, tryNativeShare } from '../utils/nativeShare';
import LoginPromptModal from './LoginPromptModal';
import { isSafeHttpUrl } from '../utils/url';
interface VideoDetailContentProps {
videoId: string;
@ -199,11 +200,14 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
{/* 헤더 */}
<div className="video-detail-header">
{isModal ? (
<>
<h2 className="video-detail-modal-title">{t('sidebar.ado2Contents')}</h2>
<button className="video-detail-close-btn" onClick={handleHeaderAction} aria-label={t('videoDetail.closeAriaLabel')}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</>
) : (
<button className="video-detail-back-btn" onClick={handleHeaderAction}>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
@ -214,6 +218,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
)}
</div>
<div className="video-detail-body">
{loading ? (
<div className="ado2-contents-loading">
<div className="loading-spinner"></div>
@ -228,6 +233,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
src={video.result_movie_url}
controls
autoPlay
playsInline
controlsList="nodownload"
onContextMenu={(e) => e.preventDefault()}
className="video-detail-player"
@ -244,9 +250,9 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
);
}}
/>
{video.official_site_url && showSiteOverlay && (
{showSiteOverlay && isSafeHttpUrl(video.official_site_url) && (
<a
href={video.official_site_url}
href={video.official_site_url as string}
target="_blank"
rel="noopener noreferrer"
className="video-site-overlay"
@ -431,6 +437,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
</div>
</div>
) : null}
</div>
{showLoginModal && (
<LoginPromptModal onClose={() => setShowLoginModal(false)} />

View File

@ -1,5 +1,6 @@
import React, { useEffect } from 'react';
import VideoDetailContent from './VideoDetailContent';
import { useOverlayClose } from '../hooks/useOverlayClose';
interface VideoDetailModalProps {
videoId: string;
@ -23,30 +24,11 @@ const VideoDetailModal: React.FC<VideoDetailModalProps> = ({ videoId, onClose })
};
}, [onClose]);
const overlayCloseHandlers = useOverlayClose(onClose);
return (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 0, 0, 0.85)',
zIndex: 2000,
display: 'flex',
overflowY: 'auto',
padding: '24px 16px',
}}
onClick={onClose}
>
<div
style={{
background: '#01282A',
borderRadius: '16px',
width: '100%',
maxWidth: '800px',
margin: 'auto',
position: 'relative',
}}
onClick={(e) => e.stopPropagation()}
>
<div className="video-detail-overlay" {...overlayCloseHandlers}>
<div className="video-detail-modal-box" onClick={(e) => e.stopPropagation()}>
<VideoDetailContent videoId={videoId} isModal={true} onClose={onClose} />
</div>
</div>

View File

@ -3,13 +3,18 @@ import React from 'react';
import { useTranslation } from 'react-i18next';
interface WizardStepperProps {
currentStep: number; // 0~3, matching wizardStep in GenerationFlow
currentStep: number; // 0-based. ADO2 는 wizardStep 0~3 과 일치
/**
* . ADO2 4 ( ).
* .
*/
steps?: string[];
}
const WizardStepper: React.FC<WizardStepperProps> = ({ currentStep }) => {
const WizardStepper: React.FC<WizardStepperProps> = ({ currentStep, steps: stepsProp }) => {
const { t } = useTranslation();
const steps = [
const steps = stepsProp ?? [
t('wizardSteps.brandAnalysis'),
t('wizardSteps.asset'),
t('wizardSteps.sound'),

View File

@ -0,0 +1,93 @@
import React from 'react';
export const NAV = {
HOME: 'ADO2 콘텐츠',
NEW: '새 프로젝트 만들기',
MY: '내 정보',
DASHBOARD: '대시보드',
} as const;
export const SETTINGS_ID = '__settings__';
export const GATED_ITEMS: string[] = [NAV.NEW, NAV.MY, NAV.DASHBOARD];
export type MyInfoTab = 'contents' | 'calendar' | 'payment' | 'business';
export interface NavItemDef {
id: string;
labelKey: string;
icon: React.ReactNode;
}
export const NAV_ITEMS: NavItemDef[] = [
{
id: NAV.HOME,
labelKey: 'sidebar.ado2Contents',
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
),
},
{
id: NAV.NEW,
labelKey: 'sidebar.newProject',
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
),
},
{
id: NAV.MY,
labelKey: 'sidebar.myInfo',
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
),
},
{
id: SETTINGS_ID,
labelKey: 'sidebar.settings',
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
),
},
];
/** 구 activeItem(6메뉴 체제) → 새 4메뉴 체계 매핑 + 비로그인 게이트 강등을 함께 처리한다. */
export function sanitizeActiveItem(
raw: string | null,
isGuest: boolean
): { item: string; myInfoTab?: MyInfoTab } {
const gate = (result: { item: string; myInfoTab?: MyInfoTab }) =>
isGuest && GATED_ITEMS.includes(result.item) ? { item: NAV.HOME } : result;
switch (raw) {
case '대시보드':
return gate({ item: NAV.DASHBOARD });
case '내 콘텐츠':
return gate({ item: NAV.MY, myInfoTab: 'contents' });
case '콘텐츠 캘린더':
return gate({ item: NAV.MY, myInfoTab: 'calendar' });
case '비즈니스 설정':
return gate({ item: NAV.MY, myInfoTab: 'business' });
case NAV.MY:
return gate({ item: NAV.MY });
case NAV.NEW:
return gate({ item: NAV.NEW });
case NAV.DASHBOARD:
return gate({ item: NAV.DASHBOARD });
case NAV.HOME:
return { item: NAV.HOME };
default:
return { item: NAV.HOME };
}
}

View File

@ -0,0 +1,69 @@
import React from 'react';
/**
* .
*
* ADO2 Design System (`assets/icons/*.svg`):
* viewBox 24×24 · fill none · stroke currentColor · stroke-width 2 · round cap/join
*
* currentColor (/) .
* ADO2 ( + CTA ) .
* **** .
*/
interface IconProps {
size?: number;
}
const base = (size: number) => ({
width: size,
height: size,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 2,
strokeLinecap: 'round' as const,
strokeLinejoin: 'round' as const,
});
/** ADO2 — 업체(가게)를 분석해 콘텐츠를 만든다 → 상점 차양 */
export const StorefrontIcon: React.FC<IconProps> = ({ size = 24 }) => (
<svg {...base(size)} aria-hidden="true">
<path d="M4 10v9a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-9" />
<path d="M3 10l1.4-4.3A1 1 0 0 1 5.35 5h13.3a1 1 0 0 1 .95.7L21 10a2.5 2.5 0 0 1-4.5 1.5A2.5 2.5 0 0 1 12 11.4a2.5 2.5 0 0 1-4.5.1A2.5 2.5 0 0 1 3 10z" />
<path d="M10 20v-5h4v5" />
</svg>
);
/** 썰박스 — 역사 이야기 → 펼친 책 */
export const BookIcon: React.FC<IconProps> = ({ size = 24 }) => (
<svg {...base(size)} aria-hidden="true">
<path d="M12 7.5v12" />
<path d="M3 5h5a4 4 0 0 1 4 2.5V19.5A3 3 0 0 0 9.5 18H3V5z" />
<path d="M21 5h-5a4 4 0 0 0-4 2.5V19.5A3 3 0 0 1 14.5 18H21V5z" />
</svg>
);
/**
* + .
*
* 2:3 (12×18). '동영상'
* **** .
*/
export const PosterVideoIcon: React.FC<IconProps> = ({ size = 24 }) => (
<svg {...base(size)} aria-hidden="true">
<rect x="6" y="3" width="12" height="18" rx="2" />
{/* 재생 삼각형만 채운다 — 아웃라인으로 그리면 이 크기에서 뭉개진다 */}
<path d="M10.5 9v6l4.2-3z" fill="currentColor" />
</svg>
);
/** 포스터 스타일 — 화풍 재해석 → 팔레트 */
export const PaletteIcon: React.FC<IconProps> = ({ size = 24 }) => (
<svg {...base(size)} aria-hidden="true">
<path d="M12 3a9 9 0 1 0 0 18 1.8 1.8 0 0 0 1.35-3 1.8 1.8 0 0 1 1.35-3H17a4 4 0 0 0 4-4c0-4.42-4.03-8-9-8z" />
<circle cx="7.6" cy="12.4" r="1.1" fill="currentColor" stroke="none" />
<circle cx="9.8" cy="8.2" r="1.1" fill="currentColor" stroke="none" />
<circle cx="14.6" cy="7.6" r="1.1" fill="currentColor" stroke="none" />
</svg>
);

View File

@ -0,0 +1,24 @@
import React, { useRef } from 'react';
/**
* () .
*
* click ,
* mousedown과 click(mouseup) .
*/
export function useOverlayClose(onClose: () => void) {
const mouseDownOnSelfRef = useRef(false);
const onMouseDown = (e: React.MouseEvent<HTMLElement>) => {
mouseDownOnSelfRef.current = e.target === e.currentTarget;
};
const onClick = (e: React.MouseEvent<HTMLElement>) => {
if (mouseDownOnSelfRef.current && e.target === e.currentTarget) {
onClose();
}
mouseDownOnSelfRef.current = false;
};
return { onMouseDown, onClick };
}

97
src/hooks/useP2vJob.ts Normal file
View File

@ -0,0 +1,97 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
P2vAuthError,
P2vJob,
P2vNotFoundError,
getPosterJob,
getStylingJob,
isP2vActive,
} from '../utils/p2vApi';
interface UseP2vJobResult {
job: P2vJob | null;
error: string | null;
/** 401 — 화면이 접근 키 입력을 띄워야 한다 */
authNeeded: boolean;
/** 404 — 서버에 없는 잡이다. 호출부가 화면을 처음으로 되돌려야 한다 */
notFound: boolean;
/** 즉시 1회 조회하고, 멈춰 있던 폴링을 다시 켠다 (승인·재시도 직후에 쓴다) */
refresh: () => void;
}
const POLL_INTERVAL = 2500;
/**
* P2V .
*
* castad (`waitForSsulComplete`) .
* "끝날 때까지 기다리는" **
* ** .
*
* queued/running . awaiting_review·done·failed
* . ·
* refresh() .
*/
export function useP2vJob(kind: 'f1' | 'f2', id: string | null): UseP2vJobResult {
const [job, setJob] = useState<P2vJob | null>(null);
const [error, setError] = useState<string | null>(null);
const [authNeeded, setAuthNeeded] = useState(false);
const [notFound, setNotFound] = useState(false);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const stop = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, []);
const fetchOnce = useCallback(async (jobId: string) => {
try {
const next = kind === 'f1' ? await getPosterJob(jobId) : await getStylingJob(jobId);
setJob(next);
setError(null);
setAuthNeeded(false);
setNotFound(false);
if (!isP2vActive(next)) stop();
} catch (e) {
if (e instanceof P2vAuthError) {
setAuthNeeded(true);
stop(); // 키가 없으면 계속 두드려봐야 401만 쌓인다
return;
}
if (e instanceof P2vNotFoundError) {
setNotFound(true);
stop(); // 없는 잡은 다시 생기지 않는다
return;
}
setError((e as Error).message);
}
}, [kind, stop]);
useEffect(() => {
if (!id) {
setJob(null);
setError(null);
setNotFound(false);
stop();
return;
}
// 잡이 바뀌면 이전 잡의 상태가 한 틱 남아 보이므로 먼저 비운다
setJob(null);
setNotFound(false);
fetchOnce(id);
timerRef.current = setInterval(() => fetchOnce(id), POLL_INTERVAL);
return stop;
}, [id, fetchOnce, stop]);
const refresh = useCallback(() => {
if (!id) return;
fetchOnce(id);
if (!timerRef.current) {
timerRef.current = setInterval(() => fetchOnce(id), POLL_INTERVAL);
}
}, [id, fetchOnce]);
return { job, error, authNeeded, notFound, refresh };
}

View File

@ -5,17 +5,6 @@ import './i18n';
import './styles/index.css';
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');
if (!rootElement) {
throw new Error("Could not find root element to mount to");

View File

@ -21,82 +21,9 @@
"credits": "Credits left: {{count}}",
"loggingOut": "Logging out...",
"logout": "Log Out",
"tutorialRestart": "Restart Tutorial",
"tutorial": "Tutorial",
"tutorialOn": "Enable Tutorial",
"tutorialOff": "Disable Tutorial",
"inquiry": "Feedback"
},
"tutorial": {
"skip": "Skip",
"next": "Next",
"prev": "Back",
"finish": "Done",
"landing": {
"intro": { "title": "Welcome to ADO2 Tutorial", "desc": "We'll guide you through ADO2 step by step." },
"field": { "title": "Enter Search Term", "desc": "Paste a Naver Maps share URL,\nor type a business name and select from the list." },
"manual": { "title": "Direct Input", "desc": "You can also enter the business name and address manually to start analysis." },
"button": { "title": "Start Brand Analysis", "desc": "Click the button to let AI start analyzing your brand." }
},
"asset": {
"image": { "title": "Image List", "desc": "Photos from Naver Place. Tap 'Show more' to see the rest, or X to remove any." },
"upload": { "title": "Add Images", "desc": "You can freely add more images." },
"ratio": { "title": "Select Video Ratio", "desc": "Choose the ratio for the video to be generated." },
"next": { "title": "Next Step", "desc": "Proceed to the next step when ready." }
},
"sound": {
"genre": { "title": "Select Genre", "desc": "Pick a music genre that fits your brand.", "note": "Background music is coming soon." },
"language": { "title": "Select Language", "desc": "You can choose the language for the sound.\nWant to continue with Korean?" },
"generate": { "title": "Generate Sound", "desc": "Click the button and AI will generate lyrics and music." },
"lyrics": { "title": "Lyrics Complete", "desc": "AI wrote lyrics in your selected language.\nCheck the generated lyrics." },
"lyricsWait": { "title": "Generating Music", "desc": "AI is composing music based on the lyrics.\nPlease wait a moment." },
"audioPlayer": { "title": "Preview the Music", "desc": "Music generation is complete.\nPress play to listen to the generated music." },
"video": { "title": "Generate Video", "desc": "Click the button to start generating your video." }
},
"completion": {
"contentInfo": { "title": "Content Info", "desc": "Check the file name, genre, resolution, and lyrics of the generated content." },
"generating": { "title": "Generating Video", "desc": "AI is creating your video.\nPlease wait a moment." },
"completion": { "title": "Video Complete!", "desc": "Your video is ready. Want to take a look?" },
"myInfo": { "title": "Connect Social Account", "desc": "To upload your video to YouTube, connect your social account in My Info. Click to go there." }
},
"myInfo": {
"myInfo": { "title": "My Info", "desc": "In My Info, you can manage your social connections and view connected accounts." },
"connect": { "title": "Connect Now", "desc": "Click the YouTube connect button to go to the connection page.", "note": "Instagram connection is coming soon." },
"connected": { "title": "Connected Accounts", "desc": "Your linked social accounts appear here.\nCheck after connecting." },
"ado2": { "title": "ADO2 Contents", "desc": "You can now upload the generated video.\nClick to navigate." }
},
"ado2": {
"list": { "title": "Generated Videos", "desc": "View all AI-created videos here." },
"download": { "title": "Download", "desc": "Download the video to your device." },
"delete": { "title": "Delete", "desc": "Remove videos you no longer need." },
"upload": { "title": "Upload to Social Media", "desc": "Select a video and upload it to social media." }
},
"upload": {
"seo": { "title": "Title & Description", "desc": "AI is generating the title and description for your video. Please wait a moment." },
"required": { "title": "Required Fields", "desc": "Fields marked with * are required.\nPlease check them before uploading." },
"schedule": { "title": "Schedule Upload", "desc": "Post now or schedule for a specific time." },
"submit": { "title": "Start Upload", "desc": "Click the Post button to start uploading." }
},
"dashboard": {
"metrics": { "title": "Key Metrics", "desc": "Check views, subscribers, and other stats for content uploaded via ADO2." },
"chart": { "title": "Growth Chart", "desc": "Track your channel's growth over time." },
"more": { "title": "More Analytics", "desc": "Even more statistics are available at a glance on the dashboard." }
},
"contentCalendar": {
"grid": { "title": "Content Calendar", "desc": "View your content schedule by date.\nWhy not select today?" },
"panel": { "title": "Content List", "desc": "Check the detailed content schedule here." }
},
"feedback": {
"complete": { "title": "Tutorial Complete 🎉", "desc": "You've completed the full flow from brand analysis to YouTube upload.\nTo replay the tutorial, click the button in the top right." },
"title": "Customer Feedback",
"desc": "Share any issues or suggestions to help us improve."
},
"restart": {
"title": "Restart Tutorial?",
"desc": "The tutorial will restart from the current screen.",
"confirm": "Start",
"cancel": "Cancel"
}
"inquiry": "Feedback",
"login": "Log In",
"settings": "Settings"
},
"footer": {
"company": "O2O Inc.",
@ -513,7 +440,9 @@
"chargeSubmit": "Submit",
"chargeSubmitting": "Submitting...",
"chargeSuccess": "Your top-up request has been submitted!",
"chargeConfirm": "OK"
"chargeConfirm": "OK",
"tabContents": "My Contents",
"tabCalendar": "Calendar"
},
"ado2Contents": {
"title": "ADO2 Contents",
@ -597,8 +526,29 @@
"scheduled": "Planned",
"failed": "Failed"
},
"months": ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],
"days": ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],
"months": [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec"
],
"days": [
"Sun",
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat"
],
"yearMonth": "{{month}} {{year}}",
"monthDay": "{{month}} {{day}}",
"loading": "Loading...",
@ -616,7 +566,8 @@
},
"loginPrompt": {
"title": "Login Required",
"loginBtn": "Login"
"loginBtn": "Login",
"description": "Sign in with Kakao in a few seconds\nto keep going."
},
"videoDetail": {
"dateFormat": "{{month}}/{{day}}/{{year}}",
@ -627,10 +578,6 @@
"share": "Share",
"copied": "Copied!",
"siteOverlayLabel": "View more",
"shareKakao": "KakaoTalk",
"shareFacebook": "Facebook",
"shareTwitter": "X (Twitter)",
"copyUrl": "Copy URL",
"commentsTitle": "Comments",
"commentPlaceholder": "Write a comment...",
"commentLoginRequired": "Please log in to write a comment",
@ -652,5 +599,115 @@
"autocompleteError": "No results found. Please check your input and try again.",
"autocompleteGeneralError": "An error occurred while retrieving business information. Please try again.",
"pageComingSoon": "{{page}} page is coming soon."
},
"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"
}
}
}
}

View File

@ -21,82 +21,9 @@
"credits": "보유 크레딧: {{count}}",
"loggingOut": "로그아웃 중...",
"logout": "로그아웃",
"tutorialRestart": "튜토리얼 다시 보기",
"tutorial": "튜토리얼",
"tutorialOn": "튜토리얼 켜기",
"tutorialOff": "튜토리얼 끄기",
"inquiry": "고객의견"
},
"tutorial": {
"skip": "건너뛰기",
"next": "다음",
"prev": "이전",
"finish": "완료",
"landing": {
"intro": { "title": "ADO2 튜토리얼 시작", "desc": "ADO2 사용 방법을 단계별로 안내해 드릴게요." },
"field": { "title": "입력하기", "desc": "네이버 지도 공유 URL을 붙여넣거나,\n업체명을 입력하면 자동으로 목록에서 선택할 수 있어요." },
"manual": { "title": "직접 입력", "desc": "업체명과 주소를 직접 입력해서 분석을 시작할 수도 있어요." },
"button": { "title": "브랜드 분석 시작", "desc": "버튼을 누르면 AI가 브랜드를 분석하기 시작해요." }
},
"asset": {
"image": { "title": "이미지 목록", "desc": "네이버 Place에서 가져 온 사진이에요. \n더보기를 누르면 나머지 사진도 볼 수 있고 X를 눌러 삭제 할 수 있어요." },
"upload": { "title": "이미지 추가", "desc": "이미지를 자유롭게 추가 할 수 있어요." },
"ratio": { "title": "영상 비율 선택", "desc": "생성 할 영상의 비율을 선택하세요." },
"next": { "title": "다음 단계로", "desc": "설정이 완료되면 다음 단계로 진행하세요." }
},
"sound": {
"genre": { "title": "장르 선택", "desc": "영상에 어울리는 음악 장르를 선택하세요.", "note": "배경음악은 이후 오픈 예정입니다." },
"language": { "title": "언어 선택", "desc": "음악의 언어를 선택할 수 있어요. \n이미 선택된 한국어로 진행해볼까요?" },
"generate": { "title": "사운드 생성", "desc": "버튼을 클릭하면 AI가 가사와 음악을 생성해요."},
"lyrics": { "title": "가사 생성 완료", "desc": "AI가 선택한 언어로 가사를 만들었어요.\n생성된 가사를 확인하세요." },
"lyricsWait": { "title": "음악 생성 중", "desc": "가사를 바탕으로 AI가 음악을 만들고 있어요.\n잠시만 기다려 주세요." },
"audioPlayer": { "title": "음악 미리 듣기", "desc": "음악 생성이 완료되었어요.\n재생 버튼을 눌러 생성된 음악을 들어보세요." },
"video": { "title": "영상 생성", "desc": "버튼을 클릭해서 영상 생성을 시작하세요." }
},
"completion": {
"contentInfo": { "title": "콘텐츠 정보", "desc": "콘텐츠의 파일명, 장르, 규격, 가사를 확인하세요." },
"generating": { "title": "영상 제작 중", "desc": "AI가 영상을 만들고 있어요. \n잠시만 기다려 주세요." },
"completion": { "title": "영상 완성!", "desc": "영상 제작이 완료되었어요. \n영상을 확인해 볼까요?" },
"myInfo": { "title": "소셜 계정 연동", "desc": "영상을 유튜브에 업로드하려면 내 정보에서 소셜 계정을 연동해야 해요. \n클릭해서 이동하세요." }
},
"myInfo": {
"myInfo": { "title": "내 정보", "desc": "내 정보에서는 소셜 연결과 연결된 계정을 확인 할 수 있어요." },
"connect": { "title": "연결하기", "desc": "YouTube 연결 버튼을 누르면 연결 페이지로 이동합니다.", "note": "Instagram 연결은 오픈 예정입니다." },
"connected": { "title": "연결 계정", "desc": "연결된 소셜 계정 목록이에요. \n연결 후 여기서 확인할 수 있어요." },
"ado2": { "title": "ADO2 콘텐츠", "desc": "이제 생성된 영상을 업로드할 수 있어요. \n클릭해서 이동하세요." }
},
"ado2": {
"list": { "title": "생성된 영상 목록", "desc": "ADO2에서 만든 영상들을 확인할 수 있어요." },
"download": { "title": "다운로드", "desc": "영상을 다운로드 할 수 있어요." },
"delete": { "title": "삭제", "desc": "필요없는 영상을 삭제할 수 있어요." },
"upload": { "title": "소셜 업로드", "desc": "선택해서 소셜미디어에 업로드하세요." }
},
"upload": {
"seo": { "title": "제목 및 설명", "desc": "영상의 제목과 설명을 AI가 만들고 있어요. 잠시만 기다려 주세요." },
"required": { "title": "필수 항목", "desc": "영상을 업로드 하기 전 *는 필수 항목으로 \n반드시 확인해 주세요." },
"schedule": { "title": "업로드 예약", "desc": "지금 게시하거나 원하는 시간에 예약할 수 있어요." },
"submit": { "title": "업로드 시작", "desc": "게시 버튼을 눌러 업로드를 시작하세요." }
},
"dashboard": {
"metrics": { "title": "핵심 지표", "desc": "조회수, 구독자 등 ADO2로 업로드한 콘텐츠의 주요 통계를 확인하세요." },
"chart": { "title": "성장 추이 차트", "desc": "기간별 성장 추이를 그래프로 확인할 수 있어요." },
"more": { "title": "더 많은 통계", "desc": "그 외에도 다양한 통계를 대시보드에서 한눈에 확인할 수 있어요." }
},
"contentCalendar": {
"grid": { "title": "콘텐츠 캘린더", "desc": "날짜별로 콘텐츠 스케줄을 확인할 수 있어요. \n오늘 날짜를 선택해 볼까요?" },
"panel": { "title": "콘텐츠 목록", "desc": "자세한 콘텐츠 스케줄을 확인 할수 있어요." }
},
"feedback": {
"complete": { "title": "튜토리얼 완료 🎉", "desc": "유튜브 업로드까지 모든 과정을 완료했어요. \n튜토리얼을 다시보고 싶다면 우측 상단의 버튼을 눌러주세요." },
"title": "고객의견",
"desc": "서비스 이용 중 불편한 점이나 개선 의견을 보내주세요."
},
"restart": {
"title": "튜토리얼을 다시 시작할까요?",
"desc": "현재 화면부터 튜토리얼이 다시 시작됩니다.",
"confirm": "시작하기",
"cancel": "취소"
}
"inquiry": "고객의견",
"login": "로그인",
"settings": "설정"
},
"footer": {
"company": "㈜에이아이오투오",
@ -182,7 +109,8 @@
"close": "닫기",
"doNotClose": "업로드가 진행 중입니다. 창을 닫지 마세요.",
"goToCalendar": "캘린더에서 확인",
"scheduleConflict": "예약 시간이 충돌하여 {{time}}으로 조정되었습니다." },
"scheduleConflict": "예약 시간이 충돌하여 {{time}}으로 조정되었습니다."
},
"landing": {
"hero": {
"searchTypeLabel": "업체명 | URL",
@ -512,7 +440,9 @@
"chargeSubmit": "요청하기",
"chargeSubmitting": "요청 중...",
"chargeSuccess": "충전 요청이 완료 되었습니다!",
"chargeConfirm": "확인"
"chargeConfirm": "확인",
"tabContents": "내 콘텐츠",
"tabCalendar": "콘텐츠 캘린더"
},
"ado2Contents": {
"title": "ADO2 콘텐츠",
@ -596,8 +526,29 @@
"scheduled": "예약",
"failed": "실패"
},
"months": ["1월","2월","3월","4월","5월","6월","7월","8월","9월","10월","11월","12월"],
"days": ["일","월","화","수","목","금","토"],
"months": [
"1월",
"2월",
"3월",
"4월",
"5월",
"6월",
"7월",
"8월",
"9월",
"10월",
"11월",
"12월"
],
"days": [
"일",
"월",
"화",
"수",
"목",
"금",
"토"
],
"yearMonth": "{{year}}년 {{month}}",
"monthDay": "{{month}} {{day}}일",
"loading": "불러오는 중...",
@ -615,7 +566,8 @@
},
"loginPrompt": {
"title": "로그인이 필요합니다.",
"loginBtn": "로그인"
"loginBtn": "로그인",
"description": "카카오 계정으로 간편하게 로그인하고\n이어서 진행해보세요."
},
"videoDetail": {
"dateFormat": "{{year}}년 {{month}}월 {{day}}일",
@ -626,10 +578,6 @@
"share": "공유하기",
"copied": "복사됨!",
"siteOverlayLabel": "자세히 보기",
"shareKakao": "카카오톡",
"shareFacebook": "페이스북",
"shareTwitter": "X (트위터)",
"copyUrl": "URL 복사",
"commentsTitle": "댓글",
"commentPlaceholder": "댓글을 입력하세요...",
"commentLoginRequired": "로그인 후 댓글을 작성할 수 있습니다",
@ -651,5 +599,213 @@
"autocompleteError": "검색 정보를 찾을 수 없습니다. 입력 정보를 다시 확인해주세요.",
"autocompleteGeneralError": "업체 정보 조회 중 오류가 발생했습니다. 다시 시도해주세요.",
"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": "직접 올린 레퍼런스는 권리를 가진 이미지만 사용하세요. 타인의 저작물을 올려 생긴 문제의 책임은 업로드한 사람에게 있습니다. 결과물은 권리 미확인으로 분류됩니다."
}
}
}

View File

@ -1,10 +1,7 @@
import React, { useEffect } from 'react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { CrawlingResponse, TargetPersona } from '../../types/api';
import { GeometricChart } from './GeometricChart';
import { useTutorial } from '../../components/Tutorial/useTutorial';
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
import TutorialOverlay from '../../components/Tutorial/TutorialOverlay';
interface AnalysisResultSectionProps {
onBack: () => void;
@ -14,19 +11,8 @@ interface AnalysisResultSectionProps {
const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, onGenerate, data }) => {
const { t } = useTranslation();
const tutorial = useTutorial();
const { processed_info, marketing_analysis } = data;
useEffect(() => {
const timer = setTimeout(() => {
if (!tutorial.hasSeen(TUTORIAL_KEYS.ANALYSIS)) {
tutorial.startTutorial(TUTORIAL_KEYS.ANALYSIS);
}
}, 600);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const brandIdentity = marketing_analysis?.brand_identity;
const marketPositioning = marketing_analysis?.market_positioning;
const targetPersonas = marketing_analysis?.target_persona || [];
@ -37,7 +23,6 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
const sortedSellingPoints = [...sellingPoints].sort((a, b) => b.score - a.score);
return (
<>
<div className="bi2-page">
{/* Header */}
<div className="bi2-header">
@ -189,18 +174,6 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
</button>
</div>
</div>
{tutorial.isActive && (
<TutorialOverlay
hints={tutorial.hints}
currentIndex={tutorial.currentHintIndex}
onNext={tutorial.nextHint}
onPrev={tutorial.prevHint}
onSkip={tutorial.skipTutorial}
groupProgress={tutorial.groupProgress}
/>
)}
</>
);
};

View File

@ -1,11 +1,12 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { getAllVideos, isLoggedIn } from '../../utils/api';
import { getAllVideos } from '../../utils/api';
import { VideoListItem } from '../../types/api';
import LoginPromptModal from '../../components/LoginPromptModal';
import VideoDetailModal from '../../components/VideoDetailModal';
import CitySelectModal from '../../components/CitySelectModal';
import ContentCardSocialActions from '../../components/ContentCardSocialActions';
import P2vViewerModal from '../../components/P2vViewerModal';
import SsulViewerModal from '../Ssulbox/SsulViewerModal';
interface ADO2ContentsPageProps {
onBack?: () => void;
@ -13,11 +14,15 @@ interface ADO2ContentsPageProps {
const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
const { t } = useTranslation();
const authed = isLoggedIn();
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null);
// 썰박스는 별도 뷰어를 쓴다 — VideoDetailModal 은 video_id 로 조회하므로
// 썰박스 id 를 넘기면 id 가 겹치는 다른 영상이 열린다.
const [selectedSsul, setSelectedSsul] = useState<VideoListItem | null>(null);
// P2V(무빙 포스터·스타일링)도 별도 뷰어 — 상세 API 없이 목록 데이터로 그린다
const [selectedP2v, setSelectedP2v] = useState<VideoListItem | null>(null);
const [videos, setVideos] = useState<VideoListItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(authed);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [hasNext, setHasNext] = useState(false);
@ -33,7 +38,6 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
const [showCityModal, setShowCityModal] = useState(false);
useEffect(() => {
if (!authed) return;
fetchVideos();
}, [page, sortBy, order, storeName, region]);
@ -141,14 +145,25 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
<>
<div className="ado2-contents-grid">
{videos.map((video) => (
// key·상세 열기 모두 (type, video_id) 로 다뤄야 한다 —
// video_id 는 종류별 독립 시퀀스라 값이 겹친다.
<div
key={video.video_id}
key={`${video.type}-${video.video_id}`}
className="ado2-content-card"
style={{ cursor: 'pointer' }}
onClick={() => handleCardClick(video.video_id)}
onClick={() => {
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"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && handleCardClick(video.video_id)}
onKeyDown={(e) => {
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">
{video.poster_url ? (
@ -188,17 +203,24 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
<div className="content-card-info">
<div className="content-card-text">
<h3 className="content-card-title">{video.store_name}</h3>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div className="content-card-meta">
<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
videoId={video.video_id}
contentType={video.type}
storeName={video.store_name}
region={video.region}
title={video.title}
description={video.description}
commentCount={video.comment_count ?? 0}
initialLikeCount={video.like_count ?? 0}
initialIsLiked={video.is_liked_by_me}
/>
)}
</div>
</div>
</div>
@ -226,9 +248,9 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
</>
)}
{!authed && (
<LoginPromptModal onClose={() => { window.location.href = '/'; }} />
)}
<SsulViewerModal item={selectedSsul} onClose={() => setSelectedSsul(null)} />
<P2vViewerModal item={selectedP2v} onClose={() => setSelectedP2v(null)} />
{selectedVideoId !== null && (
<VideoDetailModal

View File

@ -3,9 +3,7 @@ import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { generateVideo, waitForVideoComplete, getSubtitleStatus, waitForSubtitleComplete, trackFirstVideoCreated } from '../../utils/api';
import SocialPostingModal from '../../components/SocialPostingModal';
import { useTutorial } from '../../components/Tutorial/useTutorial';
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
import TutorialOverlay from '../../components/Tutorial/TutorialOverlay';
import CompletionView from '../../components/CompletionView';
interface CompletionContentProps {
onBack: () => void;
@ -46,22 +44,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
const hasStartedGeneration = useRef(false);
const displayIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const tutorial = useTutorial();
const tutorialIsActiveRef = useRef(tutorial.isActive);
tutorialIsActiveRef.current = tutorial.isActive;
// 영상 생성 중 튜토리얼 트리거 (생성 상태 안내 -> 콘텐츠 정보 -> 내 정보 이동)
useEffect(() => {
const isComplete = videoStatus === 'complete';
const isProcessing = videoStatus === 'generating' || videoStatus === 'polling';
if (isProcessing && !tutorialIsActiveRef.current && !tutorial.hasSeen(TUTORIAL_KEYS.GENERATING)) {
tutorial.startTutorial(TUTORIAL_KEYS.GENERATING);
} else if (isComplete && !tutorialIsActiveRef.current && !tutorial.hasSeen(TUTORIAL_KEYS.COMPLETION)) {
tutorial.startTutorial(TUTORIAL_KEYS.COMPLETION);
}
}, [videoStatus]);
// 소셜 미디어 포스팅 모달
const [showSocialModal, setShowSocialModal] = useState(false);
const [videoDbId, setVideoDbId] = useState<number | null>(null);
@ -410,92 +392,13 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
return `${businessName}.mp4`;
};
return (
<main className="comp2-page">
{/* <div className="comp2-header">
<button onClick={onBack} className="comp2-back-btn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 18l-6-6 6-6" />
</svg>
<span>{t('completion.back')}</span>
</button>
</div> */}
<div className="comp2-title-row">
<h1 className="comp2-page-title">{t('completion.contentComplete')}</h1>
<p className="comp2-page-subtitle">{t('completion.contentCompleteDesc')}</p>
</div>
<div className="comp2-container">
<div className="comp2-grid">
{/* 왼쪽: 영상 */}
<div className="comp2-video-section">
<div className="comp2-video-wrapper">
{isLoading ? (
<div className="comp2-video-loading">
<div className="loading-spinner">
<div className="loading-ring"></div>
<div className="loading-dot">
<div className="loading-dot-inner"></div>
</div>
</div>
<p className="comp2-loading-text">{statusMessage}</p>
<div className="loading-progress-wrapper">
<div className="loading-progress-bar">
<div
className="loading-progress-fill"
style={{ width: `${displayProgress}%` }}
/>
</div>
<span className="loading-progress-text">{Math.floor(displayProgress)}%</span>
</div>
</div>
) : videoStatus === 'error' ? (
<div className="comp2-video-error">
<svg className="comp2-error-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</svg>
<p className="comp2-error-text">{errorMessage}</p>
<button onClick={handleRetry} className="comp2-retry-btn">
{t('completion.retry')}
</button>
</div>
) : videoUrl ? (
<video
src={videoUrl}
className="comp2-video-player"
controls
playsInline
/>
) : (
<div className="comp2-video-placeholder"></div>
)}
</div>
</div>
{/* 오른쪽: 콘텐츠 정보 */}
<div className="comp2-info-section">
<div className="comp2-info-header">
<span className="comp2-info-label">{t('completion.contentInfo')}</span>
</div>
<div className="comp2-file-info">
<h3 className="comp2-filename">{getFileName()}</h3>
{/* <p className="comp2-filesize">19.6MB</p> */}
</div>
<div className="comp2-info-content">
<div className="comp2-meta-grid">
<div className="comp2-meta-item">
<span className="comp2-meta-label">{t('completion.genre')} : {songCompletionData?.genre || 'K-POP'}</span>
</div>
<div className="comp2-meta-divider"></div>
<div className="comp2-meta-item">
<span className="comp2-meta-label">{t('completion.resolution')} : {getVideoResolution()}</span>
</div>
<div className="comp2-meta-divider"></div>
<div className="comp2-lyrics-section">
<span className="comp2-meta-label">{t('completion.lyrics')}</span>
{(() => {
/**
* .
*
* `[Verse]` ,
* . .
*/
const renderLyrics = () => {
if (!songCompletionData) {
return <p className="comp2-lyrics-text">{t('completion.sampleLyrics')}</p>;
}
@ -516,7 +419,9 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
});
return (
<div className="comp2-lyrics-paragraphs">
{sections.filter(s => s.lines.length > 0).map((section, i) => (
{sections
.filter((s) => s.lines.length > 0)
.map((section, i) => (
<div key={i} className="comp2-lyrics-para-section">
{section.tag && <span className="comp2-lyrics-tag">{section.tag}</span>}
<p className="comp2-lyrics-text">{section.lines.join('\n')}</p>
@ -524,37 +429,47 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
))}
</div>
);
})()}
</div>
</div>
</div>
{/* 하단 버튼 */}
<div className="comp2-buttons">
<button
onClick={handleDownload}
disabled={videoStatus !== 'complete' || !videoUrl || isDownloading}
className="comp2-btn comp2-btn-secondary"
>
{isDownloading ? t('completion.downloading') : t('completion.download')}
</button>
<button
onClick={handleOpenSocialConnect}
disabled={videoStatus !== 'complete' || !videoDbId}
className="comp2-btn comp2-btn-primary"
>
{t('completion.uploadToSocial')}
</button>
</div>
</div>
</div>
</div>
};
return (
<CompletionView
videoState={
isLoading
? 'loading'
: videoStatus === 'error'
? 'error'
: videoUrl
? 'ready'
: 'empty'
}
videoUrl={videoUrl}
loadingText={statusMessage}
progress={displayProgress}
errorMessage={errorMessage}
onRetry={handleRetry}
fileName={getFileName()}
metaLines={[
`${t('completion.genre')} : ${songCompletionData?.genre || 'K-POP'}`,
`${t('completion.resolution')} : ${getVideoResolution()}`,
]}
detailLabel={t('completion.lyrics')}
detail={renderLyrics()}
onDownload={handleDownload}
downloadDisabled={videoStatus !== 'complete' || !videoUrl || isDownloading}
downloadLabel={isDownloading ? t('completion.downloading') : t('completion.download')}
onUpload={handleOpenSocialConnect}
uploadDisabled={videoStatus !== 'complete' || !videoDbId}
>
{/* 소셜 미디어 포스팅 모달 (기존 SocialPostingModal 컴포넌트 사용) */}
<SocialPostingModal
isOpen={showSocialModal}
onClose={handleCloseSocialConnect}
onGoToCalendar={onGoToCalendar}
video={videoUrl && videoDbId ? {
video={
videoUrl && videoDbId
? {
// ADO2 파이프라인 완료 화면이므로 항상 영상이다
type: 'video',
video_id: videoDbId,
store_name: songCompletionData?.businessName || '',
region: '',
@ -564,20 +479,11 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
like_count: 0,
comment_count: 0,
is_liked_by_me: false,
} : null}
}
: null
}
/>
{tutorial.isActive && (
<TutorialOverlay
hints={tutorial.hints}
currentIndex={tutorial.currentHintIndex}
onNext={tutorial.nextHint}
onPrev={tutorial.prevHint}
onSkip={tutorial.skipTutorial}
groupProgress={tutorial.groupProgress}
/>
)}
</main>
</CompletionView>
);
};

View File

@ -67,16 +67,17 @@ const statusLabelKey = (status: UploadStatus) => {
interface ContentCalendarContentProps {
onNavigate?: (id: string) => void;
/** 내 정보 탭 안에 임베드될 때: 탭 라벨과 중복되는 자체 제목을 숨기고 여백을 줄인다 */
embedded?: boolean;
}
const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavigate }) => {
const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavigate, embedded }) => {
const { t } = useTranslation();
const today = new Date();
const [year, setYear] = useState(today.getFullYear());
const [month, setMonth] = useState(today.getMonth());
const [activeTab, setActiveTab] = useState<TabType>('전체');
const [isMobile, setIsMobile] = useState(window.innerWidth < MOBILE_BREAKPOINT);
const [sheetOpen, setSheetOpen] = useState(false);
// allItems: 캘린더 도트용 (전체)
// panelItems: 오른쪽 패널용 (탭 필터)
const [allItems, setAllItems] = useState<UploadHistoryItem[]>([]);
@ -194,11 +195,16 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
setTimeout(() => {
const el = dateRefs.current[dateKey];
const panel = panelRef.current;
if (el && panel) {
if (!el) return;
// 패널이 자체 스크롤을 가진 경우(데스크톱 사이드 패널)만 내부 스크롤.
// 모바일은 목록이 캘린더 아래에 그대로 펼쳐지므로 페이지 스크롤로 이동한다.
if (panel && panel.scrollHeight > panel.clientHeight) {
const panelTop = panel.getBoundingClientRect().top;
const elTop = el.getBoundingClientRect().top;
const offset = elTop - panelTop + panel.scrollTop - 12; // 12px 여백
panel.scrollTo({ top: offset, behavior: 'smooth' });
} else {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 50);
};
@ -396,7 +402,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
{summary.completed > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: '#1ba64f', flexShrink: 0 }} />
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 11, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}>
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 12, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}>
{t('contentCalendar.completedCount', { count: summary.completed })}
</span>
</div>
@ -404,7 +410,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
{summary.scheduled > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: '#2563eb', flexShrink: 0 }} />
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 11, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}>
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 12, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}>
{t('contentCalendar.scheduledCount', { count: summary.scheduled })}
</span>
</div>
@ -412,7 +418,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
{summary.failed > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: '#e15252', flexShrink: 0 }} />
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 11, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}>
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 12, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}>
{t('contentCalendar.failedCount', { count: summary.failed })}
</span>
</div>
@ -428,7 +434,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
// ── 탭 바 ────────────────────────────────────────────────────
const renderTabs = () => (
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'center', width: '100%' }}>
{tabs.map(tab => (
<button
key={tab}
@ -535,13 +541,13 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
backgroundColor: statusColor(item.status as UploadStatus), flexShrink: 0,
}} />
<span style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 13,
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 14,
color: statusColor(item.status as UploadStatus), lineHeight: 1,
}}>
{t(statusLabelKey(item.status as UploadStatus))}
</span>
<span style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 400, fontSize: 13,
fontFamily: 'Pretendard, sans-serif', fontWeight: 400, fontSize: 14,
color: '#9bcacc', lineHeight: 1, marginLeft: 'auto',
}}>
{formatTime(item)}
@ -589,7 +595,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
style={{
height: 30, padding: '0 12px', borderRadius: 8,
border: '1px solid #379599', backgroundColor: 'transparent',
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 13,
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 14,
color: '#9bcacc', cursor: 'pointer',
}}
>
@ -602,7 +608,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
style={{
height: 30, padding: '0 12px', borderRadius: 8,
border: 'none', backgroundColor: '#a65eff',
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 13,
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 14,
color: '#ffffff', cursor: 'pointer',
}}
>
@ -616,10 +622,11 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
};
// ── 패널 콘텐츠 ────────────────────────────────────────────
const renderPanelContent = () => {
// inline=true (모바일): 자체 스크롤 없이 페이지 흐름에 그대로 펼친다
const renderPanelContent = (inline = false) => {
if (panelLoading) {
return (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: inline ? 120 : undefined }}>
<span style={{ color: '#9bcacc', fontFamily: 'Pretendard, sans-serif', fontSize: 14 }}>{t('contentCalendar.loading')}</span>
</div>
);
@ -629,6 +636,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
<div style={{
flex: 1, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', gap: 24, padding: 16,
minHeight: inline ? 160 : undefined,
}}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center', textAlign: 'center' }}>
<p style={{
@ -662,7 +670,11 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
<div
ref={panelRef}
className="calendar-panel-scroll"
style={{ flex: 1, overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 20, maxHeight: 700 }}
style={{
flex: 1, padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 20,
overflowY: inline ? 'visible' : 'auto',
maxHeight: inline ? undefined : 700,
}}
>
{sortedDateKeys.map(dateKey => (
<div
@ -691,9 +703,10 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
return (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
paddingTop: 24, paddingBottom: 20, width: '100%',
paddingTop: embedded ? 0 : 24, paddingBottom: 20, width: '100%',
boxSizing: 'border-box', position: 'relative',
}}>
{!embedded && (
<div style={{ width: '100%', paddingBottom: 24, paddingLeft: 20, paddingRight: 20, boxSizing: 'border-box' }}>
<p style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 30,
@ -702,6 +715,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
{t('contentCalendar.title')}
</p>
</div>
)}
<div style={{
display: 'flex', flexDirection: 'column', gap: 16,
@ -716,34 +730,24 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
{renderCalendarGrid()}
</div>
{/* 바텀시트 */}
<div style={{ position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 10, display: 'flex', flexDirection: 'column' }}>
{/* ( ).
없음: */}
<div style={{ width: '100%', marginTop: 16, boxSizing: 'border-box' }}>
<div style={{
backgroundColor: '#01393b',
borderTop: '1px solid #046266',
borderRadius: '20px 20px 0 0',
border: '1px solid #046266',
borderRadius: 20,
overflow: 'hidden',
}}>
<div
onClick={() => setSheetOpen(o => !o)}
style={{ display: 'flex', justifyContent: 'center', padding: '8px 0', cursor: 'pointer' }}
>
<div style={{ width: 49, height: 4, backgroundColor: '#9bcacc', borderRadius: 999 }} />
</div>
<div style={{
borderBottom: sheetOpen ? '1px solid #046266' : 'none',
borderBottom: '1px solid #046266',
height: 50, display: 'flex', alignItems: 'center', padding: '0 16px',
}}>
{renderTabs()}
</div>
{sheetOpen && (
<div style={{ height: 400, overflowY: 'auto', backgroundColor: '#01393b', display: 'flex', flexDirection: 'column' }}>
{renderPanelContent()}
</div>
)}
{renderPanelContent(true)}
</div>
</div>
<div style={{ height: sheetOpen ? 516 : 116 }} />
</div>
);
}
@ -754,11 +758,12 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
return (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
padding: '32px',
padding: embedded ? '0' : '32px',
width: '100%',
minWidth: 1400,
height: '100%', boxSizing: 'border-box',
height: embedded ? 'auto' : '100%', boxSizing: 'border-box',
}}>
{!embedded && (
<div style={{ width: '100%', maxWidth: 1440, paddingBottom: 32, flexShrink: 0 }}>
<p style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 30,
@ -767,13 +772,14 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
{t('contentCalendar.title')}
</p>
</div>
)}
<div style={{
display: 'grid',
gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) 340px',
gap: 16,
width: '100%', maxWidth: 1440,
flex: 1, minHeight: 0,
flex: 1, minHeight: embedded ? 'calc(100vh - 220px)' : 0,
}}>
{/* 캘린더 영역 */}
<div className="calendar-grid-area" style={{

View File

@ -740,7 +740,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
borderRadius: '8px',
color: 'rgba(255,255,255,0.85)',
padding: '5px 10px',
fontSize: '13px',
fontSize: '14px',
cursor: 'pointer',
minWidth: '160px',
}}
@ -788,7 +788,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
display: 'flex', alignItems: 'center', gap: '8px',
padding: '8px 12px',
cursor: 'pointer',
fontSize: '13px',
fontSize: '14px',
background: acc.id === selectedAccountId ? 'rgba(255,255,255,0.08)' : 'transparent',
color: 'rgba(255,255,255,0.85)',
}}
@ -940,8 +940,8 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
onClick={() => setShowMockData(prev => !prev)}
style={{
position: 'fixed',
bottom: '24px',
right: '24px',
top: '10px',
right: '5px',
zIndex: 9999,
display: 'flex',
alignItems: 'center',

View File

@ -2,31 +2,24 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Sidebar from '../../components/Sidebar';
import BottomNav from '../../components/BottomNav';
import LoginPromptModal from '../../components/LoginPromptModal';
import AssetManagementContent from './AssetManagementContent';
import SoundStudioContent from './SoundStudioContent';
import CompletionContent from './CompletionContent';
import DashboardContent from './DashboardContent';
import BusinessSettingsContent from './BusinessSettingsContent';
import UrlInputContent from './UrlInputContent';
import ADO2ContentsPage from './ADO2ContentsPage';
import MyContentsPage from './MyContentsPage';
import MyInfoContent from './MyInfoContent';
import ContentCalendarContent from './ContentCalendarContent';
import LoadingSection from '../Analysis/LoadingSection';
import AnalysisResultSection from '../Analysis/AnalysisResultSection';
import { ImageItem, type ImageListItem, CrawlingResponse, UserMeResponse } from '../../types/api';
import { crawlUrl, autocomplete, marketingAnalysis, AutocompleteRequest, getUserMe, getUserCredits, clearTokens } from '../../utils/api';
import { useTutorial } from '../../components/Tutorial/useTutorial';
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
import TutorialOverlay, { TutorialRestartPopup } from '../../components/Tutorial/TutorialOverlay';
import { crawlUrl, autocomplete, marketingAnalysis, AutocompleteRequest, getUserMe, getUserCredits, clearTokens, getActiveSsulTask, waitForSsulComplete, isLoggedIn } from '../../utils/api';
import WizardStepper from '../../components/WizardStepper';
import { createPreviewThumbnail } from '../../utils/imageCompression.ts';
import { NAV, GATED_ITEMS, sanitizeActiveItem, MyInfoTab } from '../../components/navItems';
import { useIsMobile } from '../../utils/useIsMobile';
import { createPreviewThumbnail } from '../../utils/imageCompression';
const WIZARD_STEP_KEY = 'castad_wizard_step';
const ACTIVE_ITEM_KEY = 'castad_active_item';
const SONG_TASK_ID_KEY = 'castad_song_task_id';
const IMAGE_TASK_ID_KEY = 'castad_image_task_id';
const ANALYSIS_DATA_KEY = 'castad_analysis_data';
import { saveSearchHistory } from '../../components/SearchHistory/useSearchHistory';
/** 파일 이미지의 썸네일 objectURL 을 일괄 해제한다. preview 가 null 인 항목은 건너뛴다. */
@ -37,22 +30,72 @@ const revokeAllPreviews = (items: ImageItem[]) => {
}
});
};
import { Pipeline, isPipeline } from '../../components/PipelineTabs';
import { Scen } from '../Ssulbox/ssulData';
import SsulMakingContent, { SsulJob } from '../Ssulbox/SsulMakingContent';
import SsulResultContent from '../Ssulbox/SsulResultContent';
import PosterMakingContent from '../Poster/PosterMakingContent';
import PosterReviewContent from '../Poster/PosterReviewContent';
import PosterResultContent from '../Poster/PosterResultContent';
import { useP2vJob } from '../../hooks/useP2vJob';
import { deletePosterJob, forcePosterJob, isPastReview, retryPosterJob } from '../../utils/p2vApi';
import {
K,
clearProjectStorage,
clearSessionStorage,
} from '../../utils/storageKeys';
// 다른 컴포넌트에서 사용하는 storage key들 (초기화용)
const SONG_GENERATION_KEY = 'castad_song_generation';
const VIDEO_GENERATION_KEY = 'castad_video_generation';
const VIDEO_COMPLETE_KEY = 'castad_video_complete'; // 완료된 영상 정보
const WIZARD_STEP_KEY = K.WIZARD_STEP;
const ACTIVE_ITEM_KEY = K.ACTIVE_ITEM;
const SONG_TASK_ID_KEY = K.SONG_TASK_ID;
const IMAGE_TASK_ID_KEY = K.IMAGE_TASK_ID;
const ANALYSIS_DATA_KEY = K.ANALYSIS_DATA;
// 모든 프로젝트 관련 localStorage 초기화
const clearAllProjectStorage = () => {
localStorage.removeItem(WIZARD_STEP_KEY);
localStorage.removeItem(SONG_TASK_ID_KEY);
localStorage.removeItem(IMAGE_TASK_ID_KEY);
localStorage.removeItem(SONG_GENERATION_KEY);
localStorage.removeItem(VIDEO_GENERATION_KEY);
localStorage.removeItem(VIDEO_COMPLETE_KEY);
/**
* CTA 1 .
* initializeOnNewSession
* localStorage sessionStorage .
*/
const PENDING_PIPELINE_KEY = 'castad_pending_pipeline';
/** 비로그인 게이트 클릭 시 "로그인 후 열어줄 메뉴"를 남기는 1회성 프리셋. 카카오 왕복을 견뎌야 해서 sessionStorage */
const LOGIN_NEXT_ITEM_KEY = 'castad_login_next_item';
/** 썰박스 진행 단계. ADO2 의 wizardStep(-2~3) 과 별도 축이다 */
type SsulStep = 'entry' | 'making' | 'result';
/** 썰박스 스텝퍼 라벨(i18n 키)과 0-based 인덱스 */
const SSUL_STEP_LABELS = [
'ssulbox.steps.pick',
'ssulbox.steps.making',
'ssulbox.steps.done',
] as const;
const SSUL_STEP_INDEX: Record<SsulStep, number> = {
entry: 0,
making: 1,
result: 2,
};
/**
* (F1) . status state
* ( ).
*/
type PosterStep = 'entry' | 'making' | 'review' | 'result';
/**
* . 5 ** **
* (··) (···)
* .
*/
const POSTER_STEP_LABELS = [
'poster.steps.upload',
'poster.steps.analyze',
'poster.steps.review',
'poster.steps.render',
'poster.steps.done',
] as const;
interface BusinessInfo {
customer_name: string;
region: string;
@ -60,7 +103,7 @@ interface BusinessInfo {
}
interface GenerationFlowProps {
onHome: () => void;
onHome?: () => void;
initialActiveItem?: string;
initialImageList?: ImageListItem[];
businessInfo?: BusinessInfo;
@ -77,7 +120,7 @@ interface GenerationFlowProps {
const GenerationFlow: React.FC<GenerationFlowProps> = ({
onHome,
initialActiveItem = '대시보드',
initialActiveItem = NAV.HOME,
initialImageList = [],
businessInfo,
initialAnalysisData
@ -101,7 +144,20 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
}
};
const [activeItem, setActiveItem] = useState(savedActiveItem || initialActiveItem);
const isGuest = !isLoggedIn();
/** activeItem 초기화: 로그인 복귀 메뉴(최우선) → 저장된 값 → 초기값, 마지막에 구 이름 마이그레이션+게스트 강등 */
const getInitialNav = () => {
const loginNext = sessionStorage.getItem(LOGIN_NEXT_ITEM_KEY);
if (loginNext && isLoggedIn()) {
sessionStorage.removeItem(LOGIN_NEXT_ITEM_KEY);
return sanitizeActiveItem(loginNext, isGuest);
}
return sanitizeActiveItem(savedActiveItem || initialActiveItem, isGuest);
};
const initialNav = getInitialNav();
const [activeItem, setActiveItem] = useState(initialNav.item);
// 초기 위저드 단계 결정
const getInitialWizardStep = (): number => {
@ -121,6 +177,77 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
};
const [wizardStep, setWizardStep] = useState(getInitialWizardStep());
/**
* .
*
* 2 getInitialWizardStep() "분석 데이터가 있으면 step 1부터"
* . ADO2
* 'ssul' , ADO2 .
*/
const getInitialPipeline = (): Pipeline => {
// 1) 랜딩 CTA 프리셋 (1회성). 카카오 로그인 리다이렉트 왕복을 견뎌야 해서 sessionStorage
const pending = sessionStorage.getItem(PENDING_PIPELINE_KEY);
if (isPipeline(pending)) {
sessionStorage.removeItem(PENDING_PIPELINE_KEY);
return pending;
}
// 2) 분석 데이터가 있으면 ADO2 흐름 한가운데이므로 강제
if (initialAnalysisData || savedAnalysisData) return 'ado2';
// 3) 저장된 선택
const saved = localStorage.getItem(K.PIPELINE);
return isPipeline(saved) ? saved : 'ado2';
};
const [pipeline, setPipeline] = useState<Pipeline>(getInitialPipeline);
/** 썰박스 전용 진행 축. wizardStep 숫자 축은 확장하지 않는다 */
const [ssulStep, setSsulStep] = useState<SsulStep>('entry');
const [ssulJob, setSsulJob] = useState<SsulJob | null>(null);
/** 언마운트·로그아웃·재시도 시 진행 중인 폴링을 멈추는 플래그 */
const ssulCancelledRef = useRef(false);
/**
* (F1) . status
* P2V ,
* .
*/
const [posterJobId, setPosterJobId] = useState<string | null>(
() => localStorage.getItem(K.POSTER_JOB_ID),
);
const [posterBusy, setPosterBusy] = useState(false);
const { job: posterJob, notFound: posterNotFound, refresh: refreshPosterJob } =
useP2vJob('f1', pipeline === 'poster' ? posterJobId : null);
const changePipeline = (next: Pipeline) => {
setPipeline(next);
localStorage.setItem(K.PIPELINE, next);
};
/** 포스터 진행 단계 — 잡 status 에서 파생 */
const posterStep: PosterStep = !posterJobId
? 'entry'
: posterJob?.status === 'awaiting_review'
? 'review'
: posterJob?.status === 'done'
? 'result'
: 'making'; // queued · running · failed · 조회 전(null)
/** 스텝퍼 인덱스. 검수 전 running(1) 과 승인 후 running(3) 을 구분한다 */
const posterStepIndex = posterStep === 'entry'
? 0
: posterStep === 'review'
? 2
: posterStep === 'result'
? 4
: isPastReview(posterJob) ? 3 : 1;
/** 진입 화면(탭이 보이는 단계) 여부. 파이프라인마다 진입 조건이 다르다 */
const isEntry =
pipeline === 'ado2' ? wizardStep === -2
: pipeline === 'ssul' ? ssulStep === 'entry'
: pipeline === 'poster' ? posterStep === 'entry'
// 스타일링은 전 과정이 진입 화면 안에서 끝난다 (위저드 없음)
: true;
const [songTaskId, setSongTaskId] = useState<string | null>(savedSongTaskId);
const [imageTaskId, setImageTaskId] = useState<string | null>(savedImageTaskId);
const [videoGenerationStatus, setVideoGenerationStatus] = useState<'idle' | 'generating' | 'complete' | 'error'>('idle');
@ -130,10 +257,10 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
const [isAnalysisComplete, setIsAnalysisComplete] = useState(false);
const [userInfo, setUserInfo] = useState<UserMeResponse | null>(null);
const [credits, setCredits] = useState<number | null>(null);
const [myInfoInitialTab, setMyInfoInitialTab] = useState<'basic' | 'payment' | 'business' | undefined>(undefined);
const tutorial = useTutorial();
const [myInfoInitialTab, setMyInfoInitialTab] = useState<MyInfoTab | undefined>(initialNav.myInfoTab);
const refreshCredits = useCallback(async () => {
if (!isLoggedIn()) return;
try {
const { credits } = await getUserCredits();
setCredits(credits);
@ -149,8 +276,9 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
}
}, [wizardStep]);
// 로그인 직후 사용자 정보 + 크레딧 조회
// 로그인 직후 사용자 정보 + 크레딧 조회 (비로그인이면 요청 자체를 하지 않는다 — 401 → 강제 리다이렉트 방지)
useEffect(() => {
if (!isLoggedIn()) return;
const fetchUserInfo = async () => {
try {
const [data, creditsData] = await Promise.all([getUserMe(), getUserCredits()]);
@ -166,10 +294,9 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
// 로그아웃 핸들러
const handleLogout = () => {
clearTokens();
localStorage.removeItem('castad_view_mode');
localStorage.removeItem('castad_analysis_data');
localStorage.removeItem(WIZARD_STEP_KEY);
localStorage.removeItem(ACTIVE_ITEM_KEY);
// 세션 키를 전부 지운다. 이전에는 view_mode/analysis/wizard/active_item 만 지워
// 생성 중이던 song·video 상태가 남았고, 다른 계정으로 로그인하면 그대로 복원됐다.
clearSessionStorage();
window.location.href = '/';
};
@ -245,9 +372,9 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
setImageList(prev => [...newImages, ...prev]);
};
// 홈 버튼(로고) 클릭 시 모든 상태 초기화 후 홈으로 이동
// 홈 버튼(로고) 클릭 시 프로젝트 상태를 비우고 ADO2 콘텐츠로 돌아간다.
const handleHome = () => {
clearAllProjectStorage();
clearProjectStorage();
localStorage.removeItem(ANALYSIS_DATA_KEY);
setWizardStep(-2);
setSongTaskId(null);
@ -255,7 +382,11 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
setAnalysisData(null);
revokeAllPreviews(imageListRef.current);
setImageList([]);
if (onHome) {
onHome();
return;
}
setActiveItem(NAV.HOME);
};
// 위저드 단계 이동
@ -396,51 +527,216 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
localStorage.setItem(ACTIVE_ITEM_KEY, activeItem);
}, [activeItem]);
// wizardStep 변경 시 튜토리얼 트리거 (사이드바 메뉴 화면에서는 제외)
const SIDEBAR_ITEMS = ['대시보드', 'ADO2 콘텐츠', '내 정보', '콘텐츠 캘린더'];
useEffect(() => {
if (tutorial.isActive) tutorial.skipTutorial();
if (SIDEBAR_ITEMS.includes(activeItem)) return;
const timer = setTimeout(() => {
if (wizardStep === 1 && !tutorial.hasSeen(TUTORIAL_KEYS.ASSET)) {
tutorial.startTutorial(TUTORIAL_KEYS.ASSET);
} else if (wizardStep === 2 && !tutorial.hasSeen(TUTORIAL_KEYS.SOUND)) {
tutorial.startTutorial(TUTORIAL_KEYS.SOUND);
}
}, 600);
return () => clearTimeout(timer);
}, [wizardStep, activeItem]);
const [loginGateOpen, setLoginGateOpen] = useState(false);
// activeItem 변경 시 튜토리얼 트리거
useEffect(() => {
if (tutorial.isActive) tutorial.skipTutorial();
if (activeItem === '내 정보' && !tutorial.hasSeen(TUTORIAL_KEYS.MY_INFO)) {
tutorial.startTutorial(TUTORIAL_KEYS.MY_INFO);
} else if (activeItem === '내 콘텐츠' && !tutorial.hasSeen(TUTORIAL_KEYS.ADO2_CONTENTS)) {
tutorial.startTutorial(TUTORIAL_KEYS.ADO2_CONTENTS);
} else if (activeItem === '대시보드' && !tutorial.hasSeen(TUTORIAL_KEYS.DASHBOARD)) {
tutorial.startTutorial(TUTORIAL_KEYS.DASHBOARD);
} else if (activeItem === '콘텐츠 캘린더' && !tutorial.hasSeen(TUTORIAL_KEYS.CONTENT_CALENDAR)) {
tutorial.startTutorial(TUTORIAL_KEYS.CONTENT_CALENDAR);
/** 내 정보의 특정 탭으로 딥링크 이동. 비로그인이면 로그인 관문을 띄운다 */
const goToMyInfoTab = (tab: MyInfoTab) => {
if (!isLoggedIn()) {
sessionStorage.setItem(LOGIN_NEXT_ITEM_KEY, NAV.MY);
setLoginGateOpen(true);
return;
}
}, [activeItem]);
setMyInfoInitialTab(tab);
setActiveItem(NAV.MY);
};
// 결제 탭으로 바로 이동하는 핸들러 (크레딧 충전하기 버튼용)
const handleGoToPayment = () => {
setMyInfoInitialTab('payment');
setActiveItem('내 정보');
goToMyInfoTab('payment');
};
/**
* .
*
* GenerationFlow 이유: SsulMakingContent
* .
* .
*/
const watchSsulTask = useCallback(
(taskId: number, scenario: Scen) => {
ssulCancelledRef.current = false;
setSsulJob({ taskId, scenario, status: 'queued', step: 0, error: null });
waitForSsulComplete(
taskId,
(task) => {
if (ssulCancelledRef.current) return;
setSsulJob({
taskId,
scenario,
status: task.status,
step: task.step,
error: task.error,
});
},
() => ssulCancelledRef.current
)
.then(() => {
if (ssulCancelledRef.current) return;
localStorage.removeItem(K.SSUL_TASK_ID);
setSsulStep('result');
})
.catch((e: Error) => {
if (ssulCancelledRef.current || e.message === 'CANCELLED') return;
localStorage.removeItem(K.SSUL_TASK_ID);
setSsulJob((prev) =>
prev ? { ...prev, status: 'error', error: e.message } : prev
);
})
.finally(() => {
if (ssulCancelledRef.current) return;
// 완료든 실패든 잔액을 갱신한다. 실패 시 백엔드가 환불하므로
// 갱신하지 않으면 사이드바 잔액이 낡은 값으로 굳는다.
refreshCredits();
});
},
[refreshCredits]
);
/** 생성 요청이 접수됐을 때 */
const handleSsulJobStarted = (taskId: number, scenario: Scen) => {
localStorage.setItem(K.SSUL_TASK_ID, String(taskId));
setSsulStep('making');
// 생성 시작 시점에 크레딧이 선차감되므로 사이드바 잔액을 즉시 갱신
refreshCredits();
watchSsulTask(taskId, scenario);
};
/** 실패 후 처음부터 */
const handleSsulRetry = () => {
ssulCancelledRef.current = true;
localStorage.removeItem(K.SSUL_TASK_ID);
setSsulJob(null);
setSsulStep('entry');
};
// ── 포스터 영상 (P2V F1) ─────────────────────────────────────────────
const handlePosterJobStarted = (jobId: string) => {
localStorage.setItem(K.POSTER_JOB_ID, jobId);
setPosterJobId(jobId);
};
/** 잡을 버리고 업로드 화면으로. 서버 잡은 남겨둔다(아카이브·디버깅에 쓰인다) */
const handlePosterNew = () => {
localStorage.removeItem(K.POSTER_JOB_ID);
setPosterJobId(null);
};
/**
* .
* i2v ( ·) .
*/
const handlePosterRetry = async () => {
if (!posterJobId || posterBusy) return;
setPosterBusy(true);
try {
await retryPosterJob(posterJobId);
refreshPosterJob();
} catch {
// 재시도 요청 자체가 실패해도 잡 상태는 그대로다 — 폴링 결과가 계속 화면을 그린다
} finally {
setPosterBusy(false);
}
};
/**
* 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) => {
if (!isLoggedIn() && GATED_ITEMS.includes(item)) {
sessionStorage.setItem(LOGIN_NEXT_ITEM_KEY, item);
setLoginGateOpen(true);
return;
}
setMyInfoInitialTab(undefined);
if (item === '새 프로젝트 만들기') {
if (item === NAV.NEW) {
// 기존 프로젝트 데이터 초기화
clearAllProjectStorage();
clearProjectStorage();
localStorage.removeItem(ANALYSIS_DATA_KEY);
// URL 입력 단계로 명시적 저장 (다음 진입 시 올바른 단계 복원)
localStorage.setItem(WIZARD_STEP_KEY, '-2');
setWizardStep(-2);
// 파이프라인도 기본값으로 되돌린다. clearProjectStorage() 가 K.PIPELINE 을
// 지우므로 state 만 남으면 저장소와 어긋난다.
setPipeline('ado2');
setSsulStep('entry');
// clearProjectStorage() 가 POSTER_JOB_ID 를 지우므로 state 도 함께 비운다
setPosterJobId(null);
setSongTaskId(null);
setImageTaskId(null);
setAnalysisData(null);
@ -455,17 +751,65 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
// 새 프로젝트 만들기 - 단계별 컨텐츠 렌더링
const renderWizardContent = () => {
switch (wizardStep) {
case -2:
// URL 입력 단계
// 진입 화면은 두 파이프라인이 공유하므로 switch 밖에서 먼저 처리한다.
// ADO2 는 wizardStep -2, 썰박스는 ssulStep 'entry' 가 여기 해당한다.
if (isEntry) {
return (
<UrlInputContent
pipeline={pipeline}
onPipelineChange={changePipeline}
onAnalyze={handleStartAnalysis}
onAutocomplete={handleAutocomplete}
onManualInput={handleManualInput}
error={analysisError}
onSsulJobStarted={handleSsulJobStarted}
onPosterJobStarted={handlePosterJobStarted}
onGoToPayment={handleGoToPayment}
/>
);
}
// 썰박스는 자체 축으로 진행한다
if (pipeline === 'ssul') {
switch (ssulStep) {
case 'making':
return (
<SsulMakingContent job={ssulJob} onRetry={handleSsulRetry} />
);
case 'result':
return ssulJob ? (
<SsulResultContent taskId={ssulJob.taskId} scenario={ssulJob.scenario} />
) : null;
default:
return null;
}
}
// 포스터 영상도 자체 축으로 진행한다 (단계는 잡 status 파생)
if (pipeline === 'poster') {
switch (posterStep) {
case 'review':
return posterJob ? (
<PosterReviewContent job={posterJob} onApproved={refreshPosterJob} />
) : null;
case 'result':
return posterJob ? (
<PosterResultContent job={posterJob} onNew={handlePosterNew} />
) : null;
default:
return (
<PosterMakingContent
job={posterJob}
onRetry={handlePosterRetry}
onForce={handlePosterForce}
onDiscard={handlePosterDiscard}
busy={posterBusy}
/>
);
}
}
switch (wizardStep) {
case -1:
// 로딩 단계
return (
@ -493,8 +837,8 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
onBack={() => goToWizardStep(0)}
onNext={(taskId: string) => {
// Clear video generation state to start fresh
localStorage.removeItem(VIDEO_GENERATION_KEY);
localStorage.removeItem(VIDEO_COMPLETE_KEY);
localStorage.removeItem(K.VIDEO_GENERATION);
localStorage.removeItem(K.VIDEO_COMPLETE);
setVideoGenerationStatus('idle');
setVideoGenerationProgress(0);
@ -523,13 +867,6 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
videoGenerationStatus={videoGenerationStatus}
videoGenerationProgress={videoGenerationProgress}
onGoToPayment={handleGoToPayment}
onStatusChange={(status: string) => {
if (status === 'generating_song' && !tutorial.hasSeen(TUTORIAL_KEYS.SOUND_LYRICS)) {
setTimeout(() => tutorial.startTutorial(TUTORIAL_KEYS.SOUND_LYRICS), 400);
} else if (status === 'complete' && !tutorial.hasSeen(TUTORIAL_KEYS.SOUND_AUDIO)) {
setTimeout(() => tutorial.startTutorial(TUTORIAL_KEYS.SOUND_AUDIO), 400);
}
}}
/>
);
case 3:
@ -538,8 +875,8 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
onBack={() => {
// 뒤로가기 시 비디오 생성 상태 초기화
// 새 노래 생성 후 다시 영상 생성할 수 있도록
localStorage.removeItem(VIDEO_GENERATION_KEY);
localStorage.removeItem(VIDEO_COMPLETE_KEY);
localStorage.removeItem(K.VIDEO_GENERATION);
localStorage.removeItem(K.VIDEO_COMPLETE);
setVideoGenerationStatus('idle');
setVideoGenerationProgress(0);
goToWizardStep(2);
@ -547,7 +884,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
songTaskId={songTaskId}
onVideoStatusChange={setVideoGenerationStatus}
onVideoProgressChange={setVideoGenerationProgress}
onGoToCalendar={() => handleNavigate('콘텐츠 캘린더')}
onGoToCalendar={() => goToMyInfoTab('calendar')}
/>
);
default:
@ -557,28 +894,45 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
const renderContent = () => {
switch (activeItem) {
case '대시보드':
return <DashboardContent onNavigate={handleNavigate} />;
case '비즈니스 설정':
return <BusinessSettingsContent />;
case 'ADO2 콘텐츠':
return <ADO2ContentsPage />;
case '내 콘텐츠':
case NAV.DASHBOARD:
return (
<MyContentsPage
onBack={() => setActiveItem('새 프로젝트 만들기')}
onNavigate={handleNavigate}
<DashboardContent
onNavigate={(id) => (id === NAV.MY ? goToMyInfoTab('business') : handleNavigate(id))}
/>
);
case '콘텐츠 캘린더':
return <ContentCalendarContent onNavigate={handleNavigate} />;
case '내 정보':
return <MyInfoContent initialTab={myInfoInitialTab} />;
case '새 프로젝트 만들기':
// 로딩(-1), URL 입력(-2)은 스텝퍼 없이 전체 화면으로 표시
if (wizardStep === -1 || wizardStep === -2) {
case NAV.HOME:
return <ADO2ContentsPage />;
case NAV.MY:
return <MyInfoContent initialTab={myInfoInitialTab} onNavigate={handleNavigate} />;
case NAV.NEW:
// 진입 화면(탭)과 ADO2 로딩(-1)은 스텝퍼 없이 전체 화면으로 표시
if (isEntry || (pipeline === 'ado2' && wizardStep === -1)) {
return renderWizardContent();
}
// 썰박스는 자체 3단계 스텝퍼를 쓴다
if (pipeline === 'ssul') {
return (
<div className="wizard-page-container">
<WizardStepper
steps={SSUL_STEP_LABELS.map((key) => t(key))}
currentStep={SSUL_STEP_INDEX[ssulStep]}
/>
{renderWizardContent()}
</div>
);
}
// 포스터 영상은 검수 게이트를 포함한 5단계
if (pipeline === 'poster') {
return (
<div className="wizard-page-container">
<WizardStepper
steps={POSTER_STEP_LABELS.map((key) => t(key))}
currentStep={posterStepIndex}
/>
{renderWizardContent()}
</div>
);
}
// 브랜드 분석(0)은 전체 화면 스크롤이지만 스텝퍼는 표시
if (wizardStep === 0) {
return (
@ -603,88 +957,75 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
}
};
// 로딩 화면에서만 Sidebar 숨김
// 로딩 화면에서만 Sidebar/BottomNav 숨김
const showSidebar = wizardStep !== -1;
// 브랜드 분석(0)일 때는 전체 페이지 스크롤
const isBrandAnalysis = activeItem === '새 프로젝트 만들기' && wizardStep === 0;
// 스크롤이 필요한 페이지: 대시보드, 비즈니스 설정, 브랜드 분석(0), ADO2 콘텐츠, 내 정보
const needsScroll = activeItem === '대시보드' || activeItem === '비즈니스 설정' || activeItem === 'ADO2 콘텐츠' || activeItem === '내 콘텐츠' || activeItem === '내 정보' || activeItem === '콘텐츠 캘린더' || isBrandAnalysis;
const isBrandAnalysis = activeItem === NAV.NEW && wizardStep === 0;
// 위저드 진행 중에도 탭바를 노출한다. 각 단계의 고정 CTA 는 모바일에서
// 탭바 높이만큼 띄우도록 CSS(@media max-width:767px)에서 처리한다.
const showBottomNav = showSidebar;
// 스크롤이 필요한 페이지: 새 프로젝트 만들기(위저드) 외 전부 + 브랜드 분석(0)
const needsScroll = activeItem !== NAV.NEW || isBrandAnalysis;
// 현재 화면에 맞는 튜토리얼 키 반환 (없으면 null)
const getCurrentTutorialKey = (): string | null => {
if (activeItem === '내 정보') return TUTORIAL_KEYS.MY_INFO;
if (activeItem === '내 콘텐츠') return TUTORIAL_KEYS.ADO2_CONTENTS;
if (activeItem === '대시보드') return TUTORIAL_KEYS.DASHBOARD;
if (activeItem === '콘텐츠 캘린더') return TUTORIAL_KEYS.CONTENT_CALENDAR;
if (activeItem === '새 프로젝트 만들기') {
if (wizardStep === 1) return TUTORIAL_KEYS.ASSET;
if (wizardStep === 2) return TUTORIAL_KEYS.SOUND;
}
return null;
};
const isMobile = useIsMobile();
const tutorialUI = (
<>
{getCurrentTutorialKey() && (
<button
className={`tutorial-toggle-fab ${tutorial.isEnabled ? 'active' : ''}`}
onClick={() => tutorial.toggleTutorial(getCurrentTutorialKey())}
title={tutorial.isEnabled ? t('sidebar.tutorialOff') : t('sidebar.tutorialOn')}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<circle cx="12" cy="12" r="10"/>
<path d="M12 8v4l3 3"/>
</svg>
<span>{t('sidebar.tutorial')}</span>
<span className={`tutorial-toggle-badge ${tutorial.isEnabled ? 'on' : 'off'}`}>
{tutorial.isEnabled ? 'ON' : 'OFF'}
</span>
</button>
)}
{tutorial.isActive && (
<TutorialOverlay
hints={tutorial.hints}
currentIndex={tutorial.currentHintIndex}
onNext={tutorial.nextHint}
onPrev={tutorial.prevHint}
onSkip={tutorial.skipTutorial}
groupProgress={tutorial.groupProgress}
const nav = isMobile
? (showBottomNav && (
<BottomNav
activeItem={activeItem}
onNavigate={handleNavigate}
userInfo={userInfo}
onLogout={handleLogout}
credits={credits}
isGuest={isGuest}
onLoginClick={() => setLoginGateOpen(true)}
/>
)}
{tutorial.isRestartPopupVisible && (
<TutorialRestartPopup
onConfirm={tutorial.confirmRestart}
onCancel={tutorial.cancelRestart}
))
: (showSidebar && (
<Sidebar
activeItem={activeItem}
onNavigate={handleNavigate}
onLogoClick={handleHome}
userInfo={userInfo}
onLogout={handleLogout}
credits={credits}
isGuest={isGuest}
onLoginClick={() => setLoginGateOpen(true)}
/>
));
const loginGateModal = loginGateOpen && (
<LoginPromptModal
onClose={() => {
sessionStorage.removeItem(LOGIN_NEXT_ITEM_KEY);
setLoginGateOpen(false);
}}
/>
)}
</>
);
// 브랜드 분석일 때는 전체 화면 스크롤
if (isBrandAnalysis) {
return (
<div className="analysis-page-wrapper">
{showSidebar && (
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} credits={credits} tutorialAvailable={!!getCurrentTutorialKey()} tutorialEnabled={tutorial.isEnabled} onToggleTutorial={() => tutorial.toggleTutorial(getCurrentTutorialKey())} />
)}
<main className="analysis-page-main">
{!isMobile && nav}
<main className={`analysis-page-main ${showBottomNav ? 'has-bottom-nav' : ''}`}>
{renderContent()}
</main>
{tutorialUI}
{isMobile && nav}
{loginGateModal}
</div>
);
}
return (
<div className={`flex w-full bg-[#002224] text-white ${needsScroll ? 'min-h-[100dvh]' : 'h-[100dvh] overflow-hidden'}`}>
{showSidebar && (
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} credits={credits} tutorialAvailable={!!getCurrentTutorialKey()} tutorialEnabled={tutorial.isEnabled} onToggleTutorial={() => tutorial.toggleTutorial(getCurrentTutorialKey())} />
)}
{tutorialUI}
<div className={`flex-1 relative pl-0 md:pl-0 bg-[#001a1c] ${needsScroll ? 'overflow-auto' : 'h-full overflow-hidden'}`}>
{!isMobile && nav}
<div className={`flex-1 relative pl-0 md:pl-0 bg-[#001a1c] ${needsScroll ? 'overflow-auto' : 'h-full overflow-hidden'} ${showBottomNav ? 'has-bottom-nav' : ''}`}>
{renderContent()}
</div>
{isMobile && nav}
{loginGateModal}
</div>
);
};

View File

@ -1,13 +1,15 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { getVideosList, deleteVideo } from '../../utils/api';
import { getVideosList, deleteVideo, deleteSsulContent } from '../../utils/api';
import { deletePosterJob, deleteStylingJob } from '../../utils/p2vApi';
import { VideoListItem } from '../../types/api';
import SocialPostingModal from '../../components/SocialPostingModal';
import VideoDetailModal from '../../components/VideoDetailModal';
import ContentCardSocialActions from '../../components/ContentCardSocialActions';
import { useTutorial } from '../../components/Tutorial/useTutorial';
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
import P2vViewerModal from '../../components/P2vViewerModal';
import SsulViewerModal from '../Ssulbox/SsulViewerModal';
import { useOverlayClose } from '../../hooks/useOverlayClose';
const VideoPreviewCard: React.FC<{ src: string; className?: string }> = ({ src, className }) => {
const videoRef = useRef<HTMLVideoElement>(null);
@ -89,11 +91,12 @@ const VideoPreviewCard: React.FC<{ src: string; className?: string }> = ({ src,
interface MyContentsPageProps {
onBack?: () => void;
onNavigate?: (item: string) => void;
/** 내 정보 탭 안에 임베드될 때: 탭 라벨과 중복되는 자체 제목을 숨긴다 */
embedded?: boolean;
}
const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate, embedded }) => {
const { t } = useTranslation();
const tutorial = useTutorial();
const [videos, setVideos] = useState<VideoListItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
@ -103,11 +106,16 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
const [hasPrev, setHasPrev] = useState(false);
const [totalPages, setTotalPages] = useState(1);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<number | null>(null);
const [deleteTarget, setDeleteTarget] = useState<VideoListItem | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [uploadModalOpen, setUploadModalOpen] = useState(false);
const [uploadTargetVideo, setUploadTargetVideo] = useState<VideoListItem | null>(null);
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null);
// 썰박스는 별도 뷰어를 쓴다 — VideoDetailModal 은 video_id 로 조회하므로
// 썰박스 id 를 넘기면 id 가 겹치는 다른 영상이 열린다.
const [selectedSsul, setSelectedSsul] = useState<VideoListItem | null>(null);
// P2V(무빙 포스터·스타일링)도 별도 뷰어 — 상세 API 없이 목록 데이터로 그린다
const [selectedP2v, setSelectedP2v] = useState<VideoListItem | null>(null);
const pageSize = 12;
@ -115,12 +123,6 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
fetchVideos();
}, [page]);
useEffect(() => {
if (!tutorial.hasSeen(TUTORIAL_KEYS.ADO2_CONTENTS)) {
tutorial.startTutorial(TUTORIAL_KEYS.ADO2_CONTENTS);
}
}, []);
const fetchVideos = async () => {
setLoading(true);
setError(null);
@ -145,9 +147,7 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}.${month}.${day}${hours}:${minutes}`;
return `${year}.${month}.${day}`;
};
const formatTitle = (storeName: string, dateString: string) => {
@ -177,8 +177,10 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
}
};
const handleDeleteClick = (videoId: number) => {
setDeleteTargetId(videoId);
// 아이템 전체를 저장한다 — id 만 들면 종류를 잃어 어느 삭제 API 를 부를지 알 수 없다
// (video.id 와 ssul_content.id 는 값이 겹치는 독립 시퀀스).
const handleDeleteClick = (video: VideoListItem) => {
setDeleteTarget(video);
setDeleteModalOpen(true);
};
@ -194,18 +196,34 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
const handleDeleteCancel = () => {
setDeleteModalOpen(false);
setDeleteTargetId(null);
setDeleteTarget(null);
};
const deleteModalOverlayHandlers = useOverlayClose(handleDeleteCancel);
const handleDeleteConfirm = async () => {
if (!deleteTargetId) return;
if (!deleteTarget) return;
setIsDeleting(true);
try {
await deleteVideo(deleteTargetId);
setVideos(prev => prev.filter(video => video.video_id !== deleteTargetId));
// 종류별 삭제 API 가 다르다. deleteVideo 는 Video.id 로 지우므로
// 썰박스·P2V id 를 넘기면 id 가 겹치는 엉뚱한 ADO2 영상이 삭제된다.
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));
setDeleteModalOpen(false);
setDeleteTargetId(null);
setDeleteTarget(null);
} catch (err) {
console.error('Delete failed:', err);
alert(t('ado2Contents.deleteFailed'));
@ -215,10 +233,10 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
};
return (
<div className="ado2-contents-page">
<div className={`ado2-contents-page ${embedded ? 'ado2-contents-page--embedded' : ''}`}>
{/* Header */}
<div className="ado2-contents-header">
<h1 className="ado2-contents-title">{t('sidebar.myContents')}</h1>
{!embedded && <h1 className="ado2-contents-title">{t('sidebar.myContents')}</h1>}
<span className="ado2-contents-count">{t('ado2Contents.totalCount', { count: total })}</span>
</div>
@ -241,12 +259,18 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
<>
<div className="ado2-contents-grid">
{videos.map((video) => (
<div key={video.task_id} className="ado2-content-card">
// key 는 (type, video_id) 쌍이어야 한다 — task_id 는 썰박스에 없고(빈 문자열)
// video_id 는 종류별 독립 시퀀스라 값이 겹친다.
<div key={`${video.type}-${video.video_id}`} className="ado2-content-card">
{/* Video Thumbnail */}
<div
className="content-card-thumbnail"
style={{ cursor: 'pointer' }}
onClick={() => setSelectedVideoId(video.video_id)}
onClick={() => {
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 ? (
<img
@ -283,28 +307,39 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
<h3 className="content-card-title">
{formatTitle(video.store_name, video.created_at)}
</h3>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div className="content-card-meta">
<p className="content-card-date">
{formatDate(video.created_at)}
</p>
{/* · API type .
contentType id .
P2V · ( ) . */}
{(video.type === 'video' || video.type === 'ssul') && (
<ContentCardSocialActions
videoId={video.video_id}
contentType={video.type}
storeName={video.store_name}
region={video.region}
title={video.title}
description={video.description}
commentCount={video.comment_count ?? 0}
initialLikeCount={video.like_count ?? 0}
initialIsLiked={video.is_liked_by_me}
/>
)}
</div>
</div>
{/* Action Buttons */}
<div className="content-card-actions">
{/* SocialPostingModal content_type
(social_upload ).
P2V video·ssul SNS . */}
<button
className="content-download-btn"
onClick={() => handleUploadClick(video)}
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">
<path d="M10 13V3M10 3l-4 4M10 3l4 4"/>
@ -323,9 +358,11 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
<path d="M3 15v2h14v-2"/>
</svg>
</button>
{/* API (handleDeleteConfirm).
. */}
<button
className="content-delete-btn"
onClick={() => handleDeleteClick(video.video_id)}
onClick={() => handleDeleteClick(video)}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M3 5h14M8 5V3h4v2M6 5v12h8V5"/>
@ -362,7 +399,7 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
{/* 삭제 확인 모달 */}
{deleteModalOpen && (
<div className="delete-modal-overlay" onClick={handleDeleteCancel}>
<div className="delete-modal-overlay" {...deleteModalOverlayHandlers}>
<div className="delete-modal" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<h2 className="delete-modal-title">{t('ado2Contents.deleteConfirmTitle')}</h2>
<p className="delete-modal-description">{t('ado2Contents.deleteConfirmDesc')}</p>
@ -393,6 +430,10 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
/>
)}
<SsulViewerModal item={selectedSsul} onClose={() => setSelectedSsul(null)} />
<P2vViewerModal item={selectedP2v} onClose={() => setSelectedP2v(null)} />
{/* 소셜 미디어 업로드 모달 */}
<SocialPostingModal
isOpen={uploadModalOpen}

View File

@ -3,16 +3,21 @@ import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { getSocialAccounts, getYouTubeConnectUrl, disconnectSocialAccount, TokenExpiredError, handleSocialReconnect, getUserCredits, requestCreditCharge } from '../../utils/api';
import { SocialAccount } from '../../types/api';
import { MyInfoTab } from '../../components/navItems';
import MyContentsPage from './MyContentsPage';
import ContentCalendarContent from './ContentCalendarContent';
import { useOverlayClose } from '../../hooks/useOverlayClose';
type TabType = 'basic' | 'payment' | 'business';
type TabType = MyInfoTab;
interface MyInfoContentProps {
initialTab?: TabType;
onNavigate?: (id: string) => void;
}
const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab, onNavigate }) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<TabType>(initialTab || 'business');
const [activeTab, setActiveTab] = useState<TabType>(initialTab || 'contents');
const [businessUrl, setBusinessUrl] = useState('');
const [socialAccounts, setSocialAccounts] = useState<SocialAccount[]>([]);
const [isLoadingAccounts, setIsLoadingAccounts] = useState(false);
@ -113,20 +118,33 @@ const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
const hasConnectedAccounts = youtubeAccounts.length > 0 || instagramAccounts.length > 0;
const tabs = [
// { id: 'basic' as TabType, label: t('myInfo.tabBasic') },
{ id: 'contents' as TabType, label: t('myInfo.tabContents') },
{ id: 'calendar' as TabType, label: t('myInfo.tabCalendar') },
{ id: 'payment' as TabType, label: t('myInfo.tabPayment') },
{ id: 'business' as TabType, label: t('myInfo.tabBusiness') },
];
const isWideTab = activeTab === 'contents' || activeTab === 'calendar';
const handleChildNavigate = (id: string) => {
if (id === '내 정보') { setActiveTab('business'); return; }
if (id === '콘텐츠 캘린더') { setActiveTab('calendar'); return; }
if (id === '내 콘텐츠') { setActiveTab('contents'); return; }
onNavigate?.(id);
};
const closeChargePopup = () => { setShowChargePopup(false); setChargeSuccess(false); setChargeAmount(''); setChargeNote(''); };
const chargePopupOverlayHandlers = useOverlayClose(closeChargePopup);
return (
<>
{showChargePopup && (
<div className="myinfo-popup-overlay" onClick={() => { setShowChargePopup(false); setChargeSuccess(false); setChargeAmount(''); setChargeNote(''); }}>
<div className="myinfo-popup-overlay" {...chargePopupOverlayHandlers}>
<div className="myinfo-popup" onClick={e => e.stopPropagation()}>
{chargeSuccess ? (
<>
<p className="myinfo-popup-message">{t('myInfo.chargeSuccess')}</p>
<button className="myinfo-popup-close" onClick={() => { setShowChargePopup(false); setChargeSuccess(false); setChargeAmount(''); setChargeNote(''); }}>{t('myInfo.chargeConfirm')}</button>
<button className="myinfo-popup-close" onClick={closeChargePopup}>{t('myInfo.chargeConfirm')}</button>
</>
) : (
<>
@ -187,7 +205,7 @@ const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
</div>
</div>
)}
<main className="myinfo-page">
<main className={`myinfo-page ${isWideTab ? 'myinfo-page--wide' : ''}`}>
<h1 className="myinfo-title">{t('myInfo.title')}</h1>
{/* 탭 네비게이션 */}
@ -205,11 +223,13 @@ const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
{/* 탭 컨텐츠 */}
<div className="myinfo-content">
{/* {activeTab === 'basic' && (
<div className="myinfo-section">
<p className="myinfo-placeholder">{t('myInfo.basicPlaceholder')}</p>
</div>
)} */}
{activeTab === 'contents' && (
<MyContentsPage onNavigate={handleChildNavigate} embedded />
)}
{activeTab === 'calendar' && (
<ContentCalendarContent onNavigate={handleChildNavigate} embedded />
)}
{activeTab === 'payment' && (
<div className="myinfo-section">
@ -278,13 +298,13 @@ const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
<span>{isConnecting === 'youtube' ? t('myInfo.youtubeConnecting') : t('myInfo.youtubeConnect')}</span>
</button>
<button
{/* <button
disabled
className="myinfo-social-btn disabled"
>
<img src="/assets/images/social-instagram.png" alt="Instagram" className="myinfo-social-btn-icon" />
<span>{t('myInfo.instagramConnect')}</span>
</button>
</button> */}
</div>
{/* 연결된 계정 목록 */}

View File

@ -1,28 +1,87 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { AutocompleteRequest } from '../../utils/api';
import SearchInputForm, { SearchType } from '../../components/SearchInputForm';
import PipelineTabs, { Pipeline } from '../../components/PipelineTabs';
import SsulCreateForm from '../Ssulbox/SsulCreateForm';
import { Scen } from '../Ssulbox/ssulData';
import PosterCreateForm from '../Poster/PosterCreateForm';
import StylingContent from '../Poster/StylingContent';
interface UrlInputContentProps {
/** 현재 선택된 파이프라인. 소유자는 GenerationFlow (분기·리셋 때문) */
pipeline: Pipeline;
onPipelineChange: (pipeline: Pipeline) => void;
onAnalyze: (value: string, type?: SearchType) => void;
onAutocomplete?: (data: AutocompleteRequest) => void;
onManualInput?: (businessName: string, address: string, category: string, officialSiteUrl?: string) => void;
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 }) => {
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>
/**
* .
*
* ** ** .
*/
const UrlInputContent: React.FC<UrlInputContentProps> = ({
pipeline,
onPipelineChange,
onAnalyze,
onAutocomplete,
onManualInput,
error,
onSsulJobStarted,
onPosterJobStarted,
onGoToPayment,
}) => {
const { t } = useTranslation();
// 스타일링은 한 화면짜리 도구라 진입 폼보다 넓은 판이 필요하다 (generation-flow.css 참조)
const wide = pipeline === 'styling';
const renderForm = () => {
switch (pipeline) {
case 'ado2':
return (
<SearchInputForm
onAnalyze={onAnalyze}
onAutocomplete={onAutocomplete}
onManualInput={onManualInput}
error={error}
/>
);
case 'ssul':
return <SsulCreateForm onSubmitted={onSsulJobStarted} onNeedCredit={onGoToPayment} />;
case 'poster':
return <PosterCreateForm onSubmitted={onPosterJobStarted} />;
case 'styling':
// 스타일링은 위저드가 아니라 도구다 — 진입 화면 안에서 처음부터 끝까지 끝난다
return <StylingContent />;
default:
return null;
}
};
return (
<div className="url-input-container">
<div className={`url-input-content${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>
);

View File

@ -1,51 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import Footer from '../../components/Footer';
interface DisplaySectionProps {
onStartClick?: () => void;
}
const DisplaySection: React.FC<DisplaySectionProps> = ({ onStartClick }) => {
const { t } = useTranslation();
// YouTube Shorts 영상 ID들
const videos = [
{ id: 1, videoId: 'M3iuPZ59X1I' },
{ id: 2, videoId: 'JxWQxELDHSs' },
{ id: 3, videoId: 'c2ZdwhaB7S4' },
];
return (
<div className="display-section">
<div className="content-safe-area">
{/* Main visual frames container */}
<div className="display-frames">
{videos.map((video, index) => (
<div
key={video.id}
className={`display-frame ${index === 2 ? 'display-frame-hidden-mobile' : ''}`}
>
<iframe
src={`https://www.youtube.com/embed/${video.videoId}?autoplay=1&mute=1&loop=1&playlist=${video.videoId}&controls=0&showinfo=0&rel=0&modestbranding=1&playsinline=1`}
title={`YouTube Shorts ${video.id}`}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="display-video"
/>
</div>
))}
</div>
{/* Action Button */}
<button onClick={onStartClick} className="display-button">
{t('landing.display.startButton')}
</button>
</div>
<Footer />
</div>
);
};
export default DisplaySection;

View File

@ -1,202 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { AutocompleteRequest, isLoggedIn } from '../../utils/api';
import { useTutorial } from '../../components/Tutorial/useTutorial';
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
import TutorialOverlay from '../../components/Tutorial/TutorialOverlay';
import BusinessNameInputModal from '../../components/BusinessNameInputModal';
import LoginPromptModal from '../../components/LoginPromptModal';
import SearchInputForm from '../../components/SearchInputForm';
import { SearchType } from '../../components/SearchInputForm';
// Orb configuration with movement zones to prevent overlap
interface OrbConfig {
id: string;
size: number;
initialX: number;
initialY: number;
color: string;
minX: number;
maxX: number;
minY: number;
maxY: number;
}
const orbConfigs: OrbConfig[] = [
{ id: 'orb-1', size: 500, initialX: -10, initialY: -10, color: 'radial-gradient(circle, #C490FF 20%, #AE72F9 50%, rgba(94, 235, 195, 0.4) 100%)', minX: -30, maxX: 35, minY: -30, maxY: 40 },
{ id: 'orb-2', size: 480, initialX: 70, initialY: -5, color: 'radial-gradient(circle, #5EEBC3 25%, rgba(174, 114, 249, 0.6) 70%, rgba(139, 92, 246, 0.3) 100%)', minX: 50, maxX: 110, minY: -30, maxY: 40 },
{ id: 'orb-3', size: 420, initialX: 5, initialY: 35, color: 'radial-gradient(circle, rgba(148, 251, 224, 0.8) 15%, #AE72F9 55%, rgba(94, 235, 195, 0.3) 100%)', minX: -20, maxX: 45, minY: 20, maxY: 65 },
{ id: 'orb-4', size: 400, initialX: 60, initialY: 40, color: 'radial-gradient(circle, rgba(220, 200, 255, 0.95) 10%, rgba(148, 251, 224, 0.85) 45%, rgba(174, 114, 249, 0.5) 100%)', minX: 40, maxX: 100, minY: 25, maxY: 70 },
{ id: 'orb-5', size: 520, initialX: -8, initialY: 65, color: 'radial-gradient(circle, #B794F6 30%, rgba(148, 251, 224, 0.6) 65%, rgba(174, 114, 249, 0.3) 100%)', minX: -30, maxX: 40, minY: 50, maxY: 110 },
{ id: 'orb-6', size: 450, initialX: 65, initialY: 70, color: 'radial-gradient(circle, rgba(180, 255, 235, 0.95) 15%, rgba(200, 160, 255, 0.8) 50%, rgba(94, 235, 195, 0.45) 100%)', minX: 45, maxX: 110, minY: 55, maxY: 110 },
];
interface HeroSectionProps {
onAnalyze?: (value: string, type?: SearchType) => void;
onAutocomplete?: (data: AutocompleteRequest) => void;
onManualInput?: (businessName: string, address: string, category: string, officialSiteUrl?: string) => void;
onNext?: () => void;
error?: string | null;
scrollProgress?: number;
}
const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onManualInput, onNext, error: externalError, scrollProgress = 0 }) => {
const { t } = useTranslation();
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
const [isLoginPromptOpen, setIsLoginPromptOpen] = useState(false);
const orbRefs = useRef<(HTMLDivElement | null)[]>([]);
const animationRefs = useRef<number[]>([]);
const tutorial = useTutorial();
// 첫 방문 시 랜딩 튜토리얼 시작
useEffect(() => {
if (!tutorial.hasSeen(TUTORIAL_KEYS.LANDING)) {
const timer = setTimeout(() => {
tutorial.startTutorial(TUTORIAL_KEYS.LANDING);
}, 800);
return () => clearTimeout(timer);
}
}, []);
// Orb 랜덤 이동 애니메이션
useEffect(() => {
const moveOrb = (orb: HTMLDivElement, index: number) => {
const config = orbConfigs[index];
let currentX = config.initialX;
let currentY = config.initialY;
let targetX = currentX;
let targetY = currentY;
let scale = 1;
let targetScale = 1;
let isFirstMove = true;
const generateNewTarget = () => {
const rangeX = config.maxX - config.minX;
const rangeY = config.maxY - config.minY;
if (isFirstMove) {
const smallRangeX = rangeX * 0.3;
const smallRangeY = rangeY * 0.3;
targetX = currentX + (Math.random() - 0.5) * smallRangeX;
targetY = currentY + (Math.random() - 0.5) * smallRangeY;
targetX = Math.max(config.minX, Math.min(config.maxX, targetX));
targetY = Math.max(config.minY, Math.min(config.maxY, targetY));
isFirstMove = false;
} else {
targetX = config.minX + Math.random() * rangeX;
targetY = config.minY + Math.random() * rangeY;
}
targetScale = 0.9 + Math.random() * 0.2;
};
const animate = () => {
const speed = 0.003;
currentX += (targetX - currentX) * speed;
currentY += (targetY - currentY) * speed;
scale += (targetScale - scale) * speed;
orb.style.left = `${currentX}%`;
orb.style.top = `${currentY}%`;
orb.style.transform = `scale(${scale})`;
const distance = Math.sqrt(Math.pow(targetX - currentX, 2) + Math.pow(targetY - currentY, 2));
if (distance < 1) generateNewTarget();
animationRefs.current[index] = requestAnimationFrame(animate);
};
generateNewTarget();
animate();
};
orbRefs.current.forEach((orb, index) => {
if (orb) moveOrb(orb, index);
});
return () => {
animationRefs.current.forEach(id => cancelAnimationFrame(id));
};
}, []);
return (
<div className="hero-section">
{/* Animated background orbs */}
<div
className="hero-bg-orbs"
style={{ opacity: Math.max(0, 1 - scrollProgress * 3) }}
>
{orbConfigs.map((config, index) => (
<div
key={config.id}
ref={el => { orbRefs.current[index] = el; }}
className="hero-orb-random"
style={{
width: `${config.size}px`,
height: `${config.size}px`,
background: config.color,
left: `${config.initialX}%`,
top: `${config.initialY}%`,
}}
/>
))}
</div>
<div className="hero-content">
<img
src="/assets/images/ADO2_with Slogan_white.svg"
alt="ADO2"
className="hero-logo"
/>
<SearchInputForm
onAnalyze={onAnalyze}
onAutocomplete={onAutocomplete}
onManualInput={onManualInput}
onManualButtonClick={() => {
if (isLoggedIn()) {
setIsManualModalOpen(true);
} else {
setIsLoginPromptOpen(true);
}
}}
error={externalError || null}
/>
</div>
{/* Footer Indicator */}
<button onClick={onNext} className="scroll-indicator">
<span className="scroll-indicator-text">{t('landing.hero.scrollMore')}</span>
<div className="scroll-indicator-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round">
<path d="M7 10l5 5 5-5" />
</svg>
</div>
</button>
{tutorial.isActive && !isManualModalOpen && (
<TutorialOverlay
hints={tutorial.hints}
currentIndex={tutorial.currentHintIndex}
onNext={tutorial.nextHint}
onPrev={tutorial.prevHint}
onSkip={tutorial.skipTutorial}
groupProgress={tutorial.groupProgress}
/>
)}
{isManualModalOpen && (
<BusinessNameInputModal
onClose={() => setIsManualModalOpen(false)}
onSubmit={(businessName, address, category, officialSiteUrl) => {
if (tutorial.isActive) tutorial.nextHint();
setIsManualModalOpen(false);
onManualInput?.(businessName, address, category, officialSiteUrl);
}}
/>
)}
{isLoginPromptOpen && (
<LoginPromptModal onClose={() => setIsLoginPromptOpen(false)} />
)}
</div>
);
};
export default HeroSection;

View File

@ -1,70 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
interface WelcomeSectionProps {
onStartClick?: () => void;
onNext?: () => void;
}
const WelcomeSection: React.FC<WelcomeSectionProps> = () => {
const { t } = useTranslation();
const features = [
{
id: 1,
title: t('landing.welcome.feature1Title'),
description: t('landing.welcome.feature1Desc'),
iconBg: '#9BCACC',
icon: '/assets/images/icon-analysis.svg'
},
{
id: 2,
title: t('landing.welcome.feature2Title'),
description: t('landing.welcome.feature2Desc'),
iconBg: '#DFC7FD',
icon: '/assets/images/icon-content.svg'
},
{
id: 3,
title: t('landing.welcome.feature3Title'),
description: t('landing.welcome.feature3Desc'),
iconBg: '#D4FDF3',
icon: '/assets/images/icon-deploy.svg'
},
];
return (
<div className="welcome-section">
<div className="welcome-content">
{/* Star Icon - Top Center */}
<div className="welcome-star">
<img src="/assets/images/star-icon.svg" alt="star" />
</div>
{/* Header */}
<div className="welcome-header">
<h2 className="welcome-title">{t('landing.welcome.title')}</h2>
<p className="welcome-subtitle">{t('landing.welcome.subtitle')}</p>
</div>
{/* Feature Cards */}
<div className="feature-grid">
{features.map((feature) => (
<div key={feature.id} className="feature-card">
<div className="feature-number-badge">
<span>{feature.id}</span>
</div>
<h3 className="feature-card-title">{feature.title}</h3>
<div className="feature-icon-box" style={{ backgroundColor: feature.iconBg }}>
<img src={feature.icon} alt={feature.title} className="feature-icon-img" />
</div>
<p className="feature-card-description">{feature.description}</p>
</div>
))}
</div>
</div>
</div>
);
};
export default WelcomeSection;

View File

@ -1,109 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { getKakaoLoginUrl, kakaoCallback, trackCompleteRegistration } from '../../utils/api';
interface LoginSectionProps {
onBack: () => void;
onLogin: () => void;
}
const LoginSection: React.FC<LoginSectionProps> = ({ onBack, onLogin }) => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 카카오 콜백 처리 (URL에서 code 파라미터 확인)
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
if (code) {
handleKakaoCallback(code);
}
}, []);
const handleKakaoCallback = async (code: string) => {
setIsLoading(true);
setError(null);
try {
const response = await kakaoCallback(code);
// 신규 가입이면 Meta CompleteRegistration 전환 이벤트 발화
// (서버가 계정당 최초 1회 판정 — App.tsx 경로와 중복 호출돼도 무해)
if (response.is_new_user) {
await trackCompleteRegistration();
}
// URL에서 code 파라미터 제거
const url = new URL(window.location.href);
url.searchParams.delete('code');
window.history.replaceState({}, document.title, url.pathname);
// 로그인 성공
onLogin();
} catch (err) {
console.error('Kakao callback failed:', err);
setError(t('login.kakaoLoginFailed'));
// URL에서 code 파라미터 제거
const url = new URL(window.location.href);
url.searchParams.delete('code');
window.history.replaceState({}, document.title, url.pathname);
} finally {
setIsLoading(false);
}
};
const handleKakaoLogin = async () => {
setIsLoading(true);
setError(null);
try {
const response = await getKakaoLoginUrl();
// 카카오 로그인 페이지로 리다이렉트
window.location.href = response.auth_url;
} catch (err) {
console.error('Failed to get Kakao login URL:', err);
setError(t('login.loginUrlFailed'));
setIsLoading(false);
}
};
return (
<div className="login-container">
{/* Back Button */}
<button onClick={onBack} className="login-back-btn" disabled={isLoading}>
<img src="/assets/images/icon-back.svg" alt="Back" />
<span>{t('login.back')}</span>
</button>
<div className="login-content">
{/* Logo */}
<div className="login-logo">
<img src="/assets/images/ADO2_with Slogan_white.svg" alt="ADO2" />
</div>
{/* Error Message */}
{error && (
<div className="login-error">
{error}
</div>
)}
{/* Kakao Login Button */}
<button
onClick={handleKakaoLogin}
className="btn-kakao"
disabled={isLoading}
>
{isLoading ? t('login.loggingIn') : t('login.kakaoStart')}
</button>
</div>
</div>
);
};
export default LoginSection;

View File

@ -0,0 +1,79 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import PosterDropzone from '../../components/PosterDropzone';
import P2vKeyGate from '../../components/P2vKeyGate';
import { P2vAuthError, createPosterJob } from '../../utils/p2vApi';
interface PosterCreateFormProps {
/** 생성 요청이 접수되면 호출. 폴링 소유는 GenerationFlow (썰박스와 같은 규칙) */
onSubmitted: (jobId: string) => void;
}
/**
* (F1) . '포스터 영상' .
*
* .
* ( ).
*/
const PosterCreateForm: React.FC<PosterCreateFormProps> = ({ onSubmitted }) => {
const { t } = useTranslation();
const [file, setFile] = useState<File | null>(null);
const [name, setName] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [authNeeded, setAuthNeeded] = useState(false);
const submit = async () => {
if (!file || submitting) return;
setSubmitting(true);
setError(null);
try {
const { id } = await createPosterJob(file, name.trim());
onSubmitted(id);
} catch (e) {
if (e instanceof P2vAuthError) {
setAuthNeeded(true);
} else {
setError((e as Error).message);
}
setSubmitting(false);
}
};
if (authNeeded) {
return (
<div className="p2v-scope p2v-create">
<P2vKeyGate onSaved={() => { setAuthNeeded(false); submit(); }} />
</div>
);
}
return (
<div className="p2v-scope p2v-create">
<span className="p2v-step">{t('poster.create.step')}</span>
<PosterDropzone file={file} onFile={setFile} />
<input
className="p2v-input p2v-input--center"
placeholder={t('poster.create.namePlaceholder')}
value={name}
onChange={(e) => setName(e.target.value)}
/>
{error && <div className="p2v-note warn">{error}</div>}
<button
type="button"
className="p2v-btn-cta"
disabled={!file || submitting}
onClick={submit}
>
{submitting ? t('poster.create.submitting') : t('poster.create.submit')}
</button>
</div>
);
};
export default PosterCreateForm;

View File

@ -0,0 +1,117 @@
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;

View File

@ -0,0 +1,104 @@
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;

View File

@ -0,0 +1,189 @@
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;

View File

@ -0,0 +1,341 @@
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;

View File

@ -0,0 +1,28 @@
/**
* .
*
* ssulData.ts ** i18n .**
* castad ko/en .
*/
/**
* .
*
* P2V `scripts/motion_plan.py` MOTION_PHRASE **1:1 **
* 422 . .
*/
export const MOTION_KEYS = [
'firework', 'water', 'wave', 'cloud', 'smoke',
'moon', 'sun', 'star', 'light', 'flag',
'foliage', 'wheel', 'vessel', 'crowd',
] as const;
export type MotionKey = (typeof MOTION_KEYS)[number];
/** i18n 키 헬퍼 — t(motionLabel('firework')) 형태로 쓴다 */
export const motionLabel = (key: string): string => `poster.motion.${key}`;
/** 검수 화면에서 고칠 수 있는 메타 필드. 나머지(category·keywords)는 서버 판단을 따른다 */
export const META_FIELDS = ['event_name', 'date_text', 'place'] as const;
export type MetaField = (typeof META_FIELDS)[number];

View File

@ -0,0 +1,250 @@
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
AccommodationSearchItem,
InsufficientCreditError,
cleanSearchTitle,
createSsulJob,
searchAccommodation,
} from '../../utils/api';
import { SCEN_KEYS, Scen, scenDesc, scenName } from './ssulData';
interface SsulCreateFormProps {
/** 생성 요청이 접수되면 호출. 폴링 소유는 GenerationFlow */
onSubmitted: (taskId: number, scenario: Scen) => void;
/** 크레딧 부족(402) 시 충전 화면으로 유도 */
onNeedCredit: () => void;
}
/** 네이버 지도 링크로 보이는가 — 링크면 검색을 건너뛰고 그대로 크롤링한다 */
const isNaverUrl = (s: string): boolean =>
/naver\.me|map\.naver|place\.naver|^https?:\/\//i.test(s.trim());
/** 자동완성 디바운스 — ADO2 SearchInputForm 과 동일 */
const AUTOCOMPLETE_DEBOUNCE = 300;
/**
* . .
*
* CreateSheet() .
* ( 2 ,
* ).
*
* ** ADO2 **(2026-07-31 ):
* ** API**(`/search/accommodation`, ~0.2)
* , place URL (ADO2 `_autocomplete_logic` ).
* (`/ssul/search/place`)
* **17** UX .
*/
const SsulCreateForm: React.FC<SsulCreateFormProps> = ({ onSubmitted, onNeedCredit }) => {
const { t } = useTranslation();
const [scenario, setScenario] = useState<Scen | null>(null);
const [input, setInput] = useState('');
const [results, setResults] = useState<AccommodationSearchItem[]>([]);
const [showResults, setShowResults] = useState(false);
const [selected, setSelected] = useState<AccommodationSearchItem | null>(null);
const [searching, setSearching] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const boxRef = useRef<HTMLDivElement>(null);
const isUrl = isNaverUrl(input);
// 업장을 골랐거나 링크를 직접 붙여넣었을 때만 생성 가능
const canSubmit = !!scenario && (!!selected || isUrl) && !submitting;
// 바깥 클릭 시 자동완성 닫기
useEffect(() => {
const onDown = (e: MouseEvent) => {
if (boxRef.current && !boxRef.current.contains(e.target as Node)) {
setShowResults(false);
}
};
document.addEventListener('mousedown', onDown);
return () => document.removeEventListener('mousedown', onDown);
}, []);
// 언마운트 시 대기 중인 디바운스 취소 (setState-after-unmount 방지)
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
const runAutocomplete = async (query: string) => {
if (query.trim().length < 2 || isNaverUrl(query)) {
setResults([]);
setShowResults(false);
return;
}
setSearching(true);
try {
const res = await searchAccommodation(query);
const items = res.items || [];
setResults(items);
setShowResults(items.length > 0);
} catch {
// 검색 실패는 조용히 넘긴다 — 링크 직접 입력 우회 경로가 있다
setResults([]);
setShowResults(false);
} finally {
setSearching(false);
}
};
const handleInputChange = (value: string) => {
setInput(value);
if (selected) setSelected(null);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => runAutocomplete(value), AUTOCOMPLETE_DEBOUNCE);
};
const handleSelect = (item: AccommodationSearchItem) => {
// 검색 API 는 제목에 <b> 하이라이트 태그를 섞어 준다 — 그대로 쓰면
// 업장명·SNS 제목에 태그가 노출된다.
const title = cleanSearchTitle(item.title);
setInput(title);
setSelected({ ...item, title });
setShowResults(false);
setResults([]);
};
const handleSubmit = async () => {
if (!scenario || submitting) return;
// 고른 가게가 있으면 업장명을 보내고, 서버가 place URL 을 해석한다
// (ADO2 와 동일한 경로 — NvMapPwScraper.get_place_id_url).
// 링크를 직접 붙여넣었으면 그대로 넘긴다.
const payload = selected ? selected.title : input.trim();
setSubmitting(true);
setError(null);
try {
const res = await createSsulJob({
scenario,
input: payload,
// 고른 가게가 있을 때만 함께 보낸다. 통합 목록의 업장명 표시와
// 업장명·지역 필터가 이 값에 걸린다(링크 직접 입력 시에는 없다).
// 도로명·지번을 모두 보낸다. 백엔드가 도로명에서 시/군 추출에 실패하면
// 지번으로 재시도한다(한쪽만 보내면 지역이 비는 경우가 생긴다).
...(selected && {
store_name: selected.title,
road_address: selected.roadAddress,
address: selected.address,
}),
});
onSubmitted(res.id, scenario);
} catch (e) {
if (e instanceof InsufficientCreditError) {
onNeedCredit();
} else {
setError(t('ssulbox.error.createFailed'));
}
} finally {
setSubmitting(false);
}
};
return (
<div className="ssul-scope ssul-create">
{/* ── 1단계: 시나리오 ─────────────────────────────── */}
<span className="ssul-create__step">{t('ssulbox.create.step1')}</span>
<div className="ssul-create__scenarios">
{SCEN_KEYS.map((key) => {
const active = scenario === key;
return (
<button
key={key}
type="button"
aria-pressed={active}
className={`ssul-scn${active ? ' active' : ''}`}
onClick={() => setScenario(key)}
>
<span className="ssul-scn__name">{t(scenName(key))}</span>
<span className="ssul-scn__desc">{t(scenDesc(key))}</span>
</button>
);
})}
</div>
{/* ── 2단계: 업장 ─────────────────────────────────── */}
{/* 2단계는 약하게 — 시선이 1단계(아직 안 고른 경우)로 먼저 가야 한다 */}
<span className="ssul-create__step muted">{t('ssulbox.create.step2')}</span>
{/* 자동완성 드롭다운을 붙이려면 상대 위치 컨테이너가 필요하다 */}
<div className="ssul-create__search" ref={boxRef}>
<input
className="ssul-create__input"
placeholder={t('ssulbox.create.searchPlaceholder')}
value={input}
onChange={(e) => handleInputChange(e.target.value)}
onFocus={() => results.length > 0 && setShowResults(true)}
autoComplete="off"
/>
{showResults && (
<ul className="ssul-places">
{results.map((place, i) => (
<li key={`${place.title}-${i}`}>
<button type="button" onClick={() => handleSelect(place)}>
<b>{cleanSearchTitle(place.title)}</b>
<span>{place.roadAddress || place.address}</span>
</button>
</li>
))}
</ul>
)}
</div>
{/* 상태 안내 — 한 번에 하나만 보인다 */}
{selected ? (
<div className="ssul-picked">
<div className="ssul-picked__body">
<b>{selected.title}</b>
<span>{selected.roadAddress || selected.address}</span>
</div>
</div>
) : isUrl ? (
<div className="ssul-hint ok">{t('ssulbox.create.hintUrlOk')}</div>
) : searching ? (
<div className="ssul-hint">{t('ssulbox.create.placeLoading')}</div>
) : scenario ? (
// 고른 시나리오를 되짚어 준다. 소요 시간은 기본 옵션 실측 전이라
// 분 단위를 못 박지 않는다("수 분 내외").
<div className="ssul-hint dot">
{t('ssulbox.create.hintScenario', { name: t(scenName(scenario)) })}
</div>
) : (
<div className="ssul-hint">{t('ssulbox.create.hintDefault')}</div>
)}
{error && <div className="ssul-hint warn">{error}</div>}
{/* ── 비용 고지 + 제출 ────────────────────────────── */}
{/* 요청 시점에 즉시 차감되므로 누르기 전에 반드시 알린다 */}
<div className="ssul-create__cost">
<span>{t('ssulbox.create.cost')}</span>
<span className="ssul-create__cost-badge">
<span className="ssul-create__coin" aria-hidden="true">
🪙
</span>
{t('ssulbox.create.costValue')}
</span>
</div>
<button
type="button"
className="ssul-create__submit"
disabled={!canSubmit}
onClick={handleSubmit}
>
{submitting ? t('ssulbox.create.submitting') : t('ssulbox.create.submit')}
</button>
</div>
);
};
export default SsulCreateForm;

View File

@ -0,0 +1,488 @@
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;

View File

@ -0,0 +1,25 @@
import React from 'react';
import SsulDetailContent from './SsulDetailContent';
interface SsulDetailPageProps {
contentId: string;
}
/**
* (`/ssul/{id}`) castad `VideoDetailPage` .
* ( `GET /ssul/{id}` ).
*/
const SsulDetailPage: React.FC<SsulDetailPageProps> = ({ contentId }) => {
const handleBack = () => {
localStorage.setItem('castad_active_item', 'ADO2 콘텐츠');
window.location.href = '/';
};
return (
<div className="video-detail-page">
<SsulDetailContent contentId={contentId} isModal={false} onClose={handleBack} />
</div>
);
};
export default SsulDetailPage;

View File

@ -0,0 +1,100 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Scen, scenName } from './ssulData';
/** 생성 잡의 현재 상태. GenerationFlow 가 폴링해 내려준다 */
export interface SsulJob {
taskId: number;
scenario: Scen;
status: 'queued' | 'running' | 'done' | 'error';
/** 0~4. 0=준비, 4=영상 합성 완료 */
step: number;
error: string | null;
}
interface SsulMakingContentProps {
job: SsulJob | null;
/** 실패 후 처음부터 다시 */
onRetry: () => void;
}
/** 백엔드 generator 가 뱉는 4단계. `ssul_content.step` 과 1:1 대응 */
const TOTAL_STEPS = 4;
const STEP_LABEL_KEYS = [
'ssulbox.making.step1',
'ssulbox.making.step2',
'ssulbox.making.step3',
'ssulbox.making.step4',
] as const;
/**
* .
*
* SSE , castad
* `step`(0~4) ** ** .
* ~ .
*
* GenerationFlow .
* .
* "백그라운드로 전환"
* , .
*/
const SsulMakingContent: React.FC<SsulMakingContentProps> = ({ job, onRetry }) => {
const { t } = useTranslation();
if (!job) return null;
const failed = job.status === 'error';
// step 은 "완료한 단계 수"라 진행률은 step/4. queued(0) 면 0%
const percent = Math.round((Math.min(job.step, TOTAL_STEPS) / TOTAL_STEPS) * 100);
// 진행 중인 단계는 완료 수 + 1 (마지막 단계 완료 시엔 더 올리지 않음)
const currentIndex = Math.min(job.step, TOTAL_STEPS - 1);
return (
<div className="ssul-scope ssul-making">
<span className="ssul-making__scenario">{t(scenName(job.scenario))}</span>
{failed ? (
<>
<h3 className="ssul-making__title warn">{t('ssulbox.making.failTitle')}</h3>
<p className="ssul-making__desc">{job.error || t('ssulbox.making.failDefault')}</p>
{/* 백엔드가 실패 시 크레딧을 환불한다 — 사용자가 손해 보지 않았음을 알린다 */}
<p className="ssul-making__refund">{t('ssulbox.making.refunded')}</p>
<button type="button" className="ssul-making__btn" onClick={onRetry}>
{t('ssulbox.making.retry')}
</button>
</>
) : (
<>
<h3 className="ssul-making__title">{t('ssulbox.making.title')}</h3>
{/*
.
"살아 있다" .
*/}
<div className="ssul-making__spinner" aria-hidden="true" />
<div
className="ssul-making__bar"
role="progressbar"
aria-valuenow={percent}
aria-valuemin={0}
aria-valuemax={100}
>
<div className="ssul-making__fill" style={{ width: `${percent}%` }} />
</div>
<div className="ssul-making__meta">
<span>{t(STEP_LABEL_KEYS[currentIndex])}</span>
<span className="ssul-making__pct">{percent}%</span>
</div>
<p className="ssul-making__desc">{t('ssulbox.making.takesTime')}</p>
</>
)}
</div>
);
};
export default SsulMakingContent;

View File

@ -0,0 +1,147 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { SsulTaskStatus, getSsulTask } from '../../utils/api';
import { VideoListItem } from '../../types/api';
import SocialPostingModal from '../../components/SocialPostingModal';
import CompletionView from '../../components/CompletionView';
import { Scen, scenDesc, scenName } from './ssulData';
interface SsulResultContentProps {
taskId: number;
scenario: Scen;
}
/**
* .
*
* ** ADO2 `CompletionView` **(2026-08-10 ).
* `.ssul-scope` 1 ,
* · .
* (·· ) .
*
* `.ssul-scope` 있다: 예전에는
* `text-align: center` SNS
* . (`.social-posting-overlay`
* `text-align`) .
*
* castad `SocialPostingModal` social_upload
* `content_type` .
*/
const SsulResultContent: React.FC<SsulResultContentProps> = ({ taskId, scenario }) => {
const { t } = useTranslation();
const [task, setTask] = useState<SsulTaskStatus | null>(null);
const [loadError, setLoadError] = useState(false);
// 영상 전체를 받아오는 동안 시간이 걸린다 — 중복 클릭을 막고 진행을 알린다
const [downloading, setDownloading] = useState(false);
const [showSocialModal, setShowSocialModal] = useState(false);
// 폴링이 끝난 뒤 최종 상태(video_url 포함)를 한 번 더 읽는다.
// 진행 중에는 video_url 이 비어 있으므로 완료 후 조회가 필요하다.
useEffect(() => {
let alive = true;
getSsulTask(taskId)
.then((data) => alive && setTask(data))
.catch(() => alive && setLoadError(true));
return () => {
alive = false;
};
}, [taskId]);
// 파일명은 ADO2 완료 화면과 같은 방식이다 — 업장명 그대로 + .mp4.
// store_name 은 완료(done) 시점에 서버가 반드시 채워 보내지만, 조회 실패로
// task 가 없을 수 있어 ADO2 와 동일한 폴백('콘텐츠')을 둔다.
const fileName = `${task?.store_name || '콘텐츠'}.mp4`;
/**
* .
*
* **Blob URL `<a download>`
* ** `download` URL , Azure Blob
* .
* castad `CompletionContent` fetch Blob
* objectURL .
*
* fetch (CORS )
* .
*/
const handleDownload = async () => {
if (!task?.video_url || downloading) return;
setDownloading(true);
try {
const response = await fetch(task.video_url);
const blobUrl = URL.createObjectURL(await response.blob());
const link = document.createElement('a');
link.href = blobUrl;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
} catch {
const link = document.createElement('a');
link.href = task.video_url;
link.download = fileName;
link.target = '_blank';
link.rel = 'noopener';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} finally {
setDownloading(false);
}
};
return (
<CompletionView
// 여기에 도달했다는 것은 생성이 이미 끝났다는 뜻이라 'loading' 은 쓰지 않는다.
// 남는 실패 경우는 최종 상태 조회 실패뿐이며, 재시도 버튼은 두지 않는다
// (생성을 다시 돌리는 것이 아니라 조회만 실패한 것이므로 새로고침이 맞다).
videoState={loadError ? 'error' : task?.video_url ? 'ready' : 'empty'}
videoUrl={task?.video_url}
errorMessage={t('ssulbox.result.loadFailed')}
fileName={fileName}
metaLines={[
`${t('ssulbox.result.scenarioLabel')} : ${t(scenName(scenario))}`,
`${t('ssulbox.result.createdLabel')} : ${
task?.created_at ? new Date(task.created_at).toLocaleString('ko-KR') : '-'
}`,
]}
// ADO2 의 가사 자리. 썰박스는 대본이 영상 안에 그려지므로 내려받을 텍스트가
// 없고, 대신 어떤 이야기인지 알려주는 시나리오 설명을 넣는다.
detailLabel={t('ssulbox.result.scenarioLabel')}
detail={<p className="comp2-lyrics-text">{t(scenDesc(scenario))}</p>}
onDownload={handleDownload}
downloadDisabled={!task?.video_url || downloading}
downloadLabel={
downloading ? t('ssulbox.result.downloading') : t('ssulbox.result.download')
}
onUpload={() => setShowSocialModal(true)}
uploadDisabled={!task?.video_url}
>
{/* castad VideoListItem .
type='ssul' 핵심이다: 모달이 content_type
id ADO2 . */}
<SocialPostingModal
isOpen={showSocialModal}
onClose={() => setShowSocialModal(false)}
video={
task?.video_url
? ({
type: 'ssul',
video_id: taskId,
store_name: t(scenName(scenario)),
region: '',
task_id: '',
result_movie_url: task.video_url,
created_at: task.created_at,
like_count: 0,
comment_count: 0,
} as VideoListItem)
: null
}
/>
</CompletionView>
);
};
export default SsulResultContent;

View File

@ -0,0 +1,54 @@
import React, { useEffect } from 'react';
import { VideoListItem } from '../../types/api';
import SsulDetailContent from './SsulDetailContent';
import { useOverlayClose } from '../../hooks/useOverlayClose';
interface SsulViewerModalProps {
/** null 이면 닫힘 */
item: VideoListItem | null;
onClose: () => void;
}
/**
* castad `VideoDetailModal` .
* `SsulDetailContent` ( `SsulDetailPage` ).
*
* `VideoDetailModal` : `videoId` `/video/{id}`
* , id **id ADO2 ** .
*/
const SsulViewerModal: React.FC<SsulViewerModalProps> = ({ item, onClose }) => {
useEffect(() => {
if (!item) return;
// 모달 열릴 때 배경 스크롤 잠금
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = prev;
document.removeEventListener('keydown', handleKeyDown);
};
}, [item, onClose]);
const overlayCloseHandlers = useOverlayClose(onClose);
if (!item) return null;
return (
<div className="video-detail-overlay" {...overlayCloseHandlers}>
<div className="video-detail-modal-box" onClick={(e) => e.stopPropagation()}>
<SsulDetailContent
contentId={String(item.video_id)}
isModal={true}
onClose={onClose}
/>
</div>
</div>
);
};
export default SsulViewerModal;

View File

@ -0,0 +1,20 @@
/**
* .
*
* (o2o-ssulbox/frontend/src/data.ts) **(name/desc) **.
* castad ko/en i18n .
* `t('ssulbox.scenario.<key>.name')` / `.desc` .
*
* ·· ADO2 (2026-08-20)
* ·· (/) .
*/
/** 시나리오 코드. 백엔드 `ssul_content.scenario` 와 값이 일치해야 한다 */
export type Scen = 'joseon' | 'samgukji' | 'greek' | 'odyssey';
/** 시나리오 순서. 선택 그리드(2x2)와 필터 칩이 이 순서를 따른다 */
export const SCEN_KEYS: Scen[] = ['joseon', 'samgukji', 'greek', 'odyssey'];
/** i18n 키 헬퍼 — t(scenName('joseon')) 형태로 쓴다 */
export const scenName = (s: Scen): string => `ssulbox.scenario.${s}.name`;
export const scenDesc = (s: Scen): string => `ssulbox.scenario.${s}.desc`;

View File

@ -0,0 +1,111 @@
/**
* UI .
*
* (o2o-ssulbox/frontend/src/icons.tsx) **castad **:
* IcHome / IcSearch / IcUser / IcGrid castad Sidebar
* IcBack castad
*
* · CSS() . `stroke="#fff"`
* , castad
* `currentColor` .
*
* `aria-hidden` .
*/
import React from 'react';
/** 재생 (카드 오버레이) */
export const IcPlay: React.FC = () => (
<svg viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
);
/** 좋아요 (채움) */
export const IcHeart: React.FC = () => (
<svg viewBox="0 0 24 24">
<path d="M12 21s-8-5.3-8-11a4.5 4.5 0 0 1 8-2.8A4.5 4.5 0 0 1 20 10c0 5.7-8 11-8 11z" />
</svg>
);
/** 좋아요 (원본에서 채움과 동일 — 상태는 CSS 로 구분) */
export const IcHeartLine = IcHeart;
/** 댓글 */
export const IcComment: React.FC = () => (
<svg viewBox="0 0 24 24">
<path
d="M21 12a8 8 0 0 1-11.6 7.1L4 20l1-5.2A8 8 0 1 1 21 12z"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
/>
</svg>
);
/** 공유 */
export const IcShare: React.FC = () => (
<svg viewBox="0 0 24 24">
<path
d="M22 3 11 14M22 3l-7 19-4-8-8-4z"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
/>
</svg>
);
/** 링크 복사 */
export const IcLink: React.FC = () => (
<svg viewBox="0 0 24 24">
<path
d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1m0 8a5 5 0 0 1-7 0 5 5 0 0 1 0-7l2-2a5 5 0 0 1 7 0"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
/>
</svg>
);
/** 업로드 */
export const IcUpload: React.FC = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<path d="M12 15V4M8 8l4-4 4 4M5 15v3a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-3" />
</svg>
);
/** 다운로드 */
export const IcDownload: React.FC = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<path d="M12 4v11M8 11l4 4 4-4M5 19h14" />
</svg>
);
/** 복사 (캡션) */
export const IcCopy: React.FC = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<rect x="9" y="9" width="11" height="11" rx="2" />
<path d="M5 15V5a2 2 0 0 1 2-2h8" />
</svg>
);
// ── SNS 플랫폼 ────────────────────────────────────────────────
export const IcYoutube: React.FC = () => (
<svg viewBox="0 0 24 24">
<rect x="2" y="5" width="20" height="14" rx="4" />
<path d="M10 9l5 3-5 3z" fill="#fff" />
</svg>
);
export const IcInstagram: React.FC = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<rect x="3" y="3" width="18" height="18" rx="5" />
<circle cx="12" cy="12" r="4" />
<circle cx="17.5" cy="6.5" r="1" fill="currentColor" stroke="none" />
</svg>
);
export const IcTiktok: React.FC = () => (
<svg viewBox="0 0 24 24">
<path d="M14 3c.3 2.3 1.8 4 4 4.3V10c-1.5 0-2.9-.5-4-1.3V15a5 5 0 1 1-5-5v2.6a2.4 2.4 0 1 0 2.4 2.4V3z" />
</svg>
);

View File

@ -458,27 +458,33 @@
}
}
/* 모바일: 하단 탭바에 가리지 않도록 띄운다 */
@media (max-width: 767px) {
.bottom-button-container {
bottom: calc(var(--bottom-nav-height) + 16px);
}
}
/* =====================================================
Sidebar Components
===================================================== */
/* Sidebar Container */
.sidebar {
position: fixed;
position: sticky;
top: 0;
height: 100vh;
flex-shrink: 0;
display: flex;
flex-direction: column;
background-color: var(--color-bg-dark);
border-right: 1px solid var(--color-border-white-5);
transition: all var(--transition-slow);
z-index: 50;
}
@media (min-width: 768px) {
@media (max-width: 767px) {
.sidebar {
position: sticky;
top: 0;
flex-shrink: 0;
display: none;
}
}
@ -486,24 +492,6 @@
width: 15rem;
}
.sidebar.collapsed {
width: 5rem;
}
.sidebar.mobile-open {
transform: translateX(0);
}
.sidebar.mobile-closed {
transform: translateX(-100%);
}
@media (min-width: 768px) {
.sidebar.mobile-closed {
transform: translateX(0);
}
}
/* Sidebar Header */
.sidebar-header {
padding: 1.25rem;
@ -512,11 +500,6 @@
justify-content: space-between;
}
.sidebar-header.collapsed {
flex-direction: column;
gap: 1rem;
}
/* Sidebar Logo */
.sidebar-logo {
font-family: 'Playfair Display', serif;
@ -555,15 +538,6 @@
overflow: hidden;
}
.sidebar-item.collapsed {
justify-content: center;
width: 3rem;
height: 3rem;
margin-left: auto;
margin-right: auto;
padding: 0;
}
.sidebar-item.active {
background-color: var(--color-mint);
color: var(--color-bg-dark);
@ -677,10 +651,6 @@
padding: 0 0.5rem;
}
.profile-section.collapsed {
flex-direction: column;
}
.profile-avatar {
width: 2.5rem;
height: 2.5rem;
@ -778,25 +748,6 @@
transform: translateX(calc(100% + 2px));
}
/* Collapsed state - simple button */
.lang-toggle-collapsed {
padding: 4px 6px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.03em;
color: rgba(255, 255, 255, 0.5);
background: rgba(255, 255, 255, 0.08);
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
}
.lang-toggle-collapsed:hover {
color: #ffffff;
background: rgba(255, 255, 255, 0.15);
}
/* Sidebar Footer Actions */
.sidebar-footer-actions {
display: flex;
@ -825,11 +776,6 @@
color: var(--color-text-white);
}
.sidebar-inquiry-btn.collapsed {
justify-content: center;
padding: 0.75rem;
}
/* Logout Button */
.logout-btn {
width: 100%;
@ -848,100 +794,148 @@
color: var(--color-text-white);
}
.logout-btn.collapsed {
justify-content: center;
}
.logout-btn-label {
font-size: var(--text-sm);
font-weight: 700;
}
/* 모바일 전용 사이드바 튜토리얼 토글 */
.sidebar-tutorial-btn {
display: none;
/* Settings Panel (Sidebar 4번째 메뉴 / BottomNav 설정 버튼 공용) */
.settings-panel {
background: var(--color-bg-card);
border: 1px solid var(--color-border-white-10);
border-radius: var(--radius-2xl);
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.125rem;
z-index: 60;
}
@media (max-width: 767px) {
.sidebar-tutorial-btn {
.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;
width: 100%;
margin-top: 0.5rem;
padding: 0.75rem;
padding: 0.625rem 0.75rem;
border-radius: var(--radius-lg);
color: var(--color-text-gray-400);
border: none;
background: none;
border: none;
cursor: pointer;
font-size: var(--text-sm);
font-weight: 500;
font-weight: 700;
text-decoration: none;
text-align: left;
width: 100%;
transition: color var(--transition-normal), background-color var(--transition-normal);
}
.sidebar-tutorial-btn:hover {
.settings-panel-item:hover {
color: var(--color-text-white);
background-color: rgba(255, 255, 255, 0.05);
}
.sidebar-tutorial-btn.active {
.settings-panel-item.active {
color: var(--color-mint);
}
.sidebar-tutorial-label {
text-align: left;
.settings-panel-item svg {
flex-shrink: 0;
}
.sidebar-tutorial-badge {
font-size: 11px;
font-weight: 700;
.settings-panel-language {
display: flex;
justify-content: flex-start;
padding: 0.375rem 0.75rem;
}
.sidebar-tutorial-badge.on {
color: var(--color-mint);
}
.sidebar-tutorial-badge.off {
color: var(--color-text-gray-400);
}
}
/* Mobile Menu Button */
.mobile-menu-btn {
.settings-backdrop {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 40;
padding: 0.625rem;
background-color: var(--color-bg-card);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-white-10);
inset: 0;
z-index: 55;
}
/* Mobile Bottom Nav */
.bottom-nav {
display: none;
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 50;
height: var(--bottom-nav-height);
padding-bottom: env(safe-area-inset-bottom);
background: var(--color-bg-dark);
border-top: 1px solid var(--color-border-white-5);
}
@media (max-width: 767px) {
.bottom-nav {
display: flex;
}
.has-bottom-nav {
padding-bottom: var(--bottom-nav-height);
}
}
.bottom-nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
font-size: 12px;
font-weight: 700;
color: var(--color-text-gray-400);
background: none;
border: none;
cursor: pointer;
}
.mobile-menu-btn:hover {
color: var(--color-text-white);
.bottom-nav-item svg {
width: 24px;
height: 24px;
}
@media (min-width: 768px) {
.mobile-menu-btn {
display: none;
}
}
/* Mobile Overlay */
.mobile-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 40;
}
@media (min-width: 768px) {
.mobile-overlay {
display: none;
}
.bottom-nav-item.active {
color: var(--color-mint);
}

View File

@ -64,7 +64,7 @@
@media (min-width: 768px) {
.settings-title {
font-size: 1.5rem;
font-size: 24px;
}
}
@ -76,7 +76,7 @@
.settings-description {
color: var(--color-text-gray-400);
font-size: 9px;
font-size: 10px;
font-weight: 300;
line-height: 1.625;
opacity: 0.8;
@ -124,7 +124,7 @@
.settings-card-title {
color: var(--color-mint);
font-size: 9px;
font-size: 10px;
font-weight: 700;
margin-bottom: 0.5rem;
letter-spacing: 0.1em;
@ -234,7 +234,7 @@
.social-item-connect {
color: var(--color-mint);
font-size: 9px;
font-size: 10px;
font-weight: 700;
background: none;
border: none;
@ -321,7 +321,7 @@
}
.footer-link {
font-size: 13px;
font-size: 14px;
color: #379599;
text-decoration: none;
opacity: 0.8;
@ -333,7 +333,7 @@
}
.footer-link-divider {
font-size: 13px;
font-size: 14px;
color: #379599;
opacity: 0.5;
}

View File

@ -28,7 +28,7 @@
border: 1px solid rgba(155, 202, 204, 0.3);
background: transparent;
color: #9BCACC;
font-size: 13px;
font-size: 14px;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
white-space: nowrap;
@ -54,7 +54,7 @@
}
.ado2-contents-count {
font-size: 17px;
font-size: 18px;
font-weight: 500;
color: #9BCACC;
}
@ -281,7 +281,7 @@
border: none;
border-bottom: 1px solid rgba(255,255,255,0.05);
color: var(--color-text-white);
font-size: 15px;
font-size: 16px;
cursor: pointer;
text-align: left;
}
@ -321,7 +321,7 @@
border: 1px solid rgba(255,255,255,0.15);
background: rgba(255,255,255,0.05);
color: var(--color-text-white);
font-size: 13px;
font-size: 14px;
cursor: pointer;
white-space: nowrap;
}
@ -359,6 +359,7 @@
border-radius: 20px;
overflow: hidden;
background-color: #034A4D;
min-width: 0;
}
.content-card-thumbnail {
@ -435,6 +436,16 @@
display: flex;
flex-direction: column;
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 {
@ -453,12 +464,12 @@
align-items: center;
justify-content: center;
gap: 10px;
font-size: 15px;
font-size: 16px;
font-weight: 500;
color: #9BCACC;
white-space: nowrap;
flex-shrink: 0;
margin-left: 8px;
margin-left: auto;
}
.content-card-like-btn {
@ -646,10 +657,76 @@
/* ── VideoDetailPage / VideoDetailModal ─────────────────────────── */
/* 모달 내부 콘텐츠 (풀페이지의 video-detail-page와 동일 스타일, 배경 제외) */
/* social-posting-overlay 와 동일한 구조: 배경 + 중앙 정렬 + 내부 스크롤 박스 */
.video-detail-overlay {
position: fixed;
inset: 0;
text-align: left;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
padding: 1rem;
}
/* social-posting-modal 과 동일한 박스 스타일: 헤더 고정 + 본문만 스크롤 */
.video-detail-modal-box {
width: 100%;
max-width: 800px;
max-height: 90vh;
background-color: #01282A;
border-radius: 16px;
border: 1px solid rgba(166, 255, 234, 0.1);
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
/* 일부 모바일 브라우저는 video 별도 하드웨어 컴포지팅 레이어로 그려
overflow:hidden + border-radius 클리핑을 무시하고 밖으로 삐져나온다.
contain:paint 박스 밖에는 절대 그려지지 않도록 강제한다. */
contain: paint;
}
/* 모달 내부 콘텐츠 (풀페이지의 video-detail-page와 동일 스타일, 배경 제외).
헤더는 여기의 일반 흐름 자식(스크롤 없음)이고, .video-detail-body 스크롤된다.
과거엔 헤더에 position:sticky 써서 흉내냈으나, 자동재생 <video> 같은
스크롤 컨테이너 안에 sticky 요소가 있으면 iOS Safari 에서 video 별도
레이어로 떨어져나가 모달 밖으로 삐져나오는 버그가 있어 구조 자체를 분리했다. */
.video-detail-modal-content {
color: #fff;
padding: 24px 32px;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
/* social-posting-content 에 대응하는 스크롤 영역 */
.video-detail-modal-content > .video-detail-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0 32px 24px;
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 {
@ -689,6 +766,21 @@
flex-direction: column;
}
.video-detail-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.video-detail-modal-title {
font-size: 24px;
font-weight: 600;
color: #FFFFFF;
margin-left: 24px;
}
.video-detail-back-btn {
display: flex;
align-items: center;
@ -708,7 +800,7 @@
.video-detail-content {
display: flex;
gap: 32px;
gap: 24px;
flex-wrap: wrap;
align-items: center;
flex: 1;
@ -798,7 +890,7 @@
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 8px;
padding-top: 1rem;
align-self: flex-start;
}
@ -864,6 +956,12 @@
gap: 2px;
}
/* 버튼 아래 공간이 부족하면(모달 스크롤 영역 하단 근처) 위로 펼친다 */
.video-detail-share-menu.upward {
top: auto;
bottom: calc(100% + 6px);
}
.video-detail-share-item {
display: flex;
align-items: center;
@ -954,7 +1052,7 @@
background: #1A8F93;
border: none;
color: #fff;
font-size: 11px;
font-size: 12px;
cursor: pointer;
display: flex;
align-items: center;
@ -970,7 +1068,7 @@
border-radius: 8px;
padding: 8px 12px;
color: #fff;
font-size: 13px;
font-size: 14px;
outline: none;
}
@ -984,7 +1082,7 @@
/* 댓글 닉네임 */
.video-detail-comment-nickname {
font-size: 13px;
font-size: 14px;
font-weight: 600;
color: #9BCACC;
display: block;
@ -1000,7 +1098,7 @@
}
.video-detail-comments-title {
font-size: 17px;
font-size: 18px;
font-weight: 600;
margin: 0;
}
@ -1229,6 +1327,21 @@
}
}
/* social-posting-modal 의 Mobile Adjustments 와 동일한 기준(768px) */
@media (max-width: 768px) {
.video-detail-modal-box {
max-height: 95vh;
}
.video-detail-modal-content > .video-detail-header {
padding: 1rem;
}
.video-detail-modal-content > .video-detail-body {
padding: 0 1rem 1rem;
}
}
.pagination-info {
color: #9BCACC;
font-size: 14px;
@ -1439,31 +1552,31 @@
background-color: rgba(148, 251, 224, 0.15);
color: #94FBE0;
border-radius: 9999px;
font-size: 0.875rem;
font-size: 14px;
font-weight: 500;
}
.social-connect-title {
font-size: 1.5rem;
font-size: 24px;
font-weight: 600;
color: #FFFFFF;
margin-bottom: 0.75rem;
}
.social-connect-message {
font-size: 1rem;
font-size: 16px;
color: rgba(255, 255, 255, 0.7);
margin-bottom: 0.5rem;
}
.social-connect-detail {
font-size: 0.875rem;
font-size: 14px;
color: rgba(255, 255, 255, 0.5);
margin-bottom: 1.5rem;
}
.social-connect-countdown {
font-size: 0.875rem;
font-size: 14px;
color: #94FBE0;
margin-bottom: 1rem;
}
@ -1478,7 +1591,7 @@
width: 100%;
padding: 0.875rem 1.5rem;
border-radius: 9999px;
font-size: 0.875rem;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
@ -1553,8 +1666,20 @@
background-color: var(--color-bg-darker);
}
/* 내 콘텐츠/캘린더 탭은 그리드형 화면이라 900px 폭 제한을 해제한다 */
.myinfo-page--wide {
max-width: none;
}
/* 내 정보 탭에 임베드된 내 콘텐츠: 단독 페이지용 여백을 제거해 이중 여백 방지 */
.ado2-contents-page--embedded {
padding: 0;
min-height: auto;
background-color: transparent;
}
.myinfo-title {
font-size: 1.75rem;
font-size: 28px;
font-weight: 700;
color: #FFFFFF;
margin-bottom: 1.5rem;
@ -1563,7 +1688,6 @@
/* 탭 네비게이션 */
.myinfo-tabs {
display: flex;
gap: 0.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
margin-bottom: 2rem;
}
@ -1573,7 +1697,7 @@
background: none;
border: none;
color: rgba(255, 255, 255, 0.5);
font-size: 0.9rem;
font-size: 16px;
font-weight: 500;
cursor: pointer;
position: relative;
@ -1612,7 +1736,7 @@
}
.myinfo-section-title {
font-size: 0.875rem;
font-size: 14px;
font-weight: 500;
color: rgba(255, 255, 255, 0.6);
text-transform: uppercase;
@ -1621,7 +1745,7 @@
.myinfo-placeholder {
color: rgba(255, 255, 255, 0.5);
font-size: 0.9rem;
font-size: 14px;
padding: 2rem;
text-align: center;
background: rgba(255, 255, 255, 0.03);
@ -1647,21 +1771,21 @@
.myinfo-credits-label {
color: rgba(255, 255, 255, 0.6);
font-size: 0.95rem;
font-size: 16px;
}
.myinfo-credits-value {
color: #a6ffea;
font-size: 1.4rem;
font-size: 22px;
font-weight: 700;
letter-spacing: 0.02em;
}
.myinfo-credits-desc {
color: rgba(255, 255, 255, 0.4);
font-size: 0.85rem;
margin: 0;
font-size: 14px;
margin-right: 5px;
flex: 1;
}
@ -1671,7 +1795,7 @@
border: none;
border-radius: 8px;
color: #002224;
font-size: 0.875rem;
font-size: 14px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
@ -1709,7 +1833,7 @@
.myinfo-popup-title {
color: #fff;
font-size: 1.05rem;
font-size: 16px;
font-weight: 700;
margin: 0 0 0.5rem;
align-self: flex-start;
@ -1717,7 +1841,7 @@
.myinfo-popup-message {
color: #fff;
font-size: 1.1rem;
font-size: 18px;
font-weight: 600;
text-align: center;
margin: 0;
@ -1732,7 +1856,7 @@
.myinfo-popup-label {
color: rgba(255, 255, 255, 0.6);
font-size: 0.85rem;
font-size: 14px;
}
.myinfo-popup-counter {
@ -1748,7 +1872,7 @@
background: rgba(255, 255, 255, 0.08);
border: none;
color: #fff;
font-size: 1.25rem;
font-size: 20px;
width: 48px;
min-width: 48px;
height: 44px;
@ -1766,7 +1890,7 @@
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
color: #fff;
font-size: 0.95rem;
font-size: 16px;
padding: 0.6rem 0.875rem;
outline: none;
width: 100%;
@ -1798,7 +1922,7 @@
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
color: #fff;
font-size: 0.9rem;
font-size: 14px;
padding: 0.6rem 0.875rem;
outline: none;
resize: none;
@ -1825,7 +1949,7 @@
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
padding: 0.6rem 1.25rem;
font-size: 0.9rem;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
}
@ -1840,7 +1964,7 @@
border: none;
border-radius: 8px;
padding: 0.6rem 1.5rem;
font-size: 0.9rem;
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: opacity 0.2s;
@ -1864,14 +1988,14 @@
}
.myinfo-business-empty-title {
font-size: 1.1rem;
font-size: 18px;
font-weight: 600;
color: #FFFFFF;
margin-bottom: 0.5rem;
}
.myinfo-business-empty-desc {
font-size: 0.85rem;
font-size: 14px;
color: rgba(255, 255, 255, 0.5);
margin-bottom: 1.25rem;
}
@ -1889,7 +2013,7 @@
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: #FFFFFF;
font-size: 0.9rem;
font-size: 14px;
}
.myinfo-business-input::placeholder {
@ -1907,7 +2031,7 @@
border: none;
border-radius: 8px;
color: #002224;
font-size: 0.875rem;
font-size: 14px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
@ -1935,7 +2059,7 @@
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
color: #FFFFFF;
font-size: 0.875rem;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
@ -2018,13 +2142,13 @@
}
.myinfo-connected-channel {
font-size: 0.9rem;
font-size: 14px;
font-weight: 600;
color: #FFFFFF;
}
.myinfo-connected-platform {
font-size: 0.7rem;
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
text-transform: uppercase;
letter-spacing: 0.05em;
@ -2040,7 +2164,7 @@
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
font-size: 12px;
font-weight: 500;
color: #a6ffea;
}
@ -2051,7 +2175,7 @@
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
color: rgba(255, 255, 255, 0.6);
font-size: 0.75rem;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
@ -2063,7 +2187,7 @@
}
.myinfo-loading {
font-size: 0.85rem;
font-size: 14px;
color: rgba(255, 255, 255, 0.5);
text-align: center;
padding: 0.5rem;
@ -2077,7 +2201,7 @@
}
.myinfo-title {
font-size: 1.5rem;
font-size: 24px;
}
.myinfo-tabs {
@ -2093,7 +2217,7 @@
.myinfo-tab {
white-space: nowrap;
padding: 0.625rem 0.75rem;
font-size: 0.8rem;
font-size: 14px;
}
.myinfo-business-input-row {

View File

@ -232,7 +232,7 @@
color: var(--color-bg-dark);
padding: 0.125rem 0.5rem;
border-radius: var(--radius-md);
font-size: 9px;
font-size: 10px;
font-weight: 700;
box-shadow: 0 20px 25px -5px rgba(166, 255, 234, 0.2);
}
@ -269,7 +269,7 @@
.chart-xaxis {
margin-top: 0.75rem;
padding: 0 1rem;
font-size: 9px;
font-size: 10px;
padding-top: 0.75rem;
}
}
@ -601,7 +601,7 @@
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 9px;
font-size: 10px;
font-weight: 500;
}
@ -699,7 +699,7 @@
@media (min-width: 640px) {
.yoy-chart-xaxis {
font-size: 9px;
font-size: 10px;
margin-top: 0.75rem;
}
}

View File

@ -4,7 +4,7 @@
/* Completion Page Title */
.completion-title {
font-size: 1.75rem;
font-size: 28px;
font-weight: 600;
color: #E5F1F2;
line-height: 1.19;
@ -16,7 +16,7 @@
@media (min-width: 768px) {
.completion-title {
font-size: 2.5rem;
font-size: 40px;
margin-bottom: 2rem;
}
}
@ -185,7 +185,7 @@
}
.video-overlay-title {
font-size: 1.25rem;
font-size: 20px;
font-weight: 800;
color: #FFFFFF;
line-height: 1.3;
@ -196,7 +196,7 @@
}
.video-overlay-logo {
font-size: 0.375rem;
font-size: 6px;
font-weight: 400;
color: rgba(255, 255, 255, 0.6);
line-height: 1.3;
@ -225,7 +225,7 @@
}
.ai-optimization-title {
font-size: 0.875rem;
font-size: 14px;
font-weight: 600;
color: #94FBE0;
line-height: 1.19;
@ -264,7 +264,7 @@
}
.ai-tag-text {
font-size: 0.875rem;
font-size: 14px;
font-weight: 400;
color: #E5F1F2;
line-height: 1.19;
@ -336,7 +336,7 @@
}
.completion-social-name {
font-size: 0.875rem;
font-size: 14px;
font-weight: 400;
color: #FFFFFF;
line-height: 1.19;
@ -344,7 +344,7 @@
}
.completion-social-email {
font-size: 0.75rem;
font-size: 12px;
font-weight: 400;
color: #94FBE0;
line-height: 1.19;
@ -359,7 +359,7 @@
}
.completion-social-channel {
font-size: 0.75rem;
font-size: 12px;
font-weight: 400;
color: #94FBE0;
line-height: 1.19;
@ -377,14 +377,14 @@
}
.completion-social-channel-name {
font-size: 0.875rem;
font-size: 14px;
font-weight: 600;
color: #FFFFFF;
line-height: 1.2;
}
.completion-social-platform {
font-size: 0.7rem;
font-size: 12px;
font-weight: 400;
color: rgba(255, 255, 255, 0.5);
line-height: 1.2;
@ -405,7 +405,7 @@
/* Social Card Status */
.completion-social-status {
font-size: 0.75rem;
font-size: 12px;
font-weight: 500;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
@ -438,7 +438,7 @@
}
.completion-social-disconnect {
font-size: 0.75rem;
font-size: 12px;
font-weight: 500;
color: #f87171;
background: transparent;
@ -461,7 +461,7 @@
border: none;
border-radius: var(--radius-full);
color: #FFFFFF;
font-size: 0.875rem;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all var(--transition-normal);
@ -486,7 +486,7 @@
border: 1px solid #AE72F9;
border-radius: var(--radius-full);
color: #FFFFFF;
font-size: 0.875rem;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all var(--transition-normal);
@ -531,7 +531,7 @@
gap: 4px;
background-color: #462E64;
color: #CFABFB;
font-size: 0.875rem;
font-size: 14px;
font-weight: 600;
padding: 0 1.25rem 0 0.5rem;
height: 36px;
@ -1262,7 +1262,7 @@
.report-content {
color: #CEE5E6;
font-family: 'Pretendard', sans-serif;
font-size: 15px;
font-size: 16px;
font-weight: 500;
line-height: 1.5;
letter-spacing: -0.006em;
@ -1270,7 +1270,7 @@
@media (min-width: 768px) {
.report-content {
font-size: 17px;
font-size: 18px;
line-height: 1.45;
}
}
@ -1385,7 +1385,7 @@
.selling-point-content p {
font-family: 'Pretendard', sans-serif;
font-size: 15px;
font-size: 16px;
font-weight: 500;
color: #CEE5E6;
letter-spacing: -0.006em;
@ -1395,7 +1395,7 @@
@media (min-width: 768px) {
.selling-point-content p {
font-size: 17px;
font-size: 18px;
line-height: 1;
}
}
@ -1419,7 +1419,7 @@
background-color: #034A4D;
border-radius: 999px;
font-family: 'Pretendard', sans-serif;
font-size: 15px;
font-size: 16px;
font-weight: 500;
color: #CEE5E6;
letter-spacing: -0.006em;
@ -1428,7 +1428,7 @@
@media (min-width: 768px) {
.feature-tag {
font-size: 17px;
font-size: 18px;
}
}
@ -1665,7 +1665,7 @@
}
.bi-usp-category {
font-size: 13px;
font-size: 14px;
font-weight: 500;
color: #6AB0B3;
text-transform: uppercase;
@ -1678,7 +1678,7 @@
}
.bi-usp-description {
font-size: 15px;
font-size: 16px;
font-weight: 400;
color: rgba(255, 255, 255, 0.7);
line-height: 1.5;
@ -1694,7 +1694,7 @@
}
.bi-usp-english-category {
font-size: 13px;
font-size: 14px;
font-weight: 500;
color: #6AB0B3;
text-transform: uppercase;
@ -1749,7 +1749,7 @@
}
.bi-chart-sublabel {
font-size: 13px;
font-size: 14px;
fill: rgba(255, 255, 255, 0.5);
font-weight: 500;
}
@ -1808,10 +1808,6 @@
body:has(.sidebar.expanded) .bi2-header {
left: 15rem;
}
body:has(.sidebar.collapsed) .bi2-header {
left: 5rem;
}
}
.bi2-back-btn {
@ -1889,7 +1885,7 @@
}
.bi2-subtitle {
font-size: 21px;
font-size: 22px;
font-weight: 400;
color: #E5F1F2;
letter-spacing: -0.006em;
@ -2294,9 +2290,12 @@
body:has(.sidebar.expanded) .bi2-bottom-button-container {
left: 15rem;
}
}
body:has(.sidebar.collapsed) .bi2-bottom-button-container {
left: 5rem;
/* 모바일: 하단 탭바에 가리지 않도록 띄운다 */
@media (max-width: 767px) {
.bi2-bottom-button-container {
bottom: calc(var(--bottom-nav-height) + 16px);
}
}
@ -2358,7 +2357,7 @@
color: var(--Color-white, #FFF);
text-align: center;
font-family: Pretendard;
font-size: 45px;
font-size: 46px;
font-style: normal;
font-weight: 600;
line-height: normal;
@ -2388,12 +2387,19 @@
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
/* `justify-content: center` 쓰지 않는다.
중앙정렬 상태에서 내용이 컨테이너보다 길어지면 위아래로 똑같이 넘치는데,
**위로 넘친 부분은 스크롤로도 닿을 없다**(flexbox 중앙정렬의 알려진 함정).
실제로 무빙 포스터 탭에서 로고 밴드가 47px 잘려 보였다.
대신 자식의 `margin: auto 0` 으로 중앙에 놓는다 auto 마진은 여백이 있을 때만
중앙정렬하고, 넘칠 때는 0 되어 위에서부터 정상 스크롤된다. */
justify-content: flex-start;
background-color: var(--color-bg-darker);
color: var(--color-text-white);
padding: 80px 1rem 100px;
position: relative;
overflow: hidden;
overflow-y: auto;
overflow-x: hidden;
box-sizing: border-box;
}
@ -2403,44 +2409,194 @@
align-items: center;
z-index: 20;
position: relative;
margin-top: 50px;
/* 세로 중앙정렬은 auto 마진으로 한다 (부모의 justify-content 주석 참조).
넘칠 잘리지 않는 유일한 방법이다 */
margin: auto 0;
width: 100%;
max-width: 375px;
}
/* 진입 화면에 들어가는 폼은 대부분 입력이라 375px 충분하지만,
포스터 스타일링(F2) 템플릿 그리드·입출력 2단이 화면에 있는 **도구**
폭에서는 쓴다.
(세로 넘침 처리는 이제 컨테이너 기본 규칙이 담당한다 예외를 두지 않는다) */
.url-input-content--wide {
max-width: 900px;
}
/* 375px 화면에서는 타일 4개 + 라벨이 폭을 넘긴다. 타일과 글자를 함께 줄여
줄을 지킨다 라벨이 줄바꿈되면 타일 정렬이 어긋난다 */
/* =====================================================
PipelineTabs ADO2 / 썰박스 전환 (밑줄 )
진입 화면에서 로고와 하위 사이에 형제로 얹힌다.
컨테이너 하단의 옅은 전체 구분선 위로, 활성 아래에만
진한 밑줄이 얹힌다. 밑줄 색은 탭에 따라 민트앰버로 바뀐다.
===================================================== */
/* 간격 체계 (8px 그리드 · Material 3 / 수직 리듬 가이드 기준)
8~12px 묶음 (라벨 라벨이 설명하는 , 입력 입력의 안내)
20px 묶음 사이
32px 섹션 사이 (주제가 바뀜)
이전에는 전부 10~24px 이라 "붙은 것" "다른 것" 구분되지 않아 답답해 보였다.
근접성(Gestalt) 거리 차이로만 전달되므로 값이 고르면 정보 구조가 사라진다. */
.pipeline-tabs {
position: relative;
display: flex;
justify-content: center;
/* 터치 타겟은 8dp 이상 떨어뜨린다(Material 3). 4px 이면 옆 타일 오탭이 난다 */
gap: 8px;
width: 100%;
margin-bottom: 20px;
}
.pipeline-tab {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
flex: 1;
/* 타일(52px) + 라벨로 터치 타겟이 이미 충분하다. 좌우 패딩은 라벨이
이웃 타일에 붙지 않을 만큼만 */
padding: 4px 2px;
border: none;
background: none;
font-family: inherit;
color: var(--color-text-gray-400);
cursor: pointer;
transition: color var(--transition-normal);
}
/* 아이콘 타일 — 라운드 스퀘어. 아이콘은 currentColor 라 타일이 색을 정한다 */
.pipeline-tab__tile {
display: flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
border-radius: var(--radius-xl);
border: 1px solid var(--color-border-white-10);
background: var(--color-bg-card-inner);
transition: background-color var(--transition-normal),
border-color var(--transition-normal),
color var(--transition-normal),
box-shadow var(--transition-normal);
}
.pipeline-tab__label {
font-size: 13px;
font-weight: 600;
letter-spacing: -0.006em;
white-space: nowrap;
transition: color var(--transition-normal);
}
.pipeline-tab:hover {
color: var(--color-text-white);
}
.pipeline-tab:hover .pipeline-tab__tile {
border-color: rgba(255, 255, 255, 0.25);
}
/* 선택 상태는 사이드바 활성 항목(.sidebar-item.active) 같은 문법
민트로 채우고 아이콘을 어두운 딥틸로 뒤집는다. 화면에서 가장 강한 표시이므로
라벨까지 흰색으로 올려 타일과 덩어리로 읽히게 한다 */
.pipeline-tab[aria-selected="true"] {
color: var(--color-bg-dark);
}
.pipeline-tab[aria-selected="true"] .pipeline-tab__tile {
background: var(--color-mint);
border-color: var(--color-mint);
box-shadow: var(--shadow-mint-glow);
}
.pipeline-tab[aria-selected="true"] .pipeline-tab__label {
color: var(--color-text-white);
font-weight: 700;
}
@media (prefers-reduced-motion: reduce) {
.pipeline-tab,
.pipeline-tab__tile,
.pipeline-tab__label {
transition: none;
}
}
/* 고른 기능의 설명. 탭과는 붙이고(20px), 아래 폼과는 섹션 간격(32px) 둔다
설명은 탭에 딸린 것이지 폼의 일부가 아니다 */
.pipeline-desc {
width: 100%;
margin: 0 0 32px;
font-size: 14px;
line-height: 1.6;
letter-spacing: -0.006em;
text-align: center;
color: var(--color-text-gray-300);
/* 한글은 기본값(word-break: normal)이면 단어 중간에서 잘린다 "애니메이/션".
keep-all 어절 단위 줄바꿈을 강제한다(한국어 조판 표준) */
word-break: keep-all;
/* 두 줄이 될 때 길이를 고르게 나눠 한쪽만 길게 늘어지지 않게 한다 */
text-wrap: balance;
}
/* 좁은 화면 조정.
** 블록은 반드시 기본 규칙들보다 뒤에 있어야 한다** 미디어 쿼리는 명시도를
올리지 않아서, 앞에 두면 뒤에 오는 같은 명시도의 기본 규칙에 그대로 덮인다
(실제로 그렇게 두었다가 모바일에서 타일이 48px 줄지 않았다). */
@media (max-width: 420px) {
/* 타일은 48dp 최소 터치 타겟이라 줄이지 않는다. 라벨만 줄여 줄을 지키고,
타일 사이 8px 유지한다 간격을 없애면 타일 오탭이 난다 */
.pipeline-tab__tile {
width: 48px;
height: 48px;
border-radius: var(--radius-md);
}
.pipeline-tab__label {
font-size: 12px;
}
.pipeline-desc {
margin-bottom: 28px;
font-size: 13px;
}
}
/* 썰박스 탭의 임시 자리 (Phase 7 에서 SsulCreateForm 으로 교체) */
.pipeline-placeholder {
width: 100%;
padding: 40px 16px;
border: 1px dashed var(--color-border-white-10);
border-radius: 16px;
text-align: center;
font-size: 14px;
color: var(--color-text-gray-400);
}
.url-input-icon {
color: #AE72F9;
margin-bottom: 24px;
}
.url-input-logo {
margin-bottom: 1rem;
/* 로고 → 탭은 "묶음 사이" 간격 (generation-flow.css 상단 간격 체계 참조) */
margin-bottom: 20px;
}
/* 로고는 **아래 열보다 넓어질 없다.**
이전에는 화면이 커질수록 350 400 500 554px 키웠는데, 정작 열은
375px 고정이라 데스크톱에서 로고만 좌우로 90px 삐져나왔다.
min() 으로 폭과 상한을 동시에 걸면 어느 폭에서도 넘지 않는다
브레이크포인트를 늘려도 다시 어긋날 일이 없다. */
.url-input-logo img {
width: 350px;
max-width: 100%;
display: block;
width: min(100%, 280px);
height: auto;
}
@media (min-width: 640px) {
.url-input-logo img {
width: 400px;
}
}
@media (min-width: 768px) {
.url-input-logo img {
width: 500px;
}
}
@media (min-width: 1024px) {
.url-input-logo img {
width: 554px;
}
}
.url-input-title {
font-family: 'Pretendard', sans-serif;
font-size: 40px;
@ -2714,12 +2870,6 @@
box-sizing: border-box;
}
@media (max-width: 1100px) {
.wizard-stepper {
margin: 50px auto 0;
}
}
/* 노드 셀: 노드 너비와 동일한 2rem 고정. 라벨은 이 밖으로 넘침 */
.wizard-step {
flex: none;

View File

@ -10,3 +10,12 @@
@import './studio-assets.css';
@import './contents-social.css';
@import './modals-overlays.css';
/* 썰박스 UI 모든 규칙이 .ssul-scope 안에 갇혀 있어 castad 화면에 영향 없음.
ssulbox.css 스크립트 생성 파일이므로 직접 수정 금지 (파일 상단 주석 참조).
castad 안에서 새로 만든 화면의 스타일은 ssulbox-castad.css 둔다. */
@import './ssulbox.css';
@import './ssulbox-castad.css';
/* Poster to Video (F1 영상 · F2 스타일링) — 모든 규칙이 `.p2v-scope` 안에 갇혀 있다 */
@import './poster-p2v.css';

View File

@ -129,7 +129,7 @@
/* Hero Form */
.hero-form {
padding-top: 50px;
padding-top: 20px;
width: 100%;
max-width: 375px;
display: flex;
@ -384,7 +384,7 @@
width: 100%;
gap: 12px;
color: var(--color-text-gray-300);
font-size: 13px;
font-size: 14px;
}
.hero-divider::before,
@ -581,7 +581,7 @@
}
.welcome-title {
font-size: 1.5rem;
font-size: 24px;
font-weight: 600;
margin-bottom: 1rem;
color: #E5F1F2;
@ -591,13 +591,13 @@
@media (min-width: 640px) {
.welcome-title {
font-size: 2rem;
font-size: 32px;
}
}
@media (min-width: 768px) {
.welcome-title {
font-size: 2.5rem;
font-size: 40px;
}
}
@ -617,7 +617,7 @@
@media (min-width: 768px) {
.welcome-subtitle {
font-size: 1.5rem;
font-size: 24px;
}
}
@ -833,7 +833,7 @@
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
font-size: 20px;
font-weight: 600;
color: #9BCACC;
letter-spacing: -0.006em;
@ -850,7 +850,7 @@
@media (min-width: 768px) {
.feature-title {
font-size: 1.5rem;
font-size: 24px;
}
}
@ -1425,18 +1425,18 @@
@media (min-width: 768px) {
.login-title {
font-size: 3rem;
font-size: 48px;
}
}
@media (min-width: 1024px) {
.login-title {
font-size: 3.75rem;
font-size: 60px;
}
}
.login-subtitle {
font-size: 9px;
font-size: 10px;
font-weight: 300;
margin-bottom: 1.5rem;
opacity: 0.8;
@ -1509,7 +1509,7 @@
}
.loading-title {
font-size: 2rem;
font-size: 32px;
font-weight: 700;
letter-spacing: -0.006em;
line-height: 1.19;

View File

@ -5,6 +5,10 @@
.social-posting-overlay {
position: fixed;
inset: 0;
/* 마운트 위치의 텍스트 정렬을 상속하지 않는다.
position:fixed 라도 DOM 상속은 끊기지 않아, 중앙정렬된 부모
(.ssul-scope.ssul-result ) 안에서 열면 라벨까지 가운데로 쏠린다. */
text-align: left;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
@ -134,7 +138,7 @@
background: rgba(166, 130, 255, 0.15);
border: 1px solid rgba(166, 130, 255, 0.3);
border-radius: 6px;
font-size: 0.75rem;
font-size: 12px;
font-weight: 500;
color: #a682ff;
}
@ -149,7 +153,7 @@
border: 1px dashed rgba(255, 255, 255, 0.2);
border-radius: 6px;
color: rgba(255, 255, 255, 0.4);
font-size: 1rem;
font-size: 16px;
cursor: pointer;
} */
@ -277,7 +281,7 @@
position: absolute;
right: 0;
top: 0;
font-size: 0.7rem;
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
}
@ -286,11 +290,11 @@
padding: 1rem;
text-align: center;
color: rgba(255, 255, 255, 0.5);
font-size: 0.85rem;
font-size: 14px;
}
.social-posting-no-accounts-hint {
font-size: 0.75rem;
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
margin-top: 0.25rem;
}
@ -351,7 +355,7 @@
}
.mini-calendar-title {
font-size: 0.85rem;
font-size: 14px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
}
@ -360,7 +364,7 @@
background: none;
border: none;
color: rgba(255, 255, 255, 0.6);
font-size: 1.2rem;
font-size: 20px;
cursor: pointer;
padding: 2px 8px;
border-radius: 6px;
@ -381,7 +385,7 @@
.mini-calendar-day-label {
text-align: center;
font-size: 0.72rem;
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
padding: 4px 0;
}
@ -390,7 +394,7 @@
background: none;
border: none;
color: rgba(255, 255, 255, 0.85);
font-size: 0.78rem;
font-size: 12px;
cursor: pointer;
padding: 5px 2px;
border-radius: 6px;
@ -431,7 +435,7 @@
}
.schedule-time-label {
font-size: 0.8rem;
font-size: 12px;
color: rgba(255, 255, 255, 0.6);
font-weight: 500;
}
@ -447,7 +451,7 @@
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
color: rgba(255, 255, 255, 0.9);
font-size: 0.85rem;
font-size: 14px;
padding: 8px 10px;
cursor: pointer;
outline: none;
@ -464,7 +468,7 @@
}
.schedule-datetime-preview {
font-size: 0.8rem;
font-size: 12px;
color: #a65eff;
font-weight: 600;
margin: 0;
@ -481,7 +485,7 @@
}
.social-posting-footer-note {
font-size: 0.75rem;
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
margin: 0;
}
@ -499,7 +503,7 @@
.social-posting-btn {
padding: 0.625rem 1.25rem;
border-radius: 8px;
font-size: 0.875rem;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
@ -615,7 +619,7 @@
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: #FFFFFF;
font-size: 0.875rem;
font-size: 14px;
font-family: 'Pretendard', sans-serif;
cursor: pointer;
display: flex;
@ -679,7 +683,7 @@
background: transparent;
border: none;
color: #FFFFFF;
font-size: 0.875rem;
font-size: 14px;
font-family: 'Pretendard', sans-serif;
cursor: pointer;
display: flex;
@ -740,7 +744,7 @@
}
.upload-progress-title {
font-size: 1.125rem;
font-size: 18px;
font-weight: 600;
color: #FFFFFF;
margin: 0;
@ -831,7 +835,7 @@
/* Status Text */
.upload-progress-status {
font-size: 1.25rem;
font-size: 20px;
font-weight: 600;
color: #FFFFFF;
margin: 0;
@ -875,7 +879,7 @@
display: block;
text-align: center;
margin-top: 0.5rem;
font-size: 0.875rem;
font-size: 14px;
color: rgba(255, 255, 255, 0.7);
}
@ -901,13 +905,13 @@
}
.upload-progress-label {
font-size: 0.8125rem;
font-size: 14px;
color: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
.upload-progress-value {
font-size: 0.875rem;
font-size: 14px;
color: #FFFFFF;
text-align: right;
word-break: break-word;
@ -924,7 +928,7 @@
.upload-progress-error p {
margin: 0;
font-size: 0.875rem;
font-size: 14px;
color: #f87171;
text-align: center;
}
@ -935,9 +939,9 @@
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: #FF0000;
background: #f87171;
color: #FFFFFF;
font-size: 0.875rem;
font-size: 14px;
font-weight: 500;
text-decoration: none;
border-radius: 8px;
@ -964,7 +968,7 @@
}
.upload-progress-calendar-link {
font-size: 0.8rem;
font-size: 12px;
color: rgba(255, 255, 255, 0.45);
cursor: pointer;
text-decoration: underline;
@ -978,7 +982,7 @@
.upload-progress-btn {
padding: 0.75rem 2rem;
border-radius: 8px;
font-size: 0.875rem;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
@ -997,7 +1001,7 @@
.upload-progress-note {
margin: 0;
font-size: 0.8125rem;
font-size: 14px;
color: rgba(255, 255, 255, 0.5);
text-align: center;
}
@ -1024,397 +1028,8 @@
.calendar-panel-scroll {
scrollbar-width: thin;
scrollbar-color: #067C80 transparent;
}
/* =====================================================
Tutorial Overlay
===================================================== */
.tutorial-overlay-root {
position: fixed;
inset: 0;
z-index: 10000;
pointer-events: none;
}
.tutorial-overlay-svg {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
cursor: default;
}
.tutorial-overlay-blocker {
position: fixed;
background: rgba(0, 0, 0, 0.72);
pointer-events: all;
}
@keyframes tutorial-pulse {
0%, 100% {
border-color: rgba(166, 255, 234, 0.9);
box-shadow: 0 0 0 2px rgba(166, 255, 234, 0.3),
0 0 16px 4px rgba(166, 255, 234, 0.35),
0 0 40px 8px rgba(166, 255, 234, 0.15);
}
50% {
border-color: rgba(166, 255, 234, 0.5);
box-shadow: 0 0 0 5px rgba(166, 255, 234, 0.12),
0 0 32px 10px rgba(166, 255, 234, 0.5),
0 0 64px 20px rgba(166, 255, 234, 0.2);
}
}
.tutorial-spotlight-ring {
position: fixed;
border: 4px solid rgba(166, 255, 234, 0.85);
border-radius: 12px;
box-shadow: 0 0 0 1px rgba(166, 255, 234, 0.2), 0 0 12px 2px rgba(166, 255, 234, 0.15);
pointer-events: none;
z-index: 10001;
animation: tutorial-pulse 2s ease-in-out infinite;
}
.tutorial-tooltip {
position: fixed;
pointer-events: all;
z-index: 10002;
display: flex;
width: 370px;
padding: 20px;
flex-direction: column;
gap: 8px;
border-radius: 12px;
border: 1px solid var(--Color-teal-600, #046266);
background: var(--Color-teal-800, #002224);
}
.tutorial-tooltip-title {
align-self: stretch;
color: rgba(255, 255, 255, 0.70);
/* 14 */
font-family: Pretendard;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
}
.tutorial-tooltip-desc {
display: flex;
padding-bottom: 16px;
flex-direction: column;
align-items: flex-start;
gap: 4px;
align-self: stretch;
line-height: 1.5;
white-space: pre-line;
align-self: stretch;
color: var(--Color-white, #FFF);
font-family: Pretendard;
font-size: 18px;
font-style: normal;
font-weight: 600;
line-height: 24px; /* 133.333% */
letter-spacing: -0.108px;
}
.tutorial-tooltip-note {
align-self: stretch;
color: rgba(255, 255, 255, 0.60);
font-family: Pretendard;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 100%;
letter-spacing: -0.072px;
margin: -8px 0 14px;
white-space: pre-line;
}
.tutorial-tooltip-footer {
display: flex;
align-items: center;
justify-content: space-between;
}
.tutorial-tooltip-footer-left {
display: flex;
align-items: center;
gap: 8px;
}
.tutorial-tooltip-counter {
color: rgba(255, 255, 255, 0.60);
font-family: Pretendard;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 100%; /* 12px */
letter-spacing: -0.072px;
}
.tutorial-tooltip-actions {
display: flex;
gap: 10px;
}
.tutorial-btn-skip {
color: rgba(255, 255, 255, 0.60);
font-family: Pretendard;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 100%; /* 12px */
letter-spacing: -0.072px;
}
.tutorial-btn-skip:hover {
color: rgba(255, 255, 255, 0.7);
}
.tutorial-btn-prev {
display: flex;
padding: 8px 12px;
justify-content: center;
align-items: center;
gap: 10px;
border-radius: 4px;
background: var(--Color-teal-200, #9BCACC);
color: var(--Color-teal-800, #002224);
font-family: Pretendard;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 100%; /* 13px */
letter-spacing: -0.078px;
}
.tutorial-btn-prev:hover {
background: rgba(255, 255, 255, 0.14);
}
.tutorial-btn-next {
display: flex;
padding: 8px 12px;
justify-content: center;
align-items: center;
gap: 10px;
border-radius: 4px;
background: var(--Color-teal-200, #9BCACC);
color: var(--Color-teal-800, #002224);
font-family: Pretendard;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 100%; /* 13px */
letter-spacing: -0.078px;
}
.tutorial-btn-next:hover {
background: rgba(255, 255, 255, 0.14);
}
/* 말풍선 variant — 색상은 기본 tooltip과 동일, 꼬리만 추가 */
.tutorial-tooltip-bubble {
border-radius: 12px;
}
/* 꼬리 (화살표) */
.tutorial-tooltip-bubble::before {
content: '';
position: absolute;
width: 0;
height: 0;
border: 10px solid transparent;
}
.tutorial-tooltip-bubble--bottom::before {
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-bottom-color: #1a2630;
}
.tutorial-tooltip-bubble--top::before {
top: 100%;
left: 50%;
transform: translateX(-50%);
border-top-color: #1a2630;
}
.tutorial-tooltip-bubble--right::before {
right: 100%;
top: 50%;
transform: translateY(-50%);
border-right-color: #1a2630;
}
.tutorial-tooltip-bubble--left::before {
left: 100%;
top: 50%;
transform: translateY(-50%);
border-left-color: #1a2630;
}
.tutorial-tooltip-action-hint {
font-size: 11px;
color: #a6ffea;
margin: 8px 0 0;
opacity: 0.8;
}
/* 튜토리얼 재시작 팝업 */
.tutorial-restart-backdrop {
position: fixed;
inset: 0;
z-index: 10100;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
}
.tutorial-restart-popup {
background: #1a2630;
border: 1px solid rgba(166, 255, 234, 0.25);
border-radius: 14px;
padding: 24px 28px;
width: 320px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6);
}
.tutorial-restart-title {
font-size: 16px;
font-weight: 700;
color: #a6ffea;
margin: 0 0 10px;
}
.tutorial-restart-desc {
font-size: 13px;
color: rgba(255, 255, 255, 0.65);
margin: 0 0 20px;
line-height: 1.5;
}
.tutorial-restart-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.tutorial-restart-cancel {
font-size: 13px;
color: rgba(255, 255, 255, 0.5);
background: rgba(255, 255, 255, 0.08);
border: none;
border-radius: 8px;
cursor: pointer;
padding: 7px 16px;
}
.tutorial-restart-cancel:hover {
background: rgba(255, 255, 255, 0.14);
color: rgba(255, 255, 255, 0.8);
}
.tutorial-restart-confirm {
font-size: 13px;
font-weight: 600;
color: #0d1a1f;
background: #a6ffea;
border: none;
border-radius: 8px;
cursor: pointer;
padding: 7px 18px;
}
.tutorial-restart-confirm:hover {
background: #c0fff2;
}
/* 사이드바 튜토리얼 버튼 */
.tutorial-restart-fab {
position: fixed;
top: 16px;
right: 20px;
z-index: 900;
display: flex;
align-items: center;
gap: 8px;
padding: 7px 14px;
background: rgba(0, 34, 36, 0.85);
border: 1px solid rgba(166, 255, 234, 0.25);
border-radius: 20px;
color: rgba(255, 255, 255, 0.65);
cursor: pointer;
font-size: 13px;
backdrop-filter: blur(6px);
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.tutorial-restart-fab:hover {
background: rgba(166, 255, 234, 0.12);
border-color: rgba(166, 255, 234, 0.5);
color: #a6ffea;
}
/* 튜토리얼 토글 버튼 */
.tutorial-toggle-fab {
position: fixed;
top: 16px;
right: 20px;
z-index: 900;
display: flex;
align-items: center;
gap: 8px;
padding: 7px 14px;
background: rgba(0, 34, 36, 0.85);
border: 1px solid rgba(166, 255, 234, 0.25);
border-radius: 20px;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
font-size: 13px;
backdrop-filter: blur(6px);
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.tutorial-toggle-fab:hover {
background: rgba(166, 255, 234, 0.12);
border-color: rgba(166, 255, 234, 0.5);
color: #a6ffea;
}
.tutorial-toggle-fab.active {
color: #a6ffea;
border-color: rgba(166, 255, 234, 0.4);
}
@media (max-width: 767px) {
.tutorial-toggle-fab {
display: none;
}
}
.tutorial-toggle-badge {
font-size: 10px;
font-weight: 700;
padding: 2px 6px;
border-radius: 4px;
transition: background 0.15s, color 0.15s;
}
.tutorial-toggle-badge.on {
background: #a6ffea;
color: #002224;
}
.tutorial-toggle-badge.off {
background: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.4);
/* 패널 끝까지 스크롤해도 뒤의 캘린더/페이지로 스크롤이 전파되지 않게 한다 */
overscroll-behavior: contain;
}
/* BusinessNameInputModal */
@ -1453,7 +1068,7 @@
}
.manual-modal-subtitle {
font-size: 15px;
font-size: 16px;
font-weight: 400;
color: rgba(155, 202, 204, 0.7);
padding: 15px 20px 0;

939
src/styles/poster-p2v.css Normal file
View File

@ -0,0 +1,939 @@
/* =====================================================
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; }
}

View File

@ -0,0 +1,493 @@
/* =====================================================
썰박스 castad 통합용 신규 컴포넌트 스타일
ssulbox.css 원본에서 스크립트로 변환한 파일이라 직접 수정하지 않는다.
castad 안에서 새로 만든 화면(SsulCreateForm ) 스타일은 여기에 둔다.
모든 규칙은 원본과 동일하게 `.ssul-scope` 안에 가둔다.
===================================================== */
/* ssulbox.css `.ssul-scope` 원본에서 전체 화면 (`#app`)이었던 탓에
`overflow: hidden` 갖는다. castad 안에서는 셸이 아니라 ·화면 컨테이너로
쓰므로 클리핑이 **요소 바깥으로 그려지는 것들을 잘라 먹는다**
선택 카드의 (테두리 바깥 1px) 좌우에서 잘리고, 버튼 hover 글로우도 잘린다.
생성물 파일은 직접 고치지 않으므로 여기서 되돌린다(0,2,0 이라 이긴다). */
.ssul-scope.ssul-create,
.ssul-scope.ssul-making {
overflow: visible;
}
/* ── 생성 폼 ─────────────────────────────────────── */
.ssul-scope.ssul-create {
width: 100%;
display: flex;
flex-direction: column;
color: var(--color-text-white);
}
/* 간격은 generation-flow.css 상단의 체계를 따른다 (묶음 안 8~12 / 묶음 사이 20 / 섹션 32) */
.ssul-create__step {
align-self: flex-start;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--color-mint);
/* 라벨과 그 라벨이 설명하는 것은 붙여야 한 덩어리로 읽힌다 */
margin-bottom: 12px;
}
/* 2단계 라벨. 강조를 1단계에만 두어 시선 순서를 만든다 */
.ssul-create__step.muted {
font-weight: 600;
color: var(--color-text-gray-400);
}
/* ── 시나리오 2x2 그리드 ─────────────────────────── */
.ssul-create__scenarios {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
width: 100%;
/* STEP 1 → STEP 2 는 주제가 바뀌는 자리라 섹션 간격 */
margin-bottom: 32px;
}
.ssul-scn {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
padding: 16px 14px;
/* 테두리 두께는 모든 상태에서 1px 고정한다. 선택 굵기를 바꾸면
border-box 안쪽 폭이 줄어 글자가 흔들린다 굵기는 box-shadow 링으로 더한다 */
border: 1px solid var(--color-border-white-10);
border-radius: 14px;
background: var(--color-bg-card);
color: inherit;
font-family: inherit;
text-align: left;
cursor: pointer;
overflow: hidden;
transition: border-color var(--transition-normal), transform var(--transition-normal);
}
.ssul-scn:hover {
border-color: rgba(255, 255, 255, 0.25);
transform: translateY(-1px);
}
/* 선택 표시 — castad .btn-select.selected 와 동일한 문법(민트 테두리 + 민트 틴트) */
.ssul-scn.active {
border-color: var(--color-mint);
background: var(--color-mint-10);
/* 링을 **테두리와 같은 **으로 둔다. 색이 다르면 안쪽 테두리와 바깥 링이
갈라져 줄로 보인다. 같은 색이면 1px 테두리 + 1px 링이 이어져
2px 줄로 읽히고, 테두리 두께를 바꾸지 않아 글자도 흔들리지 않는다. */
box-shadow: 0 0 0 1px var(--color-mint);
}
.ssul-scn.active .ssul-scn__desc {
color: rgba(255, 255, 255, 0.92);
}
.ssul-scn__name {
position: relative;
font-size: 14px;
font-weight: 700;
}
.ssul-scn__desc {
position: relative;
font-size: 12px;
line-height: 1.4;
color: var(--color-text-gray-400);
}
/* ── 업장 검색 (ADO2 식 자동완성) ─────────────────── */
/* position: relative 는 드롭다운(.ssul-places)의 기준점이다 */
.ssul-create__search {
position: relative;
display: flex;
gap: 8px;
width: 100%;
/* 입력과 바로 아래 안내는 한 덩어리 — 붙여 둔다 */
margin-bottom: 10px;
}
/* 폼의 가로 박스(입력·안내·선택결과·제출) 높이를 48px 맞춘다.
제각각이면(입력 46 / 안내 38 / 버튼 48) 세로로 쌓였을 리듬이 깨져
정렬이 틀어진 것처럼 보인다. 48px CTA·터치 타겟 기준값이다. */
.ssul-create__input {
flex: 1;
min-width: 0;
min-height: 48px;
padding: 8px 16px;
border: none;
border-radius: 999px;
background: #fff;
color: #1f2937;
font-family: inherit;
font-size: 14px;
font-weight: 600;
letter-spacing: -0.006em;
}
.ssul-create__input::placeholder {
color: #9ca3af;
font-weight: 500;
}
.ssul-create__search-btn {
flex: none;
padding: 0 18px;
border: 1px solid var(--color-border-white-10);
border-radius: 999px;
background: var(--color-bg-card-inner);
color: var(--color-text-white);
font-family: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: opacity var(--transition-normal);
}
.ssul-create__search-btn:disabled {
opacity: 0.4;
cursor: default;
}
/* ── 검색 결과 / 선택 / 안내 ─────────────────────── */
/* 입력창 아래로 겹쳐 뜨는 자동완성 드롭다운.
문서 흐름에 넣으면 타이핑할 때마다 아래 요소(비용·버튼) 밀려 흔들린다. */
.ssul-places {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 20;
max-height: 260px;
overflow-y: auto;
margin: 0;
padding: 0;
list-style: none;
border: 1px solid var(--color-border-white-10);
border-radius: 12px;
background: var(--color-bg-card);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
}
.ssul-places li + li {
border-top: 1px solid var(--color-border-white-10);
}
.ssul-places button {
display: flex;
flex-direction: column;
gap: 3px;
width: 100%;
padding: 11px 14px;
border: none;
background: var(--color-bg-card);
color: inherit;
font-family: inherit;
text-align: left;
cursor: pointer;
}
.ssul-places button:hover {
background: var(--color-bg-card-inner);
}
.ssul-places b {
font-size: 14px;
font-weight: 700;
}
.ssul-places span {
font-size: 12px;
color: var(--color-text-gray-400);
}
/* 안내(.ssul-hint)와 같은 자리에 번갈아 나오므로 아래 간격도 같아야 한다 */
.ssul-picked {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
/* 안내(.ssul-hint) 같은 자리에 번갈아 나오므로 하한을 맞춘다.
업체명 + 주소 2줄이라 실제로는 이보다 커지는데, 그건 내용이 늘어난 것이라 괜찮다 */
min-height: 48px;
padding: 11px 14px;
margin-bottom: 28px;
border: 1.5px solid var(--color-mint);
border-radius: 12px;
background: var(--color-mint-10);
}
.ssul-picked__body {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
flex: 1;
}
.ssul-picked__body b {
font-size: 14px;
font-weight: 700;
}
.ssul-picked__body span {
font-size: 12px;
color: var(--color-text-gray-400);
}
.ssul-hint {
/* 높이를 입력·제출과 같은 48px 로. 글이 길어 두 줄이 되면 그때만 늘어난다 */
display: flex;
align-items: center;
width: 100%;
min-height: 48px;
padding: 10px 14px;
/* 안내 다음은 비용·제출 묶음이라 섹션 간격을 준다 */
margin-bottom: 28px;
border-radius: 12px;
background: var(--color-bg-card-inner);
font-size: 12px;
line-height: 1.5;
color: var(--color-text-gray-300);
}
.ssul-hint.ok {
color: var(--color-mint);
}
/* 시나리오 확인 안내 — 앞에 점을 찍어 상태 표시임을 드러낸다 */
.ssul-hint.dot {
display: flex;
align-items: center;
gap: 8px;
color: var(--color-text-gray-300);
}
.ssul-hint.dot::before {
content: '';
flex: none;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--color-mint);
}
.ssul-hint.warn {
color: #ffb4a8;
}
/* ── 비용 고지 + 제출 ────────────────────────────── */
/* 비용 고지와 제출 버튼은 한 덩어리 — 누르기 직전에 읽어야 하므로 붙여 둔다 */
.ssul-create__cost {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0 4px;
margin-bottom: 12px;
font-size: 12px;
color: var(--color-text-gray-400);
}
.ssul-create__cost-badge {
display: inline-flex;
align-items: center;
gap: 5px;
font-weight: 700;
color: var(--color-mint);
}
.ssul-create__coin {
font-size: 14px;
line-height: 1;
}
/* castad .btn-cta 와 같은 문법 — 퍼플은 페이지에서 이 CTA 한 곳만 쓴다 */
.ssul-create__submit {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 48px;
border: none;
border-radius: 999px;
background: var(--color-purple);
color: var(--color-text-white);
font-family: inherit;
font-size: 16px;
font-weight: 700;
cursor: pointer;
box-shadow: var(--shadow-purple);
transition: background-color var(--transition-normal);
}
.ssul-create__submit:hover:not(:disabled) {
background: var(--color-purple-hover);
}
.ssul-create__submit:disabled {
opacity: 0.4;
cursor: default;
}
/* ── 생성 진행 화면 ──────────────────────────────── */
.ssul-scope.ssul-making {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 420px;
/* ssulbox.css 베이스 `.ssul-scope { height:100% }` (원본 전체화면 규칙)
여기까지 상속되어 박스 자체가 부모 높이를 채워버린다. 그러면 아래
margin:auto 나눠 가질 여유 공간이 0 되어 컨텐츠가 위로 쏠린다.
height:auto 되돌려 컨텐츠 크기만큼만 차지하게 해야 margin:auto 뜻대로
작동한다(부모 .wizard-page-container height:100% flex column). */
height: auto;
/* `0 auto` 수평만 중앙이라 스텝퍼 바로 아래에 붙고 세로 여백이 전부
아래로 몰린다. `auto` 상하 여백도 균등 분배해 스텝퍼~바닥 사이
중앙에 놓인다. */
margin: auto;
padding: 48px 20px;
text-align: center;
gap: 10px;
color: var(--color-text-white);
}
.ssul-making__scenario {
font-size: 20px;
font-weight: 700;
color: var(--color-mint);
}
.ssul-making__title {
margin: 0 0 24px;
font-size: 24px;
font-weight: 700;
letter-spacing: -0.015em;
}
.ssul-making__title.warn {
color: #ffb4a8;
}
/* 진행 스피너 원본 LoadingSection 회전 요소만 가져왔다.
색은 ADO2 생성 스피너 표준(퍼플) 따른다.
`spin` 키프레임은 castad 전역에 이미 있어 재사용한다(새로 만들면 3번째 중복). */
.ssul-making__spinner {
position: relative;
flex: none;
width: 150px;
height: 150px;
margin: 4px 0 26px;
}
/* 트랙(전체 원) */
.ssul-making__spinner::before {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
border: 7px solid var(--color-border-white-10);
}
/* 회전하는 호 — 3/4 만 칠해 원본 SVG 의 형태를 따른다 */
.ssul-making__spinner::after {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
border: 7px solid transparent;
border-top-color: var(--color-purple);
border-right-color: var(--color-purple);
border-bottom-color: var(--color-purple);
animation: spin 1.1s linear infinite;
}
.ssul-making__bar {
width: 100%;
height: 6px;
border-radius: 999px;
margin-top: 20px;
background: var(--color-bg-card-inner);
overflow: hidden;
}
.ssul-making__fill {
height: 100%;
border-radius: 999px;
background: var(--color-mint);
transition: width 400ms ease;
}
.ssul-making__meta {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
margin-top: 10px;
font-size: 16px;
color: var(--color-text-gray-300);
}
.ssul-making__pct {
font-variant-numeric: tabular-nums;
font-weight: 700;
}
.ssul-making__desc {
margin: 22px 0 0;
font-size: 14px;
line-height: 1.6;
color: var(--color-text-gray-400);
}
.ssul-making__refund {
margin: 8px 0 0;
font-size: 14px;
color: var(--color-mint);
}
.ssul-making__btn {
margin-top: 28px;
padding: 12px 24px;
border: 1px solid var(--color-border-white-10);
border-radius: 999px;
background: var(--color-bg-card);
color: var(--color-text-white);
font-family: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: border-color var(--transition-normal);
}
.ssul-making__btn:hover {
border-color: rgba(255, 255, 255, 0.3);
}
/* 진행바·스피너 애니메이션은 모션 민감 사용자에게 끈다.
스피너는 회전을 멈추면 3/4 호가 그대로 남아 정적 표시로 읽힌다. */
@media (prefers-reduced-motion: reduce) {
.ssul-making__fill {
transition: none;
}
.ssul-making__spinner::after {
animation: none;
}
}
/* 썰박스 뷰어 전용 스타일은 두지 않는다
SsulViewerModal castad `video-detail-*` 클래스를 그대로 재사용해
통합 목록에서 어떤 카드를 열든 같은 모양이 나오게 했다. */
/* 결과 화면 스타일은 없다 SsulResultContent ADO2 완료 화면과 같은
`CompletionView`(comp2-* 클래스) 쓴다(2026-08-10 통일). */

571
src/styles/ssulbox.css Normal file
View File

@ -0,0 +1,571 @@
/* =====================================================
썰박스 UI 원본(o2o-ssulbox/frontend/src/index.css)에서 스코프 변환
** 파일은 스크립트로 생성됐다.** 직접 고치지 말고
scratchpad/scope_ssulbox_css.py 고쳐 다시 생성할 .
변환 내용:
1. @import "tailwindcss" 삭제 (castad index.html Play CDN 사용)
2. #app .ssul-scope, 100dvh 100%
3. 모든 최상위 셀렉터에 .ssul-scope 접두
4. 전역 리셋(*, html, body, button, img, ul) .ssul-scope 하위로
이게 빠지면 castad 화면의 버튼·이미지·폰트가 깨진다
5. html,body{height:100%} 삭제
6. :root 변수 .ssul-scope 이동, 값은 castad 다크 팔레트로 매핑
7. .nav* .topbar .brand .login* 삭제 (castad Sidebar/카카오가 대체)
8. --nav-h/--top-h 0 (셸이 없으므로)
9. .grid .ssul-grid (Tailwind CDN .grid 충돌 회피)
10. font-family 미지정 castad Noto Sans KR 상속
===================================================== */
/* castad 다크 팔레트로 매핑한 썰박스 변수.
원본은 라이트 테마 기본이었으나 castad 대시보드는 다크 단일이다. */
.ssul-scope {
--bg: var(--color-bg-darker);
--surface: var(--color-bg-card);
--surface-2: var(--color-bg-card-inner);
--fg: var(--color-text-white);
--muted: var(--color-text-gray-400);
--faint: var(--color-text-gray-500);
--border: var(--color-border-white-10);
--elev: 0 2px 16px rgba(0, 0, 0, 0.6);
/* 시나리오 색 (원본 유지) */
--joseon: #3b4cc0;
--samgukji: #d63a2f;
--greek: #0a8f6b;
--greek-2: #e0a91b;
--odyssey: #6d4aff;
--ig: linear-gradient(45deg, #f9a03f 0%, #e6683c 25%, #dc2743 50%, #cc2366 75%, #bc1888 100%);
/* 셸(하단 네비·상단바)이 castad 에는 없으므로 0 */
--nav-h: 0px;
--top-h: 0px;
--r: 14px;
}
/* 원본의 전역 리셋 스코프 안으로 가둔다.
요소 리셋은 `:where()` 감싸 **특정도를 0 으로** 만든다.
그러지 않으면 `.ssul-scope button`(0,1,1) 우리 컴포넌트의 BEM
클래스(`.ssul-create__submit` 0,1,0) 임포트 순서와 무관하게 이겨
버튼 배경·테두리가 통째로 사라진다(2026-07-29 실제 발생).
리셋의 목적(클래스 없는 원본 마크업 정규화) 그대로 유지된다. */
.ssul-scope,
.ssul-scope * {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
:where(.ssul-scope) :where(button) {
font: inherit;
color: inherit;
background: none;
border: none;
cursor: pointer;
}
:where(.ssul-scope) :where(img) {
display: block;
max-width: 100%;
}
:where(.ssul-scope) :where(ul) {
list-style: none;
}
:where(.ssul-scope) :where(button) {font:inherit;color:inherit;background:none;border:none;cursor:pointer}
:where(.ssul-scope) :where(img) {display:block;max-width:100%}
:where(.ssul-scope) :where(ul) {list-style:none}
.ssul-scope {position:relative;
width:100%;
height:100%;
background:var(--bg);
display:flex;flex-direction:column;
overflow:hidden;}
.ssul-scope .main {flex:1 1 auto;display:flex;flex-direction:column;min-height:0;min-width:0;position:relative}
.ssul-scope .screen {flex:1 1 auto;overflow-y:auto;overflow-x:hidden;
-webkit-overflow-scrolling:touch;
padding-bottom:calc(var(--nav-h) + env(safe-area-inset-bottom) + 8px);}
.ssul-scope .screen::-webkit-scrollbar {width:0}
.ssul-scope .ssul-grid {display:grid;grid-template-columns:1fr 1fr;gap:8px;padding:10px}
.ssul-scope .card {position:relative;aspect-ratio:9/16;border-radius:var(--r);overflow:hidden;
background:#222;isolation:isolate;transition:transform .12s ease;}
.ssul-scope .card:active {transform:scale(.975)}
.ssul-scope .card .cover {position:absolute;inset:0;display:grid;place-items:center}
.ssul-scope .card .cov-vid {position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
.ssul-scope .card .emoji {font-size:58px;filter:drop-shadow(0 3px 8px rgba(0,0,0,.35));opacity:.92}
.ssul-scope .card .shade {position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,0) 42%,rgba(0,0,0,.72) 100%)}
.ssul-scope .card .meta {position:absolute;left:9px;right:9px;bottom:8px;z-index:2}
.ssul-scope .card .title {color:#fff;font-size:12px;font-weight:700;line-height:1.28;
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;
text-shadow:0 1px 3px rgba(0,0,0,.5);}
.ssul-scope .card .sub {display:flex;align-items:center;gap:4px;margin-top:5px;color:rgba(255,255,255,.9);font-size:12px;font-variant-numeric:tabular-nums}
.ssul-scope .card .sub svg {width:12px;height:12px;fill:currentColor}
.ssul-scope .badge {position:absolute;top:8px;left:8px;z-index:2;
display:inline-flex;align-items:center;gap:4px;
padding:3px 8px;border-radius:999px;
font-size:10px;font-weight:700;color:#fff;
background:rgba(0,0,0,.32);backdrop-filter:blur(3px);}
.ssul-scope .badge i {width:7px;height:7px;border-radius:50%;display:inline-block}
.ssul-scope .play-fab {position:absolute;top:8px;right:8px;z-index:2;width:24px;height:24px;border-radius:50%;
background:rgba(0,0,0,.35);backdrop-filter:blur(3px);display:grid;place-items:center}
.ssul-scope .play-fab svg {width:12px;height:12px;fill:#fff;margin-left:1px}
.ssul-scope .sec-h {display:flex;align-items:baseline;justify-content:space-between;padding:16px 14px 4px}
.ssul-scope .sec-h h3 {font-size:16px;font-weight:800;letter-spacing:-.01em}
.ssul-scope .sec-h span {font-size:12px;color:var(--muted)}
.ssul-scope .searchbar {position:sticky;top:0;z-index:4;background:var(--bg);padding:10px 12px;border-bottom:1px solid var(--border)}
.ssul-scope .searchbox {display:flex;align-items:center;gap:8px;background:var(--surface-2);border-radius:11px;padding:9px 12px}
.ssul-scope .searchbox svg {width:16px;height:16px;fill:none;stroke:var(--muted);stroke-width:2;flex:0 0 auto}
.ssul-scope .searchbox input {flex:1;background:none;border:none;outline:none;color:var(--fg);font-size:14px}
.ssul-scope .searchbox input::placeholder {color:var(--faint)}
.ssul-scope .chips {display:flex;gap:8px;overflow-x:auto;padding:10px 12px;scrollbar-width:none}
.ssul-scope .chips::-webkit-scrollbar {display:none}
.ssul-scope .chip {flex:0 0 auto;padding:7px 13px;border-radius:999px;background:var(--surface-2);
font-size:14px;font-weight:600;color:var(--muted);border:1px solid transparent;transition:.15s}
.ssul-scope .chip[aria-pressed="true"] {background:var(--fg);color:var(--bg)}
.ssul-scope .empty {padding:60px 24px;text-align:center;color:var(--muted);font-size:14px;line-height:1.6}
.ssul-scope .profile {padding:20px 16px 6px}
.ssul-scope .profile .row {display:flex;align-items:center;gap:18px}
.ssul-scope .avatar {width:74px;height:74px;border-radius:50%;flex:0 0 auto;display:grid;place-items:center;overflow:hidden;
color:#fff;font-size:30px;font-weight:800;background:var(--ig)}
.ssul-scope .avatar img {width:100%;height:100%;object-fit:cover;display:block}
.ssul-scope .avatar.lg {width:92px;height:92px;font-size:38px}
.ssul-scope .stats {flex:1;display:flex;justify-content:space-around;text-align:center}
.ssul-scope .stats b {display:block;font-size:18px;font-weight:800;font-variant-numeric:tabular-nums}
.ssul-scope .stats span {font-size:12px;color:var(--muted)}
.ssul-scope .bio {margin-top:12px}
.ssul-scope .bio .name {font-weight:700;font-size:14px}
.ssul-scope .bio .bio-txt {font-size:14px;color:var(--fg);margin-top:3px;line-height:1.45;white-space:pre-wrap}
.ssul-scope .bio .bio-txt.muted {color:var(--faint)}
.ssul-scope .pe-avatar {display:flex;flex-direction:column;align-items:center;gap:10px;padding:6px 0 14px}
.ssul-scope .pe-av-btns {display:flex;gap:8px}
.ssul-scope .pe-av-btns button {font-size:14px;font-weight:700;color:var(--samgukji);padding:6px 12px;border-radius:9px;background:var(--surface-2)}
.ssul-scope .pe-av-btns button.rm {color:var(--muted)}
.ssul-scope .pe-bio {width:100%;background:var(--surface-2);border:1px solid var(--border);border-radius:12px;padding:11px 13px;
color:var(--fg);outline:none;font-size:14px;resize:none;line-height:1.5}
.ssul-scope .pe-bio:focus {border-color:var(--samgukji)}
.ssul-scope .token-card {display:flex;align-items:center;justify-content:space-between;gap:12px;
margin:14px 0 2px;padding:14px 16px;border-radius:14px;border:1px solid var(--border);
background:linear-gradient(135deg,rgba(220,39,67,.06),rgba(188,24,136,.06))}
.ssul-scope .token-card .tk-left {display:flex;flex-direction:column;gap:1px;min-width:0}
.ssul-scope .token-card .tk-left span {font-size:12px;color:var(--muted);font-weight:600}
.ssul-scope .token-card .tk-left b {font-size:24px;font-weight:800;font-variant-numeric:tabular-nums;line-height:1.15}
.ssul-scope .token-card .tk-left em {font-size:12px;color:var(--muted);font-style:normal;margin-top:2px}
.ssul-scope .token-card .tk-btn {flex:0 0 auto;padding:11px 20px;border-radius:11px;color:#fff;font-weight:800;font-size:14px;
background:var(--ig);box-shadow:0 4px 12px rgba(220,39,67,.28)}
.ssul-scope .pf-actions {display:flex;gap:8px;margin:12px 0 4px}
.ssul-scope .pf-actions button {flex:1;padding:8px;border-radius:9px;background:var(--surface-2);font-size:14px;font-weight:700}
.ssul-scope .pf-tabs {display:flex;border-top:1px solid var(--border);margin-top:12px}
.ssul-scope .pf-tabs button {flex:1;padding:11px;display:grid;place-items:center;color:var(--faint);border-bottom:2px solid transparent}
.ssul-scope .pf-tabs button[aria-selected="true"] {color:var(--fg);border-bottom-color:var(--fg)}
.ssul-scope .pf-tabs svg {width:22px;height:22px;fill:currentColor}
.ssul-scope .scrim {position:absolute;inset:0;z-index:40;background:rgba(0,0,0,.5);opacity:0;pointer-events:none;transition:opacity .22s}
.ssul-scope .scrim.on {opacity:1;pointer-events:auto}
.ssul-scope .sheet {position:absolute;left:0;right:0;bottom:0;z-index:50;
background:var(--surface);border-radius:22px 22px 0 0;
transform:translateY(101%);transition:transform .28s cubic-bezier(.22,1,.36,1);
max-height:92%;display:flex;flex-direction:column;
padding-bottom:env(safe-area-inset-bottom);
box-shadow:0 -8px 40px rgba(0,0,0,.35);
pointer-events:none;}
.ssul-scope .sheet.on {transform:translateY(0);pointer-events:auto}
.ssul-scope .sheet .grab {width:38px;height:4px;border-radius:2px;background:var(--faint);margin:9px auto 4px}
.ssul-scope .sheet-h {display:flex;align-items:center;justify-content:space-between;padding:6px 16px 10px;border-bottom:1px solid var(--border)}
.ssul-scope .sheet-h h3 {font-size:18px;font-weight:800}
.ssul-scope .sheet-h button {color:var(--muted);font-size:22px;line-height:1;padding:4px}
.ssul-scope .sheet-body {overflow-y:auto;padding:16px}
.ssul-scope .step-label {font-size:14px;font-weight:700;color:var(--muted);margin:2px 2px 10px}
.ssul-scope .step-label b {color:var(--fg)}
.ssul-scope .scn-list {display:flex;flex-direction:column;gap:10px}
.ssul-scope .scn {display:flex;align-items:center;gap:14px;padding:14px;border-radius:16px;
border:1.5px solid var(--border);background:var(--surface);text-align:left;transition:.14s;width:100%;}
.ssul-scope .scn:active {transform:scale(.99)}
.ssul-scope .scn[aria-pressed="true"] {border-color:var(--sc);background:color-mix(in srgb,var(--sc) 8%,var(--surface))}
.ssul-scope .scn .ic {width:48px;height:48px;border-radius:13px;display:grid;place-items:center;font-size:26px;flex:0 0 auto;
background:color-mix(in srgb,var(--sc) 15%,var(--surface))}
.ssul-scope .scn .tx {flex:1}
.ssul-scope .scn .tx .t {font-weight:800;font-size:16px}
.ssul-scope .scn .tx .d {font-size:12px;color:var(--muted);margin-top:2px;line-height:1.4}
.ssul-scope .scn .ck {width:22px;height:22px;border-radius:50%;border:2px solid var(--border);flex:0 0 auto;display:grid;place-items:center}
.ssul-scope .scn[aria-pressed="true"] .ck {background:var(--sc);border-color:var(--sc)}
.ssul-scope .scn[aria-pressed="true"] .ck::after {content:"";width:7px;height:11px;border:2.5px solid #fff;border-top:0;border-left:0;transform:rotate(42deg) translateY(-1px)}
.ssul-scope .field {margin-top:20px}
.ssul-scope .field > label {display:block;font-size:14px;font-weight:700;margin-bottom:8px}
.ssul-scope .input {display:flex;align-items:center;gap:8px;background:var(--surface-2);border-radius:12px;padding:12px 14px;border:1.5px solid transparent}
.ssul-scope .input:focus-within {border-color:var(--fg)}
.ssul-scope .input svg {width:17px;height:17px;color:var(--muted);flex:0 0 auto}
.ssul-scope .input input {flex:1;background:none;border:none;outline:none;color:var(--fg);font-size:16px}
.ssul-scope .input input::placeholder {color:var(--faint)}
.ssul-scope .hint {font-size:12px;color:var(--muted);margin-top:7px;line-height:1.5;padding:0 2px}
.ssul-scope .sheet-foot {padding:12px 16px calc(14px + env(safe-area-inset-bottom));border-top:1px solid var(--border);background:var(--surface)}
.ssul-scope .cta {width:100%;padding:15px;border-radius:14px;font-size:16px;font-weight:800;color:#fff;background:var(--ig);
transition:.15s;box-shadow:0 6px 18px rgba(220,39,67,.32)}
.ssul-scope .cta:disabled {background:var(--surface-2);color:var(--faint);box-shadow:none;cursor:not-allowed}
.ssul-scope .making {padding:44px 24px;text-align:center}
@keyframes sp{to{transform:rotate(360deg)}}
.ssul-scope .making h4 {font-size:18px;font-weight:800}
.ssul-scope .making p {font-size:14px;color:var(--muted);margin-top:8px;line-height:1.6}
.ssul-scope .making .prog {margin-top:22px;display:flex;align-items:center;gap:12px}
.ssul-scope .making .prog-bar {flex:1;height:10px;border-radius:99px;background:var(--surface-2);overflow:hidden;position:relative}
.ssul-scope .making .prog-fill {height:100%;border-radius:99px;background:var(--sc,#dc2743);
transition:width .5s cubic-bezier(.22,1,.36,1);position:relative;overflow:hidden}
.ssul-scope .making .prog-fill::after {content:"";position:absolute;inset:0;
background:linear-gradient(90deg,transparent,rgba(255,255,255,.45),transparent);
animation:progsh 1.15s linear infinite}
@keyframes progsh{from{transform:translateX(-100%)}to{transform:translateX(100%)}}
.ssul-scope .making .prog-pct {font-size:14px;font-weight:800;color:var(--fg);min-width:40px;text-align:right;font-variant-numeric:tabular-nums}
.ssul-scope .viewer {position:absolute;inset:0;z-index:60;background:#000;display:none;flex-direction:column}
.ssul-scope .viewer.on {display:flex}
.ssul-scope .viewer .vtop {position:absolute;top:0;left:0;right:0;z-index:3;display:flex;align-items:center;gap:14px;
padding:14px 16px;padding-top:calc(14px + env(safe-area-inset-top));
background:linear-gradient(180deg,rgba(0,0,0,.5),transparent)}
.ssul-scope .viewer .vtop button {color:#fff;display:flex;align-items:center;justify-content:center;
width:38px;height:38px;border-radius:50%;background:rgba(0,0,0,.4);flex:0 0 auto}
.ssul-scope .viewer .vtop button svg {width:22px;height:22px}
.ssul-scope .viewer .vtop h2 {color:#fff;font-size:16px;font-weight:700}
.ssul-scope .vframe {position:absolute;inset:0;display:flex;flex-direction:column;overflow:hidden}
.ssul-scope .vstage {flex:1;position:relative;display:grid;place-items:center;overflow:hidden}
.ssul-scope .vstage .cover {position:absolute;inset:0;display:grid;place-items:center}
.ssul-scope .vstage .cover .emoji {font-size:120px;opacity:.9;filter:drop-shadow(0 6px 20px rgba(0,0,0,.5))}
.ssul-scope .vstage video {width:100%;height:100%;object-fit:contain;background:#000}
.ssul-scope .vbig {position:relative;z-index:2;width:74px;height:74px;border-radius:50%;background:rgba(255,255,255,.14);
backdrop-filter:blur(4px);display:grid;place-items:center;border:1.5px solid rgba(255,255,255,.5)}
.ssul-scope .vbig svg {width:30px;height:30px;fill:#fff;margin-left:4px}
.ssul-scope .vrail {position:absolute;right:10px;bottom:120px;z-index:3;display:flex;flex-direction:column;gap:20px;align-items:center}
.ssul-scope .vrail button {color:#fff;display:flex;flex-direction:column;align-items:center;gap:4px;font-size:12px;font-weight:600;font-variant-numeric:tabular-nums}
.ssul-scope .vrail svg {width:29px;height:29px;fill:#fff}
.ssul-scope .vbottom {position:absolute;left:0;right:64px;bottom:0;z-index:3;padding:16px;pointer-events:none;
padding-bottom:calc(56px + env(safe-area-inset-bottom));
background:linear-gradient(0deg,rgba(0,0,0,.55),transparent)}
.ssul-scope .vbottom .vt {color:#fff;font-weight:800;font-size:18px;line-height:1.3}
.ssul-scope .vbottom .vn {color:rgba(255,255,255,.86);font-size:14px;margin-top:8px;line-height:1.5;
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.ssul-scope .vbottom .vbadge {margin-bottom:10px}
.ssul-scope .note-pill {display:inline-block;margin-top:12px;font-size:12px;color:rgba(255,255,255,.7);
background:rgba(255,255,255,.12);padding:5px 10px;border-radius:8px}
@media (min-width:860px) {
.ssul-scope {flex-direction:row}
.ssul-scope .screen {padding-bottom:24px}
.ssul-scope .ssul-grid {grid-template-columns:repeat(4,1fr);gap:18px;padding:22px 26px;max-width:1240px;margin:0 auto}
.ssul-scope .sec-h {padding:24px 26px 2px;max-width:1240px;margin:0 auto}
.ssul-scope .sec-h h3 {font-size:22px}
.ssul-scope .searchbar {padding:16px 26px;max-width:1240px;margin:0 auto}
.ssul-scope .chips {padding:6px 26px 14px;max-width:1240px;margin:0 auto}
.ssul-scope .ch-wrap {max-width:1240px;margin:0 auto}
.ssul-scope .sheet {left:50%;right:auto;bottom:auto;top:50%;width:min(460px,92vw);max-height:86vh;
border-radius:22px;transform:translate(-50%,-46%);opacity:0;
transition:transform .22s cubic-bezier(.22,1,.36,1),opacity .22s}
.ssul-scope .sheet.on {transform:translate(-50%,-50%);opacity:1}
.ssul-scope .sheet .grab {display:none}
.ssul-scope .viewer.on {align-items:center;justify-content:center}
.ssul-scope .vframe {position:relative;inset:auto;width:min(430px,26vw);height:min(90vh,780px);
border-radius:18px;box-shadow:0 24px 70px rgba(0,0,0,.6)}
}
.ssul-scope .vrail button.liked svg {fill:#ff3b5c}
.ssul-scope .cmts {position:absolute;left:0;right:0;bottom:0;z-index:5;max-height:64%;display:flex;flex-direction:column;
background:var(--surface);border-radius:18px 18px 0 0;color:var(--fg);box-shadow:0 -8px 30px rgba(0,0,0,.4)}
.ssul-scope .cmts-h {display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--border);font-weight:800;font-size:14px}
.ssul-scope .cmts-h button {color:var(--muted);font-size:20px;line-height:1}
.ssul-scope .cmts-list {flex:1;overflow-y:auto;padding:6px 16px}
.ssul-scope .cmts-empty {color:var(--muted);font-size:14px;text-align:center;padding:30px 0}
.ssul-scope .cmt {padding:9px 0;border-bottom:1px solid var(--border)}
.ssul-scope .cmt .who {font-size:12px;color:var(--muted);font-weight:700}
.ssul-scope .cmt .bd {font-size:14px;margin-top:2px;line-height:1.4}
.ssul-scope .cmt .del {font-size:12px;color:var(--faint);float:right}
.ssul-scope .cmt .rep {margin:8px 0 0 16px;padding-left:10px;border-left:2px solid var(--border)}
.ssul-scope .cmts-in {display:flex;gap:8px;padding:10px 12px;border-top:1px solid var(--border);align-items:center}
.ssul-scope .cmts-in input {flex:1;background:var(--surface-2);border:none;border-radius:20px;padding:10px 14px;color:var(--fg);outline:none;font-size:14px}
.ssul-scope .cmts-in button {color:var(--samgukji);font-weight:800;padding:0 8px;font-size:14px}
.ssul-scope .cmts-in button:disabled {color:var(--faint)}
.ssul-scope .lbl-sub {font-weight:500;color:var(--muted);font-size:12px}
.ssul-scope .input.search {align-items:center}
.ssul-scope .srch-btn {flex:0 0 auto;padding:8px 14px;border-radius:9px;background:var(--fg);color:var(--bg);font-size:14px;font-weight:800}
.ssul-scope .srch-btn:disabled {opacity:.4}
.ssul-scope .places {margin:8px 0 0;list-style:none;border:1px solid var(--border);border-radius:12px;overflow:hidden;background:var(--surface);max-height:230px;overflow-y:auto}
.ssul-scope .places li+li {border-top:1px solid var(--border)}
.ssul-scope .places button {display:flex;flex-direction:column;gap:2px;width:100%;text-align:left;padding:11px 13px;background:none}
.ssul-scope .places button:hover {background:var(--surface-2)}
.ssul-scope .places b {font-size:14px;font-weight:700;color:var(--fg)}
.ssul-scope .places span {font-size:12px;color:var(--muted)}
.ssul-scope .picked {margin:8px 0 0;display:flex;align-items:center;gap:10px;padding:12px 14px;border-radius:12px;
border:1.5px solid color-mix(in srgb,var(--sc,#22c55e) 55%,var(--border));background:color-mix(in srgb,var(--sc,#22c55e) 8%,var(--surface))}
.ssul-scope .picked .pk-body {flex:1;display:flex;flex-direction:column;gap:2px;min-width:0}
.ssul-scope .picked .pk-body b {font-size:16px;font-weight:800;color:var(--fg)}
.ssul-scope .picked .pk-body b::before {content:"✓ ";color:#22c55e}
.ssul-scope .picked .pk-body span {font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ssul-scope .pk-change {flex:0 0 auto;font-size:12px;font-weight:800;color:var(--muted);padding:6px 10px;border-radius:8px;background:var(--surface-2)}
.ssul-scope .place-loading {margin:10px 2px 0;display:flex;align-items:center;gap:9px;font-size:14px;color:var(--muted)}
.ssul-scope .spin.sm {width:16px;height:16px;border-width:2px;margin:0}
.ssul-scope .hint.ok {color:#16a34a}
.ssul-scope .hint.warn {color:#d97706}
.ssul-scope .sns-card {margin:14px 16px 4px;border:1px solid var(--border);border-radius:16px;background:var(--surface);overflow:hidden}
.ssul-scope .sns-h {display:flex;align-items:baseline;gap:8px;padding:13px 16px 9px;border-bottom:1px solid var(--border)}
.ssul-scope .sns-h span {font-size:14px;font-weight:800}
.ssul-scope .sns-h em {font-style:normal;font-size:12px;color:var(--muted)}
.ssul-scope .sns-row {display:flex;align-items:center;gap:11px;padding:11px 16px}
.ssul-scope .sns-row+.sns-row {border-top:1px solid var(--border)}
.ssul-scope .sns-ic {display:grid;place-items:center;width:34px;height:34px;border-radius:9px;background:color-mix(in srgb,var(--sc) 16%,transparent)}
.ssul-scope .sns-ic svg {width:20px;height:20px;fill:var(--sc);stroke:var(--sc)}
.ssul-scope .sns-name {flex:1;font-size:14px;font-weight:600;display:flex;align-items:center;gap:6px}
.ssul-scope .sns-dot {width:7px;height:7px;border-radius:50%;background:#22c55e;display:inline-block}
.ssul-scope .sns-soon {font-style:normal;font-size:10px;font-weight:800;color:var(--muted);background:var(--surface-2);padding:2px 7px;border-radius:10px}
.ssul-scope .sns-btn {padding:7px 15px;border-radius:20px;font-size:14px;font-weight:800;background:var(--fg);color:var(--bg)}
.ssul-scope .sns-btn.on {background:var(--surface-2);color:var(--muted)}
.ssul-scope .sns-btn:disabled {opacity:.5}
.ssul-scope .sns-row.sns-sub {padding-left:61px;border-top:1px solid var(--border)}
.ssul-scope .sns-row.sns-sub .sns-name {font-weight:500;color:var(--muted)}
.ssul-scope .cmts.share .share-list {flex:1;overflow-y:auto;padding:8px 12px}
.ssul-scope .share-row {display:flex;align-items:center;gap:12px;width:100%;padding:13px 8px;background:none}
.ssul-scope .share-row+.share-row {border-top:1px solid var(--border)}
.ssul-scope .share-name {flex:1;text-align:left;font-size:16px;font-weight:700;color:var(--fg)}
.ssul-scope .share-state {font-size:12px;font-weight:800;color:var(--samgukji)}
.ssul-scope .share-row:disabled .share-state {color:var(--faint)}
.ssul-scope .share-row.share-sub {padding-left:54px;border-top:1px solid var(--border)}
.ssul-scope .share-row.share-sub .share-name {font-weight:500}
.ssul-scope .share-hint {padding:10px 16px 14px;font-size:12px;color:var(--muted);line-height:1.5}
.ssul-scope .share-hint b {color:var(--fg)}
.ssul-scope .cmts.cap .cap-body {flex:1;overflow-y:auto;padding:14px 16px;font-size:14px;line-height:1.6;color:var(--fg);white-space:pre-wrap;word-break:break-word}
.ssul-scope .cap-foot {padding:10px 12px;border-top:1px solid var(--border)}
.ssul-scope .cap-copy {display:flex;align-items:center;justify-content:center;gap:7px;width:100%;padding:12px;border-radius:12px;background:var(--samgukji);color:#fff;font-size:14px;font-weight:800}
.ssul-scope .cap-copy svg {width:17px;height:17px;stroke:#fff}
.ssul-scope .toast {position:fixed;left:50%;bottom:86px;z-index:60;transform:translate(-50%,14px);opacity:0;pointer-events:none;
max-width:min(88vw,420px);padding:12px 18px;border-radius:14px;background:rgba(20,18,16,.94);color:#fff;
font-size:14px;font-weight:600;line-height:1.45;text-align:center;box-shadow:0 10px 34px rgba(0,0,0,.4);
transition:opacity .2s,transform .2s}
.ssul-scope .toast.on {opacity:1;transform:translate(-50%,0)}
@media (min-width:860px) {
.ssul-scope .toast {bottom:32px}
}
.ssul-scope .job-badge {position:fixed;right:14px;bottom:86px;z-index:55;padding:11px 16px;border-radius:999px;
background:rgba(20,18,16,.94);color:#fff;font-size:14px;font-weight:700;box-shadow:0 10px 34px rgba(0,0,0,.4);
display:flex;align-items:center;gap:6px;cursor:pointer}
.ssul-scope .job-badge.err {background:#b3261e}
@media (min-width:860px) {
.ssul-scope .job-badge {bottom:32px}
}
.ssul-scope .prods {display:flex;flex-direction:column;gap:10px;margin-top:6px}
.ssul-scope .prod {position:relative;display:grid;grid-template-columns:1fr auto;grid-template-areas:"cr price" "bonus price" "name price";
align-items:center;gap:2px 12px;text-align:left;padding:14px 16px;border-radius:14px;border:1.5px solid var(--border);background:var(--surface)}
.ssul-scope .prod:hover {border-color:var(--samgukji)}
.ssul-scope .prod.best {border-color:var(--samgukji);background:color-mix(in srgb,var(--samgukji) 7%,var(--surface))}
.ssul-scope .prod:disabled {opacity:.55}
.ssul-scope .prod-tag {position:absolute;top:-9px;left:14px;background:var(--samgukji);color:#fff;font-size:10px;font-weight:800;padding:2px 8px;border-radius:10px}
.ssul-scope .prod-cr {grid-area:cr;font-size:16px;color:var(--muted);font-weight:600}
.ssul-scope .prod-cr b {font-size:22px;color:var(--fg);font-weight:800;margin-right:3px}
.ssul-scope .prod-bonus {grid-area:bonus;font-size:12px;color:var(--samgukji);font-weight:700}
.ssul-scope .prod-name {grid-area:name;font-size:12px;color:var(--faint)}
.ssul-scope .prod-price {grid-area:price;font-size:16px;font-weight:800;color:var(--fg);white-space:nowrap}
.ssul-scope .tb-login {background:var(--samgukji);color:#fff;font-size:14px;font-weight:800;padding:8px 15px;border-radius:20px}
.ssul-scope :focus-visible {outline:2px solid var(--samgukji);outline-offset:2px}

View File

@ -80,7 +80,7 @@
}
.progress-text {
font-size: 0.8125rem;
font-size: 14px;
font-weight: 600;
color: var(--color-text-white);
}
@ -92,14 +92,14 @@
}
.progress-current {
font-size: 0.8125rem;
font-size: 14px;
font-weight: 600;
color: var(--color-text-white);
}
.progress-divider,
.progress-total {
font-size: 0.8125rem;
font-size: 14px;
font-weight: 400;
color: #379599;
}
@ -122,7 +122,7 @@
/* Sound Studio Title */
.sound-studio-title {
font-size: 2rem;
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-align: center;
@ -192,7 +192,7 @@
/* Column Title */
.column-title {
font-size: 1.125rem;
font-size: 18px;
font-weight: 600;
color: var(--color-mint);
line-height: 1.19;
@ -337,7 +337,7 @@
}
.language-flag {
font-size: 1.25rem;
font-size: 20px;
line-height: 1.19;
letter-spacing: -0.006em;
}
@ -415,7 +415,7 @@
}
.language-dropdown-item .language-flag {
font-size: 1.25rem;
font-size: 20px;
}
.language-dropdown-item .language-name {
@ -642,7 +642,7 @@
.charge-credits-link {
display: block;
margin-top: 6px;
font-size: 13px;
font-size: 14px;
color: #AE72F9;
text-decoration: underline;
cursor: pointer;
@ -795,7 +795,7 @@
}
.column-title {
font-size: 1.25rem;
font-size: 20px;
}
.lyrics-display {
@ -857,7 +857,7 @@
.asset-upload-error-title {
margin: 0;
color: #94FBE0;
font-size: 1.25rem;
font-size: 20px;
font-weight: 700;
line-height: 1.3;
}
@ -865,7 +865,7 @@
.asset-upload-error-message {
margin: 0.75rem 0 1.5rem;
color: #E5F1F2;
font-size: 0.9375rem;
font-size: 16px;
line-height: 1.6;
overflow-wrap: anywhere;
white-space: pre-wrap;
@ -879,7 +879,7 @@
background: #94FBE0;
border: 0;
border-radius: 999px;
font-size: 0.9375rem;
font-size: 16px;
font-weight: 700;
cursor: pointer;
transition: background-color 0.2s, transform 0.2s;
@ -931,7 +931,7 @@
gap: 4px;
background-color: #462E64;
color: #CFABFB;
font-size: 0.875rem;
font-size: 14px;
font-weight: 600;
padding: 0 1.25rem 0 0.5rem;
height: 36px;
@ -999,19 +999,18 @@
display: flex;
justify-content: center;
}
}
body:has(.sidebar.collapsed) .asset-sticky-footer {
left: 5rem;
right: 0;
width: auto;
display: flex;
justify-content: center;
/* 모바일: 하단 탭바에 가리지 않도록 띄운다 */
@media (max-width: 767px) {
.asset-sticky-footer {
bottom: calc(var(--bottom-nav-height) + 16px);
}
}
/* Asset Title */
.asset-title {
font-size: 2rem;
font-size: 32px;
font-weight: 700;
color: #E5F1F2;
padding: 0 0 1.5rem;
@ -1091,7 +1090,7 @@
}
.asset-section-subtitle {
font-size: 0.875rem;
font-size: 14px;
font-weight: 400;
color: #6AB0B3;
line-height: 1.19;
@ -1099,7 +1098,7 @@
}
.asset-section-count {
font-size: 0.875rem;
font-size: 14px;
font-weight: 600;
color: #9BCACC;
line-height: 1.19;
@ -1114,7 +1113,7 @@
/* Asset Section Title */
.asset-section-title {
font-size: 1.5rem;
font-size: 24px;
font-weight: 600;
color: #94FBE0;
line-height: 1.19;
@ -1129,7 +1128,7 @@
gap: 4px;
background-color: #94FBE0;
color: #000;
font-size: 0.75rem;
font-size: 12px;
font-weight: 600;
padding: 0 10px;
height: 24px;
@ -1208,7 +1207,7 @@
padding: 0.625rem 1rem;
background-color: #002224;
color: #9BCACC;
font-size: 0.875rem;
font-size: 14px;
font-weight: 600;
border: 1px solid #379599;
border-radius: 8px;
@ -1260,7 +1259,7 @@
.asset-image-placeholder-name {
width: 100%;
font-size: 0.6875rem;
font-size: 12px;
line-height: 1.2;
color: #9BCACC;
overflow: hidden;
@ -1274,7 +1273,7 @@
left: 8px;
background-color: #EFE3FE;
color: #AE72F9;
font-size: 0.75rem;
font-size: 12px;
font-weight: 600;
padding: 4px 8px;
border-radius: 4px;
@ -1339,7 +1338,7 @@
}
.asset-upload-text {
font-size: 0.875rem;
font-size: 14px;
font-weight: 600;
color: #9BCACC;
text-align: center;
@ -1383,7 +1382,7 @@
cursor: pointer;
transition: border-color 0.2s, background-color 0.2s;
color: #FFFFFF;
font-size: 0.875rem;
font-size: 14px;
font-weight: 600;
line-height: 1.19;
letter-spacing: -0.006em;
@ -1405,7 +1404,7 @@
}
.asset-ratio-subtitle {
font-size: 0.875rem;
font-size: 14px;
font-weight: 400;
color: #9BCACC;
}
@ -1440,7 +1439,7 @@
.asset-next-button {
background-color: #AE72F9;
color: #FFFFFF;
font-size: 0.875rem;
font-size: 14px;
font-weight: 600;
padding: 10px 40px;
border: none;
@ -1463,7 +1462,7 @@
/* Responsive Styles */
@media (max-width: 639px) {
.asset-title {
font-size: 1.5rem;
font-size: 24px;
padding-bottom: 1rem;
}
@ -1478,7 +1477,7 @@
@media (min-width: 640px) and (max-width: 767px) {
.asset-title {
font-size: 1.75rem;
font-size: 28px;
padding-bottom: 1.25rem;
}
@ -1490,7 +1489,7 @@
/* Height-based responsive adjustments */
@media (max-height: 700px) {
.asset-title {
font-size: 1.75rem;
font-size: 28px;
padding-bottom: 0.75rem;
}

View File

@ -6,6 +6,10 @@
CSS Variables - Design Tokens
===================================================== */
:root {
/* 모바일 하단 탭바 높이 (safe-area 포함).
탭바 자신과, 탭바에 가리면 되는 fixed 요소(캘린더 바텀시트 ) 공유한다 */
--bottom-nav-height: calc(72px + env(safe-area-inset-bottom));
/* Primary Colors */
--color-mint: #a6ffea;
--color-mint-hover: #8affda;
@ -57,14 +61,14 @@
--radius-full: 9999px;
/* Font Sizes */
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--text-3xl: 1.875rem;
--text-4xl: 2.25rem;
--text-xs: 12px;
--text-sm: 14px;
--text-base: 16px;
--text-lg: 18px;
--text-xl: 20px;
--text-2xl: 24px;
--text-3xl: 30px;
--text-4xl: 36px;
/* Transitions */
--transition-fast: 150ms;

View File

@ -270,7 +270,22 @@ 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 {
/**
* `video_id` **type .**
* `video.id` `ssul_content.id` 1 퀀 .
* · ·React key `(type, video_id)` .
* `DELETE /archive/videos/{id}` `Video.id` ,
* id ** ADO2 .**
*/
type: ContentType;
video_id: number;
store_name: string;
region: string;
@ -401,7 +416,9 @@ export interface SocialDisconnectResponse {
// 유튜브 SEO Description 자동완성 요청
export interface YTAutoSeoRequest {
video_id: number;
/** 썰박스면 'ssul' + task_id 자리에 ssul_content.id 문자열. 생략 시 video */
content_type?: ContentType;
task_id: string; // ADO2 는 task_id, 썰박스는 콘텐츠 id 문자열
}
// 유튜브 SEO Description 자동완성 응답
@ -413,7 +430,12 @@ export interface YTAutoSeoResponse {
// 소셜 업로드 요청
export interface SocialUploadRequest {
video_id: number; // 아카이브의 비디오 ID
/**
* . video.id ssul_content.id (=video)
* id ** ADO2 .**
*/
content_type?: ContentType;
video_id: number; // 콘텐츠 ID (content_type 안에서만 유일)
social_account_id: number; // 선택된 채널 ID
title: string; // 최대 100자
description: string; // 최대 5000자

View File

@ -1,3 +1,4 @@
import { clearSessionStorage } from './storageKeys';
import {
CrawlingResponse,
LyricGenerateRequest,
@ -33,6 +34,7 @@ import {
VideoDetailItem,
CommentsResponse,
CommentItem,
ContentType,
LikeToggleResponse,
} from '../types/api';
import { uploadImagesSequentially } from './imageUpload.ts';
@ -574,7 +576,7 @@ export async function getAllVideos(
if (region.trim()) params.set('region', region.trim());
const response = await authenticatedFetch(`${API_URL}/video/all?${params.toString()}`, {
method: 'GET',
});
}, { redirectOnAuthFail: false });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
@ -587,7 +589,7 @@ export async function getAllVideos(
export async function getVideoById(videoId: string): Promise<VideoDetailItem> {
const response = await authenticatedFetch(`${API_URL}/video/${videoId}`, {
method: 'GET',
});
}, { redirectOnAuthFail: false });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
@ -596,22 +598,76 @@ export async function getVideoById(videoId: string): Promise<VideoDetailItem> {
return response.json();
}
// 댓글 목록 조회
export async function getVideoComments(videoId: string, page: number = 1, pageSize: number = 20): Promise<CommentsResponse> {
const response = await authenticatedFetch(`${API_URL}/comment/video/${videoId}?page=${page}&page_size=${pageSize}`, {
// 썰박스 콘텐츠 공개 상세 (비로그인 허용 — 공유 링크 /ssul/{id} 가 사용)
export interface SsulDetailItem {
content_id: number;
scenario: string;
video_url: string;
store_name: string | null;
region: string | null;
/** 업체 공식 링크. 영상 종료 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) {
throw new Error(`HTTP error! status: ${response.status}`);
}
}
// 댓글 목록 조회
// contentType: video.id 와 ssul_content.id 가 겹치므로 종류를 함께 보낸다 (기본 'video')
export async function getVideoComments(
videoId: string,
page: number = 1,
pageSize: number = 20,
contentType: ContentType = 'video'
): Promise<CommentsResponse> {
const response = await authenticatedFetch(
`${API_URL}/comment/video/${videoId}?page=${page}&page_size=${pageSize}&type=${contentType}`,
{
method: 'GET',
},
{ redirectOnAuthFail: false }
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
// 댓글 작성 (작성자 닉네임/프로필은 서버가 로그인된 카카오 정보로 채움)
export async function postVideoComment(videoId: string, content: string, parentId?: number): Promise<CommentItem> {
const response = await authenticatedFetch(`${API_URL}/comment/video/${videoId}`, {
export async function postVideoComment(
videoId: string,
content: string,
parentId?: number,
contentType: ContentType = 'video'
): Promise<CommentItem> {
const response = await authenticatedFetch(`${API_URL}/comment/video/${videoId}?type=${contentType}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, parent_id: parentId ?? null }),
@ -636,10 +692,18 @@ export async function deleteComment(commentId: number): Promise<void> {
}
// 좋아요 토글
export async function toggleVideoLike(videoId: string): Promise<LikeToggleResponse> {
const response = await authenticatedFetch(`${API_URL}/video/${videoId}/like`, {
export async function toggleVideoLike(
videoId: string,
// video.id 와 ssul_content.id 가 겹치므로 종류를 함께 보내야 한다.
// 기본값 'video' 라 기존 호출부는 수정이 없다.
contentType: ContentType = 'video'
): Promise<LikeToggleResponse> {
const response = await authenticatedFetch(
`${API_URL}/video/${videoId}/like?type=${contentType}`,
{
method: 'POST',
});
}
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
@ -771,11 +835,8 @@ let refreshPromise: Promise<TokenRefreshResponse> | null = null;
function redirectToLogin() {
// 토큰 삭제
clearTokens();
// localStorage 정리
localStorage.removeItem('castad_view_mode');
localStorage.removeItem('castad_analysis_data');
localStorage.removeItem('castad_wizard_step');
localStorage.removeItem('castad_active_item');
// localStorage 정리 (생성 중이던 상태까지 전부 — 재로그인 시 잔류 방지)
clearSessionStorage();
// 홈으로 리다이렉트
window.location.href = '/';
}
@ -783,7 +844,8 @@ function redirectToLogin() {
// 401 에러 시 자동으로 토큰 갱신 후 재요청하는 래퍼 함수
export async function authenticatedFetch(
url: string,
options: RequestInit = {}
options: RequestInit = {},
{ redirectOnAuthFail = true }: { redirectOnAuthFail?: boolean } = {}
): Promise<Response> {
// 인증 헤더 + 캐시 방지 헤더 추가
const headers: HeadersInit = {
@ -800,9 +862,14 @@ export async function authenticatedFetch(
const errorBody = await response.json().catch(() => null);
const errorCode = errorBody?.detail?.code;
// 애초에 토큰이 없던 익명 요청(공개 화면)까지 튕기면 안 되므로 별도 처리
const isMissingToken = errorCode === 'MISSING_TOKEN';
if (errorCode !== 'TOKEN_EXPIRED') {
// INVALID_TOKEN 등 갱신으로 해결 불가한 경우 즉시 로그인 이동
if (redirectOnAuthFail && !isMissingToken) {
redirectToLogin();
}
throw new Error(errorCode ?? 'Unauthorized');
}
@ -825,7 +892,9 @@ export async function authenticatedFetch(
response = await fetch(url, { ...options, headers: newHeaders });
} catch (refreshError) {
console.error('Token refresh failed:', refreshError);
if (redirectOnAuthFail) {
redirectToLogin();
}
throw refreshError;
}
}
@ -923,15 +992,7 @@ export async function refreshAccessToken(): Promise<TokenRefreshResponse> {
// 로컬 스토리지 전체 정리
function clearAllLocalData() {
clearTokens();
localStorage.removeItem('castad_view_mode');
localStorage.removeItem('castad_analysis_data');
localStorage.removeItem('castad_wizard_step');
localStorage.removeItem('castad_active_item');
localStorage.removeItem('castad_song_task_id');
localStorage.removeItem('castad_image_task_id');
localStorage.removeItem('castad_song_generation');
localStorage.removeItem('castad_video_generation');
localStorage.removeItem('castad_video_ratio');
clearSessionStorage();
}
// 로그아웃
@ -1336,3 +1397,130 @@ export async function retryUpload(uploadId: number): Promise<{ success: boolean;
if (!response.ok) throw new Error('재시도 실패');
return response.json();
}
// ============================================================================
// 썰박스 (Ssulbox)
// ============================================================================
export interface SsulCreateRequest {
scenario: string;
/**
* .
*
* **** place URL
* ADO2 (NvMapPwScraper) .
* .
*/
input: string;
scenes?: number;
seconds?: number;
/** 자동완성으로 고른 경우에만. 목록 표시·필터·place URL 해석에 쓰인다 */
store_name?: string;
/** 지역 추출용(도로명). 백엔드는 이 값을 저장하지 않는다 */
road_address?: string;
/** 지역 추출용(지번). 도로명에서 시/군 추출이 실패할 때의 폴백 */
address?: string;
}
export interface SsulCreateResponse {
id: number;
status: string;
poll_interval_seconds?: number;
}
export async function createSsulJob(
request: SsulCreateRequest
): Promise<SsulCreateResponse> {
const response = await authenticatedFetch(`${API_URL}/ssul/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
if (response.status === 402) {
throw new InsufficientCreditError();
}
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
/** 크레딧 부족(402). 호출부가 충전 화면으로 유도할 수 있게 별도 타입으로 던진다 */
export class InsufficientCreditError extends Error {
constructor(message = '크레딧이 부족합니다.') {
super(message);
this.name = 'InsufficientCreditError';
}
}
export interface SsulTaskStatus {
id: number;
scenario: string;
status: 'queued' | 'running' | 'done' | 'error';
/** 0~4. 0=준비, 4=영상 합성 완료 */
step: number;
error: string | null;
video_url: string | null;
/**
* . `status === 'done'` .
* .
*/
store_name: string;
created_at: string;
}
/** 폴링 간격. castad 의 waitForVideoComplete 와 동일하게 맞춘다 */
const SSUL_POLL_INTERVAL = 3000;
/** 생성이 수 분~십수 분 걸리므로 넉넉히 */
const SSUL_POLL_TIMEOUT = 30 * 60 * 1000;
/** 생성 잡 단건 조회 */
export async function getSsulTask(taskId: number): Promise<SsulTaskStatus> {
const response = await authenticatedFetch(`${API_URL}/ssul/tasks/${taskId}`);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return response.json();
}
/**
* (· ).
* null.
*/
export async function getActiveSsulTask(): Promise<SsulTaskStatus | null> {
try {
const response = await authenticatedFetch(`${API_URL}/ssul/tasks/active`);
if (!response.ok) return null;
const data = await response.json();
return data.items?.[0] ?? null;
} catch {
return null;
}
}
/**
* . castad waitForVideoComplete .
*
* SSE EventSource
* `authenticatedFetch` 401 , DB
* , .
*
* @param shouldStop . true (·).
*/
export async function waitForSsulComplete(
taskId: number,
onProgress?: (task: SsulTaskStatus) => void,
shouldStop?: () => boolean,
startedAt: number = Date.now()
): Promise<SsulTaskStatus> {
if (shouldStop?.()) throw new Error('CANCELLED');
if (Date.now() - startedAt > SSUL_POLL_TIMEOUT) throw new Error('TIMEOUT');
const task = await getSsulTask(taskId);
onProgress?.(task);
if (task.status === 'done') return task;
if (task.status === 'error') throw new Error(task.error ?? 'SSUL_FAILED');
await new Promise((resolve) => setTimeout(resolve, SSUL_POLL_INTERVAL));
return waitForSsulComplete(taskId, onProgress, shouldStop, startedAt);
}

View File

@ -23,7 +23,20 @@ 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 {
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);
}

320
src/utils/p2vApi.ts Normal file
View File

@ -0,0 +1,320 @@
/**
* 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';
};

91
src/utils/storageKeys.ts Normal file
View File

@ -0,0 +1,91 @@
/**
* localStorage .
*
* localStorage .
* "언제 무엇을 지우는가"
* 5 :
*
* App.tsx initializeOnNewSession video_complete
* GenerationFlow clearAllProjectStorage ( , )
* GenerationFlow handleLogout song/video
* api.ts redirectToLogin
* api.ts clearAllLocalData video_complete
*
* 5 .
*/
export const K = {
// 화면 상태
VIEW_MODE: 'castad_view_mode',
ACTIVE_ITEM: 'castad_active_item',
WIZARD_STEP: 'castad_wizard_step',
// 분석 결과
ANALYSIS_DATA: 'castad_analysis_data',
// 생성 진행 상태
SONG_TASK_ID: 'castad_song_task_id',
IMAGE_TASK_ID: 'castad_image_task_id',
SONG_GENERATION: 'castad_song_generation',
VIDEO_GENERATION: 'castad_video_generation',
VIDEO_COMPLETE: 'castad_video_complete',
VIDEO_RATIO: 'castad_video_ratio',
// 썰박스 통합
/** 현재 선택된 파이프라인 (PipelineTabs 의 Pipeline 값) */
PIPELINE: 'castad_pipeline',
/** 진행 중인 썰박스 생성 잡 ID. 권위는 서버(`/ssul/tasks/active`)이고 이건 복구 힌트다 */
SSUL_TASK_ID: 'castad_ssul_task_id',
// Poster to Video 통합
/**
* (F1) ID.
*
* `/ssul/tasks/active` "내 활성 잡" P2V
* ( ).
* . 404
* GenerationFlow .
*
* (castad_p2v_key) ·
* (utils/p2vApi.ts ).
*/
POSTER_JOB_ID: 'castad_poster_job_id',
} as const;
/**
* ( 1) .
* '새 프로젝트 만들기' . .
*/
export const PROJECT_KEYS: readonly string[] = [
K.WIZARD_STEP,
K.SONG_TASK_ID,
K.IMAGE_TASK_ID,
K.SONG_GENERATION,
K.VIDEO_GENERATION,
K.VIDEO_COMPLETE,
K.VIDEO_RATIO,
K.PIPELINE,
K.SSUL_TASK_ID,
K.POSTER_JOB_ID,
];
/**
* . · · .
* PROJECT_KEYS .
*/
export const SESSION_KEYS: readonly string[] = [
...PROJECT_KEYS,
K.VIEW_MODE,
K.ACTIVE_ITEM,
K.ANALYSIS_DATA,
];
/** 진행 중인 프로젝트 상태만 정리 (로그인·분석 데이터 유지) */
export const clearProjectStorage = (): void => {
PROJECT_KEYS.forEach((key) => localStorage.removeItem(key));
};
/** 세션 상태 전체 정리 (로그아웃·토큰 만료·새 탭) */
export const clearSessionStorage = (): void => {
SESSION_KEYS.forEach((key) => localStorage.removeItem(key));
};

15
src/utils/url.ts Normal file
View File

@ -0,0 +1,15 @@
/**
* · 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;
}
};

16
src/utils/useIsMobile.ts Normal file
View File

@ -0,0 +1,16 @@
import { useEffect, useState } from 'react';
const QUERY = '(max-width: 767px)';
export function useIsMobile(): boolean {
const [isMobile, setIsMobile] = useState(() => window.matchMedia(QUERY).matches);
useEffect(() => {
const mql = window.matchMedia(QUERY);
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, []);
return isMobile;
}

View File

@ -10,6 +10,13 @@ export default defineConfig({
server: {
port: 3000,
host: '0.0.0.0',
// 도커 컨테이너가 Windows 바인드 마운트를 감시할 때 inotify 이벤트가 오지 않아
// HMR 이 전혀 동작하지 않는다(호스트에서 파일을 고쳐도 옛 모듈을 계속 서빙).
// 2026-08-26 실측: 1시간 편집에 HMR 이벤트 0건 → 폴링으로 전환.
watch: {
usePolling: true,
interval: 500,
},
},
plugins: [react()],
resolve: {