feat(ssulbox): 썰박스 진입 탭·생성 플로우와 통합 콘텐츠 목록 분기
This commit is contained in:
parent
c4c46db0d9
commit
bb0ab91bcc
@ -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"
|
||||
|
||||
15
src/App.tsx
15
src/App.tsx
@ -17,11 +17,12 @@ 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 { K, clearSessionStorage } from './utils/storageKeys';
|
||||
|
||||
type ViewMode = 'landing' | 'loading' | 'analysis' | 'login' | 'generation_flow';
|
||||
|
||||
const VIEW_MODE_KEY = 'castad_view_mode';
|
||||
const ANALYSIS_DATA_KEY = 'castad_analysis_data';
|
||||
const VIEW_MODE_KEY = K.VIEW_MODE;
|
||||
const ANALYSIS_DATA_KEY = K.ANALYSIS_DATA;
|
||||
const SESSION_KEY = 'castad_session_active';
|
||||
|
||||
// 새 탭/새 창에서 접근 시 localStorage 초기화 (sessionStorage로 현재 세션 확인)
|
||||
@ -30,15 +31,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');
|
||||
}
|
||||
};
|
||||
|
||||
57
src/components/PipelineTabs.tsx
Normal file
57
src/components/PipelineTabs.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/** 생성할 콘텐츠 종류. 진입 화면에서 고르면 이후 플로우 전체가 갈린다. */
|
||||
export type Pipeline = 'ado2' | 'ssul';
|
||||
|
||||
interface PipelineTabsProps {
|
||||
value: Pipeline;
|
||||
onChange: (value: Pipeline) => void;
|
||||
}
|
||||
|
||||
const TABS: { id: Pipeline; labelKey: string; emoji?: string }[] = [
|
||||
{ id: 'ado2', labelKey: 'pipelineTabs.ado2' },
|
||||
{ id: 'ssul', labelKey: 'pipelineTabs.ssul', emoji: '📋' },
|
||||
];
|
||||
|
||||
/**
|
||||
* ADO2 / 썰박스 전환 탭 (밑줄 탭).
|
||||
*
|
||||
* 활성 탭 아래에만 밑줄이 얹히고, 색이 민트(ADO2) ↔ 앰버(썰박스)로 바뀐다.
|
||||
* 스타일은 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((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={value === tab.id}
|
||||
className="pipeline-tab"
|
||||
onClick={() => onChange(tab.id)}
|
||||
>
|
||||
{tab.emoji && (
|
||||
<span className="pipeline-tab__emoji" aria-hidden="true">
|
||||
{tab.emoji}
|
||||
</span>
|
||||
)}
|
||||
{t(tab.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PipelineTabs;
|
||||
@ -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'),
|
||||
|
||||
@ -231,6 +231,30 @@
|
||||
"loggingIn": "Logging in...",
|
||||
"kakaoStart": "Start with Kakao"
|
||||
},
|
||||
"pipelineTabs": {
|
||||
"ariaLabel": "Content type",
|
||||
"ado2": "ADO2",
|
||||
"ssul": "Ssulbox"
|
||||
},
|
||||
"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",
|
||||
"change": "Change",
|
||||
"cost": "Cost",
|
||||
"costValue": "1 credit",
|
||||
"submit": "Create Ssulbox",
|
||||
"submitting": "Requesting…"
|
||||
}
|
||||
},
|
||||
"urlInput": {
|
||||
"searchTypeBusinessName": "Business Name",
|
||||
"placeholderBusinessName": "Enter a business name",
|
||||
|
||||
@ -230,6 +230,77 @@
|
||||
"loggingIn": "로그인 중...",
|
||||
"kakaoStart": "카카오로 시작하기"
|
||||
},
|
||||
"pipelineTabs": {
|
||||
"ariaLabel": "생성할 콘텐츠 종류",
|
||||
"ado2": "ADO2",
|
||||
"ssul": "썰박스"
|
||||
},
|
||||
"ssulbox": {
|
||||
"steps": {
|
||||
"pick": "선택",
|
||||
"making": "생성",
|
||||
"done": "완성"
|
||||
},
|
||||
"create": {
|
||||
"step1": "STEP 1 · 시나리오 선택",
|
||||
"step2": "STEP 2 · 업장 검색",
|
||||
"searchPlaceholder": "가게 이름 (예: 골목냉면) 또는 네이버 지도 링크",
|
||||
"searchButton": "검색",
|
||||
"searching": "검색 중",
|
||||
"placeLoading": "가게를 찾는 중… (몇 초 걸려요)",
|
||||
"change": "변경",
|
||||
"hintUrlOk": "네이버 링크로 이 가게를 직접 크롤링합니다.",
|
||||
"hintDefault": "가게를 선택하면 그 가게의 사진과 정보를 크롤링해 썰박스에 씁니다.",
|
||||
"hintScenario": "{{name}} 시나리오로 수 분 내외 생성됩니다",
|
||||
"noResult": "검색 결과가 없어요. 이름을 바꾸거나 네이버 지도 링크를 붙여넣어 주세요.",
|
||||
"cost": "생성 비용",
|
||||
"costValue": "1 크레딧",
|
||||
"submit": "썰박스 만들기",
|
||||
"submitting": "요청 중…"
|
||||
},
|
||||
"making": {
|
||||
"title": "썰박스를 만들고 있어요",
|
||||
"step1": "대본 생성 중",
|
||||
"step2": "스토리보드 구성 중",
|
||||
"step3": "이미지·목소리 생성 중",
|
||||
"step4": "영상 합성 중",
|
||||
"takesTime": "보통 수 분 걸려요. 다른 작업을 하셔도 계속 진행됩니다.",
|
||||
"failTitle": "생성에 실패했어요",
|
||||
"failDefault": "잠시 후 다시 시도해주세요.",
|
||||
"refunded": "차감된 크레딧은 환불되었습니다.",
|
||||
"retry": "다시 만들기"
|
||||
},
|
||||
"result": {
|
||||
"title": "썰박스가 완성됐어요",
|
||||
"download": "다운로드",
|
||||
"downloading": "다운로드 중...",
|
||||
"loadFailed": "영상을 불러오지 못했어요. 내 콘텐츠에서 확인해주세요."
|
||||
},
|
||||
"viewer": {
|
||||
"untitled": "이름 없는 콘텐츠"
|
||||
},
|
||||
"error": {
|
||||
"createFailed": "생성 요청에 실패했어요. 잠시 후 다시 시도해주세요."
|
||||
},
|
||||
"scenario": {
|
||||
"joseon": {
|
||||
"name": "조선왕",
|
||||
"desc": "조선 27대 왕들의 실화 썰"
|
||||
},
|
||||
"samgukji": {
|
||||
"name": "삼국지",
|
||||
"desc": "위·촉·오 영웅 28인의 야사"
|
||||
},
|
||||
"greek": {
|
||||
"name": "그리스·로마신화",
|
||||
"desc": "올림포스 신·영웅들의 전설"
|
||||
},
|
||||
"odyssey": {
|
||||
"name": "오디세이",
|
||||
"desc": "오디세우스 10년 귀향 대모험"
|
||||
}
|
||||
}
|
||||
},
|
||||
"urlInput": {
|
||||
"searchTypeBusinessName": "업체명",
|
||||
"placeholderBusinessName": "업체명을 입력하세요.",
|
||||
|
||||
@ -6,6 +6,7 @@ import LoginPromptModal from '../../components/LoginPromptModal';
|
||||
import VideoDetailModal from '../../components/VideoDetailModal';
|
||||
import CitySelectModal from '../../components/CitySelectModal';
|
||||
import ContentCardSocialActions from '../../components/ContentCardSocialActions';
|
||||
import SsulViewerModal from '../Ssulbox/SsulViewerModal';
|
||||
|
||||
interface ADO2ContentsPageProps {
|
||||
onBack?: () => void;
|
||||
@ -15,6 +16,9 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
|
||||
const { t } = useTranslation();
|
||||
const authed = isLoggedIn();
|
||||
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null);
|
||||
// 썰박스는 별도 뷰어를 쓴다 — VideoDetailModal 은 video_id 로 조회하므로
|
||||
// 썰박스 id 를 넘기면 id 가 겹치는 다른 영상이 열린다.
|
||||
const [selectedSsul, setSelectedSsul] = useState<VideoListItem | null>(null);
|
||||
const [videos, setVideos] = useState<VideoListItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(authed);
|
||||
@ -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={() =>
|
||||
video.type === 'ssul'
|
||||
? setSelectedSsul(video)
|
||||
: handleCardClick(video.video_id)
|
||||
}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCardClick(video.video_id)}
|
||||
onKeyDown={(e) =>
|
||||
e.key === 'Enter' &&
|
||||
(video.type === 'ssul'
|
||||
? setSelectedSsul(video)
|
||||
: handleCardClick(video.video_id))
|
||||
}
|
||||
>
|
||||
<div className="content-card-thumbnail ado2-gallery-thumbnail-wrap">
|
||||
{video.thumbnail_url ? (
|
||||
@ -182,14 +197,19 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
|
||||
<h3 className="content-card-title">{video.store_name}</h3>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<p className="content-card-date">{formatDate(video.created_at)}</p>
|
||||
<ContentCardSocialActions
|
||||
videoId={video.video_id}
|
||||
storeName={video.store_name}
|
||||
region={video.region}
|
||||
commentCount={video.comment_count ?? 0}
|
||||
initialLikeCount={video.like_count ?? 0}
|
||||
initialIsLiked={video.is_liked_by_me}
|
||||
/>
|
||||
{/* 좋아요·댓글은 castad API 를 video_id 로 호출한다.
|
||||
썰박스 id 를 넘기면 id 가 겹치는 다른 영상에 반영되므로
|
||||
`/ssul/*` 반응 API 가 붙기 전까지 노출하지 않는다. */}
|
||||
{video.type === 'video' && (
|
||||
<ContentCardSocialActions
|
||||
videoId={video.video_id}
|
||||
storeName={video.store_name}
|
||||
region={video.region}
|
||||
commentCount={video.comment_count ?? 0}
|
||||
initialLikeCount={video.like_count ?? 0}
|
||||
initialIsLiked={video.is_liked_by_me}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -221,6 +241,8 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
|
||||
<LoginPromptModal onClose={() => { window.location.href = '/'; }} />
|
||||
)}
|
||||
|
||||
<SsulViewerModal item={selectedSsul} onClose={() => setSelectedSsul(null)} />
|
||||
|
||||
{selectedVideoId !== null && (
|
||||
<VideoDetailModal
|
||||
videoId={String(selectedVideoId)}
|
||||
|
||||
@ -555,6 +555,8 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
||||
onClose={handleCloseSocialConnect}
|
||||
onGoToCalendar={onGoToCalendar}
|
||||
video={videoUrl && videoDbId ? {
|
||||
// ADO2 파이프라인 완료 화면이므로 항상 영상이다
|
||||
type: 'video',
|
||||
video_id: videoDbId,
|
||||
store_name: songCompletionData?.businessName || '',
|
||||
region: '',
|
||||
|
||||
@ -15,32 +15,50 @@ 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 { crawlUrl, autocomplete, marketingAnalysis, AutocompleteRequest, getUserMe, getUserCredits, clearTokens, getActiveSsulTask, waitForSsulComplete } from '../../utils/api';
|
||||
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
||||
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
||||
import TutorialOverlay, { TutorialRestartPopup } from '../../components/Tutorial/TutorialOverlay';
|
||||
import WizardStepper from '../../components/WizardStepper';
|
||||
|
||||
const WIZARD_STEP_KEY = 'castad_wizard_step';
|
||||
const ACTIVE_ITEM_KEY = 'castad_active_item';
|
||||
const SONG_TASK_ID_KEY = 'castad_song_task_id';
|
||||
const IMAGE_TASK_ID_KEY = 'castad_image_task_id';
|
||||
const ANALYSIS_DATA_KEY = 'castad_analysis_data';
|
||||
import { saveSearchHistory } from '../../components/SearchHistory/useSearchHistory';
|
||||
import { Pipeline } from '../../components/PipelineTabs';
|
||||
import { Scen } from '../Ssulbox/ssulData';
|
||||
import SsulMakingContent, { SsulJob } from '../Ssulbox/SsulMakingContent';
|
||||
import SsulResultContent from '../Ssulbox/SsulResultContent';
|
||||
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';
|
||||
|
||||
/** 썰박스 진행 단계. 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,
|
||||
};
|
||||
|
||||
interface BusinessInfo {
|
||||
@ -111,6 +129,42 @@ 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 (pending === 'ssul' || pending === 'ado2') {
|
||||
sessionStorage.removeItem(PENDING_PIPELINE_KEY);
|
||||
return pending;
|
||||
}
|
||||
// 2) 분석 데이터가 있으면 ADO2 흐름 한가운데이므로 강제
|
||||
if (initialAnalysisData || savedAnalysisData) return 'ado2';
|
||||
// 3) 저장된 선택
|
||||
const saved = localStorage.getItem(K.PIPELINE);
|
||||
return saved === 'ssul' ? 'ssul' : '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);
|
||||
|
||||
const changePipeline = (next: Pipeline) => {
|
||||
setPipeline(next);
|
||||
localStorage.setItem(K.PIPELINE, next);
|
||||
};
|
||||
|
||||
/** 진입 화면(탭이 보이는 단계) 여부. 두 파이프라인이 공유한다 */
|
||||
const isEntry = pipeline === 'ado2' ? wizardStep === -2 : ssulStep === 'entry';
|
||||
const [songTaskId, setSongTaskId] = useState<string | null>(savedSongTaskId);
|
||||
const [imageTaskId, setImageTaskId] = useState<string | null>(savedImageTaskId);
|
||||
const [videoGenerationStatus, setVideoGenerationStatus] = useState<'idle' | 'generating' | 'complete' | 'error'>('idle');
|
||||
@ -156,10 +210,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 = '/';
|
||||
};
|
||||
|
||||
@ -212,7 +265,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
|
||||
// 홈 버튼(로고) 클릭 시 모든 상태 초기화 후 홈으로 이동
|
||||
const handleHome = () => {
|
||||
clearAllProjectStorage();
|
||||
clearProjectStorage();
|
||||
localStorage.removeItem(ANALYSIS_DATA_KEY);
|
||||
setWizardStep(-2);
|
||||
setSongTaskId(null);
|
||||
@ -395,16 +448,113 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
setActiveItem('내 정보');
|
||||
};
|
||||
|
||||
/**
|
||||
* 썰박스 잡 폴링을 시작한다.
|
||||
*
|
||||
* 폴링 소유가 GenerationFlow 인 이유: SsulMakingContent 가 들고 있으면
|
||||
* 사이드바로 이동하는 순간 언마운트되면서 폴링이 끊긴다.
|
||||
* 잡은 화면보다 오래 살아야 한다.
|
||||
*/
|
||||
const watchSsulTask = useCallback(
|
||||
(taskId: number, scenario: Scen) => {
|
||||
ssulCancelledRef.current = false;
|
||||
setSsulJob({ taskId, scenario, status: 'queued', step: 0, error: null });
|
||||
|
||||
waitForSsulComplete(
|
||||
taskId,
|
||||
(task) => {
|
||||
if (ssulCancelledRef.current) return;
|
||||
setSsulJob({
|
||||
taskId,
|
||||
scenario,
|
||||
status: task.status,
|
||||
step: task.step,
|
||||
error: task.error,
|
||||
});
|
||||
},
|
||||
() => ssulCancelledRef.current
|
||||
)
|
||||
.then(() => {
|
||||
if (ssulCancelledRef.current) return;
|
||||
localStorage.removeItem(K.SSUL_TASK_ID);
|
||||
setSsulStep('result');
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
if (ssulCancelledRef.current || e.message === 'CANCELLED') return;
|
||||
localStorage.removeItem(K.SSUL_TASK_ID);
|
||||
setSsulJob((prev) =>
|
||||
prev ? { ...prev, status: 'error', error: e.message } : prev
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (ssulCancelledRef.current) return;
|
||||
// 완료든 실패든 잔액을 갱신한다. 실패 시 백엔드가 환불하므로
|
||||
// 갱신하지 않으면 사이드바 잔액이 낡은 값으로 굳는다.
|
||||
refreshCredits();
|
||||
});
|
||||
},
|
||||
[refreshCredits]
|
||||
);
|
||||
|
||||
/** 생성 요청이 접수됐을 때 */
|
||||
const handleSsulJobStarted = (taskId: number, scenario: Scen) => {
|
||||
localStorage.setItem(K.SSUL_TASK_ID, String(taskId));
|
||||
setSsulStep('making');
|
||||
// 생성 시작 시점에 크레딧이 선차감되므로 사이드바 잔액을 즉시 갱신
|
||||
refreshCredits();
|
||||
watchSsulTask(taskId, scenario);
|
||||
};
|
||||
|
||||
/** 실패 후 처음부터 */
|
||||
const handleSsulRetry = () => {
|
||||
ssulCancelledRef.current = true;
|
||||
localStorage.removeItem(K.SSUL_TASK_ID);
|
||||
setSsulJob(null);
|
||||
setSsulStep('entry');
|
||||
};
|
||||
|
||||
/**
|
||||
* 새로고침·새 탭 진입 시 진행 중인 잡을 복구한다.
|
||||
*
|
||||
* 권위는 서버(`/ssul/tasks/active`)다. localStorage 의 SSUL_TASK_ID 는 힌트일 뿐이고,
|
||||
* initializeOnNewSession 이 새 탭에서 그것을 지우기 때문에 서버를 물어야 한다.
|
||||
*/
|
||||
useEffect(() => {
|
||||
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) => {
|
||||
setMyInfoInitialTab(undefined);
|
||||
if (item === '새 프로젝트 만들기') {
|
||||
// 기존 프로젝트 데이터 초기화
|
||||
clearAllProjectStorage();
|
||||
clearProjectStorage();
|
||||
localStorage.removeItem(ANALYSIS_DATA_KEY);
|
||||
// URL 입력 단계로 명시적 저장 (다음 진입 시 올바른 단계 복원)
|
||||
localStorage.setItem(WIZARD_STEP_KEY, '-2');
|
||||
setWizardStep(-2);
|
||||
// 파이프라인도 기본값으로 되돌린다. clearProjectStorage() 가 K.PIPELINE 을
|
||||
// 지우므로 state 만 남으면 저장소와 어긋난다.
|
||||
setPipeline('ado2');
|
||||
setSsulStep('entry');
|
||||
setSongTaskId(null);
|
||||
setImageTaskId(null);
|
||||
setAnalysisData(null);
|
||||
@ -419,17 +569,40 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
|
||||
// 새 프로젝트 만들기 - 단계별 컨텐츠 렌더링
|
||||
const renderWizardContent = () => {
|
||||
// 진입 화면은 두 파이프라인이 공유하므로 switch 밖에서 먼저 처리한다.
|
||||
// ADO2 는 wizardStep -2, 썰박스는 ssulStep 'entry' 가 여기 해당한다.
|
||||
if (isEntry) {
|
||||
return (
|
||||
<UrlInputContent
|
||||
pipeline={pipeline}
|
||||
onPipelineChange={changePipeline}
|
||||
onAnalyze={handleStartAnalysis}
|
||||
onAutocomplete={handleAutocomplete}
|
||||
onManualInput={handleManualInput}
|
||||
error={analysisError}
|
||||
onSsulJobStarted={handleSsulJobStarted}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
switch (wizardStep) {
|
||||
case -2:
|
||||
// URL 입력 단계
|
||||
return (
|
||||
<UrlInputContent
|
||||
onAnalyze={handleStartAnalysis}
|
||||
onAutocomplete={handleAutocomplete}
|
||||
onManualInput={handleManualInput}
|
||||
error={analysisError}
|
||||
/>
|
||||
);
|
||||
case -1:
|
||||
// 로딩 단계
|
||||
return (
|
||||
@ -457,8 +630,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);
|
||||
|
||||
@ -502,8 +675,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);
|
||||
@ -539,10 +712,22 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
case '내 정보':
|
||||
return <MyInfoContent initialTab={myInfoInitialTab} />;
|
||||
case '새 프로젝트 만들기':
|
||||
// 로딩(-1), URL 입력(-2)은 스텝퍼 없이 전체 화면으로 표시
|
||||
if (wizardStep === -1 || wizardStep === -2) {
|
||||
// 진입 화면(탭)과 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>
|
||||
);
|
||||
}
|
||||
// 브랜드 분석(0)은 전체 화면 스크롤이지만 스텝퍼는 표시
|
||||
if (wizardStep === 0) {
|
||||
return (
|
||||
|
||||
@ -6,6 +6,7 @@ import { VideoListItem } from '../../types/api';
|
||||
import SocialPostingModal from '../../components/SocialPostingModal';
|
||||
import VideoDetailModal from '../../components/VideoDetailModal';
|
||||
import ContentCardSocialActions from '../../components/ContentCardSocialActions';
|
||||
import SsulViewerModal from '../Ssulbox/SsulViewerModal';
|
||||
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
||||
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
||||
|
||||
@ -108,6 +109,9 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
|
||||
const [uploadModalOpen, setUploadModalOpen] = useState(false);
|
||||
const [uploadTargetVideo, setUploadTargetVideo] = useState<VideoListItem | null>(null);
|
||||
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null);
|
||||
// 썰박스는 별도 뷰어를 쓴다 — VideoDetailModal 은 video_id 로 조회하므로
|
||||
// 썰박스 id 를 넘기면 id 가 겹치는 다른 영상이 열린다.
|
||||
const [selectedSsul, setSelectedSsul] = useState<VideoListItem | null>(null);
|
||||
|
||||
const pageSize = 12;
|
||||
|
||||
@ -241,12 +245,18 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
|
||||
<>
|
||||
<div className="ado2-contents-grid">
|
||||
{videos.map((video) => (
|
||||
<div key={video.task_id} className="ado2-content-card">
|
||||
// key 는 (type, video_id) 쌍이어야 한다 — task_id 는 썰박스에 없고(빈 문자열)
|
||||
// video_id 는 종류별 독립 시퀀스라 값이 겹친다.
|
||||
<div key={`${video.type}-${video.video_id}`} className="ado2-content-card">
|
||||
{/* Video Thumbnail */}
|
||||
<div
|
||||
className="content-card-thumbnail"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => setSelectedVideoId(video.video_id)}
|
||||
onClick={() =>
|
||||
video.type === 'ssul'
|
||||
? setSelectedSsul(video)
|
||||
: setSelectedVideoId(video.video_id)
|
||||
}
|
||||
>
|
||||
{video.result_movie_url ? (
|
||||
<VideoPreviewCard
|
||||
@ -279,30 +289,40 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
|
||||
<p className="content-card-date">
|
||||
{formatDate(video.created_at)}
|
||||
</p>
|
||||
<ContentCardSocialActions
|
||||
videoId={video.video_id}
|
||||
storeName={video.store_name}
|
||||
region={video.region}
|
||||
commentCount={video.comment_count ?? 0}
|
||||
initialLikeCount={video.like_count ?? 0}
|
||||
initialIsLiked={video.is_liked_by_me}
|
||||
/>
|
||||
{/* 좋아요·댓글은 castad API 를 video_id 로 호출한다.
|
||||
썰박스 id 를 넘기면 id 가 겹치는 다른 영상에 반영되므로
|
||||
`/ssul/*` 반응 API 가 붙기 전까지 노출하지 않는다. */}
|
||||
{video.type === 'video' && (
|
||||
<ContentCardSocialActions
|
||||
videoId={video.video_id}
|
||||
storeName={video.store_name}
|
||||
region={video.region}
|
||||
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">
|
||||
<button
|
||||
className="content-download-btn"
|
||||
onClick={() => handleUploadClick(video)}
|
||||
disabled={!video.result_movie_url}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M10 13V3M10 3l-4 4M10 3l4 4"/>
|
||||
<path d="M3 15v2h14v-2"/>
|
||||
</svg>
|
||||
<span>{t('ado2Contents.uploadToSocial')}</span>
|
||||
</button>
|
||||
{/* SNS 업로드는 SocialPostingModal 이 video_id 로 업로드 API 를 부른다.
|
||||
썰박스 id 를 넘기면 **엉뚱한 ADO2 영상이 업로드된다.**
|
||||
`/ssul/upload/*` 가 붙기 전까지 노출하지 않는다. */}
|
||||
{video.type === 'video' && (
|
||||
<button
|
||||
className="content-download-btn"
|
||||
onClick={() => handleUploadClick(video)}
|
||||
disabled={!video.result_movie_url}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M10 13V3M10 3l-4 4M10 3l4 4"/>
|
||||
<path d="M3 15v2h14v-2"/>
|
||||
</svg>
|
||||
<span>{t('ado2Contents.uploadToSocial')}</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="content-upload-btn"
|
||||
onClick={() => handleDownload(video.result_movie_url, video.store_name)}
|
||||
@ -314,16 +334,22 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
|
||||
<path d="M3 15v2h14v-2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className="content-delete-btn"
|
||||
onClick={() => handleDeleteClick(video.video_id)}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M3 5h14M8 5V3h4v2M6 5v12h8V5"/>
|
||||
<line x1="8" y1="8" x2="8" y2="14"/>
|
||||
<line x1="12" y1="8" x2="12" y2="14"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/* ⚠️ 삭제는 파괴적이다. `DELETE /archive/videos/{id}` 는 `Video.id` 로
|
||||
지우므로 썰박스 id 를 넘기면 **id 가 겹치는 ADO2 영상이 삭제된다.**
|
||||
소유권 검증도 통과한다(같은 사용자가 양쪽을 다 가진 경우).
|
||||
썰박스 삭제 API 가 붙기 전까지 절대 노출하지 않는다. */}
|
||||
{video.type === 'video' && (
|
||||
<button
|
||||
className="content-delete-btn"
|
||||
onClick={() => handleDeleteClick(video.video_id)}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M3 5h14M8 5V3h4v2M6 5v12h8V5"/>
|
||||
<line x1="8" y1="8" x2="8" y2="14"/>
|
||||
<line x1="12" y1="8" x2="12" y2="14"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -384,6 +410,8 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
<SsulViewerModal item={selectedSsul} onClose={() => setSelectedSsul(null)} />
|
||||
|
||||
{/* 소셜 미디어 업로드 모달 */}
|
||||
<SocialPostingModal
|
||||
isOpen={uploadModalOpen}
|
||||
|
||||
@ -1,15 +1,40 @@
|
||||
import React from 'react';
|
||||
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';
|
||||
|
||||
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) => void;
|
||||
error: string | null;
|
||||
/** 썰박스 생성 요청 접수 시. 폴링 소유는 GenerationFlow */
|
||||
onSsulJobStarted: (taskId: number, scenario: Scen) => void;
|
||||
/** 크레딧 부족 시 충전 화면으로 */
|
||||
onGoToPayment: () => void;
|
||||
}
|
||||
|
||||
const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomplete, onManualInput, error }) => {
|
||||
/**
|
||||
* 대시보드 진입 화면.
|
||||
*
|
||||
* 탭바는 로고와 하위 폼 **사이에 형제로** 얹고 하위 폼만 교체한다.
|
||||
* SearchInputForm 은 랜딩 HeroSection 과 공유되므로 어떤 경우에도 수정하지 않는다.
|
||||
*/
|
||||
const UrlInputContent: React.FC<UrlInputContentProps> = ({
|
||||
pipeline,
|
||||
onPipelineChange,
|
||||
onAnalyze,
|
||||
onAutocomplete,
|
||||
onManualInput,
|
||||
error,
|
||||
onSsulJobStarted,
|
||||
onGoToPayment,
|
||||
}) => {
|
||||
return (
|
||||
<div className="url-input-container">
|
||||
<div className="url-input-content">
|
||||
@ -17,12 +42,18 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
|
||||
<img src="/assets/images/ADO2_with Slogan_white.svg" alt="ADO2" />
|
||||
</div>
|
||||
|
||||
<SearchInputForm
|
||||
onAnalyze={onAnalyze}
|
||||
onAutocomplete={onAutocomplete}
|
||||
onManualInput={onManualInput}
|
||||
error={error}
|
||||
/>
|
||||
<PipelineTabs value={pipeline} onChange={onPipelineChange} />
|
||||
|
||||
{pipeline === 'ado2' ? (
|
||||
<SearchInputForm
|
||||
onAnalyze={onAnalyze}
|
||||
onAutocomplete={onAutocomplete}
|
||||
onManualInput={onManualInput}
|
||||
error={error}
|
||||
/>
|
||||
) : (
|
||||
<SsulCreateForm onSubmitted={onSsulJobStarted} onNeedCredit={onGoToPayment} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
229
src/pages/Ssulbox/SsulCreateForm.tsx
Normal file
229
src/pages/Ssulbox/SsulCreateForm.tsx
Normal file
@ -0,0 +1,229 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
InsufficientCreditError,
|
||||
SsulPlaceItem,
|
||||
createSsulJob,
|
||||
searchSsulPlace,
|
||||
} from '../../utils/api';
|
||||
import { SCEN, 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());
|
||||
|
||||
/**
|
||||
* 썰박스 생성 폼. 진입 화면의 썰박스 탭에 들어간다.
|
||||
*
|
||||
* 원본 썰박스의 CreateSheet(바텀시트) 를 시트 크롬 없이 인라인으로 재배치했다.
|
||||
* 시나리오 선택과 업장 검색을 한 화면에 둔다(원본은 2단계로 나뉘어 있었으나,
|
||||
* 여기서는 위저드 스텝퍼가 이미 단계를 보여주므로 중복이다).
|
||||
*/
|
||||
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<SsulPlaceItem[]>([]);
|
||||
const [selected, setSelected] = useState<SsulPlaceItem | null>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [searched, setSearched] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isUrl = isNaverUrl(input);
|
||||
// place_url 이 확정됐거나(검색 결과 선택) 링크를 직접 붙여넣었을 때만 생성 가능
|
||||
const canSubmit = !!scenario && (!!selected || isUrl) && !submitting;
|
||||
|
||||
const runSearch = async () => {
|
||||
const query = input.trim();
|
||||
if (query.length < 2 || isUrl) return;
|
||||
|
||||
setSearching(true);
|
||||
setSearched(false);
|
||||
setResults([]);
|
||||
setSelected(null);
|
||||
try {
|
||||
setResults(await searchSsulPlace(query));
|
||||
} finally {
|
||||
setSearching(false);
|
||||
setSearched(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!scenario || submitting) return;
|
||||
|
||||
// 검색으로 고른 가게가 있으면 그 place_url 로 정확히 크롤링한다
|
||||
const payload = selected ? selected.place_url : 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 pal = SCEN[key];
|
||||
const active = scenario === key;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
className={`ssul-scn${active ? ' active' : ''}`}
|
||||
style={
|
||||
{
|
||||
// 표지 그라디언트 2단 + 대표색. CSS 가 옅게 깔아 쓴다
|
||||
'--scn': pal.hex,
|
||||
'--scn-a': pal.g[0],
|
||||
'--scn-b': pal.g[1],
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onClick={() => setScenario(key)}
|
||||
>
|
||||
<span className="ssul-scn__emoji" aria-hidden="true">
|
||||
{pal.emoji}
|
||||
</span>
|
||||
<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">
|
||||
<input
|
||||
className="ssul-create__input"
|
||||
placeholder={t('ssulbox.create.searchPlaceholder')}
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
if (selected) setSelected(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
runSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="ssul-create__search-btn"
|
||||
disabled={searching || input.trim().length < 2 || isUrl}
|
||||
onClick={runSearch}
|
||||
>
|
||||
{searching ? t('ssulbox.create.searching') : t('ssulbox.create.searchButton')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 검색 상태에 따른 안내 — 한 번에 하나만 보인다 */}
|
||||
{selected ? (
|
||||
<div className="ssul-picked">
|
||||
<div className="ssul-picked__body">
|
||||
<b>{selected.title}</b>
|
||||
<span>
|
||||
{selected.category ? `${selected.category} · ` : ''}
|
||||
{selected.address || selected.roadAddress}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" className="ssul-picked__change" onClick={() => setSelected(null)}>
|
||||
{t('ssulbox.create.change')}
|
||||
</button>
|
||||
</div>
|
||||
) : isUrl ? (
|
||||
<div className="ssul-hint ok">{t('ssulbox.create.hintUrlOk')}</div>
|
||||
) : searching ? (
|
||||
<div className="ssul-hint">{t('ssulbox.create.placeLoading')}</div>
|
||||
) : results.length > 0 ? (
|
||||
<ul className="ssul-places">
|
||||
{results.map((place, i) => (
|
||||
<li key={`${place.place_url}-${i}`}>
|
||||
<button type="button" onClick={() => setSelected(place)}>
|
||||
<b>{place.title}</b>
|
||||
<span>
|
||||
{place.category ? `${place.category} · ` : ''}
|
||||
{place.address || place.roadAddress}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : searched ? (
|
||||
<div className="ssul-hint warn">{t('ssulbox.create.noResult')}</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;
|
||||
111
src/pages/Ssulbox/SsulMakingContent.tsx
Normal file
111
src/pages/Ssulbox/SsulMakingContent.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SCEN, 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 palette = SCEN[job.scenario];
|
||||
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" style={{ color: palette.hex }}>
|
||||
<span aria-hidden="true">{palette.emoji}</span> {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"
|
||||
style={{ ['--scn' as string]: palette.hex }}
|
||||
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}%`, background: palette.hex }}
|
||||
/>
|
||||
</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;
|
||||
133
src/pages/Ssulbox/SsulResultContent.tsx
Normal file
133
src/pages/Ssulbox/SsulResultContent.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SsulTaskStatus, getSsulTask } from '../../utils/api';
|
||||
import { SCEN, Scen, scenName } from './ssulData';
|
||||
import { IcDownload } from './ssulIcons';
|
||||
|
||||
interface SsulResultContentProps {
|
||||
taskId: number;
|
||||
scenario: Scen;
|
||||
}
|
||||
|
||||
/**
|
||||
* 썰박스 생성 완료 화면.
|
||||
*
|
||||
* 완성된 영상을 재생하고 다운로드할 수 있다.
|
||||
* SNS 업로드는 castad `SocialPostingModal` 재사용을 먼저 시도해야 하므로
|
||||
* 통합 목록 작업(Phase 8)과 함께 붙인다.
|
||||
*/
|
||||
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);
|
||||
|
||||
// 폴링이 끝난 뒤 최종 상태(video_url 포함)를 한 번 더 읽는다.
|
||||
// 진행 중에는 video_url 이 비어 있으므로 완료 후 조회가 필요하다.
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
getSsulTask(taskId)
|
||||
.then((data) => alive && setTask(data))
|
||||
.catch(() => alive && setLoadError(true));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [taskId]);
|
||||
|
||||
const palette = SCEN[scenario];
|
||||
|
||||
/**
|
||||
* 영상 다운로드.
|
||||
*
|
||||
* **Blob URL 을 `<a download>` 에 그대로 걸면 다운로드가 아니라 재생 화면으로
|
||||
* 이동한다** — `download` 속성은 교차 출처 URL 에서 무시되고, 영상은 Azure Blob
|
||||
* 도메인에 있어 앱과 출처가 다르기 때문이다.
|
||||
* 그래서 castad `CompletionContent` 와 동일하게 fetch → Blob → 동일 출처
|
||||
* objectURL 로 바꿔서 내려받는다.
|
||||
*
|
||||
* fetch 가 실패하면(CORS 등) 새 탭으로라도 열어준다 — 사용자가 거기서
|
||||
* 직접 저장할 수 있으므로 아무 일도 안 일어나는 것보다 낫다.
|
||||
*/
|
||||
const handleDownload = async () => {
|
||||
if (!task?.video_url || downloading) return;
|
||||
// 파일명은 프론트가 정한다(백엔드는 title 을 갖지 않는다)
|
||||
const fileName = `ssulbox-${scenario}-${taskId}.mp4`;
|
||||
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 (
|
||||
<div className="ssul-scope ssul-result">
|
||||
<span className="ssul-result__scenario" style={{ color: palette.hex }}>
|
||||
<span aria-hidden="true">{palette.emoji}</span> {t(scenName(scenario))}
|
||||
</span>
|
||||
|
||||
<h3 className="ssul-result__title">{t('ssulbox.result.title')}</h3>
|
||||
|
||||
{loadError ? (
|
||||
<div className="ssul-hint warn">{t('ssulbox.result.loadFailed')}</div>
|
||||
) : task?.video_url ? (
|
||||
<video
|
||||
className="ssul-result__video"
|
||||
src={task.video_url}
|
||||
controls
|
||||
playsInline
|
||||
preload="metadata"
|
||||
/>
|
||||
) : (
|
||||
<div className="ssul-result__video-skeleton" />
|
||||
)}
|
||||
|
||||
<div className="ssul-result__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="ssul-result__btn primary"
|
||||
onClick={handleDownload}
|
||||
disabled={!task?.video_url || downloading}
|
||||
>
|
||||
<IcDownload />
|
||||
{downloading
|
||||
? t('ssulbox.result.downloading')
|
||||
: t('ssulbox.result.download')}
|
||||
</button>
|
||||
|
||||
{/*
|
||||
TODO(Phase 8): SNS 업로드 버튼 자리.
|
||||
castad `SocialPostingModal` 을 그대로 쓸 수 없다 — `VideoListItem` 을 받아
|
||||
`video_id` 로 업로드 API 를 호출하는데, 그 API 는 `video` 테이블을 조회하고
|
||||
`social_upload.video_id`(NOT NULL FK)에 쓴다. 썰박스에는 video_id 가 없고,
|
||||
taskId 를 넘기면 **id 가 겹치는 남의 ADO2 영상이 업로드된다**
|
||||
(ssul_content.id 와 video.id 가 둘 다 1부터 시작한다).
|
||||
→ `/ssul/upload/*` 백엔드와 모달 분기가 선행되어야 한다.
|
||||
|
||||
'새로 만들기' 버튼은 제거했다. 사이드바 '새 프로젝트 만들기'가 같은 일을
|
||||
하고, ADO2 완료 화면도 재생성 버튼을 두지 않는다.
|
||||
*/}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SsulResultContent;
|
||||
128
src/pages/Ssulbox/SsulViewerModal.tsx
Normal file
128
src/pages/Ssulbox/SsulViewerModal.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { VideoListItem } from '../../types/api';
|
||||
|
||||
interface SsulViewerModalProps {
|
||||
/** null 이면 닫힘 */
|
||||
item: VideoListItem | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 썰박스 콘텐츠 뷰어.
|
||||
*
|
||||
* **castad `VideoDetailModal` 과 같은 형식**을 쓴다 — 백드롭·패널 인라인 스타일과
|
||||
* `video-detail-*` 클래스를 그대로 재사용하므로 통합 목록에서 어떤 카드를 열든
|
||||
* 같은 모양이 나온다. 신규 CSS 를 만들지 않는다.
|
||||
*
|
||||
* 컴포넌트를 그대로 못 쓰는 이유: `VideoDetailModal` 은 `videoId` 로 `/video/{id}` 를
|
||||
* 조회하는데, 썰박스 id 를 넘기면 **id 가 겹치는 다른 ADO2 영상**이 열린다
|
||||
* (`video.id` 와 `ssul_content.id` 는 각각 1부터 시작하는 독립 시퀀스).
|
||||
* 목록이 이미 재생에 필요한 정보를 갖고 있어 추가 조회도 필요 없다.
|
||||
*
|
||||
* 좋아요·댓글·공유는 castad API 를 video_id 로 호출하므로 여기서는 빼 뒀다.
|
||||
* `/ssul/*` 반응 API 가 붙으면 추가한다.
|
||||
*/
|
||||
const SsulViewerModal: React.FC<SsulViewerModalProps> = ({ item, onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
// 가로 영상이면 castad 와 동일하게 landscape 레이아웃으로 전환한다
|
||||
const [isLandscape, setIsLandscape] = useState(false);
|
||||
|
||||
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]);
|
||||
|
||||
// 다른 콘텐츠를 열면 방향 판정을 초기화한다(이전 값이 남으면 레이아웃이 틀어진다)
|
||||
useEffect(() => {
|
||||
setIsLandscape(false);
|
||||
}, [item?.type, item?.video_id]);
|
||||
|
||||
if (!item) return null;
|
||||
|
||||
const formatDate = (value?: string) => {
|
||||
if (!value) return '';
|
||||
const d = new Date(value);
|
||||
return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(
|
||||
d.getDate()
|
||||
).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
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-modal-content">
|
||||
{/* 헤더 */}
|
||||
<div className="video-detail-header">
|
||||
<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-content ${isLandscape ? 'landscape' : ''}`}>
|
||||
<video
|
||||
src={item.result_movie_url}
|
||||
controls
|
||||
autoPlay
|
||||
controlsList="nodownload"
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
className="video-detail-player"
|
||||
onLoadedMetadata={(e) => {
|
||||
const v = e.currentTarget;
|
||||
setIsLandscape(v.videoWidth > v.videoHeight);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="video-detail-info">
|
||||
<h2 className="video-detail-store">
|
||||
{item.store_name || t('ssulbox.viewer.untitled')}
|
||||
</h2>
|
||||
<p className="video-detail-date">{formatDate(item.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SsulViewerModal;
|
||||
53
src/pages/Ssulbox/ssulData.ts
Normal file
53
src/pages/Ssulbox/ssulData.ts
Normal file
@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 썰박스 시나리오 팔레트와 표시 유틸.
|
||||
*
|
||||
* 원본(o2o-ssulbox/frontend/src/data.ts)에서 이식하되 **문구(name/desc)는 제외**했다.
|
||||
* castad 는 ko/en 다국어라 한글이 코드에 박히면 i18n 밖으로 새기 때문이다.
|
||||
* 표시 문구는 `t('ssulbox.scenario.<key>.name')` / `.desc` 로 가져온다.
|
||||
*
|
||||
* 목업(SAMPLE)도 제외했다 — 백엔드 `/ssul/feed` 가 진실 원천이다.
|
||||
*/
|
||||
|
||||
/** 시나리오 코드. 백엔드 `ssul_content.scenario` 와 값이 일치해야 한다 */
|
||||
export type Scen = 'joseon' | 'samgukji' | 'greek' | 'odyssey';
|
||||
|
||||
export interface ScenPalette {
|
||||
/** 카드 표지 이모지 */
|
||||
emoji: string;
|
||||
/** 대표색 (뱃지·강조선) */
|
||||
hex: string;
|
||||
/** 표지 그라디언트 시작/끝 */
|
||||
g: [string, string];
|
||||
}
|
||||
|
||||
export const SCEN: Record<Scen, ScenPalette> = {
|
||||
joseon: { emoji: '👑', hex: '#3b4cc0', g: ['#4054d6', '#8a3ffb'] },
|
||||
samgukji: { emoji: '⚔️', hex: '#d63a2f', g: ['#e0402f', '#f0872b'] },
|
||||
greek: { emoji: '🏛️', hex: '#0a8f6b', g: ['#0a9e78', '#3fb6d6'] },
|
||||
odyssey: { emoji: '⛵', hex: '#1565c0', g: ['#1565c0', '#42a5f5'] },
|
||||
};
|
||||
|
||||
/** 시나리오 순서. 선택 그리드(2x2)와 필터 칩이 이 순서를 따른다 */
|
||||
export const SCEN_KEYS = Object.keys(SCEN) as Scen[];
|
||||
|
||||
/** 카드 표지 그라디언트 */
|
||||
export const scGrad = (s: Scen): string =>
|
||||
`linear-gradient(150deg,${SCEN[s].g[0]} 0%,${SCEN[s].g[1]} 100%)`;
|
||||
|
||||
/** i18n 키 헬퍼 — t(scenName('joseon')) 형태로 쓴다 */
|
||||
export const scenName = (s: Scen): string => `ssulbox.scenario.${s}.name`;
|
||||
export const scenDesc = (s: Scen): string => `ssulbox.scenario.${s}.desc`;
|
||||
|
||||
/**
|
||||
* 조회수·좋아요 수 축약 표기 (1.2만 / 3.4천).
|
||||
*
|
||||
* 한글 단위가 박혀 있어 en 로케일에서는 어색하다. 통합 목록에서 castad 콘텐츠와
|
||||
* 나란히 놓이므로, 표기 방식은 Phase 8 에서 castad 쪽과 함께 정하는 게 맞다.
|
||||
* 그때까지는 원본 동작을 유지한다.
|
||||
*/
|
||||
export const fmt = (n: number): string =>
|
||||
n >= 10000
|
||||
? `${(n / 10000).toFixed(1).replace(/\.0$/, '')}만`
|
||||
: n >= 1000
|
||||
? `${(n / 1000).toFixed(1).replace(/\.0$/, '')}천`
|
||||
: `${n}`;
|
||||
111
src/pages/Ssulbox/ssulIcons.tsx
Normal file
111
src/pages/Ssulbox/ssulIcons.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 썰박스 UI 아이콘.
|
||||
*
|
||||
* 원본(o2o-ssulbox/frontend/src/icons.tsx)에서 이식하되 **castad 에 대응물이 있는 것은 제외**했다:
|
||||
* IcHome / IcSearch / IcUser / IcGrid → castad Sidebar 가 대체
|
||||
* IcBack → castad 각 화면의 뒤로가기 버튼이 대체
|
||||
*
|
||||
* 색·굵기는 대부분 CSS(컨텍스트)로 제어된다. 원본이 일부 아이콘에 `stroke="#fff"` 를
|
||||
* 하드코딩했는데, castad 다크 배경에서도 흰색이 맞아 그대로 두되
|
||||
* 재사용 가능성이 있는 것은 `currentColor` 로 바꿨다.
|
||||
*
|
||||
* 모두 장식용이므로 사용하는 쪽에서 `aria-hidden` 을 붙이거나 접근 가능한 라벨을 준다.
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
/** 재생 (카드 오버레이) */
|
||||
export const IcPlay: React.FC = () => (
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** 좋아요 (채움) */
|
||||
export const IcHeart: React.FC = () => (
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M12 21s-8-5.3-8-11a4.5 4.5 0 0 1 8-2.8A4.5 4.5 0 0 1 20 10c0 5.7-8 11-8 11z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** 좋아요 (원본에서 채움과 동일 — 상태는 CSS 로 구분) */
|
||||
export const IcHeartLine = IcHeart;
|
||||
|
||||
/** 댓글 */
|
||||
export const IcComment: React.FC = () => (
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M21 12a8 8 0 0 1-11.6 7.1L4 20l1-5.2A8 8 0 1 1 21 12z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** 공유 */
|
||||
export const IcShare: React.FC = () => (
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M22 3 11 14M22 3l-7 19-4-8-8-4z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** 링크 복사 */
|
||||
export const IcLink: React.FC = () => (
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1m0 8a5 5 0 0 1-7 0 5 5 0 0 1 0-7l2-2a5 5 0 0 1 7 0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** 업로드 */
|
||||
export const IcUpload: React.FC = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<path d="M12 15V4M8 8l4-4 4 4M5 15v3a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** 다운로드 */
|
||||
export const IcDownload: React.FC = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<path d="M12 4v11M8 11l4 4 4-4M5 19h14" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** 복사 (캡션) */
|
||||
export const IcCopy: React.FC = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<rect x="9" y="9" width="11" height="11" rx="2" />
|
||||
<path d="M5 15V5a2 2 0 0 1 2-2h8" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ── SNS 플랫폼 ────────────────────────────────────────────────
|
||||
export const IcYoutube: React.FC = () => (
|
||||
<svg viewBox="0 0 24 24">
|
||||
<rect x="2" y="5" width="20" height="14" rx="4" />
|
||||
<path d="M10 9l5 3-5 3z" fill="#fff" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IcInstagram: React.FC = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<rect x="3" y="3" width="18" height="18" rx="5" />
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<circle cx="17.5" cy="6.5" r="1" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IcTiktok: React.FC = () => (
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M14 3c.3 2.3 1.8 4 4 4.3V10c-1.5 0-2.9-.5-4-1.3V15a5 5 0 1 1-5-5v2.6a2.4 2.4 0 1 0 2.4 2.4V3z" />
|
||||
</svg>
|
||||
);
|
||||
@ -2408,6 +2408,94 @@
|
||||
max-width: 375px;
|
||||
}
|
||||
|
||||
/* =====================================================
|
||||
PipelineTabs — ADO2 / 썰박스 전환 (밑줄 탭)
|
||||
진입 화면에서 로고와 하위 폼 사이에 형제로 얹힌다.
|
||||
|
||||
컨테이너 하단의 옅은 전체 폭 구분선 위로, 활성 탭 아래에만
|
||||
진한 밑줄이 얹힌다. 밑줄 색은 탭에 따라 민트↔앰버로 바뀐다.
|
||||
===================================================== */
|
||||
.pipeline-tabs {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
/* 전체 폭 구분선. 활성 밑줄이 이 위에 겹쳐 얹힌다 */
|
||||
border-bottom: 1px solid var(--color-border-white-10);
|
||||
}
|
||||
|
||||
.pipeline-tab {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
/* 13px 상하 패딩 + 폰트로 터치 타겟 44px 이상 확보.
|
||||
밑줄 탭은 필과 달리 배경이 없어 타겟이 작아 보이기 쉽다 */
|
||||
padding: 13px 6px;
|
||||
border: none;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.006em;
|
||||
color: var(--color-text-gray-400);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-normal);
|
||||
}
|
||||
|
||||
/* 활성 표시 밑줄. 구분선(1px)을 덮도록 bottom: -1px */
|
||||
.pipeline-tab::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: -1px;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
transition: background var(--transition-normal);
|
||||
}
|
||||
|
||||
.pipeline-tab:hover {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
/* 활성 탭은 배경이 없으므로 밝은 글자 + 굵기로 대비를 만든다 */
|
||||
.pipeline-tab[aria-selected="true"] {
|
||||
color: var(--color-text-white);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pipeline-tabs[data-active="ado2"] .pipeline-tab[aria-selected="true"]::after {
|
||||
background: var(--color-mint);
|
||||
}
|
||||
|
||||
.pipeline-tabs[data-active="ssul"] .pipeline-tab[aria-selected="true"]::after {
|
||||
background: var(--color-ssul-accent);
|
||||
}
|
||||
|
||||
.pipeline-tab__emoji {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* 밑줄은 위치가 아니라 색만 바뀌므로 모션이 미미하지만,
|
||||
castad 전체에서 이 컴포넌트만 전환 효과를 쓰므로 함께 끈다 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pipeline-tab,
|
||||
.pipeline-tab::after {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 썰박스 탭의 임시 자리 (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: 13px;
|
||||
color: var(--color-text-gray-400);
|
||||
}
|
||||
|
||||
.url-input-icon {
|
||||
color: #AE72F9;
|
||||
margin-bottom: 24px;
|
||||
|
||||
@ -10,3 +10,9 @@
|
||||
@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';
|
||||
|
||||
@ -129,7 +129,7 @@
|
||||
|
||||
/* Hero Form */
|
||||
.hero-form {
|
||||
padding-top: 50px;
|
||||
padding-top: 20px;
|
||||
width: 100%;
|
||||
max-width: 375px;
|
||||
display: flex;
|
||||
|
||||
587
src/styles/ssulbox-castad.css
Normal file
587
src/styles/ssulbox-castad.css
Normal file
@ -0,0 +1,587 @@
|
||||
/* =====================================================
|
||||
썰박스 — 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,
|
||||
.ssul-scope.ssul-result {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* ── 생성 폼 ─────────────────────────────────────── */
|
||||
.ssul-scope.ssul-create {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--color-text-white);
|
||||
}
|
||||
|
||||
.ssul-create__step {
|
||||
align-self: flex-start;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-ssul-accent);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* 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: 10px;
|
||||
width: 100%;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.ssul-scn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 5px;
|
||||
padding: 14px 12px;
|
||||
/* 테두리 두께는 모든 상태에서 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);
|
||||
}
|
||||
|
||||
/* 시나리오 표지 그라디언트를 옅게 깐다.
|
||||
--scn / --scn-a / --scn-b 는 컴포넌트가 인라인으로 넘긴다. */
|
||||
.ssul-scn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(150deg, var(--scn-a), var(--scn-b));
|
||||
/* 비선택은 눌러 둔다 — 채도까지 낮춰야 선택 카드와 확실히 갈린다 */
|
||||
opacity: 0.22;
|
||||
filter: saturate(0.75);
|
||||
pointer-events: none;
|
||||
transition: opacity var(--transition-normal), filter var(--transition-normal);
|
||||
}
|
||||
|
||||
/* 선택한 카드는 채도·명도를 함께 올린다.
|
||||
불투명도만 올리면 어두운 배경과 섞여 오히려 탁해지므로 filter 로 직접 올린다. */
|
||||
.ssul-scn.active::before {
|
||||
opacity: 0.72;
|
||||
filter: saturate(1.55) brightness(1.18);
|
||||
}
|
||||
|
||||
.ssul-scn:hover {
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 선택 표시는 밝은 중립색 테두리로 둔다.
|
||||
앰버(썰박스 강조색)를 쓰면 시나리오마다 다른 표지색과 부딪힌다 —
|
||||
특히 삼국지(적)·오디세이(청) 위에서 탁해진다. */
|
||||
.ssul-scn.active {
|
||||
border-color: var(--color-mint);
|
||||
/* 링을 **테두리와 같은 색**으로 둔다. 색이 다르면 안쪽 테두리와 바깥 링이
|
||||
갈라져 두 줄로 보인다. 같은 색이면 1px 테두리 + 1px 링이 이어져
|
||||
2px 한 줄로 읽히고, 테두리 두께를 바꾸지 않아 글자도 흔들리지 않는다.
|
||||
마지막 그림자만 시나리오 대표색 글로우. */
|
||||
box-shadow:
|
||||
0 0 0 1px var(--color-mint),
|
||||
0 0 20px -2px var(--scn);
|
||||
}
|
||||
|
||||
/* 표지가 밝아진 만큼 설명 글자도 올려 대비를 지킨다 */
|
||||
.ssul-scn.active .ssul-scn__desc {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.ssul-scn__emoji {
|
||||
position: relative;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.ssul-scn__name {
|
||||
position: relative;
|
||||
font-size: 13.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ssul-scn__desc {
|
||||
position: relative;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: var(--color-text-gray-400);
|
||||
}
|
||||
|
||||
/* ── 업장 검색 ───────────────────────────────────── */
|
||||
.ssul-create__search {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.ssul-create__input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 46px;
|
||||
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: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity var(--transition-normal);
|
||||
}
|
||||
|
||||
.ssul-create__search-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── 검색 결과 / 선택 / 안내 ─────────────────────── */
|
||||
.ssul-places {
|
||||
width: 100%;
|
||||
margin: 0 0 14px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
border: 1px solid var(--color-border-white-10);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.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: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ssul-places span {
|
||||
font-size: 11.5px;
|
||||
color: var(--color-text-gray-400);
|
||||
}
|
||||
|
||||
.ssul-picked {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 11px 14px;
|
||||
margin-bottom: 14px;
|
||||
border: 1.5px solid var(--color-ssul-accent);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 184, 107, 0.08);
|
||||
}
|
||||
|
||||
.ssul-picked__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ssul-picked__body b {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ssul-picked__body span {
|
||||
font-size: 11.5px;
|
||||
color: var(--color-text-gray-400);
|
||||
}
|
||||
|
||||
.ssul-picked__change {
|
||||
flex: none;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border-white-10);
|
||||
border-radius: 999px;
|
||||
background: none;
|
||||
color: var(--color-text-gray-300);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ssul-hint {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 14px;
|
||||
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-ssul-accent);
|
||||
}
|
||||
|
||||
.ssul-hint.warn {
|
||||
color: #ffb4a8;
|
||||
}
|
||||
|
||||
/* ── 비용 고지 + 제출 ────────────────────────────── */
|
||||
.ssul-create__cost {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 10px 4px;
|
||||
font-size: 12.5px;
|
||||
color: var(--color-text-gray-400);
|
||||
}
|
||||
|
||||
.ssul-create__cost-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-weight: 700;
|
||||
color: var(--color-ssul-accent);
|
||||
}
|
||||
|
||||
.ssul-create__coin {
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.ssul-create__submit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, #ff8a5c, var(--color-ssul-accent));
|
||||
color: #1a1200;
|
||||
font-family: inherit;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
transition: filter var(--transition-normal), box-shadow var(--transition-normal);
|
||||
}
|
||||
|
||||
.ssul-create__submit:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
box-shadow: 0 4px 28px rgba(255, 180, 90, 0.4);
|
||||
}
|
||||
|
||||
.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;
|
||||
margin: 0 auto;
|
||||
padding: 48px 20px;
|
||||
text-align: center;
|
||||
color: var(--color-text-white);
|
||||
}
|
||||
|
||||
.ssul-making__scenario {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.ssul-making__title {
|
||||
margin: 0 0 24px;
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
.ssul-making__title.warn {
|
||||
color: #ffb4a8;
|
||||
}
|
||||
|
||||
/* 진행 스피너 — 원본 LoadingSection 의 회전 요소만 가져왔다.
|
||||
SVG(loading-spinner.svg)를 쓰지 않은 이유: 색이 민트로 하드코딩돼 있어
|
||||
시나리오 대표색(삼국지=적, 오디세이=청)과 부딪힌다. CSS 로 그리면 --scn 을 받는다.
|
||||
`spin` 키프레임은 castad 전역에 이미 있어 재사용한다(새로 만들면 3번째 중복). */
|
||||
.ssul-making__spinner {
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: 104px;
|
||||
height: 104px;
|
||||
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(--scn);
|
||||
border-right-color: var(--scn);
|
||||
border-bottom-color: var(--scn);
|
||||
animation: spin 1.1s linear infinite;
|
||||
}
|
||||
|
||||
.ssul-making__bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-bg-card-inner);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ssul-making__fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
transition: width 400ms ease;
|
||||
}
|
||||
|
||||
.ssul-making__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
font-size: 12.5px;
|
||||
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: 12.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text-gray-400);
|
||||
}
|
||||
|
||||
.ssul-making__refund {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12.5px;
|
||||
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: 13.5px;
|
||||
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-*` 클래스를 그대로 재사용해
|
||||
통합 목록에서 어떤 카드를 열든 같은 모양이 나오게 했다. */
|
||||
|
||||
/* ── 결과 화면 ───────────────────────────────────── */
|
||||
.ssul-scope.ssul-result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 20px 48px;
|
||||
text-align: center;
|
||||
color: var(--color-text-white);
|
||||
}
|
||||
|
||||
.ssul-result__scenario {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.ssul-result__title {
|
||||
margin: 0 0 22px;
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
/* 세로 쇼츠 비율 */
|
||||
.ssul-result__video,
|
||||
.ssul-result__video-skeleton {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
aspect-ratio: 9 / 16;
|
||||
border-radius: 14px;
|
||||
background: #000;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.ssul-result__video-skeleton {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border-white-10);
|
||||
}
|
||||
|
||||
.ssul-result__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.ssul-result__btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 46px;
|
||||
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), filter var(--transition-normal);
|
||||
}
|
||||
|
||||
.ssul-result__btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.ssul-result__btn:hover:not(:disabled) {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.ssul-result__btn.primary {
|
||||
border: none;
|
||||
background: linear-gradient(90deg, #ff8a5c, var(--color-ssul-accent));
|
||||
color: #1a1200;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.ssul-result__btn.primary:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.ssul-result__btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
571
src/styles/ssulbox.css
Normal file
571
src/styles/ssulbox.css
Normal file
@ -0,0 +1,571 @@
|
||||
/* =====================================================
|
||||
썰박스 UI — 원본(o2o-ssulbox/frontend/src/index.css)에서 스코프 변환
|
||||
|
||||
**이 파일은 스크립트로 생성됐다.** 직접 고치지 말고
|
||||
scratchpad/scope_ssulbox_css.py 를 고쳐 다시 생성할 것.
|
||||
|
||||
변환 내용:
|
||||
1. @import "tailwindcss" 삭제 (castad 는 index.html 의 Play CDN 사용)
|
||||
2. #app → .ssul-scope, 100dvh → 100%
|
||||
3. 모든 최상위 셀렉터에 .ssul-scope 접두
|
||||
4. 전역 리셋(*, html, body, button, img, ul)을 .ssul-scope 하위로
|
||||
← 이게 빠지면 castad 전 화면의 버튼·이미지·폰트가 깨진다
|
||||
5. html,body{height:100%} 삭제
|
||||
6. :root 변수 → .ssul-scope 로 이동, 값은 castad 다크 팔레트로 매핑
|
||||
7. .nav* .topbar .brand .login* 삭제 (castad Sidebar/카카오가 대체)
|
||||
8. --nav-h/--top-h → 0 (셸이 없으므로)
|
||||
9. .grid → .ssul-grid (Tailwind CDN 의 .grid 와 충돌 회피)
|
||||
10. font-family 미지정 → castad Noto Sans KR 상속
|
||||
===================================================== */
|
||||
|
||||
/* castad 다크 팔레트로 매핑한 썰박스 변수.
|
||||
원본은 라이트 테마 기본이었으나 castad 대시보드는 다크 단일이다. */
|
||||
.ssul-scope {
|
||||
--bg: var(--color-bg-darker);
|
||||
--surface: var(--color-bg-card);
|
||||
--surface-2: var(--color-bg-card-inner);
|
||||
--fg: var(--color-text-white);
|
||||
--muted: var(--color-text-gray-400);
|
||||
--faint: var(--color-text-gray-500);
|
||||
--border: var(--color-border-white-10);
|
||||
--elev: 0 2px 16px rgba(0, 0, 0, 0.6);
|
||||
|
||||
/* 시나리오 색 (원본 유지) */
|
||||
--joseon: #3b4cc0;
|
||||
--samgukji: #d63a2f;
|
||||
--greek: #0a8f6b;
|
||||
--greek-2: #e0a91b;
|
||||
--odyssey: #6d4aff;
|
||||
|
||||
--ig: linear-gradient(45deg, #f9a03f 0%, #e6683c 25%, #dc2743 50%, #cc2366 75%, #bc1888 100%);
|
||||
|
||||
/* 셸(하단 네비·상단바)이 castad 에는 없으므로 0 */
|
||||
--nav-h: 0px;
|
||||
--top-h: 0px;
|
||||
--r: 14px;
|
||||
}
|
||||
|
||||
/* 원본의 전역 리셋 — 스코프 안으로 가둔다.
|
||||
|
||||
요소 리셋은 `:where()` 로 감싸 **특정도를 0 으로** 만든다.
|
||||
그러지 않으면 `.ssul-scope button`(0,1,1)이 우리 컴포넌트의 BEM
|
||||
클래스(`.ssul-create__submit` 등 0,1,0)를 임포트 순서와 무관하게 이겨
|
||||
버튼 배경·테두리가 통째로 사라진다(2026-07-29 실제 발생).
|
||||
리셋의 목적(클래스 없는 원본 마크업 정규화)은 그대로 유지된다. */
|
||||
.ssul-scope,
|
||||
.ssul-scope * {
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
:where(.ssul-scope) :where(button) {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
:where(.ssul-scope) :where(img) {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
:where(.ssul-scope) :where(ul) {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
:where(.ssul-scope) :where(button) {font:inherit;color:inherit;background:none;border:none;cursor:pointer}
|
||||
|
||||
:where(.ssul-scope) :where(img) {display:block;max-width:100%}
|
||||
|
||||
:where(.ssul-scope) :where(ul) {list-style:none}
|
||||
|
||||
.ssul-scope {position:relative;
|
||||
width:100%;
|
||||
height:100%;
|
||||
background:var(--bg);
|
||||
display:flex;flex-direction:column;
|
||||
overflow:hidden;}
|
||||
|
||||
.ssul-scope .main {flex:1 1 auto;display:flex;flex-direction:column;min-height:0;min-width:0;position:relative}
|
||||
|
||||
.ssul-scope .screen {flex:1 1 auto;overflow-y:auto;overflow-x:hidden;
|
||||
-webkit-overflow-scrolling:touch;
|
||||
padding-bottom:calc(var(--nav-h) + env(safe-area-inset-bottom) + 8px);}
|
||||
|
||||
.ssul-scope .screen::-webkit-scrollbar {width:0}
|
||||
|
||||
.ssul-scope .ssul-grid {display:grid;grid-template-columns:1fr 1fr;gap:8px;padding:10px}
|
||||
|
||||
.ssul-scope .card {position:relative;aspect-ratio:9/16;border-radius:var(--r);overflow:hidden;
|
||||
background:#222;isolation:isolate;transition:transform .12s ease;}
|
||||
|
||||
.ssul-scope .card:active {transform:scale(.975)}
|
||||
|
||||
.ssul-scope .card .cover {position:absolute;inset:0;display:grid;place-items:center}
|
||||
|
||||
.ssul-scope .card .cov-vid {position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
|
||||
|
||||
.ssul-scope .card .emoji {font-size:58px;filter:drop-shadow(0 3px 8px rgba(0,0,0,.35));opacity:.92}
|
||||
|
||||
.ssul-scope .card .shade {position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,0) 42%,rgba(0,0,0,.72) 100%)}
|
||||
|
||||
.ssul-scope .card .meta {position:absolute;left:9px;right:9px;bottom:8px;z-index:2}
|
||||
|
||||
.ssul-scope .card .title {color:#fff;font-size:12.5px;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:11px;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:10.5px;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:13px;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:13px;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:13px;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:11.5px;color:var(--muted);font-weight:600}
|
||||
|
||||
.ssul-scope .token-card .tk-left b {font-size:23px;font-weight:800;font-variant-numeric:tabular-nums;line-height:1.15}
|
||||
|
||||
.ssul-scope .token-card .tk-left em {font-size:11px;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:13px;
|
||||
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:13px;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:17px;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:13px;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:15px}
|
||||
|
||||
.ssul-scope .scn .tx .d {font-size:12.5px;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:13px;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:15px}
|
||||
|
||||
.ssul-scope .input input::placeholder {color:var(--faint)}
|
||||
|
||||
.ssul-scope .hint {font-size:11.5px;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:15px;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:17px;font-weight:800}
|
||||
|
||||
.ssul-scope .making p {font-size:13px;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:11px;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:17px;line-height:1.3}
|
||||
|
||||
.ssul-scope .vbottom .vn {color:rgba(255,255,255,.86);font-size:13px;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:11px;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:13px;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:11px;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:11px}
|
||||
|
||||
.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:13px;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:15px;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:12.5px;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:13px;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:11.5px;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:10.5px;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:13px;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:15px;font-weight:700;color:var(--fg)}
|
||||
|
||||
.ssul-scope .share-state {font-size:12.5px;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:13.5px;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:13px;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:10.5px;font-weight:800;padding:2px 8px;border-radius:10px}
|
||||
|
||||
.ssul-scope .prod-cr {grid-area:cr;font-size:15px;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:11.5px;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:13px;font-weight:800;padding:8px 15px;border-radius:20px}
|
||||
|
||||
.ssul-scope :focus-visible {outline:2px solid var(--samgukji);outline-offset:2px}
|
||||
@ -20,6 +20,9 @@
|
||||
--color-purple-glow: rgba(166, 130, 255, 0.2);
|
||||
--color-purple-80: rgba(166, 130, 255, 0.8);
|
||||
|
||||
/* 썰박스 파이프라인 강조색 (진입 화면 탭 활성 표시 전용) */
|
||||
--color-ssul-accent: #ffb86b;
|
||||
|
||||
/* Background Colors - Teal-600 based */
|
||||
--color-bg-dark: #002224;
|
||||
--color-bg-darker: #001a1c;
|
||||
|
||||
@ -270,7 +270,18 @@ export interface UserCreditsResponse {
|
||||
}
|
||||
|
||||
// 비디오 목록 아이템 (갤러리용)
|
||||
/** 콘텐츠 종류. ADO2 영상과 썰박스가 한 목록에 섞인다 */
|
||||
export type ContentType = 'video' | 'ssul';
|
||||
|
||||
export interface VideoListItem {
|
||||
/**
|
||||
* ⚠️ `video_id` 는 **type 안에서만 유일하다.**
|
||||
* `video.id` 와 `ssul_content.id` 는 각각 1부터 시작하는 독립 시퀀스라 값이 겹친다.
|
||||
* 삭제·상세 열기·React key 는 반드시 `(type, video_id)` 쌍으로 다룰 것.
|
||||
* 특히 `DELETE /archive/videos/{id}` 는 `Video.id` 로 지우므로,
|
||||
* 썰박스 항목의 id 를 넘기면 **엉뚱한 ADO2 영상이 삭제된다.**
|
||||
*/
|
||||
type: ContentType;
|
||||
video_id: number;
|
||||
store_name: string;
|
||||
region: string;
|
||||
|
||||
174
src/utils/api.ts
174
src/utils/api.ts
@ -1,3 +1,4 @@
|
||||
import { clearSessionStorage } from './storageKeys';
|
||||
import {
|
||||
CrawlingResponse,
|
||||
LyricGenerateRequest,
|
||||
@ -776,11 +777,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 = '/';
|
||||
}
|
||||
@ -928,15 +926,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 +1326,159 @@ export async function retryUpload(uploadId: number): Promise<{ success: boolean;
|
||||
if (!response.ok) throw new Error('재시도 실패');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 썰박스 (Ssulbox)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 썰박스 장소 검색 결과.
|
||||
*
|
||||
* castad `/search/accommodation`(네이버 검색 API)과 달리 **`place_url` 을 준다.**
|
||||
* generator 가 네이버 지도 place 페이지를 크롤링하므로 이 URL 이 반드시 필요해서
|
||||
* 별도 엔드포인트를 쓴다.
|
||||
*/
|
||||
export interface SsulPlaceItem {
|
||||
title: string;
|
||||
category: string;
|
||||
address: string;
|
||||
roadAddress: string;
|
||||
place_url: string;
|
||||
}
|
||||
|
||||
export interface SsulCreateRequest {
|
||||
scenario: string;
|
||||
/** 네이버 지도 place URL 또는 업장명 */
|
||||
input: string;
|
||||
scenes?: number;
|
||||
seconds?: number;
|
||||
/**
|
||||
* 검색으로 업장을 고른 경우에만 함께 보낸다.
|
||||
*
|
||||
* 통합 콘텐츠 목록의 업장명 표시와 `store_name`/`region` 필터가 이 값에 의존한다.
|
||||
* 링크를 직접 붙여넣은 경우에는 보낼 값이 없고, 그때 업장명은 백엔드가 생성
|
||||
* 로그에서 뒤늦게 채운다(주소가 없어 지역은 채우지 못한다).
|
||||
*/
|
||||
store_name?: string;
|
||||
/** 지역 추출용(도로명). 백엔드는 이 값을 저장하지 않는다 */
|
||||
road_address?: string;
|
||||
/** 지역 추출용(지번). 도로명에서 시/군 추출이 실패할 때의 폴백 */
|
||||
address?: string;
|
||||
}
|
||||
|
||||
export interface SsulCreateResponse {
|
||||
id: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 업장 검색. 실패해도 빈 배열을 돌려준다 —
|
||||
* 사용자는 네이버 링크를 직접 붙여넣는 우회 경로가 있으므로 흐름을 막지 않는다.
|
||||
*/
|
||||
export async function searchSsulPlace(query: string): Promise<SsulPlaceItem[]> {
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${API_URL}/ssul/search/place?query=${encodeURIComponent(query)}`
|
||||
);
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return data.items ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 썰박스 생성 요청. **요청 시점에 크레딧이 선차감된다.**
|
||||
* 잔액이 부족하면 402 를 던지므로 호출부가 충전 화면으로 유도해야 한다.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/** 폴링 간격. 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);
|
||||
}
|
||||
|
||||
76
src/utils/storageKeys.ts
Normal file
76
src/utils/storageKeys.ts
Normal file
@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 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',
|
||||
|
||||
// 썰박스 통합
|
||||
/** 현재 선택된 파이프라인 ('ado2' | 'ssul') */
|
||||
PIPELINE: 'castad_pipeline',
|
||||
/** 진행 중인 썰박스 생성 잡 ID. 권위는 서버(`/ssul/tasks/active`)이고 이건 복구 힌트다 */
|
||||
SSUL_TASK_ID: 'castad_ssul_task_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,
|
||||
];
|
||||
|
||||
/**
|
||||
* 세션 전체에 걸친 키. 로그아웃·토큰 만료·새 탭 진입 시 전부 지운다.
|
||||
* 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));
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user