Compare commits
1 Commits
main
...
feature-sh
| Author | SHA1 | Date | |
|---|---|---|---|
| 530416026d |
21
index.html
21
index.html
@ -11,24 +11,10 @@
|
|||||||
<meta property="og:title" content="ADO2 - AI 마케팅 자동화 플랫폼" />
|
<meta property="og:title" content="ADO2 - AI 마케팅 자동화 플랫폼" />
|
||||||
<!-- ③ Open Graph 설명 -->
|
<!-- ③ Open Graph 설명 -->
|
||||||
<meta property="og:description" content="ADO2는 ㈜에이아이오투오의 AI 기반 마케팅 자동화 플랫폼입니다. 콘텐츠 제작부터 채널 배포까지 자동으로 처리합니다." />
|
<meta property="og:description" content="ADO2는 ㈜에이아이오투오의 AI 기반 마케팅 자동화 플랫폼입니다. 콘텐츠 제작부터 채널 배포까지 자동으로 처리합니다." />
|
||||||
<meta property="og:url" content="https://ado2.o2osolution.ai" />
|
<meta property="og:url" content="https://demo.castad.net/" />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon_32.svg" sizes="32x32">
|
<link rel="icon" type="image/svg+xml" href="/favicon_32.svg" sizes="32x32">
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon_48.svg" sizes="48x48">
|
<link rel="icon" type="image/svg+xml" href="/favicon_48.svg" sizes="48x48">
|
||||||
<!-- Meta Pixel Code -->
|
|
||||||
<script>
|
|
||||||
!function(f,b,e,v,n,t,s)
|
|
||||||
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
|
|
||||||
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
|
|
||||||
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
|
|
||||||
n.queue=[];t=b.createElement(e);t.async=!0;
|
|
||||||
t.src=v;s=b.getElementsByTagName(e)[0];
|
|
||||||
s.parentNode.insertBefore(t,s)}(window, document,'script',
|
|
||||||
'https://connect.facebook.net/en_US/fbevents.js');
|
|
||||||
fbq('init', '1572679738203392');
|
|
||||||
fbq('track', 'PageView');
|
|
||||||
</script>
|
|
||||||
<!-- End Meta Pixel Code -->
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<script>
|
<script>
|
||||||
tailwind.config = {
|
tailwind.config = {
|
||||||
@ -176,12 +162,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<link rel="stylesheet" href="/index.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-[#121a1d]">
|
<body class="bg-[#121a1d]">
|
||||||
<!-- Meta Pixel noscript fallback (head의 noscript에는 img가 허용되지 않아 body에 위치) -->
|
|
||||||
<noscript><img height="1" width="1" style="display:none"
|
|
||||||
src="https://www.facebook.com/tr?id=1572679738203392&ev=PageView&noscript=1"
|
|
||||||
/></noscript>
|
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/index.tsx"></script>
|
<script type="module" src="/src/index.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
43
package-lock.json
generated
43
package-lock.json
generated
@ -16,6 +16,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
|
"@types/react": "^19.2.17",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
"typescript": "~5.8.2",
|
"typescript": "~5.8.2",
|
||||||
"vite": "^6.2.0"
|
"vite": "^6.2.0"
|
||||||
@ -52,6 +54,7 @@
|
|||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
@ -1288,10 +1291,32 @@
|
|||||||
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
|
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/react": {
|
||||||
|
"version": "19.2.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||||
|
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"csstype": "^3.2.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/react-dom": {
|
||||||
|
"version": "19.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^19.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/use-sync-external-store": {
|
"node_modules/@types/use-sync-external-store": {
|
||||||
"version": "0.0.6",
|
"version": "0.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
@ -1349,6 +1374,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@ -1400,6 +1426,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/csstype": {
|
||||||
|
"version": "3.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/d3-array": {
|
"node_modules/d3-array": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
@ -1691,6 +1724,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.28.4"
|
"@babel/runtime": "^7.28.4"
|
||||||
},
|
},
|
||||||
@ -1811,6 +1845,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@ -1852,6 +1887,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@ -1861,6 +1897,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@ -1907,6 +1944,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/use-sync-external-store": "^0.0.6",
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
"use-sync-external-store": "^1.4.0"
|
"use-sync-external-store": "^1.4.0"
|
||||||
@ -1969,7 +2007,8 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/redux-thunk": {
|
"node_modules/redux-thunk": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
@ -2083,6 +2122,7 @@
|
|||||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@ -2166,6 +2206,7 @@
|
|||||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.4.4",
|
||||||
|
|||||||
@ -17,6 +17,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
|
"@types/react": "^19.2.17",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
"typescript": "~5.8.2",
|
"typescript": "~5.8.2",
|
||||||
"vite": "^6.2.0"
|
"vite": "^6.2.0"
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 8.7 KiB |
244
public/example_analysis.json
Normal file
244
public/example_analysis.json
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
{
|
||||||
|
"status": "completed",
|
||||||
|
"image_list": [
|
||||||
|
"https://ldb-phinf.pstatic.net/20260112_189/1768176025117RmItD_PNG/ChatGPT_Image_2026%B3%E2_1%BF%F9_9%C0%CF_%BF%C0%C8%C4_03_21_56.png",
|
||||||
|
"https://ldb-phinf.pstatic.net/20251215_290/1765776039787CALYj_JPEG/IMG_0060.JPG",
|
||||||
|
"https://ldb-phinf.pstatic.net/20251215_70/1765776016674Nbo0Q_JPEG/IMG_0067_%BA%B9%BB%E7.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20251215_136/17657760168457kDf6_JPEG/KakaoTalk_20251214_181208552_03_%BA%B9%BB%E7.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20251215_38/1765776039760cYgOj_JPEG/IMG_0044.JPG",
|
||||||
|
"https://ldb-phinf.pstatic.net/20251128_248/1764320049551B1gHl_JPEG/KakaoTalk_20251128_174635084_27.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20251128_209/1764320105053L8V2f_JPEG/KakaoTalk_20251128_174635084_17.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241205_181/1733360083850LsEVv_JPEG/KakaoTalk_20241203_155353799_09.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20251128_209/1764320017959Rh3vs_JPEG/KakaoTalk_20251128_174635084_13.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20251128_102/1764320169035vluJN_JPEG/KakaoTalk_20251128_174635084_12.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241129_249/17328584834569zWJT_JPEG/6S5A0642-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241129_196/1732858501391FjLpd_JPEG/6S5A0657.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_230/1733105198846QGbcW_JPEG/8.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_94/1733105214373Q8ncR_JPEG/%B5%BF%B1%D7%B6%F3%B9%CC_%C3%A2%B9%AE_%B9%E6%B0%A1%C0%BB_2.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_110/1701910802632rDM5r_JPEG/a_%28301%29-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_7/1733106037765MNR8e_JPEG/9-1.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240118_42/17055625561156LIx7_JPEG/6S5A0809-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240118_246/1705562556109bihHB_JPEG/6S5A1820-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_259/1733104881690lDgnH_JPEG/3-2.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_76/1701910651888PpVap_JPEG/%C6%EA%B5%E5%B6%F3%C0%CC%B7%EB.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_242/1701910651863IAAlE_JPEG/%B0%AD%BE%C6%C1%F6_%BA%F1%C7%B0.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_120/17331038087267aQ0S_JPEG/KakaoTalk_20241015_154536888_04.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_164/17521338301365LRwl_JPEG/6S5A6748-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_169/1752133830016fqFlL_JPEG/6S5A6760-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_80/1701920469022ef7TW_JPEG/6S5A0614-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_140/17173835278369nntD_JPEG/6S5A6707.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_173/1752133829861tPALd_JPEG/6S5A7136.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_248/1733106037776BGjJx_JPEG/1-1.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_275/1717383527874oJOpz_JPEG/DJI_0040-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_153/1752133811173ba7oE_JPEG/6S5A6458.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_185/1752133829868FyD76_JPEG/6S5A6815.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_220/1752133829963w1aee_JPEG/6S5A6856.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_228/1752133830002U65jg_JPEG/6S5A6864.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_178/1752133829793fQQ2E_JPEG/6S5A7016-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_279/1752133829838lTSC9_JPEG/6S5A7034-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_179/1752133811452tOl7F_JPEG/6S5A6363.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_75/1752133811152vd5ts_JPEG/6S5A6513.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_147/1752133810693TfR5M_JPEG/6S5A6556.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_202/1752133811177NRBDn_JPEG/6S5A6619-2.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_196/1752133811066ufvpA_JPEG/6S5A6657.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_280/1752133811307QCs3n_JPEG/6S5A6709.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_85/1717383527884ST2DF_JPEG/6S5A6540.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_249/1717383528285Om6dI_JPEG/6S5A6648.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_288/1717383528022TxA4c_JPEG/6S5A6785.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_284/1725865224424ux2W4_JPEG/6S5A9299-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241129_87/1732858513628bWPt7_JPEG/6S5A9837-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_50/1733105457578cGfYD_JPEG/6S5A9807-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_100/1717383527950q4yNf_JPEG/6S5A7056.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_221/1717383527791s4ziP_JPEG/6S5A6673.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_134/17331166295182o4vs_JPEG/47%C6%F2_%2829%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_249/17331060371685uFQi_JPEG/3-1_%281%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_238/1725865224397wnWE5_JPEG/DJI_0157.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231206_34/1701833355386Nsg5j_JPEG/6S5A9995.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_293/1733105206236d8l2f_JPEG/8-1.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_224/1733105239528RLN59_JPEG/3-1-min.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_31/1733116629874BYgpO_JPEG/47%C6%F2_%2823%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241205_106/1733360083918ER1RI_JPEG/KakaoTalk_20241203_155353799_27.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241129_126/17328584834200qVDw_JPEG/6S5A0645.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_35/1725865128231ar2eM_PNG/KakaoTalk_20240710_163045662.png",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_153/1733105180908PyVIC_JPEG/KakaoTalk_20231027_143438563_25-min.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241129_128/1732858517984PFmrJ_JPEG/KakaoTalk_20241119_105023750.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_124/1701910720268qeACJ_JPEG/%B9%D9%BA%A3%C5%A5.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_55/173310545746333mf8_JPEG/6S5A9837-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_11/1733106430749ovoeY_JPEG/37%C6%F2_%2820%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_185/1701910772479mexsh_JPEG/a_%28282%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240118_211/17055625561294XsN2_JPEG/6S5A1803-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240118_156/17055625560847R7SL_JPEG/6S5A0934-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_179/17019107724184CCEU_JPEG/6S5A9906.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_287/1701910772339cG7K0_JPEG/a_%28119%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_40/1701910675360dvC1i_JPEG/%C8%AD%C0%E5%BD%C7_%281%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_263/1701910675262pevKk_JPEG/%C8%AD%C0%E5%BD%C7_%284%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_73/1701910651864xPI48_JPEG/%BB%E7%B6%F7_%BC%A4%C7%AA.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_265/17019204486629hzae_JPEG/6S5A0645.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_147/1733103809000xsMrG_JPEG/KakaoTalk_20241015_154532027_01.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_77/1733103809037HSO4S_JPEG/KakaoTalk_20241015_154532027_02.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_233/1733103809020IMEdL_JPEG/KakaoTalk_20241015_154532027_06.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_53/1733103808802Wui9x_JPEG/KakaoTalk_20241015_154532027_04.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_230/1733103808998dOq0b_JPEG/KakaoTalk_20241015_154532027_15.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_122/1733103808509lOt9x_JPEG/KakaoTalk_20241015_154536888_13.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_246/1733103809045RdnEP_JPEG/KakaoTalk_20241015_154536888_11.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_287/1733103808656oB1xz_JPEG/KakaoTalk_20241015_154536888_06.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_222/1733103808508hkf5K_JPEG/KakaoTalk_20241015_154536888_08.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_229/1733103808818KGAps_JPEG/KakaoTalk_20241015_154536888_02.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_12/1733103808751cVAsT_JPEG/KakaoTalk_20241015_154536888_03.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_261/1725865224473Uo7V9_JPEG/6S5A9371-%C6%ED%C1%FD-%C6%ED%C1%FD-2.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_22/1725865224495AL798_JPEG/6S5A9557-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_177/1725865224462eX1Pi_JPEG/DJI_0149-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_165/1725865224480S7c1j_JPEG/6S5A9468-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_85/1725865224421CNRrk_JPEG/DJI_0155.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_62/1725865224361fP2d2_JPEG/6S5A9434.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_155/1725865224363Xxt8y_JPEG/6S5A9431-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_13/1725865224457BGLGs_JPEG/6S5A9456.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_162/1717383527882Vnrsk_JPEG/6S5A6786.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_256/1717383527892WXOKA_JPEG/6S5A6896.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_130/1717383527800oHSKA_JPEG/6S5A6904.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_118/17173835277794IXlt_JPEG/6S5A6946.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_257/1717383527822ITvHM_JPEG/6S5A6973.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_170/1717383527880sTdFE_JPEG/DJI_0039-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_10/1717383527863CVLJ2_JPEG/DJI_0087-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_94/1717383527921sDeDB_JPEG/DJI_0091-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231206_81/1701833426718D5Qwk_JPEG/b_%28119%29-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240118_153/17055625561274LAs5_JPEG/6S5A1460-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240118_8/1705562556139zGkjb_JPEG/6S5A1780.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240118_57/1705562556129IR40O_JPEG/6S5A1199.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240118_260/1705562556111cA7PG_JPEG/6S5A1927-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_183/1701910720221oI66j_JPEG/%B9%D9%BA%A3%C5%A52.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_247/1701910720113MBgKD_JPEG/%B9%D9%BA%A3%C5%A5_%282%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_227/1701910720098FE7Si_JPEG/6S5A0001.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231206_24/1701833408064fgoVj_JPEG/b_%2874%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_100/1717383527883LWVq4_JPEG/6S5A6022-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_169/17173835278856AEUV_JPEG/6S5A5974-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_155/1717383527937gxiyc_JPEG/6S5A5988.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231206_160/170183333003287LrV_JPEG/6S5A0243-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_217/1717383528190t41X5_JPEG/6S5A6027.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_142/1717383527892jHtgI_JPEG/6S5A6643.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_251/1717383527948jTN2a_JPEG/6S5A6125.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_189/1717383527880wu4Bv_JPEG/6S5A6326.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_14/1717383527794qCuuu_JPEG/6S5A6337.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_220/1717383528017rKdOw_JPEG/6S5A6773.jpg"
|
||||||
|
],
|
||||||
|
"image_count": 119,
|
||||||
|
"m_id": 1,
|
||||||
|
"processed_info": {
|
||||||
|
"customer_name": "풀스테이 스테이펫 홍천",
|
||||||
|
"region": "",
|
||||||
|
"detail_region_info": "강원 홍천군 화촌면 답연밭길 5-2 스테이펫 홍천"
|
||||||
|
},
|
||||||
|
"marketing_analysis": {
|
||||||
|
"brand_identity": {
|
||||||
|
"location_feature_analysis": "강원 홍천 화촌면의 자연 친화적 입지는 도심 소음에서 벗어난 ‘조용한 숲세권’ 체류 니즈에 최적이며, 수도권에서 주말 이동이 가능한 거리감으로 숏브레이크 수요를 흡수하기 유리합니다.",
|
||||||
|
"concept_scalability": "‘스테이펫’ 콘셉트는 반려동물 동반 숙박을 넘어 펫 어메니티·펫 포토·펫 웰니스(산책 코스/케어 서비스)까지 확장 가능해, 체류 전 과정의 경험 가치를 패키지화하기 좋습니다."
|
||||||
|
},
|
||||||
|
"market_positioning": {
|
||||||
|
"category_definition": "자연 속 프라이빗 애견 동반 힐링 스테이",
|
||||||
|
"core_value": "반려동물과 ‘눈치 없이’ 온전히 쉬는 프라이빗한 쉼"
|
||||||
|
},
|
||||||
|
"target_persona": [
|
||||||
|
{
|
||||||
|
"persona": "주말 펫캉스 커플(반려견 1마리) – 조용한 자연에서 둘+한 마리의 리셋 여행을 찾는 2030",
|
||||||
|
"age": {
|
||||||
|
"min_age": 27,
|
||||||
|
"max_age": 39
|
||||||
|
},
|
||||||
|
"favor_target": [
|
||||||
|
"반려견 동반 가능 숙소",
|
||||||
|
"자연 뷰/산책하기 좋은 동선",
|
||||||
|
"청결한 침구와 탈취 관리",
|
||||||
|
"프라이빗한 공간 구성",
|
||||||
|
"감성 사진이 잘 나오는 무드"
|
||||||
|
],
|
||||||
|
"decision_trigger": "반려견 동반 + 프라이빗하게 쉴 수 있다는 확신(후기/시설 안내)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"persona": "도심 탈출 1인/2인 힐링러 – 조용한 곳에서 휴식·독서를 즐기며 재충전하는 직장인",
|
||||||
|
"age": {
|
||||||
|
"min_age": 29,
|
||||||
|
"max_age": 45
|
||||||
|
},
|
||||||
|
"favor_target": [
|
||||||
|
"소음 적은 입지",
|
||||||
|
"자연 속 뷰",
|
||||||
|
"편안한 침대/휴식형 라운지",
|
||||||
|
"야간 감성 조명",
|
||||||
|
"체크인/이용 동선의 간편함"
|
||||||
|
],
|
||||||
|
"decision_trigger": "‘조용함’과 ‘자연 몰입’이 보장되는 입지/분위기"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"persona": "가족형 펫오너 – 반려견과 함께 안전하고 편하게 머물며 추억을 만드는 3040",
|
||||||
|
"age": {
|
||||||
|
"min_age": 33,
|
||||||
|
"max_age": 49
|
||||||
|
},
|
||||||
|
"favor_target": [
|
||||||
|
"반려견 안전 동선(미끄럼/울타리 등)",
|
||||||
|
"넓은 휴식 공간",
|
||||||
|
"바베큐 등 가족형 액티비티",
|
||||||
|
"주차/이동 편의",
|
||||||
|
"위생/청결 신뢰"
|
||||||
|
],
|
||||||
|
"decision_trigger": "가족 단위도 불편 없는 공간감 + 펫 프렌들리 정책"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"selling_points": [
|
||||||
|
{
|
||||||
|
"english_category": "PET FRIENDLY",
|
||||||
|
"korean_category": "애견 동반",
|
||||||
|
"description": "반려견과 함께하는 완전한 휴식",
|
||||||
|
"score": 95
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"english_category": "HEALING",
|
||||||
|
"korean_category": "힐링 요소",
|
||||||
|
"description": "홍천 자연 속 리셋 스테이",
|
||||||
|
"score": 90
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"english_category": "PRIVACY",
|
||||||
|
"korean_category": "프라이버시",
|
||||||
|
"description": "눈치 없이 즐기는 프라이빗 쉼",
|
||||||
|
"score": 88
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"english_category": "SHORT GETAWAY",
|
||||||
|
"korean_category": "숏브레이크",
|
||||||
|
"description": "주말에 딱, 가볍게 떠나는 홍천",
|
||||||
|
"score": 82
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"english_category": "NIGHT MOOD",
|
||||||
|
"korean_category": "야간 감성",
|
||||||
|
"description": "밤이 더 예쁜 감성 무드",
|
||||||
|
"score": 78
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"english_category": "BBQ PARTY",
|
||||||
|
"korean_category": "바베큐",
|
||||||
|
"description": "자연을 곁들인 바베큐 한 판",
|
||||||
|
"score": 75
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"english_category": "PHOTO SPOT",
|
||||||
|
"korean_category": "포토 스팟",
|
||||||
|
"description": "반려견 인생샷 남기는 공간",
|
||||||
|
"score": 73
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"target_keywords": [
|
||||||
|
"홍천애견동반숙소",
|
||||||
|
"강원도애견동반펜션",
|
||||||
|
"스테이펫",
|
||||||
|
"펫캉스",
|
||||||
|
"반려견동반여행",
|
||||||
|
"홍천감성숙소",
|
||||||
|
"강원도힐링스테이",
|
||||||
|
"숲뷰숙소",
|
||||||
|
"주말여행추천",
|
||||||
|
"프라이빗스테이"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
244
public/example_analysis_en.json
Normal file
244
public/example_analysis_en.json
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
{
|
||||||
|
"status": "completed",
|
||||||
|
"image_list": [
|
||||||
|
"https://ldb-phinf.pstatic.net/20260112_189/1768176025117RmItD_PNG/ChatGPT_Image_2026%B3%E2_1%BF%F9_9%C0%CF_%BF%C0%C8%C4_03_21_56.png",
|
||||||
|
"https://ldb-phinf.pstatic.net/20251215_290/1765776039787CALYj_JPEG/IMG_0060.JPG",
|
||||||
|
"https://ldb-phinf.pstatic.net/20251215_70/1765776016674Nbo0Q_JPEG/IMG_0067_%BA%B9%BB%E7.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20251215_136/17657760168457kDf6_JPEG/KakaoTalk_20251214_181208552_03_%BA%B9%BB%E7.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20251215_38/1765776039760cYgOj_JPEG/IMG_0044.JPG",
|
||||||
|
"https://ldb-phinf.pstatic.net/20251128_248/1764320049551B1gHl_JPEG/KakaoTalk_20251128_174635084_27.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20251128_209/1764320105053L8V2f_JPEG/KakaoTalk_20251128_174635084_17.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241205_181/1733360083850LsEVv_JPEG/KakaoTalk_20241203_155353799_09.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20251128_209/1764320017959Rh3vs_JPEG/KakaoTalk_20251128_174635084_13.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20251128_102/1764320169035vluJN_JPEG/KakaoTalk_20251128_174635084_12.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241129_249/17328584834569zWJT_JPEG/6S5A0642-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241129_196/1732858501391FjLpd_JPEG/6S5A0657.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_230/1733105198846QGbcW_JPEG/8.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_94/1733105214373Q8ncR_JPEG/%B5%BF%B1%D7%B6%F3%B9%CC_%C3%A2%B9%AE_%B9%E6%B0%A1%C0%BB_2.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_110/1701910802632rDM5r_JPEG/a_%28301%29-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_7/1733106037765MNR8e_JPEG/9-1.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240118_42/17055625561156LIx7_JPEG/6S5A0809-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240118_246/1705562556109bihHB_JPEG/6S5A1820-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_259/1733104881690lDgnH_JPEG/3-2.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_76/1701910651888PpVap_JPEG/%C6%EA%B5%E5%B6%F3%C0%CC%B7%EB.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_242/1701910651863IAAlE_JPEG/%B0%AD%BE%C6%C1%F6_%BA%F1%C7%B0.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_120/17331038087267aQ0S_JPEG/KakaoTalk_20241015_154536888_04.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_164/17521338301365LRwl_JPEG/6S5A6748-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_169/1752133830016fqFlL_JPEG/6S5A6760-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_80/1701920469022ef7TW_JPEG/6S5A0614-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_140/17173835278369nntD_JPEG/6S5A6707.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_173/1752133829861tPALd_JPEG/6S5A7136.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_248/1733106037776BGjJx_JPEG/1-1.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_275/1717383527874oJOpz_JPEG/DJI_0040-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_153/1752133811173ba7oE_JPEG/6S5A6458.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_185/1752133829868FyD76_JPEG/6S5A6815.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_220/1752133829963w1aee_JPEG/6S5A6856.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_228/1752133830002U65jg_JPEG/6S5A6864.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_178/1752133829793fQQ2E_JPEG/6S5A7016-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_279/1752133829838lTSC9_JPEG/6S5A7034-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_179/1752133811452tOl7F_JPEG/6S5A6363.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_75/1752133811152vd5ts_JPEG/6S5A6513.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_147/1752133810693TfR5M_JPEG/6S5A6556.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_202/1752133811177NRBDn_JPEG/6S5A6619-2.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_196/1752133811066ufvpA_JPEG/6S5A6657.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_280/1752133811307QCs3n_JPEG/6S5A6709.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_85/1717383527884ST2DF_JPEG/6S5A6540.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_249/1717383528285Om6dI_JPEG/6S5A6648.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_288/1717383528022TxA4c_JPEG/6S5A6785.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_284/1725865224424ux2W4_JPEG/6S5A9299-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241129_87/1732858513628bWPt7_JPEG/6S5A9837-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_50/1733105457578cGfYD_JPEG/6S5A9807-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_100/1717383527950q4yNf_JPEG/6S5A7056.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_221/1717383527791s4ziP_JPEG/6S5A6673.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_134/17331166295182o4vs_JPEG/47%C6%F2_%2829%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_249/17331060371685uFQi_JPEG/3-1_%281%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_238/1725865224397wnWE5_JPEG/DJI_0157.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231206_34/1701833355386Nsg5j_JPEG/6S5A9995.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_293/1733105206236d8l2f_JPEG/8-1.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_224/1733105239528RLN59_JPEG/3-1-min.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_31/1733116629874BYgpO_JPEG/47%C6%F2_%2823%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241205_106/1733360083918ER1RI_JPEG/KakaoTalk_20241203_155353799_27.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241129_126/17328584834200qVDw_JPEG/6S5A0645.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_35/1725865128231ar2eM_PNG/KakaoTalk_20240710_163045662.png",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_153/1733105180908PyVIC_JPEG/KakaoTalk_20231027_143438563_25-min.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241129_128/1732858517984PFmrJ_JPEG/KakaoTalk_20241119_105023750.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_124/1701910720268qeACJ_JPEG/%B9%D9%BA%A3%C5%A7.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_55/173310545746333mf8_JPEG/6S5A9837-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_11/1733106430749ovoeY_JPEG/37%C6%F2_%2820%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_185/1701910772479mexsh_JPEG/a_%28282%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240118_211/17055625561294XsN2_JPEG/6S5A1803-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240118_156/17055625560847R7SL_JPEG/6S5A0934-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_179/17019107724184CCEU_JPEG/6S5A9906.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_287/1701910772339cG7K0_JPEG/a_%28119%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_40/1701910675360dvC1i_JPEG/%C8%AD%C0%E5%BD%C7_%281%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_263/1701910675262pevKk_JPEG/%C8%AD%C0%E5%BD%C7_%284%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_73/1701910651864xPI48_JPEG/%BB%E7%B6%F7_%BC%A4%C7%AA.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_265/17019204486629hzae_JPEG/6S5A0645.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_147/1733103809000xsMrG_JPEG/KakaoTalk_20241015_154532027_01.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_77/1733103809037HSO4S_JPEG/KakaoTalk_20241015_154532027_02.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_233/1733103809020IMEdL_JPEG/KakaoTalk_20241015_154532027_06.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_53/1733103808802Wui9x_JPEG/KakaoTalk_20241015_154532027_04.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_230/1733103808998dOq0b_JPEG/KakaoTalk_20241015_154532027_15.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_122/1733103808509lOt9x_JPEG/KakaoTalk_20241015_154536888_13.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_246/1733103809045RdnEP_JPEG/KakaoTalk_20241015_154536888_11.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_287/1733103808656oB1xz_JPEG/KakaoTalk_20241015_154536888_06.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_222/1733103808508hkf5K_JPEG/KakaoTalk_20241015_154536888_08.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_229/1733103808818KGAps_JPEG/KakaoTalk_20241015_154536888_02.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_12/1733103808751cVAsT_JPEG/KakaoTalk_20241015_154536888_03.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_261/1725865224473Uo7V9_JPEG/6S5A9371-%C6%ED%C1%FD-%C6%ED%C1%FD-2.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_22/1725865224495AL798_JPEG/6S5A9557-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_177/1725865224462eX1Pi_JPEG/DJI_0149-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_165/1725865224480S7c1j_JPEG/6S5A9468-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_85/1725865224421CNRrk_JPEG/DJI_0155.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_62/1725865224361fP2d2_JPEG/6S5A9434.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_155/1725865224363Xxt8y_JPEG/6S5A9431-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_13/1725865224457BGLGs_JPEG/6S5A9456.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_162/1717383527882Vnrsk_JPEG/6S5A6786.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_256/1717383527892WXOKA_JPEG/6S5A6896.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_130/1717383527800oHSKA_JPEG/6S5A6904.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_118/17173835277794IXlt_JPEG/6S5A6946.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_257/1717383527822ITvHM_JPEG/6S5A6973.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_170/1717383527880sTdFE_JPEG/DJI_0039-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_10/1717383527863CVLJ2_JPEG/DJI_0087-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_94/1717383527921sDeDB_JPEG/DJI_0091-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231206_81/1701833426718D5Qwk_JPEG/b_%28119%29-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240118_153/17055625561274LAs5_JPEG/6S5A1460-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240118_8/1705562556139zGkjb_JPEG/6S5A1780.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240118_57/1705562556129IR40O_JPEG/6S5A1199.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240118_260/1705562556111cA7PG_JPEG/6S5A1927-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_183/1701910720221oI66j_JPEG/%B9%D9%BA%A3%C5%A52.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_247/1701910720113MBgKD_JPEG/%B9%D9%BA%A3%C5%A5_%282%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_227/1701910720098FE7Si_JPEG/6S5A0001.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231206_24/1701833408064fgoVj_JPEG/b_%2874%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_100/1717383527883LWVq4_JPEG/6S5A6022-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_169/17173835278856AEUV_JPEG/6S5A5974-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_155/1717383527937gxiyc_JPEG/6S5A5988.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231206_160/170183333003287LrV_JPEG/6S5A0243-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_217/1717383528190t41X5_JPEG/6S5A6027.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_142/1717383527892jHtgI_JPEG/6S5A6643.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_251/1717383527948jTN2a_JPEG/6S5A6125.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_189/1717383527880wu4Bv_JPEG/6S5A6326.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_14/1717383527794qCuuu_JPEG/6S5A6337.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_220/1717383528017rKdOw_JPEG/6S5A6773.jpg"
|
||||||
|
],
|
||||||
|
"image_count": 119,
|
||||||
|
"m_id": 1,
|
||||||
|
"processed_info": {
|
||||||
|
"customer_name": "Fullstay StayPet Hongcheon",
|
||||||
|
"region": "",
|
||||||
|
"detail_region_info": "5-2 Dapyeonbat-gil, Hwachon-myeon, Hongcheon-gun, Gangwon-do, Korea"
|
||||||
|
},
|
||||||
|
"marketing_analysis": {
|
||||||
|
"brand_identity": {
|
||||||
|
"location_feature_analysis": "Located in the nature-rich Hwachon-myeon area of Hongcheon, Gangwon-do, this property offers the ideal setting for a 'quiet forest retreat' away from urban noise. Its proximity to the Seoul metropolitan area makes it perfect for weekend short-break getaways.",
|
||||||
|
"concept_scalability": "The 'StayPet' concept extends beyond simple pet-friendly accommodation to include pet amenities, pet photo zones, and pet wellness experiences (walking trails/care services), enabling a comprehensive packaged experience throughout the entire stay."
|
||||||
|
},
|
||||||
|
"market_positioning": {
|
||||||
|
"category_definition": "Private Pet-Friendly Healing Stay in Nature",
|
||||||
|
"core_value": "A private retreat where you and your pet can relax without worry"
|
||||||
|
},
|
||||||
|
"target_persona": [
|
||||||
|
{
|
||||||
|
"persona": "Weekend Pet-cation Couples (with 1 dog) – Millennials seeking a reset trip for two + one furry friend in peaceful nature",
|
||||||
|
"age": {
|
||||||
|
"min_age": 27,
|
||||||
|
"max_age": 39
|
||||||
|
},
|
||||||
|
"favor_target": [
|
||||||
|
"Pet-friendly accommodation",
|
||||||
|
"Nature views / great walking trails",
|
||||||
|
"Clean bedding and odor management",
|
||||||
|
"Private space configuration",
|
||||||
|
"Aesthetic mood for great photos"
|
||||||
|
],
|
||||||
|
"decision_trigger": "Confidence in pet-friendly + private relaxation (verified through reviews and facility info)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"persona": "Urban Escape Solo/Duo Healers – Working professionals recharging with quiet rest and reading in nature",
|
||||||
|
"age": {
|
||||||
|
"min_age": 29,
|
||||||
|
"max_age": 45
|
||||||
|
},
|
||||||
|
"favor_target": [
|
||||||
|
"Low-noise location",
|
||||||
|
"Nature views",
|
||||||
|
"Comfortable beds / relaxation lounge",
|
||||||
|
"Ambient night lighting",
|
||||||
|
"Simple check-in / easy access"
|
||||||
|
],
|
||||||
|
"decision_trigger": "Guaranteed 'quietness' and 'nature immersion' through location and atmosphere"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"persona": "Family Pet Owners – Families in their 30s-40s creating memories with their pets in a safe, comfortable stay",
|
||||||
|
"age": {
|
||||||
|
"min_age": 33,
|
||||||
|
"max_age": 49
|
||||||
|
},
|
||||||
|
"favor_target": [
|
||||||
|
"Pet safety features (non-slip floors / fencing)",
|
||||||
|
"Spacious relaxation areas",
|
||||||
|
"Family activities like BBQ",
|
||||||
|
"Parking / travel convenience",
|
||||||
|
"Hygiene / cleanliness assurance"
|
||||||
|
],
|
||||||
|
"decision_trigger": "Spacious enough for families + reliable pet-friendly policies"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"selling_points": [
|
||||||
|
{
|
||||||
|
"english_category": "PET FRIENDLY",
|
||||||
|
"korean_category": "Pet-Friendly",
|
||||||
|
"description": "A complete retreat with your beloved pet",
|
||||||
|
"score": 95
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"english_category": "HEALING",
|
||||||
|
"korean_category": "Healing",
|
||||||
|
"description": "A reset stay in Hongcheon's natural surroundings",
|
||||||
|
"score": 90
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"english_category": "PRIVACY",
|
||||||
|
"korean_category": "Privacy",
|
||||||
|
"description": "A private retreat — relax without a care",
|
||||||
|
"score": 88
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"english_category": "SHORT GETAWAY",
|
||||||
|
"korean_category": "Short Getaway",
|
||||||
|
"description": "A quick weekend escape to Hongcheon",
|
||||||
|
"score": 82
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"english_category": "NIGHT MOOD",
|
||||||
|
"korean_category": "Night Mood",
|
||||||
|
"description": "An aesthetic ambiance that shines at night",
|
||||||
|
"score": 78
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"english_category": "BBQ PARTY",
|
||||||
|
"korean_category": "BBQ Party",
|
||||||
|
"description": "A BBQ feast surrounded by nature",
|
||||||
|
"score": 75
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"english_category": "PHOTO SPOT",
|
||||||
|
"korean_category": "Photo Spot",
|
||||||
|
"description": "The perfect spot for unforgettable pet photos",
|
||||||
|
"score": 73
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"target_keywords": [
|
||||||
|
"Hongcheon pet-friendly stay",
|
||||||
|
"Gangwon-do pet pension",
|
||||||
|
"StayPet",
|
||||||
|
"Pet-cation",
|
||||||
|
"Travel with pets",
|
||||||
|
"Hongcheon aesthetic stay",
|
||||||
|
"Gangwon healing retreat",
|
||||||
|
"Forest view accommodation",
|
||||||
|
"Weekend trip recommendation",
|
||||||
|
"Private stay"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -84,7 +84,7 @@
|
|||||||
<li><strong>회사명:</strong> ㈜에이아이오투오</li>
|
<li><strong>회사명:</strong> ㈜에이아이오투오</li>
|
||||||
<li><strong>서비스명:</strong> ADO2</li>
|
<li><strong>서비스명:</strong> ADO2</li>
|
||||||
<li><strong>이메일:</strong> <a href="mailto:o2oteam@o2o.kr">o2oteam@o2o.kr</a></li>
|
<li><strong>이메일:</strong> <a href="mailto:o2oteam@o2o.kr">o2oteam@o2o.kr</a></li>
|
||||||
<li><strong>웹사이트:</strong> https://ado2.o2osolution.ai</li>
|
<li><strong>웹사이트:</strong> https://demo.castad.net</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p style="margin-top:40px; font-size:14px; color:#999;">본 방침은 2026년 5월 7일부터 시행됩니다. 최종 수정일: 2026년 5월 27일</p>
|
<p style="margin-top:40px; font-size:14px; color:#999;">본 방침은 2026년 5월 7일부터 시행됩니다. 최종 수정일: 2026년 5월 27일</p>
|
||||||
|
|||||||
@ -58,7 +58,7 @@
|
|||||||
<li><strong>회사명:</strong> ㈜에이아이오투오</li>
|
<li><strong>회사명:</strong> ㈜에이아이오투오</li>
|
||||||
<li><strong>서비스명:</strong> ADO2</li>
|
<li><strong>서비스명:</strong> ADO2</li>
|
||||||
<li><strong>이메일:</strong> o2oteam@o2o.kr</li>
|
<li><strong>이메일:</strong> o2oteam@o2o.kr</li>
|
||||||
<li><strong>웹사이트:</strong> https://ado2.o2osolution.ai</li>
|
<li><strong>웹사이트:</strong> https://demo.castad.net</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p style="margin-top:40px; font-size:14px; color:#999;">본 약관은 2026년 5월 7일부터 시행됩니다.</p>
|
<p style="margin-top:40px; font-size:14px; color:#999;">본 약관은 2026년 5월 7일부터 시행됩니다.</p>
|
||||||
|
|||||||
2907
remotion/package-lock.json
generated
Normal file
2907
remotion/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
remotion/package.json
Normal file
22
remotion/package.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "@ado2/higgsfield-remotion",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "ADO2 Higgsfield Shorts — Remotion 자막·타이틀 오버레이 합성",
|
||||||
|
"scripts": {
|
||||||
|
"studio": "remotion studio",
|
||||||
|
"render": "remotion render MumumShort out/mumum-poc2-final.mp4",
|
||||||
|
"upgrade": "remotion upgrade"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@remotion/cli": "4.0.468",
|
||||||
|
"@remotion/google-fonts": "4.0.468",
|
||||||
|
"remotion": "4.0.468",
|
||||||
|
"react": "19.2.0",
|
||||||
|
"react-dom": "19.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "19.2.0",
|
||||||
|
"typescript": "5.7.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
remotion/public/mumum_marketing_v1.mp4
Normal file
BIN
remotion/public/mumum_marketing_v1.mp4
Normal file
Binary file not shown.
6
remotion/remotion.config.ts
Normal file
6
remotion/remotion.config.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { Config } from "@remotion/cli/config";
|
||||||
|
|
||||||
|
Config.setVideoImageFormat("jpeg");
|
||||||
|
Config.setOverwriteOutput(true);
|
||||||
|
// H.264 + AAC, 모바일 9:16 Shorts/Reels 호환
|
||||||
|
Config.setCodec("h264");
|
||||||
58
remotion/src/MumumShort.tsx
Normal file
58
remotion/src/MumumShort.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { AbsoluteFill, OffthreadVideo, Sequence, staticFile } from "remotion";
|
||||||
|
import { ShortProps } from "./data/types";
|
||||||
|
import { HookTitle } from "./components/HookTitle";
|
||||||
|
import { BrandLines } from "./components/BrandLines";
|
||||||
|
import { SellingBadge } from "./components/SellingBadge";
|
||||||
|
import { EndCard } from "./components/EndCard";
|
||||||
|
|
||||||
|
// Higgsfield 완성본을 배경으로 깔고(오디오 포함), 자막/타이틀만 오버레이.
|
||||||
|
// 모든 콘텐츠는 props로 주입 (펜션/제품마다 교체).
|
||||||
|
export const MumumShort: React.FC<ShortProps> = ({
|
||||||
|
videoSrc,
|
||||||
|
hook,
|
||||||
|
sellingPoint,
|
||||||
|
brandLines,
|
||||||
|
endCard,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<AbsoluteFill style={{ backgroundColor: "black" }}>
|
||||||
|
<OffthreadVideo
|
||||||
|
src={staticFile(videoSrc)}
|
||||||
|
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 상단 후킹 타이틀 */}
|
||||||
|
<Sequence from={hook.fromFrame} durationInFrames={hook.toFrame - hook.fromFrame}>
|
||||||
|
<HookTitle
|
||||||
|
eyebrow={hook.eyebrow}
|
||||||
|
title={hook.title}
|
||||||
|
fromFrame={0}
|
||||||
|
toFrame={hook.toFrame - hook.fromFrame}
|
||||||
|
/>
|
||||||
|
</Sequence>
|
||||||
|
|
||||||
|
{/* 셀링포인트: 3개 독립 박스 */}
|
||||||
|
<SellingBadge
|
||||||
|
items={sellingPoint.items}
|
||||||
|
fromFrame={sellingPoint.fromFrame}
|
||||||
|
toFrame={sellingPoint.toFrame}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 브랜드 감성 2줄 (상단 중앙) */}
|
||||||
|
<BrandLines
|
||||||
|
lines={brandLines.lines}
|
||||||
|
fromFrame={brandLines.fromFrame}
|
||||||
|
toFrame={brandLines.toFrame}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 엔드카드 + AI 디스클로저 */}
|
||||||
|
<EndCard
|
||||||
|
brand={endCard.brand}
|
||||||
|
location={endCard.location}
|
||||||
|
disclosure={endCard.disclosure}
|
||||||
|
fromFrame={endCard.fromFrame}
|
||||||
|
/>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
25
remotion/src/Root.tsx
Normal file
25
remotion/src/Root.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Composition } from "remotion";
|
||||||
|
import { MumumShort } from "./MumumShort";
|
||||||
|
import { defaultProps } from "./data/mumum";
|
||||||
|
|
||||||
|
export const RemotionRoot: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<Composition
|
||||||
|
id="MumumShort"
|
||||||
|
component={MumumShort}
|
||||||
|
defaultProps={defaultProps}
|
||||||
|
durationInFrames={defaultProps.durationInFrames}
|
||||||
|
fps={defaultProps.fps}
|
||||||
|
width={defaultProps.width}
|
||||||
|
height={defaultProps.height}
|
||||||
|
// props(영상 크기·길이)로 메타데이터 동적 설정 → 한 컴포지션이 모든 job 처리
|
||||||
|
calculateMetadata={({ props }) => ({
|
||||||
|
durationInFrames: props.durationInFrames,
|
||||||
|
fps: props.fps,
|
||||||
|
width: props.width,
|
||||||
|
height: props.height,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
63
remotion/src/components/BrandLines.tsx
Normal file
63
remotion/src/components/BrandLines.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useCurrentFrame, interpolate, AbsoluteFill } from "remotion";
|
||||||
|
import { serifFont } from "../fonts";
|
||||||
|
|
||||||
|
// 브랜드 감성 2줄: 우상단 통일 위치에 한 덩어리로 쌓아 노출.
|
||||||
|
// 첫 줄 먼저, 둘째 줄 살짝 뒤따라 등장 → 같은 자리에서 읽힘.
|
||||||
|
export const BrandLines: React.FC<{
|
||||||
|
lines: string[];
|
||||||
|
fromFrame: number;
|
||||||
|
toFrame: number;
|
||||||
|
}> = ({ lines, fromFrame, toFrame }) => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const LINE_STAGGER = 18; // 줄 간 등장 간격(frame)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill
|
||||||
|
style={{
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
alignItems: "center", // 가로 중앙 정렬
|
||||||
|
flexDirection: "column",
|
||||||
|
paddingTop: 230, // 상단 (~18%) 높이 유지
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{lines.map((line, i) => {
|
||||||
|
const start = fromFrame + i * LINE_STAGGER;
|
||||||
|
// interpolate는 배열이 단조증가해야 한다.
|
||||||
|
// 영상이 짧아 toFrame 이 start+24 이하로 내려오면 범위가 뒤집혀 크래시 발생.
|
||||||
|
const fadeIn = start + 12;
|
||||||
|
const fadeOut = Math.max(fadeIn + 1, toFrame - 12);
|
||||||
|
const end = Math.max(fadeOut + 1, toFrame);
|
||||||
|
const opacity = interpolate(
|
||||||
|
frame,
|
||||||
|
[start, fadeIn, fadeOut, end],
|
||||||
|
[0, 1, 1, 0],
|
||||||
|
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
|
||||||
|
);
|
||||||
|
const rise = interpolate(frame, [start, start + 16], [16, 0], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
opacity,
|
||||||
|
transform: `translateY(${rise}px)`,
|
||||||
|
fontFamily: serifFont,
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 46,
|
||||||
|
color: "#FFFFFF",
|
||||||
|
textAlign: "center",
|
||||||
|
letterSpacing: 1,
|
||||||
|
textShadow: "0 2px 16px rgba(0,0,0,0.85)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{line}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
91
remotion/src/components/EndCard.tsx
Normal file
91
remotion/src/components/EndCard.tsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
useCurrentFrame,
|
||||||
|
useVideoConfig,
|
||||||
|
interpolate,
|
||||||
|
spring,
|
||||||
|
AbsoluteFill,
|
||||||
|
} from "remotion";
|
||||||
|
import { serifFont } from "../fonts";
|
||||||
|
|
||||||
|
// 엔드카드: 브랜드 + 위치 + AI 디스클로저 (가드레일 필수)
|
||||||
|
export const EndCard: React.FC<{
|
||||||
|
brand: string;
|
||||||
|
location: string;
|
||||||
|
disclosure: string;
|
||||||
|
fromFrame: number;
|
||||||
|
}> = ({ brand, location, disclosure, fromFrame }) => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
|
||||||
|
const appear = interpolate(frame, [fromFrame, fromFrame + 12], [0, 1], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
const brandSpring = spring({
|
||||||
|
frame: frame - fromFrame,
|
||||||
|
fps,
|
||||||
|
config: { damping: 200, mass: 0.5 },
|
||||||
|
});
|
||||||
|
const brandScale = interpolate(brandSpring, [0, 1], [0.92, 1]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill
|
||||||
|
style={{
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
// 하단을 살짝 어둡게 덮어 브랜드 가독성 확보
|
||||||
|
background: `rgba(0,0,0,${0.42 * appear})`,
|
||||||
|
opacity: appear,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
transform: `scale(${brandScale})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: serifFont,
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: 76,
|
||||||
|
color: "#FFFFFF",
|
||||||
|
letterSpacing: 1,
|
||||||
|
textShadow: "0 3px 20px rgba(0,0,0,0.7)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{brand}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: serifFont,
|
||||||
|
fontWeight: 400,
|
||||||
|
fontSize: 30,
|
||||||
|
color: "#FFE9C7",
|
||||||
|
marginTop: 18,
|
||||||
|
letterSpacing: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{location}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 70,
|
||||||
|
fontFamily: serifFont,
|
||||||
|
fontWeight: 400,
|
||||||
|
fontSize: 19,
|
||||||
|
color: "rgba(255,255,255,0.72)",
|
||||||
|
textAlign: "center",
|
||||||
|
padding: "0 40px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{disclosure}
|
||||||
|
</div>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
93
remotion/src/components/HookTitle.tsx
Normal file
93
remotion/src/components/HookTitle.tsx
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
useCurrentFrame,
|
||||||
|
useVideoConfig,
|
||||||
|
interpolate,
|
||||||
|
spring,
|
||||||
|
AbsoluteFill,
|
||||||
|
} from "remotion";
|
||||||
|
import { hookFont } from "../fonts";
|
||||||
|
|
||||||
|
export const HookTitle: React.FC<{
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
fromFrame: number;
|
||||||
|
toFrame: number;
|
||||||
|
}> = ({ eyebrow, title, fromFrame, toFrame }) => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
|
||||||
|
// 스프링 슬라이드-다운 등장
|
||||||
|
const enter = spring({
|
||||||
|
frame: frame - fromFrame,
|
||||||
|
fps,
|
||||||
|
config: { damping: 200, mass: 0.6 },
|
||||||
|
});
|
||||||
|
const translateY = interpolate(enter, [0, 1], [-60, 0]);
|
||||||
|
// 퇴장 페이드
|
||||||
|
const fadeIn = fromFrame + 8;
|
||||||
|
const fadeOut = Math.max(fadeIn + 1, toFrame - 12);
|
||||||
|
const end = Math.max(fadeOut + 1, toFrame);
|
||||||
|
const opacity = interpolate(
|
||||||
|
frame,
|
||||||
|
[fromFrame, fadeIn, fadeOut, end],
|
||||||
|
[0, 1, 1, 0],
|
||||||
|
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill style={{ opacity }}>
|
||||||
|
{/* 상단 가독성 그라데이션 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 580,
|
||||||
|
background:
|
||||||
|
"linear-gradient(180deg, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0) 100%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 280,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
transform: `translateY(${translateY}px)`,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "0 48px",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: hookFont,
|
||||||
|
fontSize: 34,
|
||||||
|
color: "#FFE9C7",
|
||||||
|
letterSpacing: 2,
|
||||||
|
marginBottom: 14,
|
||||||
|
textShadow: "0 2px 12px rgba(0,0,0,0.7)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{eyebrow}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: hookFont,
|
||||||
|
fontSize: 62,
|
||||||
|
lineHeight: 1.18,
|
||||||
|
color: "#FFFFFF",
|
||||||
|
textShadow: "0 3px 18px rgba(0,0,0,0.85)",
|
||||||
|
WebkitTextStroke: "1.5px rgba(0,0,0,0.35)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
72
remotion/src/components/SellingBadge.tsx
Normal file
72
remotion/src/components/SellingBadge.tsx
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
useCurrentFrame,
|
||||||
|
useVideoConfig,
|
||||||
|
interpolate,
|
||||||
|
spring,
|
||||||
|
AbsoluteFill,
|
||||||
|
} from "remotion";
|
||||||
|
import { serifFont } from "../fonts";
|
||||||
|
|
||||||
|
// 셀링포인트: 3개 독립 pill 박스를 세로로 쌓고 순차 등장.
|
||||||
|
// 컬러 지양 → 반투명 다크 pill + 흰색 텍스트. 앞 구간 단독 노출(시선 집중).
|
||||||
|
export const SellingBadge: React.FC<{
|
||||||
|
items: string[];
|
||||||
|
fromFrame: number;
|
||||||
|
toFrame: number;
|
||||||
|
}> = ({ items, fromFrame, toFrame }) => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
const STAGGER = 9; // 박스 간 등장 간격(frame)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill
|
||||||
|
style={{
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
alignItems: "center",
|
||||||
|
flexDirection: "column",
|
||||||
|
paddingTop: 200, // 상단 배치 (~16%) — 이미지 가림 최소화
|
||||||
|
gap: 18,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map((item, i) => {
|
||||||
|
const start = fromFrame + i * STAGGER;
|
||||||
|
const enter = spring({
|
||||||
|
frame: frame - start,
|
||||||
|
fps,
|
||||||
|
config: { damping: 200, mass: 0.5 },
|
||||||
|
});
|
||||||
|
const rise = interpolate(enter, [0, 1], [22, 0]);
|
||||||
|
const fadeIn = start + 8;
|
||||||
|
const fadeOut = Math.max(fadeIn + 1, toFrame - 10);
|
||||||
|
const end = Math.max(fadeOut + 1, toFrame);
|
||||||
|
const appear = interpolate(
|
||||||
|
frame,
|
||||||
|
[start, fadeIn, fadeOut, end],
|
||||||
|
[0, 1, 1, 0],
|
||||||
|
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
opacity: appear,
|
||||||
|
transform: `translateY(${rise}px)`,
|
||||||
|
fontFamily: serifFont,
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 38,
|
||||||
|
color: "#FFFFFF",
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
// 배경 제거 → 블러리 소프트 쉐도우로 가독성 확보
|
||||||
|
textShadow:
|
||||||
|
"0 2px 22px rgba(0,0,0,0.95), 0 0 14px rgba(0,0,0,0.85), 0 0 40px rgba(0,0,0,0.6)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
53
remotion/src/components/Subtitle.tsx
Normal file
53
remotion/src/components/Subtitle.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
useCurrentFrame,
|
||||||
|
interpolate,
|
||||||
|
AbsoluteFill,
|
||||||
|
} from "remotion";
|
||||||
|
import { serifFont } from "../fonts";
|
||||||
|
|
||||||
|
// 브랜드 자막: 페이드 + 미세 상승 (Profile A 모션 프리셋)
|
||||||
|
export const Subtitle: React.FC<{
|
||||||
|
text: string;
|
||||||
|
fromFrame: number;
|
||||||
|
toFrame: number;
|
||||||
|
}> = ({ text, fromFrame, toFrame }) => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
|
||||||
|
const opacity = interpolate(
|
||||||
|
frame,
|
||||||
|
[fromFrame, fromFrame + 10, toFrame - 10, toFrame],
|
||||||
|
[0, 1, 1, 0],
|
||||||
|
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
|
||||||
|
);
|
||||||
|
const rise = interpolate(frame, [fromFrame, fromFrame + 18], [18, 0], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill
|
||||||
|
style={{
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingBottom: 220,
|
||||||
|
opacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: serifFont,
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 50,
|
||||||
|
color: "#FFFFFF",
|
||||||
|
textAlign: "center",
|
||||||
|
letterSpacing: 1,
|
||||||
|
transform: `translateY(${rise}px)`,
|
||||||
|
textShadow: "0 2px 16px rgba(0,0,0,0.8)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
35
remotion/src/data/mumum.ts
Normal file
35
remotion/src/data/mumum.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
// Default props = the verified 머뭄 PoC #2 layout.
|
||||||
|
// The Python render bridge overrides these per-job via --props.
|
||||||
|
import { ShortProps } from "./types";
|
||||||
|
|
||||||
|
export const defaultProps: ShortProps = {
|
||||||
|
videoSrc: "mumum_marketing_v1.mp4",
|
||||||
|
fps: 30,
|
||||||
|
width: 720,
|
||||||
|
height: 1280,
|
||||||
|
durationInFrames: 241, // ≈ 8.04s
|
||||||
|
|
||||||
|
hook: {
|
||||||
|
eyebrow: "아무도 모르는 군산",
|
||||||
|
title: "골목 안, 단 한 채의 독채 스테이 머뭄",
|
||||||
|
fromFrame: 0,
|
||||||
|
toFrame: 78,
|
||||||
|
},
|
||||||
|
sellingPoint: {
|
||||||
|
items: ["프라이빗 독채펜션", "넓은 정원", "핵심 관광지 가까이"],
|
||||||
|
fromFrame: 80,
|
||||||
|
toFrame: 138,
|
||||||
|
},
|
||||||
|
brandLines: {
|
||||||
|
lines: ["머무는 시간이", "다르게 흐르는 곳"],
|
||||||
|
fromFrame: 138,
|
||||||
|
toFrame: 196,
|
||||||
|
},
|
||||||
|
endCard: {
|
||||||
|
brand: "Stay, 머뭄",
|
||||||
|
location: "전북 군산시 절골길 18",
|
||||||
|
disclosure: "실제 사진 기반, AI 카메라 효과를 적용한 영상입니다.",
|
||||||
|
fromFrame: 192,
|
||||||
|
toFrame: 241,
|
||||||
|
},
|
||||||
|
};
|
||||||
19
remotion/src/data/types.ts
Normal file
19
remotion/src/data/types.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
// Props contract — shared by Remotion composition and the Python render bridge.
|
||||||
|
// Mirrors server/app/schemas.py VideoSpec (+ video config + timings).
|
||||||
|
export type ShortProps = {
|
||||||
|
videoSrc: string; // file in public/ (staticFile name)
|
||||||
|
fps: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
durationInFrames: number;
|
||||||
|
hook: { eyebrow: string; title: string; fromFrame: number; toFrame: number };
|
||||||
|
sellingPoint: { items: string[]; fromFrame: number; toFrame: number };
|
||||||
|
brandLines: { lines: string[]; fromFrame: number; toFrame: number };
|
||||||
|
endCard: {
|
||||||
|
brand: string;
|
||||||
|
location: string;
|
||||||
|
disclosure: string;
|
||||||
|
fromFrame: number;
|
||||||
|
toFrame: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
21
remotion/src/fonts.ts
Normal file
21
remotion/src/fonts.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
// 한글 폰트 로딩 (@remotion/google-fonts, korean subset)
|
||||||
|
// Hook = Black Han Sans (굵고 강렬한 바이럴 헤드라인)
|
||||||
|
// Body/Brand = Nanum Myeongjo (우아한 명조 — 머뭄의 정적 브랜드 톤)
|
||||||
|
//
|
||||||
|
// 주의: korean subset 자체가 unicode-range 파일이 많아 요청 수가 많다.
|
||||||
|
// weights 를 최소화(NanumMyeongjo는 400/700만 실제 존재)해 요청 수를 줄임.
|
||||||
|
// latin 서브셋 제거 — 콘텐츠가 한국어 전용이므로 불필요.
|
||||||
|
import { loadFont as loadHook } from "@remotion/google-fonts/BlackHanSans";
|
||||||
|
import { loadFont as loadSerif } from "@remotion/google-fonts/NanumMyeongjo";
|
||||||
|
|
||||||
|
export const hookFont = loadHook("normal", {
|
||||||
|
weights: ["400"],
|
||||||
|
subsets: ["korean"],
|
||||||
|
ignoreTooManyRequestsWarning: true,
|
||||||
|
}).fontFamily;
|
||||||
|
|
||||||
|
export const serifFont = loadSerif("normal", {
|
||||||
|
weights: ["400", "700"], // 800은 NanumMyeongjo에 없음 → 제거
|
||||||
|
subsets: ["korean"],
|
||||||
|
ignoreTooManyRequestsWarning: true,
|
||||||
|
}).fontFamily;
|
||||||
4
remotion/src/index.ts
Normal file
4
remotion/src/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { registerRoot } from "remotion";
|
||||||
|
import { RemotionRoot } from "./Root";
|
||||||
|
|
||||||
|
registerRoot(RemotionRoot);
|
||||||
15
remotion/tsconfig.json
Normal file
15
remotion/tsconfig.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
61
src/App.tsx
61
src/App.tsx
@ -14,7 +14,7 @@ import SocialConnectError from './pages/Social/SocialConnectError';
|
|||||||
import YouTubeOAuthCallback from './pages/Social/YouTubeOAuthCallback';
|
import YouTubeOAuthCallback from './pages/Social/YouTubeOAuthCallback';
|
||||||
import ADO2ContentsPage from './pages/Dashboard/ADO2ContentsPage';
|
import ADO2ContentsPage from './pages/Dashboard/ADO2ContentsPage';
|
||||||
import VideoDetailPage from './components/VideoDetailPage';
|
import VideoDetailPage from './components/VideoDetailPage';
|
||||||
import { crawlUrl, autocomplete, marketingAnalysis, kakaoCallback, isLoggedIn, saveTokens, getVideosList, AutocompleteRequest, storeUtmFromUrl, trackViewContent, trackCompleteRegistration } from './utils/api';
|
import { crawlUrl, autocomplete, marketingAnalysis, kakaoCallback, isLoggedIn, saveTokens, getVideosList, AutocompleteRequest } from './utils/api';
|
||||||
import { saveSearchHistory } from './components/SearchHistory/useSearchHistory';
|
import { saveSearchHistory } from './components/SearchHistory/useSearchHistory';
|
||||||
import { CrawlingResponse } from './types/api';
|
import { CrawlingResponse } from './types/api';
|
||||||
|
|
||||||
@ -123,9 +123,6 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
// 카카오 로그인 콜백 처리 (URL에서 토큰 또는 code 파라미터 확인)
|
// 카카오 로그인 콜백 처리 (URL에서 토큰 또는 code 파라미터 확인)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 광고 유입 UTM 파라미터 캡처 (콜백 처리로 URL이 정리되기 전에 최우선 실행)
|
|
||||||
storeUtmFromUrl();
|
|
||||||
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const accessToken = urlParams.get('access_token');
|
const accessToken = urlParams.get('access_token');
|
||||||
const refreshToken = urlParams.get('refresh_token');
|
const refreshToken = urlParams.get('refresh_token');
|
||||||
@ -139,21 +136,17 @@ const App: React.FC = () => {
|
|||||||
// 백엔드에서 토큰을 URL 파라미터로 전달한 경우
|
// 백엔드에서 토큰을 URL 파라미터로 전달한 경우
|
||||||
if (accessToken && refreshToken && !isProcessingCallback) {
|
if (accessToken && refreshToken && !isProcessingCallback) {
|
||||||
setIsProcessingCallback(true);
|
setIsProcessingCallback(true);
|
||||||
handleTokenCallback(accessToken, refreshToken, urlParams.get('is_new_user') === 'true');
|
handleTokenCallback(accessToken, refreshToken);
|
||||||
}
|
}
|
||||||
// 기존 code 방식 (Redirect URI가 프론트엔드인 경우) - Social OAuth는 제외
|
// 기존 code 방식 (Redirect URI가 프론트엔드인 경우) - Social OAuth는 제외
|
||||||
else if (code && !isProcessingCallback && !isSocialOAuthCallback) {
|
else if (code && !isProcessingCallback && !isSocialOAuthCallback) {
|
||||||
setIsProcessingCallback(true);
|
setIsProcessingCallback(true);
|
||||||
handleKakaoCallback(code);
|
handleKakaoCallback(code);
|
||||||
}
|
}
|
||||||
// 콜백이 아닌 일반 진입이고 랜딩이 첫 화면이면 ViewContent 발화
|
|
||||||
else if (viewMode === 'landing') {
|
|
||||||
trackViewContent();
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 백엔드에서 토큰을 URL 파라미터로 전달받은 경우 처리
|
// 백엔드에서 토큰을 URL 파라미터로 전달받은 경우 처리
|
||||||
const handleTokenCallback = async (accessToken: string, refreshToken: string, isNewUser: boolean = false) => {
|
const handleTokenCallback = (accessToken: string, refreshToken: string) => {
|
||||||
try {
|
try {
|
||||||
// 토큰 저장
|
// 토큰 저장
|
||||||
saveTokens(accessToken, refreshToken);
|
saveTokens(accessToken, refreshToken);
|
||||||
@ -162,15 +155,8 @@ const App: React.FC = () => {
|
|||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
url.searchParams.delete('access_token');
|
url.searchParams.delete('access_token');
|
||||||
url.searchParams.delete('refresh_token');
|
url.searchParams.delete('refresh_token');
|
||||||
url.searchParams.delete('is_new_user');
|
|
||||||
window.history.replaceState({}, document.title, url.pathname);
|
window.history.replaceState({}, document.title, url.pathname);
|
||||||
|
|
||||||
// 신규 가입이면 Meta CompleteRegistration 전환 이벤트 발화
|
|
||||||
// (서버가 계정당 최초 1회 판정 + UTM 저장, 실패해도 로그인 흐름에 영향 없음)
|
|
||||||
if (isNewUser) {
|
|
||||||
await trackCompleteRegistration();
|
|
||||||
}
|
|
||||||
|
|
||||||
const savedData = localStorage.getItem(ANALYSIS_DATA_KEY);
|
const savedData = localStorage.getItem(ANALYSIS_DATA_KEY);
|
||||||
if (savedData) {
|
if (savedData) {
|
||||||
// 분석 데이터가 있으면 에셋 관리(step 1)부터 시작
|
// 분석 데이터가 있으면 에셋 관리(step 1)부터 시작
|
||||||
@ -198,12 +184,6 @@ const App: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const response = await kakaoCallback(code);
|
const response = await kakaoCallback(code);
|
||||||
|
|
||||||
// 신규 가입이면 Meta CompleteRegistration 전환 이벤트 발화
|
|
||||||
// (페이지 이동 전에 완료해야 요청이 유실되지 않음)
|
|
||||||
if (response.is_new_user) {
|
|
||||||
await trackCompleteRegistration();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 로그인 성공 - 서버에서 받은 redirect_url로 이동
|
// 로그인 성공 - 서버에서 받은 redirect_url로 이동
|
||||||
window.location.href = response.redirect_url;
|
window.location.href = response.redirect_url;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -354,14 +334,14 @@ const App: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 업체명·주소 수동 입력으로 마케팅 분석 API 호출
|
// 업체명·주소 수동 입력으로 마케팅 분석 API 호출
|
||||||
const handleManualInput = async (businessName: string, address: string, category: string, officialSiteUrl?: string) => {
|
const handleManualInput = async (businessName: string, address: string) => {
|
||||||
setAfterLoadTarget('generation_flow');
|
setAfterLoadTarget('generation_flow');
|
||||||
setViewMode('loading');
|
setViewMode('loading');
|
||||||
setIsAnalysisComplete(false);
|
setIsAnalysisComplete(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await marketingAnalysis(businessName, address, category, officialSiteUrl);
|
const data = await marketingAnalysis(businessName, address);
|
||||||
|
|
||||||
if (!validateCrawlingResponse(data)) {
|
if (!validateCrawlingResponse(data)) {
|
||||||
throw new Error(t('app.autocompleteError'));
|
throw new Error(t('app.autocompleteError'));
|
||||||
@ -378,32 +358,6 @@ const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 테스트 데이터로 브랜드 분석 페이지 이동
|
|
||||||
const handleTestData = (data: CrawlingResponse) => {
|
|
||||||
const tagged = { ...data, _isTestData: true };
|
|
||||||
setAnalysisData(tagged);
|
|
||||||
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged));
|
|
||||||
setViewMode('analysis');
|
|
||||||
};
|
|
||||||
|
|
||||||
// 언어 변경 시 테스트 데이터 다시 로드
|
|
||||||
useEffect(() => {
|
|
||||||
const saved = localStorage.getItem(ANALYSIS_DATA_KEY);
|
|
||||||
if (!saved) return;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(saved);
|
|
||||||
if (!parsed._isTestData) return;
|
|
||||||
const jsonFile = i18n.language === 'en' ? '/example_analysis_en.json' : '/example_analysis.json';
|
|
||||||
fetch(jsonFile)
|
|
||||||
.then(res => res.json())
|
|
||||||
.then((data: CrawlingResponse) => {
|
|
||||||
const tagged = { ...data, _isTestData: true };
|
|
||||||
setAnalysisData(tagged);
|
|
||||||
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged));
|
|
||||||
})
|
|
||||||
.catch(err => console.error('Failed to reload test data:', err));
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}, [i18n.language]);
|
|
||||||
|
|
||||||
const handleToLogin = async () => {
|
const handleToLogin = async () => {
|
||||||
// 이미 로그인된 상태면 바로 generation_flow로 이동
|
// 이미 로그인된 상태면 바로 generation_flow로 이동
|
||||||
@ -524,11 +478,8 @@ const App: React.FC = () => {
|
|||||||
<Header onStartClick={handleHeaderStart} />
|
<Header onStartClick={handleHeaderStart} />
|
||||||
<section className="landing-section">
|
<section className="landing-section">
|
||||||
<HeroSection
|
<HeroSection
|
||||||
onAnalyze={handleStartAnalysis}
|
onLogin={handleToLogin}
|
||||||
onAutocomplete={handleAutocomplete}
|
|
||||||
onManualInput={handleManualInput}
|
|
||||||
onNext={() => scrollToSection(1)}
|
onNext={() => scrollToSection(1)}
|
||||||
error={error}
|
|
||||||
scrollProgress={scrollProgress}
|
scrollProgress={scrollProgress}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import CitySelectModal, { REGIONS } from './CitySelectModal';
|
|||||||
|
|
||||||
interface BusinessNameInputModalProps {
|
interface BusinessNameInputModalProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: (businessName: string, address: string, category: string, officialSiteUrl: string) => void;
|
onSubmit: (businessName: string, address: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose, onSubmit }) => {
|
const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose, onSubmit }) => {
|
||||||
@ -13,8 +13,6 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
|
|||||||
const [businessName, setBusinessName] = useState('');
|
const [businessName, setBusinessName] = useState('');
|
||||||
const [selectedCity, setSelectedCity] = useState('');
|
const [selectedCity, setSelectedCity] = useState('');
|
||||||
const [detailAddress, setDetailAddress] = useState('');
|
const [detailAddress, setDetailAddress] = useState('');
|
||||||
const [category, setCategory] = useState('');
|
|
||||||
const [officialSiteUrl, setOfficialSiteUrl] = useState('');
|
|
||||||
const [isCityModalOpen, setIsCityModalOpen] = useState(false);
|
const [isCityModalOpen, setIsCityModalOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -43,12 +41,7 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
|
|||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!isValid) return;
|
if (!isValid) return;
|
||||||
const fullAddress = `${selectedCity} ${detailAddress.trim()}`;
|
const fullAddress = `${selectedCity} ${detailAddress.trim()}`;
|
||||||
// 프로토콜 없이 입력하면 https:// 를 붙여서 전달
|
onSubmit(businessName.trim(), fullAddress);
|
||||||
const trimmedUrl = officialSiteUrl.trim();
|
|
||||||
const normalizedUrl = trimmedUrl && !/^https?:\/\//i.test(trimmedUrl)
|
|
||||||
? `https://${trimmedUrl}`
|
|
||||||
: trimmedUrl;
|
|
||||||
onSubmit(businessName.trim(), fullAddress, category.trim(), normalizedUrl);
|
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -67,7 +60,6 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
|
|||||||
<span className="manual-modal-title">{t('landing.hero.manualModalTitle')}</span>
|
<span className="manual-modal-title">{t('landing.hero.manualModalTitle')}</span>
|
||||||
<button className="manual-modal-close" onClick={onClose}>✕</button>
|
<button className="manual-modal-close" onClick={onClose}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="manual-modal-subtitle">{t('landing.hero.manualModalSubtitle')}</p>
|
|
||||||
|
|
||||||
<div className="manual-modal-body">
|
<div className="manual-modal-body">
|
||||||
<div className="manual-modal-field">
|
<div className="manual-modal-field">
|
||||||
@ -111,32 +103,6 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="manual-modal-field">
|
|
||||||
<label className="manual-modal-label">{t('landing.hero.manualLabelCategory')}</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="manual-modal-input"
|
|
||||||
placeholder={t('landing.hero.manualPlaceholderCategory')}
|
|
||||||
value={category}
|
|
||||||
onChange={e => setCategory(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
maxLength={30}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="manual-modal-field">
|
|
||||||
<label className="manual-modal-label">{t('landing.hero.manualLabelSiteUrl')}</label>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
className="manual-modal-input"
|
|
||||||
placeholder={t('landing.hero.manualPlaceholderSiteUrl')}
|
|
||||||
value={officialSiteUrl}
|
|
||||||
onChange={e => setOfficialSiteUrl(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
maxLength={2048}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="manual-modal-actions">
|
<div className="manual-modal-actions">
|
||||||
<button type="button" className="manual-modal-cancel" onClick={onClose}>
|
<button type="button" className="manual-modal-cancel" onClick={onClose}>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
@ -147,7 +113,7 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!isValid}
|
disabled={!isValid}
|
||||||
>
|
>
|
||||||
{t('landing.hero.manualAnalyzeButton')}
|
{t('landing.hero.analyzeButton')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,107 +0,0 @@
|
|||||||
import React, { useRef, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { API_URL, toggleVideoLike } from '../utils/api';
|
|
||||||
import { buildVideoShareUrl, tryNativeShare } from '../utils/nativeShare';
|
|
||||||
|
|
||||||
interface ContentCardSocialActionsProps {
|
|
||||||
videoId: number;
|
|
||||||
storeName: string;
|
|
||||||
region?: string;
|
|
||||||
title?: string | null;
|
|
||||||
commentCount: number;
|
|
||||||
initialLikeCount: number;
|
|
||||||
initialIsLiked?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
|
|
||||||
videoId,
|
|
||||||
storeName,
|
|
||||||
title,
|
|
||||||
commentCount,
|
|
||||||
initialLikeCount,
|
|
||||||
initialIsLiked = false,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const [likeCount, setLikeCount] = useState(initialLikeCount);
|
|
||||||
const [isLiked, setIsLiked] = useState(initialIsLiked);
|
|
||||||
const likeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
const shareUrl = buildVideoShareUrl(API_URL, videoId);
|
|
||||||
const shareTitle = title || storeName || t('videoDetail.kakaoDefaultTitle');
|
|
||||||
|
|
||||||
const handleShareBtnClick = async (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
const handled = await tryNativeShare({
|
|
||||||
title: shareTitle,
|
|
||||||
url: shareUrl,
|
|
||||||
});
|
|
||||||
if (handled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(shareUrl);
|
|
||||||
} catch {
|
|
||||||
// clipboard API 미지원 환경에서는 무시
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLikeClick = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
const prevLiked = isLiked;
|
|
||||||
const prevCount = likeCount;
|
|
||||||
|
|
||||||
setIsLiked(!prevLiked);
|
|
||||||
setLikeCount(prevLiked ? prevCount - 1 : prevCount + 1);
|
|
||||||
|
|
||||||
if (likeDebounceRef.current) clearTimeout(likeDebounceRef.current);
|
|
||||||
likeDebounceRef.current = setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
const res = await toggleVideoLike(String(videoId));
|
|
||||||
setIsLiked(res.is_liked);
|
|
||||||
setLikeCount(res.like_count);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to toggle like:', err);
|
|
||||||
setIsLiked(prevLiked);
|
|
||||||
setLikeCount(prevCount);
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="content-card-social" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<button
|
|
||||||
className={`content-card-like-btn ${isLiked ? 'liked' : ''}`}
|
|
||||||
onClick={handleLikeClick}
|
|
||||||
>
|
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill={isLiked ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
|
||||||
</svg>
|
|
||||||
{likeCount}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<span className="content-card-comment">
|
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
|
||||||
</svg>
|
|
||||||
{commentCount}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="content-card-share-btn"
|
|
||||||
onClick={handleShareBtnClick}
|
|
||||||
title={t('videoDetail.share')}
|
|
||||||
>
|
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" />
|
|
||||||
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" /><line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ContentCardSocialActions;
|
|
||||||
@ -11,9 +11,9 @@ const Footer: React.FC = () => {
|
|||||||
<img src="/assets/images/ado2-sidebar-logo.svg" alt="ADO2" className="footer-logo" />
|
<img src="/assets/images/ado2-sidebar-logo.svg" alt="ADO2" className="footer-logo" />
|
||||||
<p className="footer-copyright">Copyright ⓒ O2O Inc. All rights reserved</p>
|
<p className="footer-copyright">Copyright ⓒ O2O Inc. All rights reserved</p>
|
||||||
<div className="footer-links">
|
<div className="footer-links">
|
||||||
<a href="https://ado2.o2osolution.ai/privacy.html" rel="noopener noreferrer" className="footer-link">{t('footer.privacyPolicy')}</a>
|
<a href="https://demo.castad.net/privacy.html" rel="noopener noreferrer" className="footer-link">{t('footer.privacyPolicy')}</a>
|
||||||
<span className="footer-link-divider">|</span>
|
<span className="footer-link-divider">|</span>
|
||||||
<a href="https://ado2.o2osolution.ai/terms.html" rel="noopener noreferrer" className="footer-link">{t('footer.termsOfService')}</a>
|
<a href="https://demo.castad.net/terms.html" rel="noopener noreferrer" className="footer-link">{t('footer.termsOfService')}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="footer-right">
|
<div className="footer-right">
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { searchAccommodation, cleanSearchTitle, AccommodationSearchItem, AutocompleteRequest } from '../utils/api';
|
import { searchAccommodation, AccommodationSearchItem, AutocompleteRequest } from '../utils/api';
|
||||||
import { useSearchHistory } from './SearchHistory/useSearchHistory';
|
import { useSearchHistory } from './SearchHistory/useSearchHistory';
|
||||||
import SearchHistoryDropdown from './SearchHistory/SearchHistoryDropdown';
|
import SearchHistoryDropdown from './SearchHistory/SearchHistoryDropdown';
|
||||||
import BusinessNameInputModal from './BusinessNameInputModal';
|
import BusinessNameInputModal from './BusinessNameInputModal';
|
||||||
@ -18,29 +18,13 @@ const isValidUrl = (string: string): boolean => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// URL 형태로 판단되는 입력: 프로토콜/www. 시작 또는 도메인 형태(naver.me/xxx 등)
|
|
||||||
const isUrlLike = (value: string): boolean => {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
return /^(https?:\/\/|www\.)/i.test(trimmed)
|
|
||||||
|| /^[a-z0-9-]+(\.[a-z0-9-]+)+([/?#]|$)/i.test(trimmed);
|
|
||||||
};
|
|
||||||
|
|
||||||
const sanitizeUrlInput = (value: string): string => {
|
|
||||||
return value.replace(/[^\x00-\x7F]/g, '');
|
|
||||||
};
|
|
||||||
|
|
||||||
// 텍스트 안에 포함된 첫 번째 URL을 추출 (없으면 null)
|
|
||||||
const extractUrl = (text: string): string | null => {
|
|
||||||
const match = text.match(/(https?:\/\/|www\.)[^\s]+/i);
|
|
||||||
return match ? match[0] : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface SearchInputFormProps {
|
interface SearchInputFormProps {
|
||||||
onAnalyze?: (value: string, type: SearchType) => void;
|
onAnalyze?: (value: string, type: SearchType) => void;
|
||||||
onAutocomplete?: (data: AutocompleteRequest) => void;
|
onAutocomplete?: (data: AutocompleteRequest) => void;
|
||||||
onManualInput?: (businessName: string, address: string, category: string, officialSiteUrl?: string) => void;
|
onManualInput?: (businessName: string, address: string) => void;
|
||||||
/** 직접입력 버튼 클릭 시 기본 동작(모달 열기)을 대체합니다. 제공 시 모달을 직접 관리해야 합니다. */
|
/** 직접입력 버튼 클릭 시 기본 동작(모달 열기)을 대체합니다. 제공 시 모달을 직접 관리해야 합니다. */
|
||||||
onManualButtonClick?: () => void;
|
onManualButtonClick?: () => void;
|
||||||
|
onShortform?: () => void;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -49,11 +33,13 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
|
|||||||
onAutocomplete,
|
onAutocomplete,
|
||||||
onManualInput,
|
onManualInput,
|
||||||
onManualButtonClick,
|
onManualButtonClick,
|
||||||
|
onShortform,
|
||||||
error: externalError,
|
error: externalError,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [inputValue, setInputValue] = useState('');
|
const [inputValue, setInputValue] = useState('');
|
||||||
const [searchType] = useState<SearchType>('name');
|
const [searchType, setSearchType] = useState<SearchType>('name');
|
||||||
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
|
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
|
||||||
const [localError, setLocalError] = useState('');
|
const [localError, setLocalError] = useState('');
|
||||||
@ -63,12 +49,16 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
|
|||||||
const [selectedItem, setSelectedItem] = useState<AccommodationSearchItem | null>(null);
|
const [selectedItem, setSelectedItem] = useState<AccommodationSearchItem | null>(null);
|
||||||
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
|
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
|
||||||
|
|
||||||
const { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem } = useSearchHistory('name');
|
const { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem } = useSearchHistory(searchType === 'manual' ? 'name' : searchType);
|
||||||
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const autocompleteRef = useRef<HTMLDivElement>(null);
|
const autocompleteRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
}
|
||||||
if (autocompleteRef.current && !autocompleteRef.current.contains(event.target as Node)) {
|
if (autocompleteRef.current && !autocompleteRef.current.contains(event.target as Node)) {
|
||||||
setShowAutocomplete(false);
|
setShowAutocomplete(false);
|
||||||
}
|
}
|
||||||
@ -77,8 +67,20 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
|
|||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const searchTypeOptions = [
|
||||||
|
{ value: 'url' as SearchType, label: 'URL' },
|
||||||
|
{ value: 'name' as SearchType, label: t('landing.hero.searchTypeBusinessName') },
|
||||||
|
{ value: 'manual' as SearchType, label: t('landing.hero.searchTypeManual') },
|
||||||
|
];
|
||||||
|
|
||||||
|
const getPlaceholder = () =>
|
||||||
|
searchType === 'url' ? 'https://naver.me/abcdef' : t('landing.hero.placeholderBusinessName');
|
||||||
|
|
||||||
|
const getGuideText = () =>
|
||||||
|
searchType === 'url' ? t('landing.hero.guideUrl') : t('landing.hero.guideBusinessName');
|
||||||
|
|
||||||
const handleAutocompleteSearch = useCallback(async (query: string) => {
|
const handleAutocompleteSearch = useCallback(async (query: string) => {
|
||||||
if (!query.trim()) {
|
if (!query.trim() || searchType !== 'name') {
|
||||||
setAutocompleteResults([]);
|
setAutocompleteResults([]);
|
||||||
setShowAutocomplete(false);
|
setShowAutocomplete(false);
|
||||||
return;
|
return;
|
||||||
@ -94,12 +96,11 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsAutocompleteLoading(false);
|
setIsAutocompleteLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [searchType]);
|
||||||
|
|
||||||
const handleSelectAutocomplete = (item: AccommodationSearchItem) => {
|
const handleSelectAutocomplete = (item: AccommodationSearchItem) => {
|
||||||
const cleanTitle = cleanSearchTitle(item.title);
|
setInputValue(item.title.replace(/<[^>]*>/g, ''));
|
||||||
setInputValue(cleanTitle);
|
setSelectedItem(item);
|
||||||
setSelectedItem({ ...item, title: cleanTitle });
|
|
||||||
setShowAutocomplete(false);
|
setShowAutocomplete(false);
|
||||||
setAutocompleteResults([]);
|
setAutocompleteResults([]);
|
||||||
setHighlightedIndex(-1);
|
setHighlightedIndex(-1);
|
||||||
@ -135,20 +136,18 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleStart = () => {
|
const handleStart = () => {
|
||||||
const trimmed = inputValue.trim();
|
if (!inputValue.trim()) {
|
||||||
if (!trimmed) {
|
setLocalError(searchType === 'url' ? t('landing.hero.errorUrlRequired') : t('landing.hero.errorNameRequired'));
|
||||||
setLocalError(t('landing.hero.errorInputRequired'));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isValidUrl(trimmed)) {
|
if (searchType === 'url' && !isValidUrl(inputValue.trim())) {
|
||||||
setLocalError('');
|
setLocalError(t('landing.hero.errorInvalidUrl'));
|
||||||
onAnalyze?.(trimmed, 'url');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
|
|
||||||
if (selectedItem && onAutocomplete) {
|
if (searchType === 'name' && selectedItem && onAutocomplete) {
|
||||||
onAutocomplete({
|
onAutocomplete({
|
||||||
address: selectedItem.address,
|
address: selectedItem.address,
|
||||||
roadAddress: selectedItem.roadAddress,
|
roadAddress: selectedItem.roadAddress,
|
||||||
@ -159,67 +158,6 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyInputChange = (value: string) => {
|
|
||||||
setInputValue(value);
|
|
||||||
setSelectedItem(null);
|
|
||||||
setHighlightedIndex(-1);
|
|
||||||
hideOnInput(value);
|
|
||||||
if (localError) setLocalError('');
|
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
||||||
if (isValidUrl(value)) {
|
|
||||||
setAutocompleteResults([]);
|
|
||||||
setShowAutocomplete(false);
|
|
||||||
} else {
|
|
||||||
debounceRef.current = setTimeout(() => handleAutocompleteSearch(value), 300);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 모바일 키보드 클립보드 추천 등 paste 이벤트 없이 여러 글자가 한 번에 들어오는 경우 처리
|
|
||||||
const handleChange = (value: string) => {
|
|
||||||
const isBulkInsert = value.length - inputValue.length > 1;
|
|
||||||
if (isBulkInsert) {
|
|
||||||
const extracted = extractUrl(value);
|
|
||||||
if (extracted && extracted !== value.trim()) {
|
|
||||||
applyInputChange(sanitizeUrlInput(extracted));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isUrlLike(value)) {
|
|
||||||
const sanitized = sanitizeUrlInput(value);
|
|
||||||
if (sanitized !== value) {
|
|
||||||
applyInputChange(sanitized);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
applyInputChange(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
|
||||||
const pastedText = e.clipboardData.getData('text');
|
|
||||||
|
|
||||||
// 붙여넣은 텍스트에 URL이 포함되어 있고 URL 외 다른 텍스트도 있으면 URL만 추출해서 입력창에 넣기
|
|
||||||
const extracted = extractUrl(pastedText);
|
|
||||||
if (extracted && extracted !== pastedText.trim()) {
|
|
||||||
e.preventDefault();
|
|
||||||
applyInputChange(sanitizeUrlInput(extracted));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const input = e.currentTarget;
|
|
||||||
const start = input.selectionStart ?? inputValue.length;
|
|
||||||
const end = input.selectionEnd ?? inputValue.length;
|
|
||||||
const merged = inputValue.slice(0, start) + pastedText + inputValue.slice(end);
|
|
||||||
|
|
||||||
// 붙여넣은 후 완성될 전체 값이 URL 형태일 때만 비영문 문자 제거
|
|
||||||
if (!isUrlLike(merged)) return;
|
|
||||||
|
|
||||||
const sanitized = sanitizeUrlInput(merged);
|
|
||||||
if (sanitized === merged) return;
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
applyInputChange(sanitized);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleManualButtonClick = () => {
|
const handleManualButtonClick = () => {
|
||||||
if (onManualButtonClick) {
|
if (onManualButtonClick) {
|
||||||
onManualButtonClick();
|
onManualButtonClick();
|
||||||
@ -233,30 +171,88 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className="hero-form">
|
<div className="hero-form">
|
||||||
<div className={`hero-input-wrapper ${isFocused ? 'focused' : ''} ${error ? 'error' : ''} ${inputValue && !isFocused ? 'filled' : ''}`}>
|
<div className={`hero-input-wrapper ${isFocused ? 'focused' : ''} ${error ? 'error' : ''} ${inputValue && !isFocused ? 'filled' : ''}`}>
|
||||||
{/* 유형 라벨 */}
|
{/* 드롭다운 */}
|
||||||
{/* <div className="hero-dropdown-container">
|
<div className="hero-dropdown-container" ref={dropdownRef}>
|
||||||
<div className="hero-dropdown-trigger">
|
<button
|
||||||
<span className="hero-dropdown-main">
|
type="button"
|
||||||
{t('landing.hero.searchTypeLabel')}
|
className="hero-dropdown-trigger"
|
||||||
</span>
|
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||||
|
>
|
||||||
|
<span>{searchTypeOptions.find(opt => opt.value === searchType)?.label}</span>
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
className={`hero-dropdown-arrow ${isDropdownOpen ? 'open' : ''}`}
|
||||||
|
>
|
||||||
|
<path d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{isDropdownOpen && (
|
||||||
|
<div className="hero-dropdown-menu">
|
||||||
|
{searchTypeOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`hero-dropdown-item ${searchType === option.value ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
if (option.value === 'manual') {
|
||||||
|
handleManualButtonClick();
|
||||||
|
} else {
|
||||||
|
setSearchType(option.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div> */}
|
|
||||||
|
|
||||||
{/* 입력 필드 */}
|
{/* 입력 필드 */}
|
||||||
<div className="hero-input-container" ref={autocompleteRef}>
|
<div className="hero-input-container" ref={autocompleteRef}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(e) => handleChange(e.target.value)}
|
onChange={(e) => {
|
||||||
onPaste={handlePaste}
|
let value = e.target.value;
|
||||||
|
if (searchType === 'url') {
|
||||||
|
const urlMatch = value.match(/https?:\/\/\S+/);
|
||||||
|
if (urlMatch && urlMatch[0] !== value) value = urlMatch[0];
|
||||||
|
}
|
||||||
|
setInputValue(value);
|
||||||
|
setSelectedItem(null);
|
||||||
|
setHighlightedIndex(-1);
|
||||||
|
hideOnInput(value);
|
||||||
|
if (localError) setLocalError('');
|
||||||
|
if (searchType === 'name') {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(() => handleAutocompleteSearch(value), 300);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPaste={(e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||||
|
if (searchType !== 'url') return;
|
||||||
|
const pasted = e.clipboardData.getData('text');
|
||||||
|
const urlMatch = pasted.match(/https?:\/\/[^\s]+/);
|
||||||
|
if (urlMatch) {
|
||||||
|
e.preventDefault();
|
||||||
|
setInputValue(urlMatch[0]);
|
||||||
|
if (localError) setLocalError('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
setIsFocused(true);
|
setIsFocused(true);
|
||||||
if (autocompleteResults.length > 0) setShowAutocomplete(true);
|
if (searchType === 'name' && autocompleteResults.length > 0) setShowAutocomplete(true);
|
||||||
openHistory(inputValue);
|
openHistory(inputValue);
|
||||||
}}
|
}}
|
||||||
onBlur={() => { setIsFocused(false); setTimeout(() => closeHistory(), 150); }}
|
onBlur={() => { setIsFocused(false); setTimeout(() => closeHistory(), 150); }}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={t('landing.hero.placeholderCombined')}
|
placeholder={getPlaceholder()}
|
||||||
className={`hero-input ${inputValue ? 'has-value' : ''}`}
|
className={`hero-input ${inputValue ? 'has-value' : ''}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -266,8 +262,12 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
|
|||||||
items={filteredHistory}
|
items={filteredHistory}
|
||||||
onSelect={(item) => {
|
onSelect={(item) => {
|
||||||
closeHistory();
|
closeHistory();
|
||||||
|
if (item.type === 'url') {
|
||||||
|
setSearchType('url');
|
||||||
|
setInputValue(item.value);
|
||||||
|
} else {
|
||||||
|
setSearchType('name');
|
||||||
setInputValue(item.value);
|
setInputValue(item.value);
|
||||||
if (item.type === 'name') {
|
|
||||||
setSelectedItem({ title: item.value, address: item.address || '', roadAddress: item.roadAddress || '' });
|
setSelectedItem({ title: item.value, address: item.address || '', roadAddress: item.roadAddress || '' });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@ -280,7 +280,7 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 자동완성 결과 */}
|
{/* 자동완성 결과 */}
|
||||||
{showAutocomplete && (
|
{showAutocomplete && searchType === 'name' && (
|
||||||
<div className="hero-autocomplete-dropdown">
|
<div className="hero-autocomplete-dropdown">
|
||||||
{isAutocompleteLoading ? (
|
{isAutocompleteLoading ? (
|
||||||
<div className="hero-autocomplete-loading">{t('landing.hero.searching')}</div>
|
<div className="hero-autocomplete-loading">{t('landing.hero.searching')}</div>
|
||||||
@ -320,7 +320,7 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="hero-input-hint">{t('landing.hero.guideCombined')}</span>
|
<span className="hero-input-hint">{getGuideText()}</span>
|
||||||
|
|
||||||
{error && <p className="hero-error">{error}</p>}
|
{error && <p className="hero-error">{error}</p>}
|
||||||
|
|
||||||
@ -328,21 +328,27 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
|
|||||||
{t('landing.hero.analyzeButton')}
|
{t('landing.hero.analyzeButton')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="hero-divider"><span>또는</span></div>
|
{onShortform && (
|
||||||
|
<button
|
||||||
<div className="hero-manual-card" onClick={handleManualButtonClick} role="button" tabIndex={0}
|
type="button"
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleManualButtonClick()}>
|
className="url-input-shortform-btn"
|
||||||
<span className="hero-manual-card-title">{t('landing.hero.searchTypeManual')}</span>
|
onClick={onShortform}
|
||||||
<p className="hero-manual-button-desc">{t('landing.hero.manualButtonDesc')}</p>
|
>
|
||||||
</div>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polygon points="23 7 16 12 23 17 23 7" />
|
||||||
|
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
|
||||||
|
</svg>
|
||||||
|
숏폼 영상 만들기
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* onManualButtonClick 미제공 시 모달을 내부에서 관리 */}
|
{/* onManualButtonClick 미제공 시 모달을 내부에서 관리 */}
|
||||||
{!onManualButtonClick && isManualModalOpen && (
|
{!onManualButtonClick && isManualModalOpen && (
|
||||||
<BusinessNameInputModal
|
<BusinessNameInputModal
|
||||||
onClose={() => setIsManualModalOpen(false)}
|
onClose={() => setIsManualModalOpen(false)}
|
||||||
onSubmit={(businessName, address, category, officialSiteUrl) => {
|
onSubmit={(businessName, address) => {
|
||||||
setIsManualModalOpen(false);
|
setIsManualModalOpen(false);
|
||||||
onManualInput?.(businessName, address, category, officialSiteUrl);
|
onManualInput?.(businessName, address);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -142,7 +142,10 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||||||
const channelDropdownRef = useRef<HTMLDivElement>(null);
|
const channelDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const privacyDropdownRef = useRef<HTMLDivElement>(null);
|
const privacyDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const hasBeenOpenedRef = useRef(false);
|
const hasBeenOpenedRef = useRef(false);
|
||||||
const loadedForVideoIdRef = useRef<number | null>(null);
|
const loadedForTaskIdRef = useRef<string | null>(null);
|
||||||
|
const loadedAtRef = useRef<number>(0);
|
||||||
|
const seoCache = useRef<Map<string, { title: string; description: string; tags: string }>>(new Map());
|
||||||
|
const SEO_CACHE_TTL = 50 * 60 * 1000;
|
||||||
|
|
||||||
// Upload progress modal state
|
// Upload progress modal state
|
||||||
const [showUploadProgress, setShowUploadProgress] = useState(false);
|
const [showUploadProgress, setShowUploadProgress] = useState(false);
|
||||||
@ -209,30 +212,28 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||||||
|
|
||||||
// 소셜 계정 로드
|
// 소셜 계정 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) return;
|
||||||
loadedForVideoIdRef.current = null;
|
|
||||||
return;
|
const now = Date.now();
|
||||||
}
|
|
||||||
|
|
||||||
loadSocialAccounts();
|
loadSocialAccounts();
|
||||||
|
|
||||||
const videoId = video?.video_id ?? null;
|
const taskId = video?.task_id ?? null;
|
||||||
if (!videoId || videoId === loadedForVideoIdRef.current) {
|
const expired = now - loadedAtRef.current > SEO_CACHE_TTL;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadedForVideoIdRef.current = videoId;
|
|
||||||
|
|
||||||
if (video?.title) {
|
|
||||||
setTitle(video.title);
|
|
||||||
setDescription(video.description || '');
|
|
||||||
setTags((video.hashtags || []).join(','));
|
|
||||||
setIsLoadingAutoDescription(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (taskId && (taskId !== loadedForTaskIdRef.current || expired)) {
|
||||||
|
loadedForTaskIdRef.current = taskId;
|
||||||
|
loadedAtRef.current = now;
|
||||||
loadAutocomplete();
|
loadAutocomplete();
|
||||||
}, [isOpen, video?.video_id, video?.title, video?.description, video?.hashtags]);
|
} else if (taskId) {
|
||||||
|
const cached = seoCache.current.get(taskId);
|
||||||
|
if (cached) {
|
||||||
|
setTitle(cached.title);
|
||||||
|
setDescription(cached.description);
|
||||||
|
setTags(cached.tags);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isOpen, video?.task_id]);
|
||||||
|
|
||||||
const loadSocialAccounts = async () => {
|
const loadSocialAccounts = async () => {
|
||||||
setIsLoadingAccounts(true);
|
setIsLoadingAccounts(true);
|
||||||
@ -258,20 +259,29 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const loadAutocomplete = async () => {
|
const loadAutocomplete = async () => {
|
||||||
if (!video?.video_id) return;
|
if (!video?.task_id) return;
|
||||||
|
|
||||||
setIsLoadingAutoDescription(true);
|
setIsLoadingAutoDescription(true);
|
||||||
try {
|
try {
|
||||||
const requestPayload = {
|
const requestPayload = {
|
||||||
video_id: video.video_id,
|
task_id : video.task_id,
|
||||||
};
|
};
|
||||||
|
// Call autoSEO API
|
||||||
|
console.log('[Upload] Request payload:', requestPayload);
|
||||||
const autoSeoResponse = await getAutoSeoYoutube(requestPayload);
|
const autoSeoResponse = await getAutoSeoYoutube(requestPayload);
|
||||||
|
|
||||||
|
// 각 필드가 있을 때만 덮어씌움 (기존 값 보호)
|
||||||
if (autoSeoResponse.title) setTitle(autoSeoResponse.title);
|
if (autoSeoResponse.title) setTitle(autoSeoResponse.title);
|
||||||
if (autoSeoResponse.description) setDescription(autoSeoResponse.description);
|
if (autoSeoResponse.description) setDescription(autoSeoResponse.description);
|
||||||
if (autoSeoResponse.keywords) setTags(autoSeoResponse.keywords.join(','));
|
if (autoSeoResponse.keywords) setTags(autoSeoResponse.keywords.join(','));
|
||||||
|
seoCache.current.set(video.task_id, {
|
||||||
|
title: autoSeoResponse.title || '',
|
||||||
|
description: autoSeoResponse.description || '',
|
||||||
|
tags: autoSeoResponse.keywords?.join(',') || '',
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load autocomplete:', error);
|
console.error('Failed to load autocomplete:', error);
|
||||||
|
// 실패해도 사용자에게 별도 알림 없이 조용히 처리
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingAutoDescription(false);
|
setIsLoadingAutoDescription(false);
|
||||||
}
|
}
|
||||||
@ -370,7 +380,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||||||
} else {
|
} else {
|
||||||
// 즉시 업로드: 완료될 때까지 폴링
|
// 즉시 업로드: 완료될 때까지 폴링
|
||||||
const result = await waitForUploadComplete(
|
const result = await waitForUploadComplete(
|
||||||
uploadResponse.upload_id.toString(),
|
uploadResponse.upload_id,
|
||||||
(status, progress) => {
|
(status, progress) => {
|
||||||
setUploadStatus(status as UploadStatus);
|
setUploadStatus(status as UploadStatus);
|
||||||
setUploadProgress(progress || 0);
|
setUploadProgress(progress || 0);
|
||||||
|
|||||||
@ -45,35 +45,7 @@ export const TUTORIAL_PAGE_GROUPS: string[][] = [
|
|||||||
export const tutorialSteps: TutorialStepDef[] = [
|
export const tutorialSteps: TutorialStepDef[] = [
|
||||||
{
|
{
|
||||||
key: TUTORIAL_KEYS.LANDING,
|
key: TUTORIAL_KEYS.LANDING,
|
||||||
hints: [
|
hints: [],
|
||||||
{
|
|
||||||
targetSelector: '.hero-input-wrapper',
|
|
||||||
titleKey: 'tutorial.landing.field.title',
|
|
||||||
descriptionKey: 'tutorial.landing.field.desc',
|
|
||||||
position: 'top',
|
|
||||||
clickToAdvance: false,
|
|
||||||
noSpotlight: true,
|
|
||||||
variant: 'bubble',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
targetSelector: '.hero-manual-card-title',
|
|
||||||
titleKey: 'tutorial.landing.manual.title',
|
|
||||||
descriptionKey: 'tutorial.landing.manual.desc',
|
|
||||||
position: 'top',
|
|
||||||
clickToAdvance: false,
|
|
||||||
noSpotlight: true,
|
|
||||||
variant: 'bubble',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
targetSelector: '.hero-button',
|
|
||||||
titleKey: 'tutorial.landing.button.title',
|
|
||||||
descriptionKey: 'tutorial.landing.button.desc',
|
|
||||||
position: 'bottom',
|
|
||||||
clickToAdvance: true,
|
|
||||||
noSpotlight: true,
|
|
||||||
variant: 'bubble',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: TUTORIAL_KEYS.ASSET,
|
key: TUTORIAL_KEYS.ASSET,
|
||||||
|
|||||||
@ -7,11 +7,8 @@ import {
|
|||||||
deleteComment,
|
deleteComment,
|
||||||
toggleVideoLike,
|
toggleVideoLike,
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
getUserMe,
|
|
||||||
API_URL,
|
|
||||||
} from '../utils/api';
|
} from '../utils/api';
|
||||||
import { VideoDetailItem, CommentItem, UserMeResponse } from '../types/api';
|
import { VideoDetailItem, CommentItem } from '../types/api';
|
||||||
import { buildVideoShareUrl, tryNativeShare } from '../utils/nativeShare';
|
|
||||||
import LoginPromptModal from './LoginPromptModal';
|
import LoginPromptModal from './LoginPromptModal';
|
||||||
|
|
||||||
interface VideoDetailContentProps {
|
interface VideoDetailContentProps {
|
||||||
@ -32,8 +29,8 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
|||||||
const [isLiked, setIsLiked] = useState(false);
|
const [isLiked, setIsLiked] = useState(false);
|
||||||
|
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [shareMenuOpen, setShareMenuOpen] = useState(false);
|
||||||
const [isLandscape, setIsLandscape] = useState(false);
|
const [isLandscape, setIsLandscape] = useState(false);
|
||||||
const [showSiteOverlay, setShowSiteOverlay] = useState(false);
|
|
||||||
const [showLoginModal, setShowLoginModal] = useState(false);
|
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||||
|
|
||||||
const [comments, setComments] = useState<CommentItem[]>([]);
|
const [comments, setComments] = useState<CommentItem[]>([]);
|
||||||
@ -45,7 +42,16 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
|||||||
const [commentSubmitting, setCommentSubmitting] = useState(false);
|
const [commentSubmitting, setCommentSubmitting] = useState(false);
|
||||||
const commentTextareaRef = useRef<HTMLTextAreaElement>(null);
|
const commentTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
const [currentUser, setCurrentUser] = useState<UserMeResponse | null>(null);
|
const [commentNickname, setCommentNickname] = useState('');
|
||||||
|
const [commentAvatarSeedIdx, setCommentAvatarSeedIdx] = useState(0);
|
||||||
|
|
||||||
|
// 고정 seed 목록: 브라우저가 캐싱하여 중복 요청 없음
|
||||||
|
const AVATAR_SEEDS = ['42', '77', '123', '256', '512', '888', '1024', '2048', '3141', '9999'];
|
||||||
|
const commentAvatarSeed = AVATAR_SEEDS[commentAvatarSeedIdx % AVATAR_SEEDS.length];
|
||||||
|
|
||||||
|
const handleChangeAvatar = useCallback(() => {
|
||||||
|
setCommentAvatarSeedIdx(prev => (prev + 1) % AVATAR_SEEDS.length);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const fetchComments = useCallback(async (page: number, append = false) => {
|
const fetchComments = useCallback(async (page: number, append = false) => {
|
||||||
setCommentsLoading(true);
|
setCommentsLoading(true);
|
||||||
@ -69,7 +75,6 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
|||||||
const fetchVideo = async () => {
|
const fetchVideo = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setShowSiteOverlay(false);
|
|
||||||
try {
|
try {
|
||||||
const data = await getVideoById(videoId);
|
const data = await getVideoById(videoId);
|
||||||
setVideo(data);
|
setVideo(data);
|
||||||
@ -87,16 +92,9 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
|||||||
fetchComments(1);
|
fetchComments(1);
|
||||||
}, [videoId, fetchComments]);
|
}, [videoId, fetchComments]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!authed) return;
|
|
||||||
getUserMe().then(setCurrentUser).catch((err) => {
|
|
||||||
console.error('Failed to fetch current user:', err);
|
|
||||||
});
|
|
||||||
}, [authed]);
|
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
return t('videoDetail.dateFormat', { year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate() });
|
return `${date.getFullYear()}년 ${date.getMonth() + 1}월 ${date.getDate()}일`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatCommentDate = (dateString: string) => {
|
const formatCommentDate = (dateString: string) => {
|
||||||
@ -104,8 +102,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
|||||||
return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`;
|
return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const shareUrl = buildVideoShareUrl(API_URL, videoId);
|
const shareUrl = `${window.location.origin}/video/${videoId}`;
|
||||||
const shareTitle = video?.title || video?.store_name || t('videoDetail.kakaoDefaultTitle');
|
|
||||||
|
|
||||||
const handleCopyLink = async () => {
|
const handleCopyLink = async () => {
|
||||||
try {
|
try {
|
||||||
@ -117,17 +114,50 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
|||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleShareButtonClick = async () => {
|
const handleKakaoShare = () => {
|
||||||
const handled = await tryNativeShare({
|
const kakao = window.Kakao;
|
||||||
title: shareTitle,
|
if (kakao?.Share) {
|
||||||
url: shareUrl,
|
kakao.Share.sendDefault({
|
||||||
|
objectType: 'feed',
|
||||||
|
content: {
|
||||||
|
title: video?.store_name ?? 'ADO2 영상',
|
||||||
|
description: `${video?.region ?? ''} · ADO2 AI 마케팅 영상`,
|
||||||
|
imageUrl: 'https://demo.castad.net/favicon_48.svg',
|
||||||
|
link: { mobileWebUrl: shareUrl, webUrl: shareUrl },
|
||||||
|
},
|
||||||
|
buttons: [{ title: '영상 보기', link: { mobileWebUrl: shareUrl, webUrl: shareUrl } }],
|
||||||
});
|
});
|
||||||
if (handled) {
|
} else if (navigator.share) {
|
||||||
return;
|
navigator.share({ url: shareUrl }).catch(() => {});
|
||||||
|
} else {
|
||||||
|
handleCopyLink();
|
||||||
}
|
}
|
||||||
await handleCopyLink();
|
setShareMenuOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFacebookShare = () => {
|
||||||
|
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,width=600,height=600');
|
||||||
|
setShareMenuOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTwitterShare = () => {
|
||||||
|
window.open(`https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,width=600,height=600');
|
||||||
|
setShareMenuOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const shareMenuRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shareMenuOpen) return;
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (shareMenuRef.current && !shareMenuRef.current.contains(e.target as Node)) {
|
||||||
|
setShareMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, [shareMenuOpen]);
|
||||||
|
|
||||||
const likeDebounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
const likeDebounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const handleLike = () => {
|
const handleLike = () => {
|
||||||
@ -167,11 +197,13 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
|||||||
if (!commentInput.trim() || commentSubmitting) return;
|
if (!commentInput.trim() || commentSubmitting) return;
|
||||||
setCommentSubmitting(true);
|
setCommentSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await postVideoComment(videoId, commentInput.trim());
|
await postVideoComment(videoId, commentInput.trim(), commentNickname);
|
||||||
setCommentInput('');
|
setCommentInput('');
|
||||||
if (commentTextareaRef.current) {
|
if (commentTextareaRef.current) {
|
||||||
commentTextareaRef.current.style.height = 'auto';
|
commentTextareaRef.current.style.height = 'auto';
|
||||||
}
|
}
|
||||||
|
setCommentNickname('');
|
||||||
|
setCommentAvatarSeedIdx(prev => (prev + 1) % AVATAR_SEEDS.length);
|
||||||
await fetchComments(1);
|
await fetchComments(1);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to post comment:', err);
|
console.error('Failed to post comment:', err);
|
||||||
@ -190,7 +222,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderCommentContent = (content: string | null, isDeleted: boolean) => {
|
const renderCommentContent = (content: string | null, isDeleted: boolean) => {
|
||||||
if (isDeleted) return <span style={{ color: '#6B9EA0', fontStyle: 'italic' }}>{t('videoDetail.deletedComment')}</span>;
|
if (isDeleted) return <span style={{ color: '#6B9EA0', fontStyle: 'italic' }}>(삭제된 댓글입니다.)</span>;
|
||||||
return content;
|
return content;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -199,7 +231,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
|||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="video-detail-header">
|
<div className="video-detail-header">
|
||||||
{isModal ? (
|
{isModal ? (
|
||||||
<button className="video-detail-close-btn" onClick={handleHeaderAction} aria-label={t('videoDetail.closeAriaLabel')}>
|
<button className="video-detail-close-btn" onClick={handleHeaderAction} aria-label="닫기">
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
</svg>
|
</svg>
|
||||||
@ -223,7 +255,6 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
|||||||
<div className="ado2-contents-error"><p>{error}</p></div>
|
<div className="ado2-contents-error"><p>{error}</p></div>
|
||||||
) : video ? (
|
) : video ? (
|
||||||
<div className={`video-detail-content ${isLandscape ? 'landscape' : ''}`}>
|
<div className={`video-detail-content ${isLandscape ? 'landscape' : ''}`}>
|
||||||
<div className="video-detail-player-wrap">
|
|
||||||
<video
|
<video
|
||||||
src={video.result_movie_url}
|
src={video.result_movie_url}
|
||||||
controls
|
controls
|
||||||
@ -235,34 +266,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
|||||||
const v = e.currentTarget;
|
const v = e.currentTarget;
|
||||||
setIsLandscape(v.videoWidth > v.videoHeight);
|
setIsLandscape(v.videoWidth > v.videoHeight);
|
||||||
}}
|
}}
|
||||||
onTimeUpdate={(e) => {
|
|
||||||
if (!video.official_site_url) return;
|
|
||||||
const v = e.currentTarget;
|
|
||||||
// 영상 종료 3초 전부터 공식 홈페이지 오버레이 노출
|
|
||||||
setShowSiteOverlay(
|
|
||||||
Number.isFinite(v.duration) && v.duration - v.currentTime <= 3
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{video.official_site_url && showSiteOverlay && (
|
|
||||||
<a
|
|
||||||
href={video.official_site_url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="video-site-overlay"
|
|
||||||
>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<circle cx="12" cy="12" r="10"/>
|
|
||||||
<line x1="2" y1="12" x2="22" y2="12"/>
|
|
||||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
|
||||||
</svg>
|
|
||||||
<span className="video-site-overlay-text">
|
|
||||||
<strong>{video.store_name}</strong>
|
|
||||||
{t('videoDetail.siteOverlayLabel')}
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="video-detail-info">
|
<div className="video-detail-info">
|
||||||
<h2 className="video-detail-store">{video.store_name}</h2>
|
<h2 className="video-detail-store">{video.store_name}</h2>
|
||||||
@ -280,37 +284,80 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
|||||||
</svg>
|
</svg>
|
||||||
{likeCount}
|
{likeCount}
|
||||||
</button>
|
</button>
|
||||||
|
<div style={{ position: 'relative' }} ref={shareMenuRef}>
|
||||||
<button
|
<button
|
||||||
className="video-detail-copy-btn"
|
className="video-detail-copy-btn"
|
||||||
onClick={handleShareButtonClick}
|
onClick={() => setShareMenuOpen(v => !v)}
|
||||||
title={t('videoDetail.share')}
|
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
|
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
|
||||||
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
|
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
|
||||||
</svg>
|
</svg>
|
||||||
{copied ? t('videoDetail.copied') : t('videoDetail.share')}
|
{copied ? '복사됨!' : '공유하기'}
|
||||||
</button>
|
</button>
|
||||||
|
{shareMenuOpen && (
|
||||||
|
<div className="video-detail-share-menu">
|
||||||
|
{/* 카카오톡 */}
|
||||||
|
<button className="video-detail-share-item" onClick={handleKakaoShare}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="20" height="20" rx="4" fill="#FEE500"/>
|
||||||
|
<path fillRule="evenodd" clipRule="evenodd" d="M10 3.5C6.134 3.5 3 6.01 3 9.1c0 1.98 1.2 3.72 3.01 4.76l-.74 2.75a.19.19 0 0 0 .28.21l3.37-2.23c.34.04.69.06 1.06.06 3.866 0 7-2.51 7-5.6S13.866 3.5 10 3.5z" fill="#3C1E1E"/>
|
||||||
|
</svg>
|
||||||
|
카카오톡
|
||||||
|
</button>
|
||||||
|
{/* 페이스북 */}
|
||||||
|
<button className="video-detail-share-item" onClick={handleFacebookShare}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="#1877F2">
|
||||||
|
<path d="M24 12.073C24 5.405 18.627 0 12 0S0 5.405 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.41c0-3.025 1.792-4.697 4.533-4.697 1.312 0 2.686.235 2.686.235v2.97h-1.513c-1.491 0-1.956.93-1.956 1.887v2.268h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z"/>
|
||||||
|
</svg>
|
||||||
|
페이스북
|
||||||
|
</button>
|
||||||
|
{/* X (트위터) */}
|
||||||
|
<button className="video-detail-share-item" onClick={handleTwitterShare}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
||||||
|
</svg>
|
||||||
|
X (트위터)
|
||||||
|
</button>
|
||||||
|
{/* URL 복사 */}
|
||||||
|
<button className="video-detail-share-item" onClick={() => { handleCopyLink(); setShareMenuOpen(false); }}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2"/>
|
||||||
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||||
|
</svg>
|
||||||
|
{copied ? '복사됨!' : 'URL 복사'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 댓글 섹션 */}
|
{/* 댓글 섹션 */}
|
||||||
<div className="video-detail-comments">
|
<div className="video-detail-comments">
|
||||||
<div className="video-detail-comments-header">
|
<div className="video-detail-comments-header">
|
||||||
<h3 className="video-detail-comments-title">{t('videoDetail.commentsTitle')}</h3>
|
<h3 className="video-detail-comments-title">댓글</h3>
|
||||||
<span className="video-detail-comments-count">{commentsTotal}</span>
|
<span className="video-detail-comments-count">{commentsTotal}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 댓글 작성자 프로필 (카카오 로그인 정보) */}
|
{/* 댓글 작성자 프로필 선택 */}
|
||||||
{authed && currentUser && (
|
{authed && (
|
||||||
<div className="video-detail-comment-profile">
|
<div className="video-detail-comment-profile">
|
||||||
{currentUser.profile_image_url && (
|
|
||||||
<img
|
<img
|
||||||
src={currentUser.profile_image_url}
|
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${commentAvatarSeed}`}
|
||||||
alt={currentUser.nickname}
|
alt="아바타 변경"
|
||||||
className="video-detail-comment-avatar"
|
className="video-detail-comment-avatar"
|
||||||
|
onClick={handleChangeAvatar}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
title="클릭하여 아바타 변경"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="video-detail-nickname-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="작성자 이름"
|
||||||
|
value={commentNickname}
|
||||||
|
onChange={(e) => setCommentNickname(e.target.value)}
|
||||||
|
maxLength={20}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
<span className="video-detail-comment-nickname">{currentUser.nickname}</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -318,7 +365,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
|||||||
<textarea
|
<textarea
|
||||||
ref={commentTextareaRef}
|
ref={commentTextareaRef}
|
||||||
className="video-detail-comment-input"
|
className="video-detail-comment-input"
|
||||||
placeholder={authed ? t('videoDetail.commentPlaceholder') : t('videoDetail.commentLoginRequired')}
|
placeholder={authed ? '댓글을 입력하세요...' : '로그인 후 댓글을 작성할 수 있습니다'}
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
rows={1}
|
rows={1}
|
||||||
value={commentInput}
|
value={commentInput}
|
||||||
@ -341,26 +388,24 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
|||||||
onClick={handleCommentSubmit}
|
onClick={handleCommentSubmit}
|
||||||
disabled={!authed || !commentInput.trim() || commentSubmitting}
|
disabled={!authed || !commentInput.trim() || commentSubmitting}
|
||||||
>
|
>
|
||||||
{commentSubmitting ? t('videoDetail.commentSubmitting') : t('videoDetail.commentSubmit')}
|
{commentSubmitting ? '작성 중' : '작성'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{comments.length === 0 && !commentsLoading ? (
|
{comments.length === 0 && !commentsLoading ? (
|
||||||
<p className="video-detail-comments-empty">{t('videoDetail.noComments')}</p>
|
<p className="video-detail-comments-empty">아직 댓글이 없습니다.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="video-detail-comment-list">
|
<ul className="video-detail-comment-list">
|
||||||
{comments.map((c) => (
|
{comments.map((c) => (
|
||||||
<li key={c.id} className="video-detail-comment-item">
|
<li key={c.id} className="video-detail-comment-item">
|
||||||
{c.profile_image_url && (
|
|
||||||
<img
|
<img
|
||||||
src={c.profile_image_url}
|
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${c.id}`}
|
||||||
alt={c.nickname || t('videoDetail.anonymous')}
|
alt="avatar"
|
||||||
className="video-detail-comment-avatar"
|
className="video-detail-comment-avatar"
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
<div className="video-detail-comment-body">
|
<div className="video-detail-comment-body">
|
||||||
<span className="video-detail-comment-nickname">
|
<span className="video-detail-comment-nickname">
|
||||||
{c.nickname || t('videoDetail.anonymous')}
|
{c.nickname || '익명'}
|
||||||
</span>
|
</span>
|
||||||
<p className="video-detail-comment-text">
|
<p className="video-detail-comment-text">
|
||||||
{renderCommentContent(c.content, c.is_deleted)}
|
{renderCommentContent(c.content, c.is_deleted)}
|
||||||
@ -372,7 +417,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
|||||||
className="video-detail-comment-delete"
|
className="video-detail-comment-delete"
|
||||||
onClick={() => handleDeleteComment(c.id)}
|
onClick={() => handleDeleteComment(c.id)}
|
||||||
>
|
>
|
||||||
{t('videoDetail.deleteComment')}
|
삭제
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -382,16 +427,14 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
|||||||
<ul className="video-detail-reply-list">
|
<ul className="video-detail-reply-list">
|
||||||
{c.replies.map((r) => (
|
{c.replies.map((r) => (
|
||||||
<li key={r.id} className="video-detail-reply-item">
|
<li key={r.id} className="video-detail-reply-item">
|
||||||
{r.profile_image_url && (
|
|
||||||
<img
|
<img
|
||||||
src={r.profile_image_url}
|
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${r.id}`}
|
||||||
alt={r.nickname || t('videoDetail.anonymous')}
|
alt="avatar"
|
||||||
className="video-detail-comment-avatar small"
|
className="video-detail-comment-avatar small"
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
<div className="video-detail-comment-body">
|
<div className="video-detail-comment-body">
|
||||||
<span className="video-detail-comment-nickname">
|
<span className="video-detail-comment-nickname">
|
||||||
{r.nickname || t('videoDetail.anonymous')}
|
{r.nickname || '익명'}
|
||||||
</span>
|
</span>
|
||||||
<p className="video-detail-comment-text">
|
<p className="video-detail-comment-text">
|
||||||
{renderCommentContent(r.content, r.is_deleted)}
|
{renderCommentContent(r.content, r.is_deleted)}
|
||||||
@ -403,7 +446,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
|||||||
className="video-detail-comment-delete"
|
className="video-detail-comment-delete"
|
||||||
onClick={() => handleDeleteComment(r.id)}
|
onClick={() => handleDeleteComment(r.id)}
|
||||||
>
|
>
|
||||||
{t('videoDetail.deleteComment')}
|
삭제
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -424,7 +467,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
|||||||
onClick={() => fetchComments(commentsPage + 1, true)}
|
onClick={() => fetchComments(commentsPage + 1, true)}
|
||||||
disabled={commentsLoading}
|
disabled={commentsLoading}
|
||||||
>
|
>
|
||||||
{commentsLoading ? t('videoDetail.loadingComments') : t('videoDetail.loadMoreComments')}
|
{commentsLoading ? '불러오는 중...' : '댓글 더 보기'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -41,7 +41,7 @@ const VideoDetailModal: React.FC<VideoDetailModalProps> = ({ videoId, onClose })
|
|||||||
background: '#01282A',
|
background: '#01282A',
|
||||||
borderRadius: '16px',
|
borderRadius: '16px',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxWidth: '800px',
|
maxWidth: '900px',
|
||||||
margin: 'auto',
|
margin: 'auto',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -1,53 +0,0 @@
|
|||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
interface WizardStepperProps {
|
|
||||||
currentStep: number; // 0~3, matching wizardStep in GenerationFlow
|
|
||||||
}
|
|
||||||
|
|
||||||
const WizardStepper: React.FC<WizardStepperProps> = ({ currentStep }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const steps = [
|
|
||||||
t('wizardSteps.brandAnalysis'),
|
|
||||||
t('wizardSteps.asset'),
|
|
||||||
t('wizardSteps.sound'),
|
|
||||||
t('wizardSteps.video'),
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="wizard-stepper">
|
|
||||||
{steps.map((label, index) => {
|
|
||||||
const isDone = index < currentStep;
|
|
||||||
const isCurrent = index === currentStep;
|
|
||||||
const stateClass = isDone ? ' done' : isCurrent ? ' current' : ' pending';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<React.Fragment key={index}>
|
|
||||||
{/* 노드: 2rem 고정 너비. 라벨은 absolute로 노드 중심 아래에 붙음 */}
|
|
||||||
<div className={`wizard-step${stateClass}`}>
|
|
||||||
<div className="wizard-stepper-node">
|
|
||||||
{isDone ? (
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
||||||
<path d="M2.5 7L5.5 10L11.5 4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<span>{index + 1}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="wizard-stepper-label">{label}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 연결선: flex:1로 남은 공간을 균등하게 채움 */}
|
|
||||||
{index < steps.length - 1 && (
|
|
||||||
<div className={`wizard-step-line${isDone ? ' done' : ''}`} />
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default WizardStepper;
|
|
||||||
@ -2,7 +2,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import './i18n';
|
import './i18n';
|
||||||
import './styles/index.css';
|
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@ -1,10 +1,4 @@
|
|||||||
{
|
{
|
||||||
"wizardSteps": {
|
|
||||||
"brandAnalysis": "Analysis",
|
|
||||||
"asset": "Image",
|
|
||||||
"sound": "Sound",
|
|
||||||
"video": "Video"
|
|
||||||
},
|
|
||||||
"header": {
|
"header": {
|
||||||
"loginFailedAlert": "Failed to get login URL. Please try again.",
|
"loginFailedAlert": "Failed to get login URL. Please try again.",
|
||||||
"start": "Get Started",
|
"start": "Get Started",
|
||||||
@ -34,7 +28,8 @@
|
|||||||
"finish": "Done",
|
"finish": "Done",
|
||||||
"landing": {
|
"landing": {
|
||||||
"intro": { "title": "Welcome to ADO2 Tutorial", "desc": "We'll guide you through ADO2 step by step." },
|
"intro": { "title": "Welcome to ADO2 Tutorial", "desc": "We'll guide you through ADO2 step by step." },
|
||||||
"field": { "title": "Enter Search Term", "desc": "Paste a Naver Maps share URL,\nor type a business name and select from the list." },
|
"dropdown": { "title": "Choose Search Type", "desc": "Select URL or business name from the dropdown." },
|
||||||
|
"field": { "title": "Enter Search Term", "desc": "For URL, paste a Naver Maps share URL.\nFor business name, type the name and select from the list." },
|
||||||
"manual": { "title": "Direct Input", "desc": "You can also enter the business name and address manually to start analysis." },
|
"manual": { "title": "Direct Input", "desc": "You can also enter the business name and address manually to start analysis." },
|
||||||
"button": { "title": "Start Brand Analysis", "desc": "Click the button to let AI start analyzing your brand." }
|
"button": { "title": "Start Brand Analysis", "desc": "Click the button to let AI start analyzing your brand." }
|
||||||
},
|
},
|
||||||
@ -47,18 +42,12 @@
|
|||||||
"sound": {
|
"sound": {
|
||||||
"genre": { "title": "Select Genre", "desc": "Pick a music genre that fits your brand.", "note": "Background music is coming soon." },
|
"genre": { "title": "Select Genre", "desc": "Pick a music genre that fits your brand.", "note": "Background music is coming soon." },
|
||||||
"language": { "title": "Select Language", "desc": "You can choose the language for the sound.\nWant to continue with Korean?" },
|
"language": { "title": "Select Language", "desc": "You can choose the language for the sound.\nWant to continue with Korean?" },
|
||||||
"generate": { "title": "Generate Sound", "desc": "Click the button and AI will generate lyrics and music." },
|
"generate": { "title": "Generate Sound", "desc": "Click the button and AI will generate lyrics and music in your chosen genre and language." },
|
||||||
"lyrics": { "title": "Lyrics Complete", "desc": "AI wrote lyrics in your selected language.\nCheck the generated lyrics." },
|
"lyrics": { "title": "Lyrics Complete", "desc": "AI wrote lyrics in your selected language.\nCheck the generated lyrics." },
|
||||||
"lyricsWait": { "title": "Generating Music", "desc": "AI is composing music based on the lyrics.\nPlease wait a moment." },
|
"lyricsWait": { "title": "Generating Music", "desc": "AI is composing music based on the lyrics.\nPlease wait a moment." },
|
||||||
"audioPlayer": { "title": "Preview the Music", "desc": "Music generation is complete.\nPress play to listen to the generated music." },
|
"audioPlayer": { "title": "Preview the Music", "desc": "Music generation is complete.\nPress play to listen to the generated music." },
|
||||||
"video": { "title": "Generate Video", "desc": "Click the button to start generating your video." }
|
"video": { "title": "Generate Video", "desc": "Click the button to start generating your video." }
|
||||||
},
|
},
|
||||||
"completion": {
|
|
||||||
"contentInfo": { "title": "Content Info", "desc": "Check the file name, genre, resolution, and lyrics of the generated content." },
|
|
||||||
"generating": { "title": "Generating Video", "desc": "AI is creating your video.\nPlease wait a moment." },
|
|
||||||
"completion": { "title": "Video Complete!", "desc": "Your video is ready. Want to take a look?" },
|
|
||||||
"myInfo": { "title": "Connect Social Account", "desc": "To upload your video to YouTube, connect your social account in My Info. Click to go there." }
|
|
||||||
},
|
|
||||||
"myInfo": {
|
"myInfo": {
|
||||||
"myInfo": { "title": "My Info", "desc": "In My Info, you can manage your social connections and view connected accounts." },
|
"myInfo": { "title": "My Info", "desc": "In My Info, you can manage your social connections and view connected accounts." },
|
||||||
"connect": { "title": "Connect Now", "desc": "Click the YouTube connect button to go to the connection page.", "note": "Instagram connection is coming soon." },
|
"connect": { "title": "Connect Now", "desc": "Click the YouTube connect button to go to the connection page.", "note": "Instagram connection is coming soon." },
|
||||||
@ -71,6 +60,12 @@
|
|||||||
"delete": { "title": "Delete", "desc": "Remove videos you no longer need." },
|
"delete": { "title": "Delete", "desc": "Remove videos you no longer need." },
|
||||||
"upload": { "title": "Upload to Social Media", "desc": "Select a video and upload it to social media." }
|
"upload": { "title": "Upload to Social Media", "desc": "Select a video and upload it to social media." }
|
||||||
},
|
},
|
||||||
|
"completion": {
|
||||||
|
"contentInfo": { "title": "Content Info", "desc": "Check the title, genre, resolution, and lyrics of the generated content." },
|
||||||
|
"generating": { "title": "Generating Video", "desc": "AI is creating your video.\nPlease wait a moment." },
|
||||||
|
"completion": { "title": "Video Complete!", "desc": "Your video is ready. Want to take a look?" },
|
||||||
|
"myInfo": { "title": "Connect Social Account", "desc": "To upload your video to YouTube, connect your social account in My Info. Click to go there." }
|
||||||
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
"seo": { "title": "Title & Description", "desc": "AI is generating the title and description for your video. Please wait a moment." },
|
"seo": { "title": "Title & Description", "desc": "AI is generating the title and description for your video. Please wait a moment." },
|
||||||
"required": { "title": "Required Fields", "desc": "Fields marked with * are required.\nPlease check them before uploading." },
|
"required": { "title": "Required Fields", "desc": "Fields marked with * are required.\nPlease check them before uploading." },
|
||||||
@ -128,7 +123,7 @@
|
|||||||
"privacyPrivate": "Private",
|
"privacyPrivate": "Private",
|
||||||
"publishTimeLabel": "Publish Time",
|
"publishTimeLabel": "Publish Time",
|
||||||
"publishNow": "Publish Now",
|
"publishNow": "Publish Now",
|
||||||
"publishSchedule": "Schedule",
|
"publishSchedule": "Schedule (Coming Soon)",
|
||||||
"footerNote": "",
|
"footerNote": "",
|
||||||
"footerNoteLink": "",
|
"footerNoteLink": "",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
@ -142,29 +137,7 @@
|
|||||||
"uploadFailed": "Upload failed.",
|
"uploadFailed": "Upload failed.",
|
||||||
"autoSeoTitle": "Auto-generating... (30–60 sec)",
|
"autoSeoTitle": "Auto-generating... (30–60 sec)",
|
||||||
"autoSeoDescription": "Auto-generating... (30–60 sec)",
|
"autoSeoDescription": "Auto-generating... (30–60 sec)",
|
||||||
"autoSeoTags": "Auto-generating... (30–60 sec)",
|
"autoSeoTags": "Auto-generating... (30–60 sec)"
|
||||||
"youtubeCallback": {
|
|
||||||
"connecting": "Connecting YouTube...",
|
|
||||||
"pleaseWait": "Please wait a moment.",
|
|
||||||
"failed": "Connection Failed",
|
|
||||||
"errorAuthCancelled": "Google authentication was cancelled or failed.",
|
|
||||||
"errorNoCode": "Authorization code was not received.",
|
|
||||||
"errorProcessing": "An error occurred while processing the YouTube connection."
|
|
||||||
},
|
|
||||||
"connectError": {
|
|
||||||
"title": "{{platform}} Connection Failed",
|
|
||||||
"defaultError": "An unknown error occurred.",
|
|
||||||
"detail": "Please try again. If the problem persists, contact customer support.",
|
|
||||||
"retry": "Try Again"
|
|
||||||
},
|
|
||||||
"connectSuccess": {
|
|
||||||
"title": "{{name}} Connected",
|
|
||||||
"messageWithChannel": "{{platform}} account has been successfully connected.",
|
|
||||||
"messageWithoutChannel": "Account has been successfully connected.",
|
|
||||||
"verified": "Verified",
|
|
||||||
"countdown": "Redirecting in {{count}} seconds...",
|
|
||||||
"goNow": "Go Now"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
"title": "YouTube Upload",
|
"title": "YouTube Upload",
|
||||||
@ -186,31 +159,29 @@
|
|||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"hero": {
|
"hero": {
|
||||||
"searchTypeLabel": "Business Name | URL",
|
"searchTypeBusinessName": "Business Name",
|
||||||
"placeholderCombined": "Enter a business name or Naver Maps/Place URL",
|
"placeholderBusinessName": "Enter a business name",
|
||||||
"guideCombined": "Paste a Naver Maps share URL, or enter a business name to automatically retrieve information.",
|
"guideUrl": "Search for a place on Naver Maps, tap Share,\nthen paste the URL here.",
|
||||||
"errorInputRequired": "Please enter a business name or URL.",
|
"guideBusinessName": "Search by business name to retrieve information.",
|
||||||
"errorInvalidUrl": "Invalid URL format. (e.g., https://naver.me/abcdef)",
|
"errorUrlRequired": "Please enter a URL.",
|
||||||
|
"errorNameRequired": "Please enter a business name.",
|
||||||
|
"errorInvalidUrl": "Invalid URL format. (e.g., https://example.com)",
|
||||||
"analyzeButton": "Brand Analysis",
|
"analyzeButton": "Brand Analysis",
|
||||||
"scrollMore": "Scroll to see more",
|
"scrollMore": "Scroll to see more",
|
||||||
|
"testDataLoading": "Loading...",
|
||||||
|
"testData": "Test Data",
|
||||||
|
"testDataLoadFailed": "Failed to load test data.",
|
||||||
"searching": "Searching...",
|
"searching": "Searching...",
|
||||||
"searchTypeManual": "Not on Naver? Enter manually",
|
"searchTypeManual": "Manual Input",
|
||||||
"manualButtonDesc": "Even businesses not listed on Naver Maps/Place\ncan get lyrics, music, and video production\njust by entering the name, region, and address.",
|
"manualModalTitle": "Enter Business Info",
|
||||||
"manualModalTitle": "Enter Business Info Directly",
|
|
||||||
"manualModalSubtitle": "Even businesses not on Naver can get lyrics, music, and video production by filling in the details below.",
|
|
||||||
"manualAnalyzeButton": "Get Started",
|
|
||||||
"manualLabelName": "Business Name",
|
"manualLabelName": "Business Name",
|
||||||
"manualLabelAddress": "Address",
|
"manualLabelAddress": "Address",
|
||||||
"manualLabelRegion": "Region",
|
"manualLabelRegion": "Region",
|
||||||
"manualLabelDetail": "Detail Address",
|
"manualLabelDetail": "Detail Address",
|
||||||
"manualLabelCategory": "Category",
|
|
||||||
"manualPlaceholderName": "Enter the business name",
|
"manualPlaceholderName": "Enter the business name",
|
||||||
"manualPlaceholderAddress": "Enter the address",
|
"manualPlaceholderAddress": "Enter the address",
|
||||||
"manualPlaceholderRegion": "Select a region",
|
"manualPlaceholderRegion": "Select a region",
|
||||||
"manualPlaceholderDetail": "Enter detail address (e.g. Gangnam-gu Teheran-ro 123)",
|
"manualPlaceholderDetail": "Enter detail address (e.g. Gangnam-gu Teheran-ro 123)"
|
||||||
"manualPlaceholderCategory": "Enter the business category (e.g. pension, cafe, salon)",
|
|
||||||
"manualLabelSiteUrl": "Website link (optional)",
|
|
||||||
"manualPlaceholderSiteUrl": "Enter the official website URL (e.g. https://example.com)"
|
|
||||||
},
|
},
|
||||||
"welcome": {
|
"welcome": {
|
||||||
"title": "Welcome to ADO2.AI",
|
"title": "Welcome to ADO2.AI",
|
||||||
@ -240,6 +211,8 @@
|
|||||||
"guideBusinessName": "Search by business name to retrieve information.",
|
"guideBusinessName": "Search by business name to retrieve information.",
|
||||||
"searchButton": "Search",
|
"searchButton": "Search",
|
||||||
"searching": "Searching...",
|
"searching": "Searching...",
|
||||||
|
"testDataLoading": "Loading...",
|
||||||
|
"testData": "Test Data",
|
||||||
"searchTypeManual": "Manual Input",
|
"searchTypeManual": "Manual Input",
|
||||||
"errorSelectFromList": "Please select a business from the list.",
|
"errorSelectFromList": "Please select a business from the list.",
|
||||||
"errorUrlRequired": "Please enter a URL.",
|
"errorUrlRequired": "Please enter a URL.",
|
||||||
@ -250,34 +223,26 @@
|
|||||||
"manualLabelAddress": "Address",
|
"manualLabelAddress": "Address",
|
||||||
"manualLabelRegion": "Region",
|
"manualLabelRegion": "Region",
|
||||||
"manualLabelDetail": "Detail Address",
|
"manualLabelDetail": "Detail Address",
|
||||||
"manualLabelCategory": "Category",
|
|
||||||
"manualPlaceholderName": "Enter the business name",
|
"manualPlaceholderName": "Enter the business name",
|
||||||
"manualPlaceholderAddress": "Enter the address",
|
"manualPlaceholderAddress": "Enter the address",
|
||||||
"manualPlaceholderRegion": "Select a region",
|
"manualPlaceholderRegion": "Select a region",
|
||||||
"manualPlaceholderDetail": "Enter detail address (e.g. Gangnam-gu Teheran-ro 123)",
|
"manualPlaceholderDetail": "Enter detail address (e.g. Gangnam-gu Teheran-ro 123)"
|
||||||
"manualPlaceholderCategory": "Enter the business category (e.g. pension, cafe, salon)"
|
|
||||||
},
|
},
|
||||||
"assetManagement": {
|
"assetManagement": {
|
||||||
"title": "Brand Assets",
|
"title": "Brand Assets",
|
||||||
"selectedImages": "Selected Images",
|
"selectedImages": "Selected Images",
|
||||||
"imageAlt": "Image",
|
"imageAlt": "Image",
|
||||||
"uploadBadge": "Uploaded",
|
"uploadBadge": "Uploaded",
|
||||||
"previewUnavailable": "No preview",
|
|
||||||
"imageUpload": "Image Upload",
|
"imageUpload": "Image Upload",
|
||||||
"dragAndDrop": "Drag & drop or\nclick to upload",
|
"dragAndDrop": "Drag & drop or\nclick to upload",
|
||||||
"videoRatio": "Video Ratio",
|
"videoRatio": "Video Ratio",
|
||||||
"minImages": "Recommended 30+ images",
|
"minImages": "Min. 5 images",
|
||||||
"youtubeShorts": "YouTube Shorts",
|
"youtubeShorts": "YouTube Shorts",
|
||||||
"youtubeVideo": "YouTube Video",
|
"youtubeVideo": "YouTube Video",
|
||||||
"back": "Go Back",
|
"back": "Go Back",
|
||||||
"loadMore": "Load more",
|
"loadMore": "Load more",
|
||||||
"uploadFailed": "Image upload failed.",
|
"uploadFailed": "Image upload failed.",
|
||||||
"uploadErrorTitle": "Image Upload Error",
|
"uploading": "Uploading... (30 sec – 2 min)",
|
||||||
"uploadErrorConfirm": "OK",
|
|
||||||
"duplicateSkippedTitle": "Duplicate Images Skipped",
|
|
||||||
"duplicateSkippedMessage": "Skipped {{count}} image(s) that were already added.",
|
|
||||||
"preparingPreviews": "Preparing previews ({{done}} / {{total}})",
|
|
||||||
"uploading": "Uploading... (30 sec – 1 min)",
|
|
||||||
"nextStep": "Next Step"
|
"nextStep": "Next Step"
|
||||||
},
|
},
|
||||||
"soundStudio": {
|
"soundStudio": {
|
||||||
@ -290,7 +255,7 @@
|
|||||||
"genreLabel": "Select Genre",
|
"genreLabel": "Select Genre",
|
||||||
"genreAuto": "Auto Select",
|
"genreAuto": "Auto Select",
|
||||||
"genreBallad": "Ballad",
|
"genreBallad": "Ballad",
|
||||||
"languageLabel": "Select Language",
|
"languageLabel": "Language",
|
||||||
"languageKorean": "Korean",
|
"languageKorean": "Korean",
|
||||||
"lyricsColumn": "Lyrics",
|
"lyricsColumn": "Lyrics",
|
||||||
"lyricsHint": "Select the lyrics area to edit",
|
"lyricsHint": "Select the lyrics area to edit",
|
||||||
@ -304,8 +269,8 @@
|
|||||||
"videoGenerating": "Generating Video",
|
"videoGenerating": "Generating Video",
|
||||||
"noBusinessInfo": "No business information. Please try again.",
|
"noBusinessInfo": "No business information. Please try again.",
|
||||||
"noImageUploadInfo": "No image upload information. Please go back to the previous step and try again.",
|
"noImageUploadInfo": "No image upload information. Please go back to the previous step and try again.",
|
||||||
"generatingLyrics": "Generating lyrics... (30–60 sec)",
|
"generatingLyrics": "Generating lyrics...\n(30–60 sec)",
|
||||||
"generatingSong": "Generating music... (1–2 min)",
|
"generatingSong": "Generating music...\n(1–2 min)",
|
||||||
"songQueued": "Song generation queued...",
|
"songQueued": "Song generation queued...",
|
||||||
"retryMessage": "Regenerating due to timeout... ({{count}}/{{max}})",
|
"retryMessage": "Regenerating due to timeout... ({{count}}/{{max}})",
|
||||||
"lyricGenerationFailed": "Lyrics generation request failed.",
|
"lyricGenerationFailed": "Lyrics generation request failed.",
|
||||||
@ -319,7 +284,7 @@
|
|||||||
"songRegenerationError": "An error occurred during music regeneration.",
|
"songRegenerationError": "An error occurred during music regeneration.",
|
||||||
"creditsRemaining": "{{count}} left",
|
"creditsRemaining": "{{count}} left",
|
||||||
"creditsExhausted": "Not enough credits.",
|
"creditsExhausted": "Not enough credits.",
|
||||||
"chargeCredits": "Top Up Credits"
|
"chargeCredits": "Purchase credits"
|
||||||
},
|
},
|
||||||
"completion": {
|
"completion": {
|
||||||
"back": "Go Back",
|
"back": "Go Back",
|
||||||
@ -373,7 +338,7 @@
|
|||||||
"noLyricsBGM": "Background music is generated without lyrics.",
|
"noLyricsBGM": "Background music is generated without lyrics.",
|
||||||
"downloading": "Downloading...",
|
"downloading": "Downloading...",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"uploadToSocial": "Upload"
|
"uploadToSocial": "Upload to Social"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
@ -471,8 +436,8 @@
|
|||||||
"monthOverMonth": "Month-over-Month Growth",
|
"monthOverMonth": "Month-over-Month Growth",
|
||||||
"noAudienceData": "Not enough data to show real audience insights.",
|
"noAudienceData": "Not enough data to show real audience insights.",
|
||||||
"mockContentNotice": "The content shown below is sample data, not your actual content.",
|
"mockContentNotice": "The content shown below is sample data, not your actual content.",
|
||||||
"dataDelayTitle": "No data to display",
|
"dataDelayTitle": "Data is being prepared",
|
||||||
"dataDelayDesc": "There is no analytics data to display. YouTube may take up to 48 hours to reflect data after upload, and statistics are also not collected if the video was uploaded as private or has been deleted from YouTube.",
|
"dataDelayDesc": "According to YouTube's policy, analytics data may take up to 48 hours to be reflected after a video is uploaded. Please check back later.",
|
||||||
"sampleHide": "Hide Sample",
|
"sampleHide": "Hide Sample",
|
||||||
"sampleShow": "Show Sample",
|
"sampleShow": "Show Sample",
|
||||||
"recentVideosDesc": "Showing stats for the latest 30 videos uploaded via ADO2.",
|
"recentVideosDesc": "Showing stats for the latest 30 videos uploaded via ADO2.",
|
||||||
@ -532,7 +497,7 @@
|
|||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"previous": "Previous",
|
"previous": "Previous",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
"uploadToSocial": "Upload",
|
"uploadToSocial": "Upload to social media",
|
||||||
"sortLatest": "Latest",
|
"sortLatest": "Latest",
|
||||||
"sortOldest": "Oldest",
|
"sortOldest": "Oldest",
|
||||||
"sortLikes": "Most Liked",
|
"sortLikes": "Most Liked",
|
||||||
@ -561,9 +526,9 @@
|
|||||||
"marketPositioning": "Market Positioning",
|
"marketPositioning": "Market Positioning",
|
||||||
"coreValue": "Core Value",
|
"coreValue": "Core Value",
|
||||||
"categoryDefinition": "Category Definition",
|
"categoryDefinition": "Category Definition",
|
||||||
"targetPersona": "Key Customer Type",
|
"targetPersona": "Target Persona",
|
||||||
"ageSuffix": "years old",
|
"ageSuffix": "years old",
|
||||||
"sellingPoints": "Key Selling Points",
|
"sellingPoints": "Unique Selling Points (USP)",
|
||||||
"recommendedKeywords": "Recommended Target Keywords",
|
"recommendedKeywords": "Recommended Target Keywords",
|
||||||
"generateContent": "Generate Content",
|
"generateContent": "Generate Content",
|
||||||
"pageDescBefore": " reveals ",
|
"pageDescBefore": " reveals ",
|
||||||
@ -604,7 +569,7 @@
|
|||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"noResults": "No recent results",
|
"noResults": "No recent results",
|
||||||
"noResultsDesc": "Upload your content to social channels",
|
"noResultsDesc": "Upload your content to social channels",
|
||||||
"myContents": "My Contents",
|
"ado2Contents": "ADO2 Contents",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"cancelConfirm": "Are you sure you want to cancel this reservation?",
|
"cancelConfirm": "Are you sure you want to cancel this reservation?",
|
||||||
@ -616,31 +581,7 @@
|
|||||||
},
|
},
|
||||||
"loginPrompt": {
|
"loginPrompt": {
|
||||||
"title": "Login Required",
|
"title": "Login Required",
|
||||||
"loginBtn": "Login"
|
"loginBtn": "Login with Kakao"
|
||||||
},
|
|
||||||
"videoDetail": {
|
|
||||||
"dateFormat": "{{month}}/{{day}}/{{year}}",
|
|
||||||
"kakaoDefaultTitle": "ADO2 Video",
|
|
||||||
"kakaoDescription": "{{region}} · ADO2 AI Marketing Video",
|
|
||||||
"deletedComment": "(This comment has been deleted.)",
|
|
||||||
"closeAriaLabel": "Close",
|
|
||||||
"share": "Share",
|
|
||||||
"copied": "Copied!",
|
|
||||||
"siteOverlayLabel": "View more",
|
|
||||||
"shareKakao": "KakaoTalk",
|
|
||||||
"shareFacebook": "Facebook",
|
|
||||||
"shareTwitter": "X (Twitter)",
|
|
||||||
"copyUrl": "Copy URL",
|
|
||||||
"commentsTitle": "Comments",
|
|
||||||
"commentPlaceholder": "Write a comment...",
|
|
||||||
"commentLoginRequired": "Please log in to write a comment",
|
|
||||||
"commentSubmitting": "Submitting",
|
|
||||||
"commentSubmit": "Submit",
|
|
||||||
"noComments": "No comments yet.",
|
|
||||||
"anonymous": "Anonymous",
|
|
||||||
"deleteComment": "Delete",
|
|
||||||
"loadMoreComments": "Load more comments",
|
|
||||||
"loadingComments": "Loading..."
|
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"loginProcessing": "Processing login...",
|
"loginProcessing": "Processing login...",
|
||||||
|
|||||||
@ -1,10 +1,4 @@
|
|||||||
{
|
{
|
||||||
"wizardSteps": {
|
|
||||||
"brandAnalysis": "분석",
|
|
||||||
"asset": "이미지",
|
|
||||||
"sound": "사운드",
|
|
||||||
"video": "영상"
|
|
||||||
},
|
|
||||||
"header": {
|
"header": {
|
||||||
"loginFailedAlert": "로그인 URL을 가져오는데 실패했습니다. 다시 시도해주세요.",
|
"loginFailedAlert": "로그인 URL을 가져오는데 실패했습니다. 다시 시도해주세요.",
|
||||||
"start": "시작하기",
|
"start": "시작하기",
|
||||||
@ -34,7 +28,8 @@
|
|||||||
"finish": "완료",
|
"finish": "완료",
|
||||||
"landing": {
|
"landing": {
|
||||||
"intro": { "title": "ADO2 튜토리얼 시작", "desc": "ADO2 사용 방법을 단계별로 안내해 드릴게요." },
|
"intro": { "title": "ADO2 튜토리얼 시작", "desc": "ADO2 사용 방법을 단계별로 안내해 드릴게요." },
|
||||||
"field": { "title": "입력하기", "desc": "네이버 지도 공유 URL을 붙여넣거나,\n업체명을 입력하면 자동으로 목록에서 선택할 수 있어요." },
|
"dropdown": { "title": "검색 방식 선택", "desc": "드롭다운에서 URL 또는 업체명 중 원하는 방식을 선택하세요." },
|
||||||
|
"field": { "title": "입력하기", "desc": "URL 방식이라면 네이버 지도 공유 URL,\n업체명 방식이라면 업체명을 입력하고 목록에서 선택하세요." },
|
||||||
"manual": { "title": "직접 입력", "desc": "업체명과 주소를 직접 입력해서 분석을 시작할 수도 있어요." },
|
"manual": { "title": "직접 입력", "desc": "업체명과 주소를 직접 입력해서 분석을 시작할 수도 있어요." },
|
||||||
"button": { "title": "브랜드 분석 시작", "desc": "버튼을 누르면 AI가 브랜드를 분석하기 시작해요." }
|
"button": { "title": "브랜드 분석 시작", "desc": "버튼을 누르면 AI가 브랜드를 분석하기 시작해요." }
|
||||||
},
|
},
|
||||||
@ -142,29 +137,7 @@
|
|||||||
"uploadFailed": "업로드에 실패했습니다.",
|
"uploadFailed": "업로드에 실패했습니다.",
|
||||||
"autoSeoTitle": "자동으로 작성중입니다. (30~60초 소요)",
|
"autoSeoTitle": "자동으로 작성중입니다. (30~60초 소요)",
|
||||||
"autoSeoDescription": "자동으로 작성중입니다. (30~60초 소요)",
|
"autoSeoDescription": "자동으로 작성중입니다. (30~60초 소요)",
|
||||||
"autoSeoTags": "자동으로 작성중입니다. (30~60초 소요)",
|
"autoSeoTags": "자동으로 작성중입니다. (30~60초 소요)"
|
||||||
"youtubeCallback": {
|
|
||||||
"connecting": "YouTube 연결 중...",
|
|
||||||
"pleaseWait": "잠시만 기다려주세요.",
|
|
||||||
"failed": "연결 실패",
|
|
||||||
"errorAuthCancelled": "Google 인증이 취소되었거나 실패했습니다.",
|
|
||||||
"errorNoCode": "인증 코드를 받지 못했습니다.",
|
|
||||||
"errorProcessing": "YouTube 연결 처리 중 오류가 발생했습니다."
|
|
||||||
},
|
|
||||||
"connectError": {
|
|
||||||
"title": "{{platform}} 연결 실패",
|
|
||||||
"defaultError": "알 수 없는 오류가 발생했습니다.",
|
|
||||||
"detail": "다시 시도해주세요. 문제가 지속되면 고객 지원에 문의해주세요.",
|
|
||||||
"retry": "다시 시도"
|
|
||||||
},
|
|
||||||
"connectSuccess": {
|
|
||||||
"title": "{{name}} 연결 완료",
|
|
||||||
"messageWithChannel": "{{platform}} 계정이 성공적으로 연결되었습니다.",
|
|
||||||
"messageWithoutChannel": "계정이 성공적으로 연결되었습니다.",
|
|
||||||
"verified": "인증됨",
|
|
||||||
"countdown": "{{count}}초 후 자동으로 이동합니다...",
|
|
||||||
"goNow": "지금 이동"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
"title": "YouTube 업로드",
|
"title": "YouTube 업로드",
|
||||||
@ -182,34 +155,33 @@
|
|||||||
"close": "닫기",
|
"close": "닫기",
|
||||||
"doNotClose": "업로드가 진행 중입니다. 창을 닫지 마세요.",
|
"doNotClose": "업로드가 진행 중입니다. 창을 닫지 마세요.",
|
||||||
"goToCalendar": "캘린더에서 확인",
|
"goToCalendar": "캘린더에서 확인",
|
||||||
"scheduleConflict": "예약 시간이 충돌하여 {{time}}으로 조정되었습니다." },
|
"scheduleConflict": "예약 시간이 충돌하여 {{time}}으로 조정되었습니다."
|
||||||
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"hero": {
|
"hero": {
|
||||||
"searchTypeLabel": "업체명 | URL",
|
"searchTypeBusinessName": "업체명",
|
||||||
"placeholderCombined": "업체명 또는 URL을 입력하세요.",
|
"placeholderBusinessName": "업체명을 입력하세요.",
|
||||||
"guideCombined": "네이버 지도 공유 URL을 붙여넣거나,\n업체명을 입력하면 자동으로 정보를 가져옵니다.",
|
"guideUrl": "네이버지도에서 장소를 검색하고 공유 선택, \n나오는 URL을 붙여 넣어 주세요.",
|
||||||
"errorInputRequired": "업체명 또는 URL을 입력해주세요.",
|
"guideBusinessName": "업체명으로 검색하여 정보를 가져옵니다.",
|
||||||
"errorInvalidUrl": "올바른 URL 형식이 아닙니다. (예: https://naver.me/abcdef)",
|
"errorUrlRequired": "URL을 입력해주세요.",
|
||||||
|
"errorNameRequired": "업체명을 입력해주세요.",
|
||||||
|
"errorInvalidUrl": "올바른 URL 형식이 아닙니다. (예: https://example.com)",
|
||||||
"analyzeButton": "브랜드 분석",
|
"analyzeButton": "브랜드 분석",
|
||||||
"scrollMore": "스크롤하여 더 보기",
|
"scrollMore": "스크롤하여 더 보기",
|
||||||
|
"testDataLoading": "로딩 중...",
|
||||||
|
"testData": "테스트 데이터",
|
||||||
|
"testDataLoadFailed": "테스트 데이터를 불러오는데 실패했습니다.",
|
||||||
"searching": "검색 중...",
|
"searching": "검색 중...",
|
||||||
"searchTypeManual": "네이버에 없는 업체인가요? 직접 입력하기",
|
"searchTypeManual": "직접 입력",
|
||||||
"manualButtonDesc": "네이버 지도/플레이스에 등록되지 않은 업체도\n업체명·지역·상세주소만 입력하면\n가사·음악·영상 제작까지 가능합니다.",
|
"manualModalTitle": "업체 정보 입력",
|
||||||
"manualModalTitle": "업체 정보 직접 입력",
|
|
||||||
"manualModalSubtitle": "네이버에 등록되지 않은 업체도 아래 정보만 입력하면 가사·음악·영상 제작까지 할 수 있어요.",
|
|
||||||
"manualAnalyzeButton": "시작하기",
|
|
||||||
"manualLabelName": "업체명",
|
"manualLabelName": "업체명",
|
||||||
"manualLabelAddress": "주소",
|
"manualLabelAddress": "주소",
|
||||||
"manualLabelRegion": "지역",
|
"manualLabelRegion": "지역",
|
||||||
"manualLabelDetail": "상세 주소",
|
"manualLabelDetail": "상세 주소",
|
||||||
"manualLabelCategory": "업종",
|
|
||||||
"manualPlaceholderName": "업체명을 입력하세요.",
|
"manualPlaceholderName": "업체명을 입력하세요.",
|
||||||
"manualPlaceholderAddress": "주소를 입력하세요.",
|
"manualPlaceholderAddress": "주소를 입력하세요.",
|
||||||
"manualPlaceholderRegion": "지역을 선택하세요.",
|
"manualPlaceholderRegion": "지역을 선택하세요.",
|
||||||
"manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)",
|
"manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)"
|
||||||
"manualPlaceholderCategory": "업종을 입력하세요. (예: 펜션, 카페, 미용실)",
|
|
||||||
"manualLabelSiteUrl": "홈페이지 링크 (선택)",
|
|
||||||
"manualPlaceholderSiteUrl": "공식 홈페이지 주소를 입력하세요. (예: https://example.com)"
|
|
||||||
},
|
},
|
||||||
"welcome": {
|
"welcome": {
|
||||||
"title": "ADO2.AI에 오신 것을 환영합니다.",
|
"title": "ADO2.AI에 오신 것을 환영합니다.",
|
||||||
@ -239,6 +211,8 @@
|
|||||||
"guideBusinessName": "업체명으로 검색하여 정보를 가져옵니다.",
|
"guideBusinessName": "업체명으로 검색하여 정보를 가져옵니다.",
|
||||||
"searchButton": "검색하기",
|
"searchButton": "검색하기",
|
||||||
"searching": "검색 중...",
|
"searching": "검색 중...",
|
||||||
|
"testDataLoading": "로딩 중...",
|
||||||
|
"testData": "테스트 데이터",
|
||||||
"searchTypeManual": "직접 입력",
|
"searchTypeManual": "직접 입력",
|
||||||
"errorSelectFromList": "목록에서 업체를 선택해 주세요.",
|
"errorSelectFromList": "목록에서 업체를 선택해 주세요.",
|
||||||
"errorUrlRequired": "URL을 입력해주세요.",
|
"errorUrlRequired": "URL을 입력해주세요.",
|
||||||
@ -249,34 +223,26 @@
|
|||||||
"manualLabelAddress": "주소",
|
"manualLabelAddress": "주소",
|
||||||
"manualLabelRegion": "지역",
|
"manualLabelRegion": "지역",
|
||||||
"manualLabelDetail": "상세 주소",
|
"manualLabelDetail": "상세 주소",
|
||||||
"manualLabelCategory": "업종",
|
|
||||||
"manualPlaceholderName": "업체명을 입력하세요.",
|
"manualPlaceholderName": "업체명을 입력하세요.",
|
||||||
"manualPlaceholderAddress": "주소를 입력하세요.",
|
"manualPlaceholderAddress": "주소를 입력하세요.",
|
||||||
"manualPlaceholderRegion": "지역을 선택하세요.",
|
"manualPlaceholderRegion": "지역을 선택하세요.",
|
||||||
"manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)",
|
"manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)"
|
||||||
"manualPlaceholderCategory": "업종을 입력하세요. (예: 펜션, 카페, 미용실)"
|
|
||||||
},
|
},
|
||||||
"assetManagement": {
|
"assetManagement": {
|
||||||
"title": "브랜드 에셋",
|
"title": "브랜드 에셋",
|
||||||
"selectedImages": "선택된 이미지",
|
"selectedImages": "선택된 이미지",
|
||||||
"imageAlt": "이미지",
|
"imageAlt": "이미지",
|
||||||
"uploadBadge": "업로드",
|
"uploadBadge": "업로드",
|
||||||
"previewUnavailable": "미리보기 없음",
|
|
||||||
"imageUpload": "이미지 업로드",
|
"imageUpload": "이미지 업로드",
|
||||||
"dragAndDrop": "이미지를 끌어다 놓거나\n클릭하여 업로드",
|
"dragAndDrop": "이미지를 끌어다 놓거나\n클릭하여 업로드",
|
||||||
"videoRatio": "영상 비율",
|
"videoRatio": "영상 비율",
|
||||||
"minImages": "권장 30장 이상",
|
"minImages": "최소 5장",
|
||||||
"youtubeShorts": "유튜브 쇼츠",
|
"youtubeShorts": "유튜브 쇼츠",
|
||||||
"youtubeVideo": "유튜브 일반",
|
"youtubeVideo": "유튜브 일반",
|
||||||
"back": "뒤로가기",
|
"back": "뒤로가기",
|
||||||
"loadMore": "더보기",
|
"loadMore": "더보기",
|
||||||
"uploadFailed": "이미지 업로드에 실패했습니다.",
|
"uploadFailed": "이미지 업로드에 실패했습니다.",
|
||||||
"uploadErrorTitle": "이미지 업로드 초과",
|
"uploading": "업로드 중 (30초~2분 소요)",
|
||||||
"uploadErrorConfirm": "확인",
|
|
||||||
"duplicateSkippedTitle": "중복 이미지 제외",
|
|
||||||
"duplicateSkippedMessage": "이미 추가된 이미지 {{count}}장을 제외했습니다.",
|
|
||||||
"preparingPreviews": "미리보기 준비 중 ({{done}} / {{total}})",
|
|
||||||
"uploading": "업로드 중 (30~60초 소요)",
|
|
||||||
"nextStep": "다음 단계"
|
"nextStep": "다음 단계"
|
||||||
},
|
},
|
||||||
"soundStudio": {
|
"soundStudio": {
|
||||||
@ -372,7 +338,7 @@
|
|||||||
"noLyricsBGM": "배경음악은 가사 없이 생성됩니다.",
|
"noLyricsBGM": "배경음악은 가사 없이 생성됩니다.",
|
||||||
"downloading": "다운로드 중...",
|
"downloading": "다운로드 중...",
|
||||||
"download": "다운로드",
|
"download": "다운로드",
|
||||||
"uploadToSocial": "업로드"
|
"uploadToSocial": "소셜 채널 업로드"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "대시보드",
|
"title": "대시보드",
|
||||||
@ -470,8 +436,8 @@
|
|||||||
"monthOverMonth": "전월 대비 성장",
|
"monthOverMonth": "전월 대비 성장",
|
||||||
"noAudienceData": "누적 데이터가 부족하여 실제 시청자 정보가 없습니다.",
|
"noAudienceData": "누적 데이터가 부족하여 실제 시청자 정보가 없습니다.",
|
||||||
"mockContentNotice": "현재 표시된 콘텐츠는 실제 데이터가 아닌 샘플 데이터입니다.",
|
"mockContentNotice": "현재 표시된 콘텐츠는 실제 데이터가 아닌 샘플 데이터입니다.",
|
||||||
"dataDelayTitle": "표시할 데이터 없음",
|
"dataDelayTitle": "데이터 준비 중",
|
||||||
"dataDelayDesc": "표시할 분석 데이터가 없습니다. 유튜브 정책상 업로드 후 최대 48시간이 지나야 데이터가 반영되며, 영상을 비공개로 업로드했거나 유튜브에서 삭제한 경우에도 통계가 집계되지 않습니다.",
|
"dataDelayDesc": "유튜브 정책에 따라 분석 데이터는 영상 업로드 후 최대 48시간이 지나야 반영됩니다. 나중에 다시 확인해 주세요.",
|
||||||
"sampleHide": "Sample 숨기기",
|
"sampleHide": "Sample 숨기기",
|
||||||
"sampleShow": "Sample 보기",
|
"sampleShow": "Sample 보기",
|
||||||
"recentVideosDesc": "ADO2에서 업로드한 최근 30개의 영상 통계가 표시됩니다.",
|
"recentVideosDesc": "ADO2에서 업로드한 최근 30개의 영상 통계가 표시됩니다.",
|
||||||
@ -531,7 +497,7 @@
|
|||||||
"delete": "삭제",
|
"delete": "삭제",
|
||||||
"previous": "이전",
|
"previous": "이전",
|
||||||
"next": "다음",
|
"next": "다음",
|
||||||
"uploadToSocial": "업로드",
|
"uploadToSocial": "소셜 미디어에 업로드",
|
||||||
"sortLatest": "최신순",
|
"sortLatest": "최신순",
|
||||||
"sortOldest": "오래된순",
|
"sortOldest": "오래된순",
|
||||||
"sortLikes": "좋아요 많은순",
|
"sortLikes": "좋아요 많은순",
|
||||||
@ -603,7 +569,7 @@
|
|||||||
"loading": "불러오는 중...",
|
"loading": "불러오는 중...",
|
||||||
"noResults": "최근 결과 없음",
|
"noResults": "최근 결과 없음",
|
||||||
"noResultsDesc": "제작한 콘텐츠를 소셜 채널에 업로드해 보세요",
|
"noResultsDesc": "제작한 콘텐츠를 소셜 채널에 업로드해 보세요",
|
||||||
"myContents": "내 콘텐츠",
|
"ado2Contents": "ADO2 콘텐츠",
|
||||||
"cancel": "취소",
|
"cancel": "취소",
|
||||||
"retry": "재시도",
|
"retry": "재시도",
|
||||||
"cancelConfirm": "예약을 취소하시겠습니까?",
|
"cancelConfirm": "예약을 취소하시겠습니까?",
|
||||||
@ -617,30 +583,6 @@
|
|||||||
"title": "로그인이 필요합니다.",
|
"title": "로그인이 필요합니다.",
|
||||||
"loginBtn": "로그인"
|
"loginBtn": "로그인"
|
||||||
},
|
},
|
||||||
"videoDetail": {
|
|
||||||
"dateFormat": "{{year}}년 {{month}}월 {{day}}일",
|
|
||||||
"kakaoDefaultTitle": "ADO2 영상",
|
|
||||||
"kakaoDescription": "{{region}} · ADO2 AI 마케팅 영상",
|
|
||||||
"deletedComment": "(삭제된 댓글입니다.)",
|
|
||||||
"closeAriaLabel": "닫기",
|
|
||||||
"share": "공유하기",
|
|
||||||
"copied": "복사됨!",
|
|
||||||
"siteOverlayLabel": "자세히 보기",
|
|
||||||
"shareKakao": "카카오톡",
|
|
||||||
"shareFacebook": "페이스북",
|
|
||||||
"shareTwitter": "X (트위터)",
|
|
||||||
"copyUrl": "URL 복사",
|
|
||||||
"commentsTitle": "댓글",
|
|
||||||
"commentPlaceholder": "댓글을 입력하세요...",
|
|
||||||
"commentLoginRequired": "로그인 후 댓글을 작성할 수 있습니다",
|
|
||||||
"commentSubmitting": "작성 중",
|
|
||||||
"commentSubmit": "작성",
|
|
||||||
"noComments": "아직 댓글이 없습니다.",
|
|
||||||
"anonymous": "익명",
|
|
||||||
"deleteComment": "삭제",
|
|
||||||
"loadMoreComments": "댓글 더 보기",
|
|
||||||
"loadingComments": "불러오는 중..."
|
|
||||||
},
|
|
||||||
"app": {
|
"app": {
|
||||||
"loginProcessing": "로그인 처리 중...",
|
"loginProcessing": "로그인 처리 중...",
|
||||||
"loginFailed": "로그인 처리에 실패했습니다. 다시 시도해주세요.",
|
"loginFailed": "로그인 처리에 실패했습니다. 다시 시도해주세요.",
|
||||||
|
|||||||
@ -22,7 +22,7 @@ const LoadingSection: React.FC<LoadingSectionProps> = ({ onComplete, isComplete
|
|||||||
const increment = prev < 70 ? 0.5 : prev < 90 ? 0.2 : 0.1;
|
const increment = prev < 70 ? 0.5 : prev < 90 ? 0.2 : 0.1;
|
||||||
return Math.min(prev + increment, 100);
|
return Math.min(prev + increment, 100);
|
||||||
});
|
});
|
||||||
}, 200);
|
}, 100);
|
||||||
return () => {
|
return () => {
|
||||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import { VideoListItem } from '../../types/api';
|
|||||||
import LoginPromptModal from '../../components/LoginPromptModal';
|
import LoginPromptModal from '../../components/LoginPromptModal';
|
||||||
import VideoDetailModal from '../../components/VideoDetailModal';
|
import VideoDetailModal from '../../components/VideoDetailModal';
|
||||||
import CitySelectModal from '../../components/CitySelectModal';
|
import CitySelectModal from '../../components/CitySelectModal';
|
||||||
import ContentCardSocialActions from '../../components/ContentCardSocialActions';
|
|
||||||
|
|
||||||
interface ADO2ContentsPageProps {
|
interface ADO2ContentsPageProps {
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
@ -151,15 +150,7 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
|
|||||||
onKeyDown={(e) => e.key === 'Enter' && handleCardClick(video.video_id)}
|
onKeyDown={(e) => e.key === 'Enter' && handleCardClick(video.video_id)}
|
||||||
>
|
>
|
||||||
<div className="content-card-thumbnail ado2-gallery-thumbnail-wrap">
|
<div className="content-card-thumbnail ado2-gallery-thumbnail-wrap">
|
||||||
{video.poster_url ? (
|
{video.thumbnail_url ? (
|
||||||
<img
|
|
||||||
src={video.poster_url}
|
|
||||||
alt={video.store_name}
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
className="content-video-preview"
|
|
||||||
/>
|
|
||||||
) : video.thumbnail_url ? (
|
|
||||||
<img
|
<img
|
||||||
src={video.thumbnail_url}
|
src={video.thumbnail_url}
|
||||||
alt={video.store_name}
|
alt={video.store_name}
|
||||||
@ -190,15 +181,16 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
|
|||||||
<h3 className="content-card-title">{video.store_name}</h3>
|
<h3 className="content-card-title">{video.store_name}</h3>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<p className="content-card-date">{formatDate(video.created_at)}</p>
|
<p className="content-card-date">{formatDate(video.created_at)}</p>
|
||||||
<ContentCardSocialActions
|
<span className="content-card-like">
|
||||||
videoId={video.video_id}
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2">
|
||||||
storeName={video.store_name}
|
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
|
||||||
region={video.region}
|
</svg>
|
||||||
title={video.title}
|
{video.like_count ?? 0}
|
||||||
commentCount={video.comment_count ?? 0}
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginLeft: '6px' }}>
|
||||||
initialLikeCount={video.like_count ?? 0}
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||||
initialIsLiked={video.is_liked_by_me}
|
</svg>
|
||||||
/>
|
{video.comment_count ?? 0}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,19 +3,13 @@ import React, { useRef, useState, useEffect } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ImageItem, ImageUrlItem } from '../../types/api';
|
import { ImageItem, ImageUrlItem } from '../../types/api';
|
||||||
import { uploadImages } from '../../utils/api';
|
import { uploadImages } from '../../utils/api';
|
||||||
import { isImageInputFile } from '../../utils/imageCompression.ts';
|
|
||||||
import { MAX_IMAGES_PER_UPLOAD_TASK } from '../../utils/imageUpload.ts';
|
|
||||||
import { splitDuplicateFiles } from '../../utils/imageDedup.ts';
|
|
||||||
|
|
||||||
interface AssetManagementContentProps {
|
interface AssetManagementContentProps {
|
||||||
onNext: (imageTaskId: string) => void;
|
onNext: (imageTaskId: string) => void;
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
imageList: ImageItem[];
|
imageList: ImageItem[];
|
||||||
onRemoveImage: (index: number) => void;
|
onRemoveImage: (index: number) => void;
|
||||||
onAddImages: (
|
onAddImages: (files: File[]) => void;
|
||||||
files: File[],
|
|
||||||
onProgress: (done: number, total: number) => void
|
|
||||||
) => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type VideoRatio = 'vertical' | 'horizontal';
|
type VideoRatio = 'vertical' | 'horizontal';
|
||||||
@ -32,12 +26,9 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||||
// 업로드 실패와 중복 제외 안내가 같은 다이얼로그 마크업을 공유한다.
|
|
||||||
const [dialog, setDialog] = useState<{ title: string; message: string } | null>(null);
|
|
||||||
const [videoRatio, setVideoRatio] = useState<VideoRatio>('vertical');
|
const [videoRatio, setVideoRatio] = useState<VideoRatio>('vertical');
|
||||||
const [displayCount, setDisplayCount] = useState(IMAGES_PER_PAGE);
|
const [displayCount, setDisplayCount] = useState(IMAGES_PER_PAGE);
|
||||||
const [thumbnailProgress, setThumbnailProgress] = useState<{ done: number; total: number } | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedRatio = localStorage.getItem('castad_video_ratio') as VideoRatio;
|
const savedRatio = localStorage.getItem('castad_video_ratio') as VideoRatio;
|
||||||
@ -46,42 +37,20 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!dialog) return;
|
|
||||||
|
|
||||||
const handleEscape = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
event.preventDefault();
|
|
||||||
setDialog(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handleEscape);
|
|
||||||
return () => window.removeEventListener('keydown', handleEscape);
|
|
||||||
}, [dialog]);
|
|
||||||
|
|
||||||
const handleVideoRatioChange = (ratio: VideoRatio) => {
|
const handleVideoRatioChange = (ratio: VideoRatio) => {
|
||||||
setVideoRatio(ratio);
|
setVideoRatio(ratio);
|
||||||
localStorage.setItem('castad_video_ratio', ratio);
|
localStorage.setItem('castad_video_ratio', ratio);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getImageSrc = (item: ImageItem): string | null => {
|
const getImageSrc = (item: ImageItem): string => {
|
||||||
return item.type === 'url' ? item.preview_url : item.preview;
|
return item.type === 'url' ? item.url : item.preview;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNextWithUpload = async () => {
|
const handleNextWithUpload = async () => {
|
||||||
if (imageList.length === 0) return;
|
if (imageList.length === 0) return;
|
||||||
|
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
setUploadProgress(0);
|
setUploadError(null);
|
||||||
setDialog(null);
|
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
setUploadProgress(prev => {
|
|
||||||
if (prev >= 99) return prev;
|
|
||||||
return prev + (prev < 50 ? 0.6 : prev < 80 ? 0.3 : prev < 90 ? 0.15 : 0.11);
|
|
||||||
});
|
|
||||||
}, 200);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const urlImages: ImageUrlItem[] = imageList
|
const urlImages: ImageUrlItem[] = imageList
|
||||||
@ -94,47 +63,13 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
|
|||||||
|
|
||||||
if (urlImages.length > 0 || fileImages.length > 0) {
|
if (urlImages.length > 0 || fileImages.length > 0) {
|
||||||
const response = await uploadImages(urlImages, fileImages);
|
const response = await uploadImages(urlImages, fileImages);
|
||||||
clearInterval(interval);
|
|
||||||
setUploadProgress(100);
|
|
||||||
// 이미지 변경 시 이전 음악/영상 생성 결과 초기화 — 반드시 재생성하도록 강제
|
|
||||||
localStorage.removeItem('castad_song_generation');
|
|
||||||
localStorage.removeItem('castad_song_completion');
|
|
||||||
localStorage.removeItem('castad_song_task_id');
|
|
||||||
localStorage.removeItem('castad_video_generation');
|
|
||||||
localStorage.removeItem('castad_video_complete');
|
|
||||||
onNext(response.task_id);
|
onNext(response.task_id);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
clearInterval(interval);
|
|
||||||
console.error('Image upload failed:', error);
|
console.error('Image upload failed:', error);
|
||||||
setDialog({
|
setUploadError(error instanceof Error ? error.message : t('assetManagement.uploadFailed'));
|
||||||
title: t('assetManagement.uploadErrorTitle'),
|
|
||||||
message: error instanceof Error ? error.message : t('assetManagement.uploadFailed'),
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
setUploadProgress(0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const runAddImages = async (files: File[]) => {
|
|
||||||
const { newFiles, duplicateCount } = splitDuplicateFiles(imageList, files);
|
|
||||||
|
|
||||||
if (newFiles.length > 0) {
|
|
||||||
setThumbnailProgress({ done: 0, total: newFiles.length });
|
|
||||||
try {
|
|
||||||
await onAddImages(newFiles, (done, total) => setThumbnailProgress({ done, total }));
|
|
||||||
} finally {
|
|
||||||
setThumbnailProgress(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 썸네일 생성 오버레이에 가려지지 않도록 작업이 끝난 뒤에 안내한다.
|
|
||||||
if (duplicateCount > 0) {
|
|
||||||
setDialog({
|
|
||||||
title: t('assetManagement.duplicateSkippedTitle'),
|
|
||||||
message: t('assetManagement.duplicateSkippedMessage', { count: duplicateCount }),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -146,8 +81,10 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
|
|||||||
const handleDrop = (e: React.DragEvent) => {
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const files = Array.from(e.dataTransfer.files).filter(isImageInputFile);
|
const files = Array.from(e.dataTransfer.files).filter((file: File) =>
|
||||||
if (files.length > 0) void runAddImages(files);
|
file.type.startsWith('image/')
|
||||||
|
);
|
||||||
|
if (files.length > 0) onAddImages(files);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileSelect = () => {
|
const handleFileSelect = () => {
|
||||||
@ -157,7 +94,7 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
|
|||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = e.target.files;
|
const files = e.target.files;
|
||||||
if (files && files.length > 0) {
|
if (files && files.length > 0) {
|
||||||
void runAddImages(Array.from(files));
|
onAddImages(Array.from(files));
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -167,79 +104,6 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="asset-page">
|
<main className="asset-page">
|
||||||
{thumbnailProgress && (
|
|
||||||
<div className="asset-upload-overlay">
|
|
||||||
<div className="asset-upload-overlay-content">
|
|
||||||
<div className="loading-spinner">
|
|
||||||
<div className="loading-ring"></div>
|
|
||||||
<div className="loading-dot">
|
|
||||||
<div className="loading-dot-inner"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="comp2-loading-text">
|
|
||||||
{t('assetManagement.preparingPreviews', {
|
|
||||||
done: thumbnailProgress.done,
|
|
||||||
total: thumbnailProgress.total,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isUploading && (
|
|
||||||
<div className="asset-upload-overlay">
|
|
||||||
<div className="asset-upload-overlay-content">
|
|
||||||
<div className="loading-spinner">
|
|
||||||
<div className="loading-ring"></div>
|
|
||||||
<div className="loading-dot">
|
|
||||||
<div className="loading-dot-inner"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="comp2-loading-text">{t('assetManagement.uploading')}</p>
|
|
||||||
<div className="loading-progress-wrapper">
|
|
||||||
<div className="loading-progress-bar">
|
|
||||||
<div
|
|
||||||
className="loading-progress-fill"
|
|
||||||
style={{ width: `${uploadProgress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="loading-progress-text">{Math.floor(uploadProgress)}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{dialog && (
|
|
||||||
<div
|
|
||||||
className="asset-upload-error-overlay"
|
|
||||||
onClick={() => setDialog(null)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="asset-upload-error-dialog"
|
|
||||||
role="alertdialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="asset-upload-error-title"
|
|
||||||
aria-describedby="asset-upload-error-message"
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
>
|
|
||||||
<h2 id="asset-upload-error-title" className="asset-upload-error-title">
|
|
||||||
{dialog.title}
|
|
||||||
</h2>
|
|
||||||
<p id="asset-upload-error-message" className="asset-upload-error-message">
|
|
||||||
{dialog.message}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="asset-upload-error-confirm"
|
|
||||||
onClick={() => setDialog(null)}
|
|
||||||
autoFocus
|
|
||||||
>
|
|
||||||
{t('assetManagement.uploadErrorConfirm')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Fixed Header - 뒤로가기 버튼 */}
|
{/* Fixed Header - 뒤로가기 버튼 */}
|
||||||
<div className="asset-sticky-header">
|
<div className="asset-sticky-header">
|
||||||
{onBack && (
|
{onBack && (
|
||||||
@ -263,13 +127,6 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
|
|||||||
<div className="asset-section-header-left">
|
<div className="asset-section-header-left">
|
||||||
<h3 className="asset-section-title">{t('assetManagement.selectedImages')}</h3>
|
<h3 className="asset-section-title">{t('assetManagement.selectedImages')}</h3>
|
||||||
<span className="asset-section-subtitle">{t('assetManagement.minImages')}</span>
|
<span className="asset-section-subtitle">{t('assetManagement.minImages')}</span>
|
||||||
<span
|
|
||||||
className={`asset-section-count${
|
|
||||||
imageList.length > MAX_IMAGES_PER_UPLOAD_TASK ? ' asset-section-count-over' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{imageList.length} / {MAX_IMAGES_PER_UPLOAD_TASK}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<button onClick={handleFileSelect} className="asset-mobile-upload-btn">
|
<button onClick={handleFileSelect} className="asset-mobile-upload-btn">
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
@ -283,31 +140,12 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
|
|||||||
<div className="asset-image-list">
|
<div className="asset-image-list">
|
||||||
{visibleImages.length > 0 && (
|
{visibleImages.length > 0 && (
|
||||||
<div className="asset-image-grid">
|
<div className="asset-image-grid">
|
||||||
{visibleImages.map((item, i) => {
|
{visibleImages.map((item, i) => (
|
||||||
const src = getImageSrc(item);
|
|
||||||
const fileName = item.type === 'file' ? item.file.name : '';
|
|
||||||
return (
|
|
||||||
<div key={i} className="asset-image-item">
|
<div key={i} className="asset-image-item">
|
||||||
{src ? (
|
|
||||||
<img
|
<img
|
||||||
src={src}
|
src={getImageSrc(item)}
|
||||||
alt={`${t('assetManagement.imageAlt')} ${i + 1}`}
|
alt={`${t('assetManagement.imageAlt')} ${i + 1}`}
|
||||||
referrerPolicy="no-referrer"
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<div className="asset-image-placeholder" title={fileName}>
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
|
||||||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
|
||||||
<path d="M21 15l-5-5L5 21"/>
|
|
||||||
</svg>
|
|
||||||
<span className="asset-image-placeholder-name">
|
|
||||||
{fileName || t('assetManagement.previewUnavailable')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{item.type === 'file' && (
|
{item.type === 'file' && (
|
||||||
<div className="asset-image-badge">{t('assetManagement.uploadBadge')}</div>
|
<div className="asset-image-badge">{t('assetManagement.uploadBadge')}</div>
|
||||||
)}
|
)}
|
||||||
@ -321,8 +159,7 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -388,6 +225,9 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
|
|||||||
|
|
||||||
{/* Fixed Footer - 다음 단계 버튼 */}
|
{/* Fixed Footer - 다음 단계 버튼 */}
|
||||||
<div className="asset-sticky-footer">
|
<div className="asset-sticky-footer">
|
||||||
|
{uploadError && (
|
||||||
|
<p className="text-red-500 text-sm mb-2">{uploadError}</p>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handleNextWithUpload}
|
onClick={handleNextWithUpload}
|
||||||
disabled={imageList.length === 0 || isUploading}
|
disabled={imageList.length === 0 || isUploading}
|
||||||
@ -400,7 +240,7 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
|
|||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*,.heic,.heif"
|
accept="image/*"
|
||||||
multiple
|
multiple
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
|
|||||||
21
src/pages/Dashboard/AssetManagementSection.tsx
Executable file
21
src/pages/Dashboard/AssetManagementSection.tsx
Executable file
@ -0,0 +1,21 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import Sidebar from '../../components/Sidebar';
|
||||||
|
import AssetManagementContent from './AssetManagementContent';
|
||||||
|
|
||||||
|
interface AssetManagementSectionProps {
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AssetManagementSection: React.FC<AssetManagementSectionProps> = ({ onBack }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full h-screen bg-[#002224] text-white overflow-hidden">
|
||||||
|
<Sidebar />
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<AssetManagementContent onBack={onBack} onNext={() => {}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AssetManagementSection;
|
||||||
@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { generateVideo, waitForVideoComplete, getSubtitleStatus, waitForSubtitleComplete, trackFirstVideoCreated } from '../../utils/api';
|
import { generateVideo, waitForVideoComplete, getSubtitleStatus, waitForSubtitleComplete } from '../../utils/api';
|
||||||
import SocialPostingModal from '../../components/SocialPostingModal';
|
import SocialPostingModal from '../../components/SocialPostingModal';
|
||||||
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
||||||
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
||||||
@ -266,9 +266,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||||||
setVideoStatus('complete');
|
setVideoStatus('complete');
|
||||||
setStatusMessage('');
|
setStatusMessage('');
|
||||||
saveToStorage(videoTaskId, currentSongTaskId, 'complete', videoUrlFromResponse, videoId);
|
saveToStorage(videoTaskId, currentSongTaskId, 'complete', videoUrlFromResponse, videoId);
|
||||||
|
|
||||||
// Meta FirstVideoCreated 전환 이벤트 발화 (서버가 계정당 최초 1회 판정, 실패해도 무시)
|
|
||||||
trackFirstVideoCreated();
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error(t('completion.videoUrlMissing'));
|
throw new Error(t('completion.videoUrlMissing'));
|
||||||
}
|
}
|
||||||
@ -412,14 +409,14 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="comp2-page">
|
<main className="comp2-page">
|
||||||
{/* <div className="comp2-header">
|
<div className="comp2-header">
|
||||||
<button onClick={onBack} className="comp2-back-btn">
|
<button onClick={onBack} className="comp2-back-btn">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<path d="M15 18l-6-6 6-6" />
|
<path d="M15 18l-6-6 6-6" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>{t('completion.back')}</span>
|
<span>{t('completion.back')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div> */}
|
</div>
|
||||||
|
|
||||||
<div className="comp2-title-row">
|
<div className="comp2-title-row">
|
||||||
<h1 className="comp2-page-title">{t('completion.contentComplete')}</h1>
|
<h1 className="comp2-page-title">{t('completion.contentComplete')}</h1>
|
||||||
@ -479,11 +476,11 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||||||
<div className="comp2-info-header">
|
<div className="comp2-info-header">
|
||||||
<span className="comp2-info-label">{t('completion.contentInfo')}</span>
|
<span className="comp2-info-label">{t('completion.contentInfo')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="comp2-info-content">
|
||||||
<div className="comp2-file-info">
|
<div className="comp2-file-info">
|
||||||
<h3 className="comp2-filename">{getFileName()}</h3>
|
<h3 className="comp2-filename">{getFileName()}</h3>
|
||||||
{/* <p className="comp2-filesize">19.6MB</p> */}
|
{/* <p className="comp2-filesize">19.6MB</p> */}
|
||||||
</div>
|
</div>
|
||||||
<div className="comp2-info-content">
|
|
||||||
<div className="comp2-meta-grid">
|
<div className="comp2-meta-grid">
|
||||||
<div className="comp2-meta-item">
|
<div className="comp2-meta-item">
|
||||||
<span className="comp2-meta-label">{t('completion.genre')} : {songCompletionData?.genre || 'K-POP'}</span>
|
<span className="comp2-meta-label">{t('completion.genre')} : {songCompletionData?.genre || 'K-POP'}</span>
|
||||||
@ -503,22 +500,19 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||||||
return <p className="comp2-lyrics-text">{t('completion.noLyricsBGM')}</p>;
|
return <p className="comp2-lyrics-text">{t('completion.noLyricsBGM')}</p>;
|
||||||
}
|
}
|
||||||
const lines = songCompletionData.lyrics.split('\n').filter((l: string) => l.trim());
|
const lines = songCompletionData.lyrics.split('\n').filter((l: string) => l.trim());
|
||||||
const sections: { tag: string | null; lines: string[] }[] = [];
|
const outro = lines.slice(-1);
|
||||||
lines.forEach((line: string) => {
|
const body = lines.slice(0, -1);
|
||||||
const tagMatch = line.trim().match(/^\[(.+)\]$/);
|
const half = Math.ceil(body.length / 2);
|
||||||
if (tagMatch) {
|
const sections = [
|
||||||
sections.push({ tag: `[${tagMatch[1]}]`, lines: [] });
|
{ tag: '[Verse]', lines: body.slice(0, half) },
|
||||||
} else if (sections.length === 0) {
|
{ tag: '[Chorus]', lines: body.slice(half) },
|
||||||
sections.push({ tag: null, lines: [line] });
|
{ tag: '[Outro]', lines: outro },
|
||||||
} else {
|
].filter(s => s.lines.length > 0);
|
||||||
sections[sections.length - 1].lines.push(line);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return (
|
return (
|
||||||
<div className="comp2-lyrics-paragraphs">
|
<div className="comp2-lyrics-paragraphs">
|
||||||
{sections.filter(s => s.lines.length > 0).map((section, i) => (
|
{sections.map((section, i) => (
|
||||||
<div key={i} className="comp2-lyrics-para-section">
|
<div key={i} className="comp2-lyrics-para-section">
|
||||||
{section.tag && <span className="comp2-lyrics-tag">{section.tag}</span>}
|
<span className="comp2-lyrics-tag">{section.tag}</span>
|
||||||
<p className="comp2-lyrics-text">{section.lines.join('\n')}</p>
|
<p className="comp2-lyrics-text">{section.lines.join('\n')}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -563,7 +557,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
like_count: 0,
|
like_count: 0,
|
||||||
comment_count: 0,
|
comment_count: 0,
|
||||||
is_liked_by_me: false,
|
|
||||||
} : null}
|
} : null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -645,7 +645,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => onNavigate?.('내 콘텐츠')}
|
onClick={() => onNavigate?.('ADO2 콘텐츠')}
|
||||||
style={{
|
style={{
|
||||||
height: 34, padding: '0 10px', borderRadius: 8, border: 'none',
|
height: 34, padding: '0 10px', borderRadius: 8, border: 'none',
|
||||||
backgroundColor: '#a65eff', cursor: 'pointer',
|
backgroundColor: '#a65eff', cursor: 'pointer',
|
||||||
@ -653,7 +653,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
|||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('contentCalendar.myContents')}
|
{t('contentCalendar.ado2Contents')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -717,7 +717,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 바텀시트 */}
|
{/* 바텀시트 */}
|
||||||
<div style={{ position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 10, display: 'flex', flexDirection: 'column' }}>
|
<div style={{ position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 100, display: 'flex', flexDirection: 'column' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
backgroundColor: '#01393b',
|
backgroundColor: '#01393b',
|
||||||
borderTop: '1px solid #046266',
|
borderTop: '1px solid #046266',
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import Sidebar from '../../components/Sidebar';
|
import Sidebar from '../../components/Sidebar';
|
||||||
|
import ShortformContent from './ShortformContent';
|
||||||
import AssetManagementContent from './AssetManagementContent';
|
import AssetManagementContent from './AssetManagementContent';
|
||||||
import SoundStudioContent from './SoundStudioContent';
|
import SoundStudioContent from './SoundStudioContent';
|
||||||
import CompletionContent from './CompletionContent';
|
import CompletionContent from './CompletionContent';
|
||||||
@ -14,13 +15,11 @@ import MyInfoContent from './MyInfoContent';
|
|||||||
import ContentCalendarContent from './ContentCalendarContent';
|
import ContentCalendarContent from './ContentCalendarContent';
|
||||||
import LoadingSection from '../Analysis/LoadingSection';
|
import LoadingSection from '../Analysis/LoadingSection';
|
||||||
import AnalysisResultSection from '../Analysis/AnalysisResultSection';
|
import AnalysisResultSection from '../Analysis/AnalysisResultSection';
|
||||||
import { ImageItem, type ImageListItem, CrawlingResponse, UserMeResponse } from '../../types/api';
|
import { ImageItem, CrawlingResponse, UserMeResponse } from '../../types/api';
|
||||||
import { crawlUrl, autocomplete, marketingAnalysis, AutocompleteRequest, getUserMe, getUserCredits, clearTokens } from '../../utils/api';
|
import { crawlUrl, autocomplete, marketingAnalysis, AutocompleteRequest, getUserMe, getUserCredits, clearTokens } from '../../utils/api';
|
||||||
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
||||||
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
||||||
import TutorialOverlay, { TutorialRestartPopup } from '../../components/Tutorial/TutorialOverlay';
|
import TutorialOverlay, { TutorialRestartPopup } from '../../components/Tutorial/TutorialOverlay';
|
||||||
import WizardStepper from '../../components/WizardStepper';
|
|
||||||
import { createPreviewThumbnail } from '../../utils/imageCompression.ts';
|
|
||||||
|
|
||||||
const WIZARD_STEP_KEY = 'castad_wizard_step';
|
const WIZARD_STEP_KEY = 'castad_wizard_step';
|
||||||
const ACTIVE_ITEM_KEY = 'castad_active_item';
|
const ACTIVE_ITEM_KEY = 'castad_active_item';
|
||||||
@ -29,15 +28,6 @@ const IMAGE_TASK_ID_KEY = 'castad_image_task_id';
|
|||||||
const ANALYSIS_DATA_KEY = 'castad_analysis_data';
|
const ANALYSIS_DATA_KEY = 'castad_analysis_data';
|
||||||
import { saveSearchHistory } from '../../components/SearchHistory/useSearchHistory';
|
import { saveSearchHistory } from '../../components/SearchHistory/useSearchHistory';
|
||||||
|
|
||||||
/** 파일 이미지의 썸네일 objectURL 을 일괄 해제한다. preview 가 null 인 항목은 건너뛴다. */
|
|
||||||
const revokeAllPreviews = (items: ImageItem[]) => {
|
|
||||||
items.forEach(item => {
|
|
||||||
if (item.type === 'file' && item.preview) {
|
|
||||||
URL.revokeObjectURL(item.preview);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 다른 컴포넌트에서 사용하는 storage key들 (초기화용)
|
// 다른 컴포넌트에서 사용하는 storage key들 (초기화용)
|
||||||
const SONG_GENERATION_KEY = 'castad_song_generation';
|
const SONG_GENERATION_KEY = 'castad_song_generation';
|
||||||
const VIDEO_GENERATION_KEY = 'castad_video_generation';
|
const VIDEO_GENERATION_KEY = 'castad_video_generation';
|
||||||
@ -62,9 +52,10 @@ interface BusinessInfo {
|
|||||||
interface GenerationFlowProps {
|
interface GenerationFlowProps {
|
||||||
onHome: () => void;
|
onHome: () => void;
|
||||||
initialActiveItem?: string;
|
initialActiveItem?: string;
|
||||||
initialImageList?: ImageListItem[];
|
initialImageList?: string[];
|
||||||
businessInfo?: BusinessInfo;
|
businessInfo?: BusinessInfo;
|
||||||
initialAnalysisData?: CrawlingResponse | null;
|
initialAnalysisData?: CrawlingResponse | null;
|
||||||
|
initialShortform?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 위저드 단계:
|
// 위저드 단계:
|
||||||
@ -80,7 +71,8 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||||||
initialActiveItem = '대시보드',
|
initialActiveItem = '대시보드',
|
||||||
initialImageList = [],
|
initialImageList = [],
|
||||||
businessInfo,
|
businessInfo,
|
||||||
initialAnalysisData
|
initialAnalysisData,
|
||||||
|
initialShortform = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
// localStorage에서 저장된 상태 복원
|
// localStorage에서 저장된 상태 복원
|
||||||
@ -105,6 +97,9 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||||||
|
|
||||||
// 초기 위저드 단계 결정
|
// 초기 위저드 단계 결정
|
||||||
const getInitialWizardStep = (): number => {
|
const getInitialWizardStep = (): number => {
|
||||||
|
// 숏폼 진입 플래그가 있으면 숏폼 화면(-3)으로 바로
|
||||||
|
if (initialShortform) return -3;
|
||||||
|
|
||||||
if (savedWizardStep !== null) {
|
if (savedWizardStep !== null) {
|
||||||
const step = parseInt(savedWizardStep, 10);
|
const step = parseInt(savedWizardStep, 10);
|
||||||
// 분석 데이터가 있는데 URL 입력(-2) 또는 로딩(-1) 단계로 저장된 경우:
|
// 분석 데이터가 있는데 URL 입력(-2) 또는 로딩(-1) 단계로 저장된 경우:
|
||||||
@ -179,21 +174,12 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||||||
// URL 이미지를 ImageItem 형태로 변환하여 초기화
|
// URL 이미지를 ImageItem 형태로 변환하여 초기화
|
||||||
const getInitialImageList = (): ImageItem[] => {
|
const getInitialImageList = (): ImageItem[] => {
|
||||||
if (analysisData?.image_list && analysisData.image_list.length > 0) {
|
if (analysisData?.image_list && analysisData.image_list.length > 0) {
|
||||||
return analysisData.image_list.map(item => ({ type: 'url' as const, url: item.original, preview_url: item.preview }));
|
return analysisData.image_list.map(url => ({ type: 'url', url }));
|
||||||
}
|
}
|
||||||
return initialImageList.map(item => ({ type: 'url' as const, url: item.original, preview_url: item.preview }));
|
return initialImageList.map(url => ({ type: 'url', url }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const [imageList, setImageList] = useState<ImageItem[]>(getInitialImageList());
|
const [imageList, setImageList] = useState<ImageItem[]>(getInitialImageList());
|
||||||
const imageListRef = useRef<ImageItem[]>(imageList);
|
|
||||||
imageListRef.current = imageList;
|
|
||||||
|
|
||||||
// 의존성 배열에 imageList 를 넣으면 이미지를 추가할 때마다 cleanup 이 돌아
|
|
||||||
// 방금 만든 썸네일이 즉시 revoke 된다. 반드시 빈 배열이어야 한다.
|
|
||||||
useEffect(() => {
|
|
||||||
return () => revokeAllPreviews(imageListRef.current);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const prevAnalysisMIdRef = useRef<number | null | undefined>(analysisData?.m_id);
|
const prevAnalysisMIdRef = useRef<number | null | undefined>(analysisData?.m_id);
|
||||||
|
|
||||||
// analysisData 변경 시 m_id가 바뀐 경우(새로운 분석)에만 imageList 업데이트
|
// analysisData 변경 시 m_id가 바뀐 경우(새로운 분석)에만 imageList 업데이트
|
||||||
@ -202,9 +188,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||||||
console.log('[GenerationFlow] analysisData updated, m_id:', analysisData?.m_id);
|
console.log('[GenerationFlow] analysisData updated, m_id:', analysisData?.m_id);
|
||||||
if (analysisData?.image_list && analysisData.image_list.length > 0) {
|
if (analysisData?.image_list && analysisData.image_list.length > 0) {
|
||||||
if (prevAnalysisMIdRef.current !== analysisData.m_id) {
|
if (prevAnalysisMIdRef.current !== analysisData.m_id) {
|
||||||
// 목록을 통째로 교체하므로 기존 파일 썸네일을 먼저 해제한다.
|
setImageList(analysisData.image_list.map(url => ({ type: 'url', url })));
|
||||||
revokeAllPreviews(imageListRef.current);
|
|
||||||
setImageList(analysisData.image_list.map(item => ({ type: 'url' as const, url: item.original, preview_url: item.preview })));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prevAnalysisMIdRef.current = analysisData?.m_id;
|
prevAnalysisMIdRef.current = analysisData?.m_id;
|
||||||
@ -213,35 +197,21 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||||||
const handleRemoveImage = (index: number) => {
|
const handleRemoveImage = (index: number) => {
|
||||||
setImageList(prev => {
|
setImageList(prev => {
|
||||||
const item = prev[index];
|
const item = prev[index];
|
||||||
// 파일 이미지인 경우 썸네일 메모리 해제 (생성 실패 시 preview 는 null)
|
// 파일 이미지인 경우 메모리 해제
|
||||||
if (item.type === 'file' && item.preview) {
|
if (item.type === 'file') {
|
||||||
URL.revokeObjectURL(item.preview);
|
URL.revokeObjectURL(item.preview);
|
||||||
}
|
}
|
||||||
return prev.filter((_, i) => i !== index);
|
return prev.filter((_, i) => i !== index);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddImages = async (
|
const handleAddImages = (files: File[]) => {
|
||||||
files: File[],
|
const newImages: ImageItem[] = files.map(file => ({
|
||||||
onProgress: (done: number, total: number) => void
|
type: 'file',
|
||||||
) => {
|
file,
|
||||||
const total = files.length;
|
preview: URL.createObjectURL(file),
|
||||||
const newImages: ImageItem[] = [];
|
}));
|
||||||
|
// 새로 업로드된 이미지를 배열 앞에 추가 (최신 이미지가 상단에 표시)
|
||||||
onProgress(0, total);
|
|
||||||
|
|
||||||
// 반드시 순차 처리한다. Promise.all 로 동시에 돌리면 원본 크기 비트맵이
|
|
||||||
// 한꺼번에 메모리에 올라가, 이 작업이 없애려던 문제가 그대로 재현된다.
|
|
||||||
for (const file of files) {
|
|
||||||
const preview = await createPreviewThumbnail(file);
|
|
||||||
newImages.push({ type: 'file', file, preview });
|
|
||||||
onProgress(newImages.length, total);
|
|
||||||
// 진행률이 실제로 화면에 그려지도록 매 장마다 렌더링 기회를 넘긴다.
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 장마다 setState 하면 그리드가 N 번 재조정된다. 전부 끝난 뒤 한 번만 반영한다.
|
|
||||||
// 새로 추가된 이미지를 배열 앞에 둔다 (최신 이미지가 상단에 표시)
|
|
||||||
setImageList(prev => [...newImages, ...prev]);
|
setImageList(prev => [...newImages, ...prev]);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -253,7 +223,6 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||||||
setSongTaskId(null);
|
setSongTaskId(null);
|
||||||
setImageTaskId(null);
|
setImageTaskId(null);
|
||||||
setAnalysisData(null);
|
setAnalysisData(null);
|
||||||
revokeAllPreviews(imageListRef.current);
|
|
||||||
setImageList([]);
|
setImageList([]);
|
||||||
onHome();
|
onHome();
|
||||||
};
|
};
|
||||||
@ -299,13 +268,13 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 업체명·주소 수동 입력으로 마케팅 분석 API 호출
|
// 업체명·주소 수동 입력으로 마케팅 분석 API 호출
|
||||||
const handleManualInput = async (businessName: string, address: string, category: string, officialSiteUrl?: string) => {
|
const handleManualInput = async (businessName: string, address: string) => {
|
||||||
goToWizardStep(-1);
|
goToWizardStep(-1);
|
||||||
setIsAnalysisComplete(false);
|
setIsAnalysisComplete(false);
|
||||||
setAnalysisError(null);
|
setAnalysisError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await marketingAnalysis(businessName, address, category, officialSiteUrl);
|
const data = await marketingAnalysis(businessName, address);
|
||||||
|
|
||||||
if (data.processed_info) {
|
if (data.processed_info) {
|
||||||
data.processed_info.customer_name = data.processed_info.customer_name || businessName;
|
data.processed_info.customer_name = data.processed_info.customer_name || businessName;
|
||||||
@ -325,25 +294,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 언어 변경 시 테스트 데이터 다시 로드
|
// 테스트용 랜덤 m_id 생성 (99 ~ 300)
|
||||||
useEffect(() => {
|
|
||||||
const saved = localStorage.getItem(ANALYSIS_DATA_KEY);
|
|
||||||
if (!saved) return;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(saved);
|
|
||||||
if (!parsed._isTestData) return;
|
|
||||||
const jsonFile = i18n.language === 'en' ? '/example_analysis_en.json' : '/example_analysis.json';
|
|
||||||
fetch(jsonFile)
|
|
||||||
.then(res => res.json())
|
|
||||||
.then((data: CrawlingResponse) => {
|
|
||||||
// 언어 변경 시에는 기존 m_id 유지
|
|
||||||
const tagged = { ...data, m_id: parsed.m_id, _isTestData: true };
|
|
||||||
setAnalysisData(tagged);
|
|
||||||
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged));
|
|
||||||
})
|
|
||||||
.catch(err => console.error('Failed to reload test data:', err));
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}, [i18n.language]);
|
|
||||||
|
|
||||||
// URL 분석 시작
|
// URL 분석 시작
|
||||||
const handleStartAnalysis = async (url: string) => {
|
const handleStartAnalysis = async (url: string) => {
|
||||||
@ -456,6 +407,13 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||||||
// 새 프로젝트 만들기 - 단계별 컨텐츠 렌더링
|
// 새 프로젝트 만들기 - 단계별 컨텐츠 렌더링
|
||||||
const renderWizardContent = () => {
|
const renderWizardContent = () => {
|
||||||
switch (wizardStep) {
|
switch (wizardStep) {
|
||||||
|
case -3:
|
||||||
|
// 숏폼 생성 단계
|
||||||
|
return (
|
||||||
|
<ShortformContent
|
||||||
|
onComplete={refreshCredits}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case -2:
|
case -2:
|
||||||
// URL 입력 단계
|
// URL 입력 단계
|
||||||
return (
|
return (
|
||||||
@ -463,6 +421,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||||||
onAnalyze={handleStartAnalysis}
|
onAnalyze={handleStartAnalysis}
|
||||||
onAutocomplete={handleAutocomplete}
|
onAutocomplete={handleAutocomplete}
|
||||||
onManualInput={handleManualInput}
|
onManualInput={handleManualInput}
|
||||||
|
onShortform={() => goToWizardStep(-3)}
|
||||||
error={analysisError}
|
error={analysisError}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@ -519,7 +478,6 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||||||
businessInfo={currentBusinessInfo}
|
businessInfo={currentBusinessInfo}
|
||||||
imageTaskId={imageTaskId}
|
imageTaskId={imageTaskId}
|
||||||
mId={analysisData?.m_id ?? 0}
|
mId={analysisData?.m_id ?? 0}
|
||||||
industry={analysisData?.industry ?? ''}
|
|
||||||
videoGenerationStatus={videoGenerationStatus}
|
videoGenerationStatus={videoGenerationStatus}
|
||||||
videoGenerationProgress={videoGenerationProgress}
|
videoGenerationProgress={videoGenerationProgress}
|
||||||
onGoToPayment={handleGoToPayment}
|
onGoToPayment={handleGoToPayment}
|
||||||
@ -575,22 +533,12 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||||||
case '내 정보':
|
case '내 정보':
|
||||||
return <MyInfoContent initialTab={myInfoInitialTab} />;
|
return <MyInfoContent initialTab={myInfoInitialTab} />;
|
||||||
case '새 프로젝트 만들기':
|
case '새 프로젝트 만들기':
|
||||||
// 로딩(-1), URL 입력(-2)은 스텝퍼 없이 전체 화면으로 표시
|
// 브랜드 분석(0), 로딩(-1), 숏폼(-3)은 전체 화면으로 표시
|
||||||
if (wizardStep === -1 || wizardStep === -2) {
|
if (wizardStep === 0 || wizardStep === -1 || wizardStep === -3) {
|
||||||
return renderWizardContent();
|
return renderWizardContent();
|
||||||
}
|
}
|
||||||
// 브랜드 분석(0)은 전체 화면 스크롤이지만 스텝퍼는 표시
|
|
||||||
if (wizardStep === 0) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<WizardStepper currentStep={wizardStep} />
|
|
||||||
{renderWizardContent()}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div className="wizard-page-container">
|
<div className="wizard-page-container">
|
||||||
<WizardStepper currentStep={wizardStep} />
|
|
||||||
{renderWizardContent()}
|
{renderWizardContent()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -682,7 +630,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||||||
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} credits={credits} tutorialAvailable={!!getCurrentTutorialKey()} tutorialEnabled={tutorial.isEnabled} onToggleTutorial={() => tutorial.toggleTutorial(getCurrentTutorialKey())} />
|
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} credits={credits} tutorialAvailable={!!getCurrentTutorialKey()} tutorialEnabled={tutorial.isEnabled} onToggleTutorial={() => tutorial.toggleTutorial(getCurrentTutorialKey())} />
|
||||||
)}
|
)}
|
||||||
{tutorialUI}
|
{tutorialUI}
|
||||||
<div className={`flex-1 relative pl-0 md:pl-0 bg-[#001a1c] ${needsScroll ? 'overflow-auto' : 'h-full overflow-hidden'}`}>
|
<div className={`flex-1 relative pl-0 md:pl-0 ${needsScroll ? 'overflow-auto' : 'h-full overflow-hidden'}`}>
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import { getVideosList, deleteVideo } from '../../utils/api';
|
|||||||
import { VideoListItem } from '../../types/api';
|
import { VideoListItem } from '../../types/api';
|
||||||
import SocialPostingModal from '../../components/SocialPostingModal';
|
import SocialPostingModal from '../../components/SocialPostingModal';
|
||||||
import VideoDetailModal from '../../components/VideoDetailModal';
|
import VideoDetailModal from '../../components/VideoDetailModal';
|
||||||
import ContentCardSocialActions from '../../components/ContentCardSocialActions';
|
|
||||||
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
||||||
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
||||||
|
|
||||||
@ -248,15 +247,7 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
|
|||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
onClick={() => setSelectedVideoId(video.video_id)}
|
onClick={() => setSelectedVideoId(video.video_id)}
|
||||||
>
|
>
|
||||||
{video.poster_url ? (
|
{video.result_movie_url ? (
|
||||||
<img
|
|
||||||
src={video.poster_url}
|
|
||||||
alt={video.store_name}
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
className="content-video-preview"
|
|
||||||
/>
|
|
||||||
) : video.result_movie_url ? (
|
|
||||||
<VideoPreviewCard
|
<VideoPreviewCard
|
||||||
src={video.result_movie_url}
|
src={video.result_movie_url}
|
||||||
className="content-video-preview"
|
className="content-video-preview"
|
||||||
@ -287,15 +278,16 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
|
|||||||
<p className="content-card-date">
|
<p className="content-card-date">
|
||||||
{formatDate(video.created_at)}
|
{formatDate(video.created_at)}
|
||||||
</p>
|
</p>
|
||||||
<ContentCardSocialActions
|
<span className="content-card-like">
|
||||||
videoId={video.video_id}
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2">
|
||||||
storeName={video.store_name}
|
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
|
||||||
region={video.region}
|
</svg>
|
||||||
title={video.title}
|
{video.like_count ?? 0}
|
||||||
commentCount={video.comment_count ?? 0}
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginLeft: '6px' }}>
|
||||||
initialLikeCount={video.like_count ?? 0}
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||||
initialIsLiked={video.is_liked_by_me}
|
</svg>
|
||||||
/>
|
{video.comment_count ?? 0}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
478
src/pages/Dashboard/ShortformContent.tsx
Normal file
478
src/pages/Dashboard/ShortformContent.tsx
Normal file
@ -0,0 +1,478 @@
|
|||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
import { uploadImages, generateShortform, waitForShortformComplete, previewShortform } from '../../utils/api';
|
||||||
|
import { ShortformStatusResponse, VideoSpec } from '../../types/api';
|
||||||
|
|
||||||
|
type Kind = 'place' | 'product' | 'message';
|
||||||
|
type Step = 1 | 2 | 3 | 4;
|
||||||
|
|
||||||
|
interface ShortformContentProps {
|
||||||
|
onComplete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIND_OPTIONS: { value: Kind; label: string; desc: string }[] = [
|
||||||
|
{ value: 'place', label: '장소', desc: '펜션·카페·식당·매장 등 공간' },
|
||||||
|
{ value: 'product', label: '물건', desc: '제품, 음식 등 시각적인 아이템' },
|
||||||
|
{ value: 'message', label: '메시지', desc: '축하·안부·홍보 등' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STEP_META = [
|
||||||
|
{ label: '사진 업로드', sub: '4~5장' },
|
||||||
|
{ label: '정보 입력', sub: '몇 가지만 답하기' },
|
||||||
|
{ label: '영상 생성', sub: 'AI 카메라 + 자막' },
|
||||||
|
{ label: '완성', sub: '다운로드' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MIN_PHOTOS = 4;
|
||||||
|
const MIN_PHOTOS_LABEL = `${MIN_PHOTOS}장`;
|
||||||
|
|
||||||
|
const ShortformContent: React.FC<ShortformContentProps> = ({ onComplete }) => {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const dropRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 단계
|
||||||
|
const [step, setStep] = useState<Step>(1);
|
||||||
|
|
||||||
|
// Step 1 — 사진
|
||||||
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
const [previews, setPreviews] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Step 2 — 폼
|
||||||
|
const [kind, setKind] = useState<Kind>('place');
|
||||||
|
const [bizName, setBizName] = useState('');
|
||||||
|
const [selling, setSelling] = useState('');
|
||||||
|
const [addr, setAddr] = useState('');
|
||||||
|
const [price, setPrice] = useState('');
|
||||||
|
|
||||||
|
// Step 3/4 — 생성 상태
|
||||||
|
const [statusMsg, setStatusMsg] = useState('');
|
||||||
|
const [result, setResult] = useState<ShortformStatusResponse | null>(null);
|
||||||
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
|
|
||||||
|
// 자막 미리보기
|
||||||
|
const [preview, setPreview] = useState<VideoSpec | null>(null);
|
||||||
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
|
const [previewError, setPreviewError] = useState('');
|
||||||
|
|
||||||
|
// --- 사진 관리 ---
|
||||||
|
const addFiles = (newFiles: File[]) => {
|
||||||
|
const images = newFiles.filter(f => f.type.startsWith('image/'));
|
||||||
|
if (!images.length) return;
|
||||||
|
const newUrls = images.map(f => URL.createObjectURL(f));
|
||||||
|
setFiles(prev => [...prev, ...images]);
|
||||||
|
setPreviews(prev => [...prev, ...newUrls]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFile = (index: number) => {
|
||||||
|
URL.revokeObjectURL(previews[index]);
|
||||||
|
setFiles(prev => prev.filter((_, i) => i !== index));
|
||||||
|
setPreviews(prev => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); };
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
addFiles(Array.from(e.dataTransfer.files));
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 미리보기 필드 수정 ---
|
||||||
|
const patchPreview = (updater: (prev: NonNullable<typeof preview>) => NonNullable<typeof preview>) => {
|
||||||
|
setPreview(prev => prev ? updater(prev) : prev);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 자막 미리보기 ---
|
||||||
|
const handlePreview = async () => {
|
||||||
|
if (!selling.trim()) return;
|
||||||
|
setPreviewLoading(true);
|
||||||
|
setPreviewError('');
|
||||||
|
try {
|
||||||
|
const spec = await previewShortform({
|
||||||
|
kind,
|
||||||
|
biz_name: bizName.trim(),
|
||||||
|
selling: selling.trim(),
|
||||||
|
addr: addr.trim() || undefined,
|
||||||
|
price: price.trim() || undefined,
|
||||||
|
});
|
||||||
|
setPreview(spec);
|
||||||
|
} catch (err) {
|
||||||
|
setPreviewError(err instanceof Error ? err.message : '미리보기 생성에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setPreviewLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 다운로드 ---
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
const handleDownload = async (url: string) => {
|
||||||
|
if (isDownloading) return;
|
||||||
|
setIsDownloading(true);
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url);
|
||||||
|
const blob = await resp.blob();
|
||||||
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = blobUrl;
|
||||||
|
link.download = `${bizName || 'shortform'}.mp4`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(blobUrl);
|
||||||
|
} catch {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `${bizName || 'shortform'}.mp4`;
|
||||||
|
link.target = '_blank';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
} finally {
|
||||||
|
setIsDownloading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 생성 실행 ---
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
setStep(3);
|
||||||
|
setErrorMsg('');
|
||||||
|
setResult(null);
|
||||||
|
try {
|
||||||
|
setStatusMsg('사진 업로드 중...');
|
||||||
|
const uploadResp = await uploadImages([], files);
|
||||||
|
|
||||||
|
setStatusMsg('영상 생성 요청 중...');
|
||||||
|
await generateShortform({
|
||||||
|
task_id: uploadResp.task_id,
|
||||||
|
kind,
|
||||||
|
biz_name: bizName.trim(),
|
||||||
|
addr: addr.trim() || undefined,
|
||||||
|
price: price.trim() || undefined,
|
||||||
|
selling: selling.trim(),
|
||||||
|
spec: preview ?? undefined,
|
||||||
|
dry_run: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
setStatusMsg('AI 카메라 무브·자막 렌더링 중...');
|
||||||
|
const statusResp = await waitForShortformComplete(uploadResp.task_id, s => {
|
||||||
|
setStatusMsg(s === 'processing' ? 'AI 카메라 무브·자막 렌더링 중...' : s);
|
||||||
|
});
|
||||||
|
|
||||||
|
setResult(statusResp);
|
||||||
|
setStep(4);
|
||||||
|
onComplete?.();
|
||||||
|
} catch (err) {
|
||||||
|
setErrorMsg(err instanceof Error ? err.message : '생성 중 오류가 발생했습니다.');
|
||||||
|
setStep(2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const step1Valid = files.length >= MIN_PHOTOS;
|
||||||
|
const step2Valid = bizName.trim() && selling.trim();
|
||||||
|
|
||||||
|
// ===================== 렌더 =====================
|
||||||
|
|
||||||
|
// 스텝 인디케이터
|
||||||
|
const StepBar = () => (
|
||||||
|
<div className="sf-step-bar">
|
||||||
|
{STEP_META.map((s, i) => {
|
||||||
|
const n = (i + 1) as Step;
|
||||||
|
const done = step > n;
|
||||||
|
const active = step === n;
|
||||||
|
return (
|
||||||
|
<div key={n} className={`sf-step-item ${active ? 'active' : ''} ${done ? 'done' : ''}`}>
|
||||||
|
<div className="sf-step-circle">
|
||||||
|
{done
|
||||||
|
? <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3"><polyline points="20 6 9 17 4 12"/></svg>
|
||||||
|
: n}
|
||||||
|
</div>
|
||||||
|
<div className="sf-step-label">
|
||||||
|
<span className="sf-step-name">{s.label}</span>
|
||||||
|
<span className="sf-step-sub">{s.sub}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Step 1: 사진 업로드 ----
|
||||||
|
if (step === 1) return (
|
||||||
|
<main className="sf-page">
|
||||||
|
<div className="sf-scroll-area">
|
||||||
|
<div className="sf-hero">
|
||||||
|
<h1 className="sf-hero-title">사진 몇 장과 한마디면, <span className="sf-hero-accent">8초 홍보 영상</span>이 됩니다</h1>
|
||||||
|
<p className="sf-hero-desc">복잡한 분석 없이 — 사진을 올리고 몇 가지만 답하면, AI 카메라 무브와 자막을 입힌 세로형 숏폼이 완성됩니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StepBar />
|
||||||
|
|
||||||
|
<div className="sf-card">
|
||||||
|
<h2 className="sf-card-title">사진 업로드</h2>
|
||||||
|
<p className="sf-card-desc">홍보할 장소나 물건 사진 4~5장. 전경 1장·디테일 2~3장을 섞으면 영상의 서사가 살아납니다.</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={dropRef}
|
||||||
|
className="sf-drop-zone"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
<div className="sf-drop-icon">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||||||
|
</div>
|
||||||
|
<p className="sf-drop-text">여기로 사진을 끌어다 놓거나 클릭</p>
|
||||||
|
<p className="sf-drop-hint">JPG·PNG·{MIN_PHOTOS_LABEL} 이상</p>
|
||||||
|
</div>
|
||||||
|
<input ref={fileInputRef} type="file" accept="image/*" multiple style={{ display: 'none' }} onChange={e => { addFiles(Array.from(e.target.files || [])); e.target.value = ''; }} />
|
||||||
|
|
||||||
|
{previews.length > 0 && (
|
||||||
|
<div className="sf-photo-grid">
|
||||||
|
{previews.map((url, i) => (
|
||||||
|
<div key={i} className="sf-photo-item">
|
||||||
|
<span className="sf-photo-num">{i + 1}</span>
|
||||||
|
<img src={url} alt={`사진 ${i + 1}`} />
|
||||||
|
<button className="sf-photo-remove" onClick={() => removeFile(i)}>×</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sf-footer">
|
||||||
|
<div className="sf-footer-info">
|
||||||
|
<span className="sf-footer-count">{files.length}장</span>
|
||||||
|
{step1Valid
|
||||||
|
? <span className="sf-footer-status ok">업로드됨·준비 완료</span>
|
||||||
|
: <span className="sf-footer-status">{MIN_PHOTOS - files.length}장 더 추가해주세요</span>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="sf-btn-next"
|
||||||
|
disabled={!step1Valid}
|
||||||
|
onClick={() => setStep(2)}
|
||||||
|
>
|
||||||
|
다음: 정보 입력 →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Step 2: 정보 입력 ----
|
||||||
|
if (step === 2) return (
|
||||||
|
<main className="sf-page">
|
||||||
|
<div className="sf-scroll-area">
|
||||||
|
<StepBar />
|
||||||
|
|
||||||
|
<div className="sf-card">
|
||||||
|
<h2 className="sf-card-title">몇 가지만 알려주세요</h2>
|
||||||
|
<p className="sf-card-desc">사장님·마케터가 가장 잘 아는 정보로 카피를 만듭니다.</p>
|
||||||
|
|
||||||
|
{/* Kind */}
|
||||||
|
<div className="sf-field">
|
||||||
|
<label className="sf-label">영상의 목적이 무엇인가요? <span className="sf-required">*</span></label>
|
||||||
|
<div className="sf-kind-grid">
|
||||||
|
{KIND_OPTIONS.map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
className={`sf-kind-card ${kind === opt.value ? 'active' : ''}`}
|
||||||
|
onClick={() => setKind(opt.value)}
|
||||||
|
>
|
||||||
|
<span className="sf-kind-label">{opt.label}</span>
|
||||||
|
<span className="sf-kind-desc">{opt.desc}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 업체명 */}
|
||||||
|
<div className="sf-field">
|
||||||
|
<label className="sf-label">업체·상품·메시지 제목 <span className="sf-required">*</span></label>
|
||||||
|
<input
|
||||||
|
className="sf-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="예: 스테이 머뭄"
|
||||||
|
value={bizName}
|
||||||
|
onChange={e => setBizName(e.target.value)}
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 셀링포인트 */}
|
||||||
|
<div className="sf-field">
|
||||||
|
<label className="sf-label">가장 자랑하고 싶은 한 가지 <span className="sf-required">*</span></label>
|
||||||
|
<textarea
|
||||||
|
className="sf-textarea"
|
||||||
|
placeholder="예: 열방 손님 없는 완전한 독채, 개별 정원까지"
|
||||||
|
value={selling}
|
||||||
|
onChange={e => setSelling(e.target.value)}
|
||||||
|
maxLength={100}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<p className="sf-field-hint">이 한마디가 영상의 후킹 타이틀·핵심 메시지가 됩니다. 구체적일수록 좋아요.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 자막 미리보기 */}
|
||||||
|
<div className="sf-field">
|
||||||
|
<div className="sf-subtitle-header">
|
||||||
|
<label className="sf-label">영상 자막·스크립트</label>
|
||||||
|
<button
|
||||||
|
className="sf-subtitle-btn"
|
||||||
|
disabled={!selling.trim() || previewLoading}
|
||||||
|
onClick={handlePreview}
|
||||||
|
>
|
||||||
|
{previewLoading ? '생성 중...' : preview ? '재생성하기' : '자막 생성하기'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="sf-field-hint">위 한 가지를 적은 뒤 누르면, AI가 인트로·셀링포인트·감성 스토리·CTA 4가지를 만들어줍니다.</p>
|
||||||
|
{previewError && <p className="sf-field-error">{previewError}</p>}
|
||||||
|
{preview && (
|
||||||
|
<div className="sf-preview-spec">
|
||||||
|
<div className="sf-preview-block">
|
||||||
|
<span className="sf-preview-tag">인트로</span>
|
||||||
|
<input className="sf-preview-input" value={preview.hook.eyebrow}
|
||||||
|
onChange={e => patchPreview(p => ({ ...p, hook: { ...p.hook, eyebrow: e.target.value } }))} />
|
||||||
|
<input className="sf-preview-input sf-preview-input-bold" value={preview.hook.title}
|
||||||
|
onChange={e => patchPreview(p => ({ ...p, hook: { ...p.hook, title: e.target.value } }))} />
|
||||||
|
</div>
|
||||||
|
<div className="sf-preview-block">
|
||||||
|
<span className="sf-preview-tag">셀링포인트</span>
|
||||||
|
<div className="sf-preview-badge-inputs">
|
||||||
|
{preview.selling_point.items.slice(0, 3).map((item, i) => (
|
||||||
|
<input key={i} className="sf-preview-badge-input" value={item}
|
||||||
|
onChange={e => patchPreview(p => {
|
||||||
|
const items = [...p.selling_point.items];
|
||||||
|
items[i] = e.target.value;
|
||||||
|
return { ...p, selling_point: { items } };
|
||||||
|
})} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sf-preview-block">
|
||||||
|
<span className="sf-preview-tag">감성 스토리</span>
|
||||||
|
{preview.brand_lines.slice(0, 2).map((line, i) => (
|
||||||
|
<input key={i} className="sf-preview-input" value={line}
|
||||||
|
onChange={e => patchPreview(p => {
|
||||||
|
const brand_lines = [...p.brand_lines];
|
||||||
|
brand_lines[i] = e.target.value;
|
||||||
|
return { ...p, brand_lines };
|
||||||
|
})} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="sf-preview-block">
|
||||||
|
<span className="sf-preview-tag">CTA</span>
|
||||||
|
<input className="sf-preview-input" value={preview.end_card.brand}
|
||||||
|
onChange={e => patchPreview(p => ({ ...p, end_card: { ...p.end_card, brand: e.target.value } }))} />
|
||||||
|
<input className="sf-preview-input" value={preview.end_card.location}
|
||||||
|
onChange={e => patchPreview(p => ({ ...p, end_card: { ...p.end_card, location: e.target.value } }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 주소 */}
|
||||||
|
<div className="sf-field">
|
||||||
|
<label className="sf-label">주소 또는 판매 사이트 URL <span className="sf-optional">(선택)</span></label>
|
||||||
|
<input
|
||||||
|
className="sf-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="예: 전북 군산시 절골길 18"
|
||||||
|
value={addr}
|
||||||
|
onChange={e => setAddr(e.target.value)}
|
||||||
|
maxLength={100}
|
||||||
|
/>
|
||||||
|
<p className="sf-field-hint">영상 엔드카드·해시태그에 활용됩니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 가격 */}
|
||||||
|
<div className="sf-field">
|
||||||
|
<label className="sf-label">가격 정보 <span className="sf-optional">(선택)</span></label>
|
||||||
|
<input
|
||||||
|
className="sf-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="예: 1박 19만원~"
|
||||||
|
value={price}
|
||||||
|
onChange={e => setPrice(e.target.value)}
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sf-footer sf-footer-two">
|
||||||
|
<button className="sf-btn-prev" onClick={() => setStep(1)}>← 사진 다시</button>
|
||||||
|
<button className="sf-btn-generate" disabled={!step2Valid} onClick={handleGenerate}>
|
||||||
|
영상 생성 →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errorMsg && (
|
||||||
|
<div className="sf-error-toast">{errorMsg}</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Step 3: 생성 중 ----
|
||||||
|
if (step === 3) return (
|
||||||
|
<main className="sf-page">
|
||||||
|
<div className="sf-scroll-area">
|
||||||
|
<StepBar />
|
||||||
|
<div className="sf-generating">
|
||||||
|
<div className="sf-gen-orb-ring">
|
||||||
|
<div className="sf-gen-spinner-lg" />
|
||||||
|
<div className="sf-gen-icon">
|
||||||
|
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M15 10l4.553-2.069A1 1 0 0121 8.87V15.13a1 1 0 01-1.447.899L15 14M3 8a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V8z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 className="sf-gen-title">AI가 영상을 만들고 있어요</h2>
|
||||||
|
<p className="sf-gen-msg">{statusMsg || 'AI 카메라 무브·자막 렌더링 중...'}</p>
|
||||||
|
<p className="sf-gen-sub">보통 1~3분 정도 소요됩니다. 잠시만 기다려주세요.</p>
|
||||||
|
<div className="sf-gen-steps">
|
||||||
|
<div className={`sf-gen-step ${statusMsg.includes('업로드') ? 'active' : statusMsg ? 'done' : ''}`}>
|
||||||
|
<span className="sf-gen-step-dot" />사진 업로드
|
||||||
|
</div>
|
||||||
|
<div className={`sf-gen-step ${statusMsg.includes('요청') ? 'active' : statusMsg.includes('렌더링') || statusMsg.includes('완성') ? 'done' : ''}`}>
|
||||||
|
<span className="sf-gen-step-dot" />영상 생성 요청
|
||||||
|
</div>
|
||||||
|
<div className={`sf-gen-step ${statusMsg.includes('렌더링') ? 'active' : ''}`}>
|
||||||
|
<span className="sf-gen-step-dot" />AI 렌더링
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Step 4: 완성 ----
|
||||||
|
return (
|
||||||
|
<main className="sf-page">
|
||||||
|
<div className="sf-scroll-area">
|
||||||
|
<StepBar />
|
||||||
|
<div className="sf-result">
|
||||||
|
<p className="sf-result-label">✓ 영상이 완성됐습니다!</p>
|
||||||
|
{result?.result_url && (
|
||||||
|
<div className="sf-result-video-wrap">
|
||||||
|
<video className="sf-result-video" src={result.result_url} controls playsInline />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="sf-result-actions">
|
||||||
|
{result?.result_url && (
|
||||||
|
<button
|
||||||
|
className="sf-download-btn"
|
||||||
|
disabled={isDownloading}
|
||||||
|
onClick={() => handleDownload(result.result_url!)}
|
||||||
|
>
|
||||||
|
{isDownloading ? '다운로드 중...' : '다운로드'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="sf-btn-prev" onClick={() => { setStep(1); setFiles([]); setPreviews([]); setBizName(''); setSelling(''); setAddr(''); setPrice(''); }}>
|
||||||
|
새 영상 만들기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShortformContent;
|
||||||
@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { generateLyric, waitForLyricComplete, generateSong, waitForSongComplete } from '../../utils/api';
|
import { generateLyric, waitForLyricComplete, generateSong, waitForSongComplete } from '../../utils/api';
|
||||||
import { LANGUAGE_MAP } from '../../types/api';
|
import { LANGUAGE_MAP } from '../../types/api';
|
||||||
@ -16,7 +16,6 @@ interface SoundStudioContentProps {
|
|||||||
businessInfo?: BusinessInfo;
|
businessInfo?: BusinessInfo;
|
||||||
imageTaskId: string | null;
|
imageTaskId: string | null;
|
||||||
mId: number;
|
mId: number;
|
||||||
industry?: string;
|
|
||||||
onStatusChange?: (status: string) => void;
|
onStatusChange?: (status: string) => void;
|
||||||
videoGenerationStatus?: 'idle' | 'generating' | 'complete' | 'error';
|
videoGenerationStatus?: 'idle' | 'generating' | 'complete' | 'error';
|
||||||
videoGenerationProgress?: number;
|
videoGenerationProgress?: number;
|
||||||
@ -27,46 +26,6 @@ type GenerationStatus = 'idle' | 'generating_lyric' | 'generating_song' | 'polli
|
|||||||
|
|
||||||
const MAX_RETRY_COUNT = 3;
|
const MAX_RETRY_COUNT = 3;
|
||||||
|
|
||||||
// 생성 완료된 곡 상태를 새로고침 후에도 복원하기 위한 키
|
|
||||||
// GenerationFlow의 clearAllProjectStorage가 이 키를 포함해 정리함
|
|
||||||
const SONG_GENERATION_KEY = 'castad_song_generation';
|
|
||||||
|
|
||||||
const GENRE_VALUES = ['kpop', 'pop', 'ballad', 'hip-hop', 'rnb', 'edm', 'jazz', 'rock'];
|
|
||||||
|
|
||||||
const genreMap: Record<string, string> = {
|
|
||||||
'자동 선택': '', // 런타임에 랜덤 결정
|
|
||||||
'K-POP': 'kpop',
|
|
||||||
'POP': 'pop',
|
|
||||||
'발라드': 'ballad',
|
|
||||||
'Hip-Hop': 'hip-hop',
|
|
||||||
'R&B': 'rnb',
|
|
||||||
'EDM': 'edm',
|
|
||||||
'JAZZ': 'jazz',
|
|
||||||
'ROCK': 'rock',
|
|
||||||
};
|
|
||||||
|
|
||||||
const getEffectiveGenre = (selectedGenre: string): string => {
|
|
||||||
if (selectedGenre === '자동 선택') {
|
|
||||||
return GENRE_VALUES[Math.floor(Math.random() * GENRE_VALUES.length)];
|
|
||||||
}
|
|
||||||
return genreMap[selectedGenre] || 'pop';
|
|
||||||
};
|
|
||||||
|
|
||||||
// 실제 영상 생성에 사용된 genre 값(kpop, hip-hop 등)을 화면 표시용 라벨로 변환
|
|
||||||
const getGenreDisplayLabel = (genreValue: string, t: (key: string) => string): string => {
|
|
||||||
const displayMap: Record<string, string> = {
|
|
||||||
kpop: 'K-POP',
|
|
||||||
pop: 'POP',
|
|
||||||
ballad: t('soundStudio.genreBallad'),
|
|
||||||
'hip-hop': 'Hip-Hop',
|
|
||||||
rnb: 'R&B',
|
|
||||||
edm: 'EDM',
|
|
||||||
jazz: 'JAZZ',
|
|
||||||
rock: 'ROCK',
|
|
||||||
};
|
|
||||||
return displayMap[genreValue] || genreValue;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sanitizeError = (message: string, fallback: string): string => {
|
const sanitizeError = (message: string, fallback: string): string => {
|
||||||
if (
|
if (
|
||||||
message.includes('Traceback') ||
|
message.includes('Traceback') ||
|
||||||
@ -95,7 +54,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||||||
businessInfo,
|
businessInfo,
|
||||||
imageTaskId,
|
imageTaskId,
|
||||||
mId,
|
mId,
|
||||||
industry = '',
|
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
videoGenerationStatus = 'idle',
|
videoGenerationStatus = 'idle',
|
||||||
videoGenerationProgress = 0,
|
videoGenerationProgress = 0,
|
||||||
@ -105,8 +63,8 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||||||
const [selectedType, setSelectedType] = useState('보컬');
|
const [selectedType, setSelectedType] = useState('보컬');
|
||||||
const [selectedLang, setSelectedLang] = useState('한국어');
|
const [selectedLang, setSelectedLang] = useState('한국어');
|
||||||
const [selectedGenre, setSelectedGenre] = useState('자동 선택');
|
const [selectedGenre, setSelectedGenre] = useState('자동 선택');
|
||||||
const [usedGenre, setUsedGenre] = useState('');
|
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [status, setStatus] = useState<GenerationStatus>('idle');
|
const [status, setStatus] = useState<GenerationStatus>('idle');
|
||||||
const [lyrics, setLyrics] = useState('');
|
const [lyrics, setLyrics] = useState('');
|
||||||
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||||
@ -117,40 +75,20 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||||||
const [statusMessage, setStatusMessage] = useState('');
|
const [statusMessage, setStatusMessage] = useState('');
|
||||||
const [retryCount, setRetryCount] = useState(0);
|
const [retryCount, setRetryCount] = useState(0);
|
||||||
const [songTaskId, setSongTaskId] = useState<string | null>(null);
|
const [songTaskId, setSongTaskId] = useState<string | null>(null);
|
||||||
|
const progressBarRef = useRef<HTMLDivElement>(null);
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
|
||||||
// 완료 데이터를 localStorage에 저장
|
// 완료 데이터를 localStorage에 저장
|
||||||
const saveSongCompletionData = () => {
|
const saveSongCompletionData = () => {
|
||||||
const completionData = {
|
const completionData = {
|
||||||
businessName: businessInfo?.customer_name || '알 수 없음',
|
businessName: businessInfo?.customer_name || '알 수 없음',
|
||||||
// '자동 선택'처럼 UI에서 고른 값이 아니라, 실제 영상 생성에 사용된 genre(usedGenre)를 저장
|
genre: selectedGenre === '자동 선택' ? 'K-POP' : selectedGenre,
|
||||||
genre: usedGenre ? getGenreDisplayLabel(usedGenre, t) : selectedGenre,
|
|
||||||
lyrics: lyrics,
|
lyrics: lyrics,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
localStorage.setItem('castad_song_completion', JSON.stringify(completionData));
|
localStorage.setItem('castad_song_completion', JSON.stringify(completionData));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 새로고침 후 생성 완료된 곡 상태 복원
|
|
||||||
useEffect(() => {
|
|
||||||
const saved = localStorage.getItem(SONG_GENERATION_KEY);
|
|
||||||
if (!saved) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(saved);
|
|
||||||
if (parsed.status !== 'complete' || !parsed.audioUrl || !parsed.songTaskId) return;
|
|
||||||
|
|
||||||
// 저장된 URL로 복원 (public Blob URL이라 만료되지 않음)
|
|
||||||
// 백엔드에 /song/download 엔드포인트가 없으므로 재조회하지 않는다.
|
|
||||||
setSongTaskId(parsed.songTaskId);
|
|
||||||
setAudioUrl(parsed.audioUrl);
|
|
||||||
setLyrics(parsed.lyrics ?? '');
|
|
||||||
setStatus('complete');
|
|
||||||
} catch {
|
|
||||||
// 파싱 실패 시 무시
|
|
||||||
}
|
|
||||||
}, []); // 마운트 1회만 실행
|
|
||||||
|
|
||||||
// Close language dropdown when clicking outside
|
// Close language dropdown when clicking outside
|
||||||
// Auto-navigate to next page when video generation is complete
|
// Auto-navigate to next page when video generation is complete
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -190,18 +128,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||||||
setStatusMessage('');
|
setStatusMessage('');
|
||||||
setRetryCount(0);
|
setRetryCount(0);
|
||||||
|
|
||||||
// 새로고침 후에도 복원할 수 있도록 완료된 곡 상태를 저장
|
|
||||||
try {
|
|
||||||
localStorage.setItem(SONG_GENERATION_KEY, JSON.stringify({
|
|
||||||
audioUrl: statusResponse.song_result_url,
|
|
||||||
songTaskId: taskId,
|
|
||||||
lyrics: currentLyrics,
|
|
||||||
status: 'complete',
|
|
||||||
}));
|
|
||||||
} catch {
|
|
||||||
// localStorage 쓰기 실패는 무시
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Polling failed:', error);
|
console.error('Polling failed:', error);
|
||||||
|
|
||||||
@ -232,13 +158,20 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const language = LANGUAGE_MAP[selectedLang] || 'Korean';
|
const language = LANGUAGE_MAP[selectedLang] || 'Korean';
|
||||||
|
const genreMap: Record<string, string> = {
|
||||||
|
'자동 선택': 'pop',
|
||||||
|
'K-POP': 'kpop',
|
||||||
|
'발라드': 'ballad',
|
||||||
|
'Hip-Hop': 'hip-hop',
|
||||||
|
'R&B': 'rnb',
|
||||||
|
'EDM': 'edm',
|
||||||
|
'JAZZ': 'jazz',
|
||||||
|
'ROCK': 'rock',
|
||||||
|
};
|
||||||
|
|
||||||
const isInstrumental = selectedType === '배경음악';
|
const isInstrumental = selectedType === '배경음악';
|
||||||
const effectiveGenre = getEffectiveGenre(selectedGenre);
|
|
||||||
setUsedGenre(effectiveGenre);
|
|
||||||
|
|
||||||
const songResponse = await generateSong(imageTaskId, {
|
const songResponse = await generateSong(imageTaskId, {
|
||||||
genre: effectiveGenre,
|
genre: genreMap[selectedGenre] || 'pop',
|
||||||
...(isInstrumental ? { instrumental: true, lyrics: '' } : { language, lyrics: currentLyrics }),
|
...(isInstrumental ? { instrumental: true, lyrics: '' } : { language, lyrics: currentLyrics }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -259,6 +192,41 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMove = (clientX: number) => {
|
||||||
|
if (!progressBarRef.current || !audioRef.current) return;
|
||||||
|
const rect = progressBarRef.current.getBoundingClientRect();
|
||||||
|
const newProgress = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
|
||||||
|
const newTime = (newProgress / 100) * duration;
|
||||||
|
audioRef.current.currentTime = newTime;
|
||||||
|
setProgress(newProgress);
|
||||||
|
setCurrentTime(newTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseDown = (e: React.MouseEvent) => {
|
||||||
|
if (!audioUrl) return;
|
||||||
|
setIsDragging(true);
|
||||||
|
handleMove(e.clientX);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onMouseMove = (e: MouseEvent) => {
|
||||||
|
if (isDragging) handleMove(e.clientX);
|
||||||
|
};
|
||||||
|
const onMouseUp = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isDragging) {
|
||||||
|
window.addEventListener('mousemove', onMouseMove);
|
||||||
|
window.addEventListener('mouseup', onMouseUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', onMouseMove);
|
||||||
|
window.removeEventListener('mouseup', onMouseUp);
|
||||||
|
};
|
||||||
|
}, [isDragging]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const audio = audioRef.current;
|
const audio = audioRef.current;
|
||||||
if (!audio) return;
|
if (!audio) return;
|
||||||
@ -318,9 +286,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 새 곡 생성 시작 — 이전에 저장된 완료 곡 제거
|
|
||||||
localStorage.removeItem(SONG_GENERATION_KEY);
|
|
||||||
|
|
||||||
setStatus('generating_lyric');
|
setStatus('generating_lyric');
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
setStatusMessage(t('soundStudio.generatingLyrics'));
|
setStatusMessage(t('soundStudio.generatingLyrics'));
|
||||||
@ -331,9 +296,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||||||
try {
|
try {
|
||||||
const language = LANGUAGE_MAP[selectedLang] || 'Korean';
|
const language = LANGUAGE_MAP[selectedLang] || 'Korean';
|
||||||
const isInstrumental = selectedType === '배경음악';
|
const isInstrumental = selectedType === '배경음악';
|
||||||
// 수동 선택한 장르는 가사 생성 시점부터 넘겨서, 그 장르 템포에 맞는 줄 수로 가사를 쓰게 한다.
|
|
||||||
// '자동 선택'이면 넘기지 않고, 가사 생성 결과로 받은 recommended genre를 그대로 쓴다.
|
|
||||||
const manualGenre = selectedGenre === '자동 선택' ? undefined : genreMap[selectedGenre];
|
|
||||||
|
|
||||||
// 1. 가사 생성 요청
|
// 1. 가사 생성 요청
|
||||||
console.log('[SoundStudio] Sending m_id:', mId);
|
console.log('[SoundStudio] Sending m_id:', mId);
|
||||||
@ -344,20 +306,26 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||||||
m_id: mId,
|
m_id: mId,
|
||||||
region: businessInfo.region,
|
region: businessInfo.region,
|
||||||
task_id: imageTaskId,
|
task_id: imageTaskId,
|
||||||
industry,
|
|
||||||
orientation,
|
orientation,
|
||||||
...(isInstrumental && { instrumental: true }),
|
...(isInstrumental && { instrumental: true }),
|
||||||
...(manualGenre && { genre: manualGenre }),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!lyricResponse.success || !lyricResponse.task_id) {
|
if (!lyricResponse.success || !lyricResponse.task_id) {
|
||||||
throw new Error(lyricResponse.error_message || t('soundStudio.lyricGenerationFailed'));
|
throw new Error(lyricResponse.error_message || t('soundStudio.lyricGenerationFailed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const genreMap: Record<string, string> = {
|
||||||
|
'자동 선택': 'pop',
|
||||||
|
'K-POP': 'kpop',
|
||||||
|
'발라드': 'ballad',
|
||||||
|
'Hip-Hop': 'hip-hop',
|
||||||
|
'R&B': 'rnb',
|
||||||
|
'EDM': 'edm',
|
||||||
|
'JAZZ': 'jazz',
|
||||||
|
'ROCK': 'rock',
|
||||||
|
};
|
||||||
|
|
||||||
let songLyrics: string | undefined;
|
let songLyrics: string | undefined;
|
||||||
// BGM 모드는 백엔드가 가사/장르 추천 없이 즉시 completed 처리하므로 GPT 추천 장르가 없다.
|
|
||||||
// 이 경우에만 기존 랜덤 선택 로직으로 폴백한다.
|
|
||||||
let effectiveGenre = manualGenre || getEffectiveGenre(selectedGenre);
|
|
||||||
|
|
||||||
if (!isInstrumental) {
|
if (!isInstrumental) {
|
||||||
// 2. 가사 생성 상태 폴링 → 완료 시 상세 조회
|
// 2. 가사 생성 상태 폴링 → 완료 시 상세 조회
|
||||||
@ -382,16 +350,13 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||||||
|
|
||||||
setLyrics(lyricDetailResponse.lyric_result);
|
setLyrics(lyricDetailResponse.lyric_result);
|
||||||
songLyrics = lyricDetailResponse.lyric_result;
|
songLyrics = lyricDetailResponse.lyric_result;
|
||||||
// 가사와 함께 확정된 장르(수동 선택값 또는 GPT 추천값)를 그대로 음악 생성에 사용
|
|
||||||
effectiveGenre = lyricDetailResponse.genre || effectiveGenre;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus('generating_song');
|
setStatus('generating_song');
|
||||||
setStatusMessage(t('soundStudio.generatingSong'));
|
setStatusMessage(t('soundStudio.generatingSong'));
|
||||||
setUsedGenre(effectiveGenre);
|
|
||||||
|
|
||||||
const songResponse = await generateSong(imageTaskId, {
|
const songResponse = await generateSong(imageTaskId, {
|
||||||
genre: effectiveGenre,
|
genre: genreMap[selectedGenre] || 'pop',
|
||||||
...(isInstrumental ? { instrumental: true, lyrics: '' } : { language, lyrics: songLyrics }),
|
...(isInstrumental ? { instrumental: true, lyrics: '' } : { language, lyrics: songLyrics }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -437,24 +402,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||||||
|
|
||||||
const isGenerating = status === 'generating_lyric' || status === 'generating_song' || status === 'polling';
|
const isGenerating = status === 'generating_lyric' || status === 'generating_song' || status === 'polling';
|
||||||
|
|
||||||
// 드래그로 인한 잦은 리렌더에서도 가사 파싱(정규식/배열 처리)이 매번 재실행되지 않도록 메모이제이션
|
|
||||||
const lyricsSections = useMemo(() => {
|
|
||||||
if (!lyrics) return [];
|
|
||||||
const lines = lyrics.split('\n').filter(l => l.trim());
|
|
||||||
const sections: { tag: string | null; lines: string[] }[] = [];
|
|
||||||
lines.forEach(line => {
|
|
||||||
const tagMatch = line.trim().match(/^\[(.+)\]$/);
|
|
||||||
if (tagMatch) {
|
|
||||||
sections.push({ tag: `[${tagMatch[1]}]`, lines: [] });
|
|
||||||
} else if (sections.length === 0) {
|
|
||||||
sections.push({ tag: null, lines: [line] });
|
|
||||||
} else {
|
|
||||||
sections[sections.length - 1].lines.push(line);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return sections.filter(s => s.lines.length > 0);
|
|
||||||
}, [lyrics]);
|
|
||||||
|
|
||||||
// status 변경 시 부모에 알림
|
// status 변경 시 부모에 알림
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onStatusChange?.(status);
|
onStatusChange?.(status);
|
||||||
@ -548,7 +495,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="genre-row">
|
<div className="genre-row">
|
||||||
{['JAZZ', 'ROCK', 'POP'].map(genre => (
|
{['JAZZ', 'ROCK'].map(genre => (
|
||||||
<button
|
<button
|
||||||
key={genre}
|
key={genre}
|
||||||
onClick={() => setSelectedGenre(genre)}
|
onClick={() => setSelectedGenre(genre)}
|
||||||
@ -558,6 +505,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||||||
{genre}
|
{genre}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
<div className="genre-btn-placeholder"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -667,8 +615,9 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 진행 표시 전용 — Azure Blob 익명 응답에 Accept-Ranges가 없어 seek가 불안정하므로 드래그 미지원 */}
|
|
||||||
<div
|
<div
|
||||||
|
ref={progressBarRef}
|
||||||
|
onMouseDown={onMouseDown}
|
||||||
className={`audio-progress-container ${!audioUrl ? 'disabled' : ''}`}
|
className={`audio-progress-container ${!audioUrl ? 'disabled' : ''}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@ -684,16 +633,27 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||||||
|
|
||||||
{/* Lyrics Display */}
|
{/* Lyrics Display */}
|
||||||
<div className="lyrics-display">
|
<div className="lyrics-display">
|
||||||
{lyrics ? (
|
{lyrics ? (() => {
|
||||||
|
const lines = lyrics.split('\n').filter(l => l.trim());
|
||||||
|
const outro = lines.slice(-1);
|
||||||
|
const body = lines.slice(0, -1);
|
||||||
|
const half = Math.ceil(body.length / 2);
|
||||||
|
const sections = [
|
||||||
|
{ tag: '[Verse]', lines: body.slice(0, half) },
|
||||||
|
{ tag: '[Chorus]', lines: body.slice(half) },
|
||||||
|
{ tag: '[Outro]', lines: outro },
|
||||||
|
].filter(s => s.lines.length > 0);
|
||||||
|
return (
|
||||||
<div className="lyrics-paragraphs">
|
<div className="lyrics-paragraphs">
|
||||||
{lyricsSections.map((section, i) => (
|
{sections.map((section, i) => (
|
||||||
<div key={i} className="lyrics-section">
|
<div key={i} className="lyrics-section">
|
||||||
{section.tag && <span className="lyrics-tag">{section.tag}</span>}
|
<span className="lyrics-tag">{section.tag}</span>
|
||||||
<p className="lyrics-paragraph">{section.lines.join('\n')}</p>
|
<p className="lyrics-paragraph">{section.lines.join('\n')}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
);
|
||||||
|
})() : (
|
||||||
<div className="lyrics-placeholder">
|
<div className="lyrics-placeholder">
|
||||||
{selectedType === '배경음악' ? t('soundStudio.lyricsPlaceholderBGM') : t('soundStudio.lyricsPlaceholder')}
|
{selectedType === '배경음악' ? t('soundStudio.lyricsPlaceholderBGM') : t('soundStudio.lyricsPlaceholder')}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -5,14 +5,16 @@ import SearchInputForm, { SearchType } from '../../components/SearchInputForm';
|
|||||||
interface UrlInputContentProps {
|
interface UrlInputContentProps {
|
||||||
onAnalyze: (value: string, type?: SearchType) => void;
|
onAnalyze: (value: string, type?: SearchType) => void;
|
||||||
onAutocomplete?: (data: AutocompleteRequest) => void;
|
onAutocomplete?: (data: AutocompleteRequest) => void;
|
||||||
onManualInput?: (businessName: string, address: string, category: string, officialSiteUrl?: string) => void;
|
onManualInput?: (businessName: string, address: string) => void;
|
||||||
|
onShortform?: () => void;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomplete, onManualInput, error }) => {
|
const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomplete, onManualInput, onShortform, error }) => {
|
||||||
return (
|
return (
|
||||||
<div className="url-input-container">
|
<div className="url-input-container">
|
||||||
<div className="url-input-content">
|
<div className="url-input-content">
|
||||||
|
{/* 로고 */}
|
||||||
<div className="url-input-logo">
|
<div className="url-input-logo">
|
||||||
<img src="/assets/images/ADO2_with Slogan_white.svg" alt="ADO2" />
|
<img src="/assets/images/ADO2_with Slogan_white.svg" alt="ADO2" />
|
||||||
</div>
|
</div>
|
||||||
@ -21,6 +23,7 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
|
|||||||
onAnalyze={onAnalyze}
|
onAnalyze={onAnalyze}
|
||||||
onAutocomplete={onAutocomplete}
|
onAutocomplete={onAutocomplete}
|
||||||
onManualInput={onManualInput}
|
onManualInput={onManualInput}
|
||||||
|
onShortform={onShortform}
|
||||||
error={error}
|
error={error}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,14 +1,4 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { AutocompleteRequest, isLoggedIn } from '../../utils/api';
|
|
||||||
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
|
||||||
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
|
||||||
import TutorialOverlay from '../../components/Tutorial/TutorialOverlay';
|
|
||||||
import BusinessNameInputModal from '../../components/BusinessNameInputModal';
|
|
||||||
import LoginPromptModal from '../../components/LoginPromptModal';
|
|
||||||
import SearchInputForm from '../../components/SearchInputForm';
|
|
||||||
import { SearchType } from '../../components/SearchInputForm';
|
|
||||||
|
|
||||||
// Orb configuration with movement zones to prevent overlap
|
// Orb configuration with movement zones to prevent overlap
|
||||||
interface OrbConfig {
|
interface OrbConfig {
|
||||||
@ -33,31 +23,14 @@ const orbConfigs: OrbConfig[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
interface HeroSectionProps {
|
interface HeroSectionProps {
|
||||||
onAnalyze?: (value: string, type?: SearchType) => void;
|
onLogin?: () => void;
|
||||||
onAutocomplete?: (data: AutocompleteRequest) => void;
|
|
||||||
onManualInput?: (businessName: string, address: string, category: string, officialSiteUrl?: string) => void;
|
|
||||||
onNext?: () => void;
|
onNext?: () => void;
|
||||||
error?: string | null;
|
|
||||||
scrollProgress?: number;
|
scrollProgress?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onManualInput, onNext, error: externalError, scrollProgress = 0 }) => {
|
const HeroSection: React.FC<HeroSectionProps> = ({ onLogin, onNext, scrollProgress = 0 }) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
|
|
||||||
const [isLoginPromptOpen, setIsLoginPromptOpen] = useState(false);
|
|
||||||
const orbRefs = useRef<(HTMLDivElement | null)[]>([]);
|
const orbRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
const animationRefs = useRef<number[]>([]);
|
const animationRefs = useRef<number[]>([]);
|
||||||
const tutorial = useTutorial();
|
|
||||||
|
|
||||||
// 첫 방문 시 랜딩 튜토리얼 시작
|
|
||||||
useEffect(() => {
|
|
||||||
if (!tutorial.hasSeen(TUTORIAL_KEYS.LANDING)) {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
tutorial.startTutorial(TUTORIAL_KEYS.LANDING);
|
|
||||||
}, 800);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Orb 랜덤 이동 애니메이션
|
// Orb 랜덤 이동 애니메이션
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -145,56 +118,20 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
|||||||
className="hero-logo"
|
className="hero-logo"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SearchInputForm
|
<button className="hero-login-btn" onClick={onLogin}>
|
||||||
onAnalyze={onAnalyze}
|
로그인
|
||||||
onAutocomplete={onAutocomplete}
|
</button>
|
||||||
onManualInput={onManualInput}
|
|
||||||
onManualButtonClick={() => {
|
|
||||||
if (isLoggedIn()) {
|
|
||||||
setIsManualModalOpen(true);
|
|
||||||
} else {
|
|
||||||
setIsLoginPromptOpen(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
error={externalError || null}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer Indicator */}
|
{/* Footer Indicator */}
|
||||||
<button onClick={onNext} className="scroll-indicator">
|
<button onClick={onNext} className="scroll-indicator">
|
||||||
<span className="scroll-indicator-text">{t('landing.hero.scrollMore')}</span>
|
<span className="scroll-indicator-text">더 알아보기</span>
|
||||||
<div className="scroll-indicator-icon">
|
<div className="scroll-indicator-icon">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M7 10l5 5 5-5" />
|
<path d="M7 10l5 5 5-5" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{tutorial.isActive && !isManualModalOpen && (
|
|
||||||
<TutorialOverlay
|
|
||||||
hints={tutorial.hints}
|
|
||||||
currentIndex={tutorial.currentHintIndex}
|
|
||||||
onNext={tutorial.nextHint}
|
|
||||||
onPrev={tutorial.prevHint}
|
|
||||||
onSkip={tutorial.skipTutorial}
|
|
||||||
groupProgress={tutorial.groupProgress}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isManualModalOpen && (
|
|
||||||
<BusinessNameInputModal
|
|
||||||
onClose={() => setIsManualModalOpen(false)}
|
|
||||||
onSubmit={(businessName, address, category, officialSiteUrl) => {
|
|
||||||
if (tutorial.isActive) tutorial.nextHint();
|
|
||||||
setIsManualModalOpen(false);
|
|
||||||
onManualInput?.(businessName, address, category, officialSiteUrl);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoginPromptOpen && (
|
|
||||||
<LoginPromptModal onClose={() => setIsLoginPromptOpen(false)} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { getKakaoLoginUrl, kakaoCallback, trackCompleteRegistration } from '../../utils/api';
|
import { getKakaoLoginUrl, kakaoCallback } from '../../utils/api';
|
||||||
|
|
||||||
interface LoginSectionProps {
|
interface LoginSectionProps {
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
@ -28,13 +28,7 @@ const LoginSection: React.FC<LoginSectionProps> = ({ onBack, onLogin }) => {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await kakaoCallback(code);
|
await kakaoCallback(code);
|
||||||
|
|
||||||
// 신규 가입이면 Meta CompleteRegistration 전환 이벤트 발화
|
|
||||||
// (서버가 계정당 최초 1회 판정 — App.tsx 경로와 중복 호출돼도 무해)
|
|
||||||
if (response.is_new_user) {
|
|
||||||
await trackCompleteRegistration();
|
|
||||||
}
|
|
||||||
|
|
||||||
// URL에서 code 파라미터 제거
|
// URL에서 code 파라미터 제거
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
|
|||||||
@ -1,13 +1,11 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
const SocialConnectError: React.FC = () => {
|
const SocialConnectError: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
// URL 파라미터 파싱
|
||||||
|
|
||||||
const searchParams = new URLSearchParams(window.location.search);
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
const platform = searchParams.get('platform') || 'unknown';
|
const platform = searchParams.get('platform') || 'unknown';
|
||||||
const errorMessage = searchParams.get('error') || t('social.connectError.defaultError');
|
const errorMessage = searchParams.get('error') || '알 수 없는 오류가 발생했습니다.';
|
||||||
|
|
||||||
const platformName = platform === 'youtube' ? 'YouTube' :
|
const platformName = platform === 'youtube' ? 'YouTube' :
|
||||||
platform === 'instagram' ? 'Instagram' :
|
platform === 'instagram' ? 'Instagram' :
|
||||||
@ -27,19 +25,19 @@ const SocialConnectError: React.FC = () => {
|
|||||||
<line x1="9" y1="9" x2="15" y2="15" />
|
<line x1="9" y1="9" x2="15" y2="15" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="social-connect-title">{t('social.connectError.title', { platform: platformName })}</h1>
|
<h1 className="social-connect-title">{platformName} 연결 실패</h1>
|
||||||
<p className="social-connect-message">
|
<p className="social-connect-message">
|
||||||
{errorMessage}
|
{errorMessage}
|
||||||
</p>
|
</p>
|
||||||
<p className="social-connect-detail">
|
<p className="social-connect-detail">
|
||||||
{t('social.connectError.detail')}
|
다시 시도해주세요. 문제가 지속되면 고객 지원에 문의해주세요.
|
||||||
</p>
|
</p>
|
||||||
<div className="social-connect-actions">
|
<div className="social-connect-actions">
|
||||||
<button
|
<button
|
||||||
onClick={navigateToHome}
|
onClick={navigateToHome}
|
||||||
className="social-connect-button"
|
className="social-connect-button"
|
||||||
>
|
>
|
||||||
{t('social.connectError.retry')}
|
다시 시도
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
|
// 연결된 소셜 계정 정보를 저장하는 localStorage 키
|
||||||
const SOCIAL_CONNECTED_KEY = 'castad_social_connected';
|
const SOCIAL_CONNECTED_KEY = 'castad_social_connected';
|
||||||
|
|
||||||
interface ConnectedSocialAccount {
|
interface ConnectedSocialAccount {
|
||||||
@ -13,9 +13,9 @@ interface ConnectedSocialAccount {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SocialConnectSuccess: React.FC = () => {
|
const SocialConnectSuccess: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [countdown, setCountdown] = useState(3);
|
const [countdown, setCountdown] = useState(3);
|
||||||
|
|
||||||
|
// URL 파라미터 파싱
|
||||||
const searchParams = new URLSearchParams(window.location.search);
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
const platform = searchParams.get('platform') || 'unknown';
|
const platform = searchParams.get('platform') || 'unknown';
|
||||||
const accountId = searchParams.get('account_id') || '';
|
const accountId = searchParams.get('account_id') || '';
|
||||||
@ -26,6 +26,7 @@ const SocialConnectSuccess: React.FC = () => {
|
|||||||
platform === 'instagram' ? 'Instagram' :
|
platform === 'instagram' ? 'Instagram' :
|
||||||
platform === 'facebook' ? 'Facebook' : platform;
|
platform === 'facebook' ? 'Facebook' : platform;
|
||||||
|
|
||||||
|
// 연결된 계정 정보를 localStorage에 저장
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (platform && accountId) {
|
if (platform && accountId) {
|
||||||
const connectedAccount: ConnectedSocialAccount = {
|
const connectedAccount: ConnectedSocialAccount = {
|
||||||
@ -41,6 +42,7 @@ const SocialConnectSuccess: React.FC = () => {
|
|||||||
}, [platform, accountId, channelName, profileImage]);
|
}, [platform, accountId, channelName, profileImage]);
|
||||||
|
|
||||||
const navigateToHome = () => {
|
const navigateToHome = () => {
|
||||||
|
// URL을 루트로 변경하고 페이지 새로고침하여 generation_flow로 이동
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -62,6 +64,7 @@ const SocialConnectSuccess: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="social-connect-page">
|
<div className="social-connect-page">
|
||||||
<div className="social-connect-card success">
|
<div className="social-connect-card success">
|
||||||
|
{/* 프로필 이미지가 있으면 표시 */}
|
||||||
{profileImage ? (
|
{profileImage ? (
|
||||||
<img
|
<img
|
||||||
src={profileImage}
|
src={profileImage}
|
||||||
@ -78,13 +81,15 @@ const SocialConnectSuccess: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<h1 className="social-connect-title">
|
<h1 className="social-connect-title">
|
||||||
{t('social.connectSuccess.title', { name: channelName || platformName })}
|
{channelName || platformName} 연결 완료
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="social-connect-message">
|
<p className="social-connect-message">
|
||||||
{channelName
|
{channelName ? (
|
||||||
? t('social.connectSuccess.messageWithChannel', { platform: platformName })
|
<>{platformName} 계정이 성공적으로 연결되었습니다.</>
|
||||||
: t('social.connectSuccess.messageWithoutChannel')}
|
) : (
|
||||||
|
<>계정이 성공적으로 연결되었습니다.</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{channelName && (
|
{channelName && (
|
||||||
@ -93,20 +98,20 @@ const SocialConnectSuccess: React.FC = () => {
|
|||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<polyline points="20 6 9 17 4 12" />
|
<polyline points="20 6 9 17 4 12" />
|
||||||
</svg>
|
</svg>
|
||||||
{t('social.connectSuccess.verified')}
|
인증됨
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="social-connect-countdown">
|
<p className="social-connect-countdown">
|
||||||
{t('social.connectSuccess.countdown', { count: countdown })}
|
{countdown}초 후 자동으로 이동합니다...
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={navigateToHome}
|
onClick={navigateToHome}
|
||||||
className="social-connect-button"
|
className="social-connect-button"
|
||||||
>
|
>
|
||||||
{t('social.connectSuccess.goNow')}
|
지금 이동
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,11 +1,9 @@
|
|||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44';
|
const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44';
|
||||||
|
|
||||||
const YouTubeOAuthCallback: React.FC = () => {
|
const YouTubeOAuthCallback: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [status, setStatus] = useState<'processing' | 'error'>('processing');
|
const [status, setStatus] = useState<'processing' | 'error'>('processing');
|
||||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||||
|
|
||||||
@ -20,7 +18,7 @@ const YouTubeOAuthCallback: React.FC = () => {
|
|||||||
if (error) {
|
if (error) {
|
||||||
console.error('[YouTube OAuth] Error from Google:', error);
|
console.error('[YouTube OAuth] Error from Google:', error);
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setErrorMessage(t('social.youtubeCallback.errorAuthCancelled'));
|
setErrorMessage('Google 인증이 취소되었거나 실패했습니다.');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = '/social/connect/error?platform=youtube&error=' + encodeURIComponent(error);
|
window.location.href = '/social/connect/error?platform=youtube&error=' + encodeURIComponent(error);
|
||||||
}, 1500);
|
}, 1500);
|
||||||
@ -30,7 +28,7 @@ const YouTubeOAuthCallback: React.FC = () => {
|
|||||||
if (!code) {
|
if (!code) {
|
||||||
console.error('[YouTube OAuth] No code received');
|
console.error('[YouTube OAuth] No code received');
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setErrorMessage(t('social.youtubeCallback.errorNoCode'));
|
setErrorMessage('인증 코드를 받지 못했습니다.');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = '/social/connect/error?platform=youtube&error=no_code';
|
window.location.href = '/social/connect/error?platform=youtube&error=no_code';
|
||||||
}, 1500);
|
}, 1500);
|
||||||
@ -48,7 +46,7 @@ const YouTubeOAuthCallback: React.FC = () => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[YouTube OAuth] Failed to process callback:', err);
|
console.error('[YouTube OAuth] Failed to process callback:', err);
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setErrorMessage(t('social.youtubeCallback.errorProcessing'));
|
setErrorMessage('YouTube 연결 처리 중 오류가 발생했습니다.');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = '/social/connect/error?platform=youtube&error=processing_failed';
|
window.location.href = '/social/connect/error?platform=youtube&error=processing_failed';
|
||||||
}, 1500);
|
}, 1500);
|
||||||
@ -68,8 +66,8 @@ const YouTubeOAuthCallback: React.FC = () => {
|
|||||||
<circle cx="25" cy="25" r="20" fill="none" strokeWidth="4" />
|
<circle cx="25" cy="25" r="20" fill="none" strokeWidth="4" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="social-connect-title">{t('social.youtubeCallback.connecting')}</h1>
|
<h1 className="social-connect-title">YouTube 연결 중...</h1>
|
||||||
<p className="social-connect-message">{t('social.youtubeCallback.pleaseWait')}</p>
|
<p className="social-connect-message">잠시만 기다려주세요.</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@ -80,7 +78,7 @@ const YouTubeOAuthCallback: React.FC = () => {
|
|||||||
<line x1="9" y1="9" x2="15" y2="15" />
|
<line x1="9" y1="9" x2="15" y2="15" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="social-connect-title">{t('social.youtubeCallback.failed')}</h1>
|
<h1 className="social-connect-title">연결 실패</h1>
|
||||||
<p className="social-connect-message">{errorMessage}</p>
|
<p className="social-connect-message">{errorMessage}</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,947 +0,0 @@
|
|||||||
/* =====================================================
|
|
||||||
Layout Components
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/* Landing Page Container - Free Scroll */
|
|
||||||
.landing-container {
|
|
||||||
width: 100%;
|
|
||||||
height: 100dvh;
|
|
||||||
overflow-y: scroll;
|
|
||||||
overflow-x: hidden;
|
|
||||||
background-color: var(--color-bg-darker);
|
|
||||||
}
|
|
||||||
|
|
||||||
.landing-section {
|
|
||||||
background: var(--Color-teal-800, #002224);
|
|
||||||
min-height: 100dvh;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Page Container */
|
|
||||||
.page-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
padding: var(--spacing-page);
|
|
||||||
overflow: hidden;
|
|
||||||
background-color: #002224;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.page-container {
|
|
||||||
padding: var(--spacing-page-md);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Page Container - Full Height with Background */
|
|
||||||
.page-container-full {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 100dvh;
|
|
||||||
color: var(--color-text-white);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: var(--spacing-page);
|
|
||||||
background-color: var(--color-bg-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.page-container-full {
|
|
||||||
padding: var(--spacing-page-md);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.page-container-full {
|
|
||||||
padding: 2.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Wizard Page Container - 단계별 페이지 전환용 */
|
|
||||||
.wizard-page-container {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background-color: var(--color-bg-darker);
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Main Content Area */
|
|
||||||
.main-content {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1.5rem;
|
|
||||||
max-width: 72rem;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.main-content {
|
|
||||||
flex-direction: row;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dashboard Layout */
|
|
||||||
.dashboard-layout {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 100dvh;
|
|
||||||
background-color: var(--color-bg-darker);
|
|
||||||
color: var(--color-text-white);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-content {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
position: relative;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
Card Components
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/* Base Card */
|
|
||||||
.card {
|
|
||||||
background-color: var(--color-bg-card);
|
|
||||||
border-radius: var(--radius-3xl);
|
|
||||||
padding: var(--spacing-page);
|
|
||||||
border: 1px solid var(--color-border-white-5);
|
|
||||||
box-shadow: var(--shadow-xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.card {
|
|
||||||
padding: var(--spacing-page-md);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Card Variants */
|
|
||||||
.card-flex {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-flex-1 {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-flex-2 {
|
|
||||||
flex: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-overflow-hidden {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Card Inner (Darker Background) */
|
|
||||||
.card-inner {
|
|
||||||
background-color: rgba(18, 26, 29, 0.5);
|
|
||||||
border-radius: var(--radius-2xl);
|
|
||||||
border: 1px solid var(--color-border-white-5);
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
Header Components
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/* Page Header */
|
|
||||||
.page-header {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Page Title */
|
|
||||||
.page-title {
|
|
||||||
font-size: var(--text-3xl);
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
letter-spacing: -0.025em;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.page-title {
|
|
||||||
font-size: var(--text-4xl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Page Subtitle */
|
|
||||||
.page-subtitle {
|
|
||||||
color: var(--color-text-gray-400);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.page-subtitle {
|
|
||||||
font-size: var(--text-lg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Section Title (Mint color uppercase) */
|
|
||||||
.section-title {
|
|
||||||
color: #94FBE0;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: -0.006em;
|
|
||||||
margin-bottom: 0;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Section Title Purple */
|
|
||||||
.section-title-purple {
|
|
||||||
color: #AE72F9;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: -0.006em;
|
|
||||||
margin-bottom: 0;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
Button Components
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/* Primary Button (Purple) */
|
|
||||||
.btn-primary {
|
|
||||||
background-color: var(--color-purple);
|
|
||||||
color: var(--color-text-white);
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 1rem 4rem;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
transition: all var(--transition-normal);
|
|
||||||
transform: scale(1);
|
|
||||||
box-shadow: var(--shadow-purple);
|
|
||||||
font-size: var(--text-lg);
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background-color: var(--color-purple-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:active {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:disabled {
|
|
||||||
background-color: #4b5563;
|
|
||||||
color: var(--color-text-gray-400);
|
|
||||||
cursor: not-allowed;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Secondary Button (Mint) */
|
|
||||||
.btn-secondary {
|
|
||||||
background-color: var(--color-mint);
|
|
||||||
color: var(--color-bg-dark);
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
transition: all var(--transition-normal);
|
|
||||||
transform: scale(1);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background-color: var(--color-mint-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:active {
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:disabled {
|
|
||||||
background-color: rgba(166, 255, 234, 0.5);
|
|
||||||
color: rgba(18, 26, 29, 0.5);
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Outline Button */
|
|
||||||
.btn-outline {
|
|
||||||
background-color: transparent;
|
|
||||||
border: 1px solid var(--color-border-gray-600);
|
|
||||||
color: var(--color-text-white);
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 1rem 4rem;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
transition: all var(--transition-normal);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline:hover {
|
|
||||||
border-color: var(--color-text-gray-500);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Back Button */
|
|
||||||
.btn-back {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.5rem 1.25rem;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
border: 1px solid var(--color-border-gray-600);
|
|
||||||
background-color: transparent;
|
|
||||||
color: var(--color-text-gray-300);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
transition: background-color var(--transition-normal);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-back:hover {
|
|
||||||
background-color: rgba(31, 41, 55, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Regenerate Button (Mint Outline) */
|
|
||||||
.btn-regenerate {
|
|
||||||
margin-top: 1rem;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background-color: var(--color-mint-20);
|
|
||||||
color: var(--color-mint);
|
|
||||||
border: 1px solid var(--color-mint-30);
|
|
||||||
font-weight: 700;
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
transition: background-color var(--transition-normal);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
flex-shrink: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-regenerate:hover {
|
|
||||||
background-color: var(--color-mint-30);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-regenerate:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Selection Button */
|
|
||||||
.btn-select {
|
|
||||||
padding: 0.75rem;
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
border: 1px solid var(--color-border-gray-700);
|
|
||||||
background-color: rgba(18, 26, 29, 0.4);
|
|
||||||
color: var(--color-text-gray-400);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: 700;
|
|
||||||
transition: all var(--transition-normal);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-select:hover {
|
|
||||||
border-color: var(--color-border-gray-600);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-select.active {
|
|
||||||
border-color: var(--color-mint);
|
|
||||||
background-color: var(--color-bg-dark);
|
|
||||||
color: var(--color-mint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-select:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
Form Components
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/* Select Input */
|
|
||||||
.select-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
background-color: var(--color-bg-dark);
|
|
||||||
border: 1px solid var(--color-border-gray-700);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
color: var(--color-text-gray-300);
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--color-mint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-input:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Text Input */
|
|
||||||
.text-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
background-color: var(--color-bg-dark);
|
|
||||||
border: 1px solid var(--color-border-gray-700);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
color: var(--color-text-gray-300);
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--color-mint);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Textarea - Lyrics */
|
|
||||||
.textarea-lyrics {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--color-text-gray-400);
|
|
||||||
background-color: transparent;
|
|
||||||
resize: none;
|
|
||||||
border: none;
|
|
||||||
padding-right: 0.5rem;
|
|
||||||
line-height: 1.625;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.textarea-lyrics:focus {
|
|
||||||
outline: none;
|
|
||||||
color: var(--color-text-gray-300);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
Navigation Components
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/* Back Button Container */
|
|
||||||
.back-button-container {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 1040px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-start;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bottom Button Container */
|
|
||||||
.bottom-button-container {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 32px;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 30;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottom-button-container > * {
|
|
||||||
pointer-events: all;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.bottom-button-container {
|
|
||||||
left: 240px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
Sidebar Components
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/* Sidebar Container */
|
|
||||||
.sidebar {
|
|
||||||
position: fixed;
|
|
||||||
height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background-color: var(--color-bg-dark);
|
|
||||||
border-right: 1px solid var(--color-border-white-5);
|
|
||||||
transition: all var(--transition-slow);
|
|
||||||
z-index: 50;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.sidebar {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar.expanded {
|
|
||||||
width: 15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar.collapsed {
|
|
||||||
width: 5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar.mobile-open {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar.mobile-closed {
|
|
||||||
transform: translateX(-100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.sidebar.mobile-closed {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar Header */
|
|
||||||
.sidebar-header {
|
|
||||||
padding: 1.25rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-header.collapsed {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar Logo */
|
|
||||||
.sidebar-logo {
|
|
||||||
font-family: 'Playfair Display', serif;
|
|
||||||
font-style: italic;
|
|
||||||
font-size: var(--text-2xl);
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: -0.025em;
|
|
||||||
color: var(--color-text-white);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color var(--transition-normal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-logo:hover {
|
|
||||||
color: var(--color-mint);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar Menu */
|
|
||||||
.sidebar-menu {
|
|
||||||
flex: 1;
|
|
||||||
padding: 0 0.75rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar Menu Item */
|
|
||||||
.sidebar-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
transition: all var(--transition-normal);
|
|
||||||
cursor: pointer;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-item.collapsed {
|
|
||||||
justify-content: center;
|
|
||||||
width: 3rem;
|
|
||||||
height: 3rem;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-item.active {
|
|
||||||
background-color: var(--color-mint);
|
|
||||||
color: var(--color-bg-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-item:not(.active) {
|
|
||||||
color: var(--color-text-gray-400);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-item:not(.active):not(.disabled):hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.05);
|
|
||||||
color: var(--color-text-white);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-item.disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: not-allowed;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-item-icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: color var(--transition-normal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-item.active .sidebar-item-icon {
|
|
||||||
color: var(--color-bg-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-item-label {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 700;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar Footer */
|
|
||||||
.sidebar-footer {
|
|
||||||
padding: 1rem;
|
|
||||||
margin-top: auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Credit Card */
|
|
||||||
.credit-card {
|
|
||||||
background-color: var(--color-bg-card);
|
|
||||||
border-radius: var(--radius-2xl);
|
|
||||||
padding: 1rem;
|
|
||||||
border: 1px solid var(--color-border-white-5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.credit-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credit-label {
|
|
||||||
color: var(--color-text-gray-400);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credit-value {
|
|
||||||
color: var(--color-text-white);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credit-bar {
|
|
||||||
width: 100%;
|
|
||||||
height: 0.375rem;
|
|
||||||
background-color: #1f2937;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
overflow: hidden;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credit-bar-fill {
|
|
||||||
height: 100%;
|
|
||||||
background-color: var(--color-mint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.credit-upgrade-btn {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.625rem;
|
|
||||||
background-color: var(--color-bg-dark);
|
|
||||||
color: var(--color-text-white);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: 700;
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
border: 1px solid var(--color-border-gray-700);
|
|
||||||
transition: background-color var(--transition-normal);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credit-upgrade-btn:hover {
|
|
||||||
background-color: #1f2937;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Profile Section */
|
|
||||||
.profile-section {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-section.collapsed {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-avatar {
|
|
||||||
width: 2.5rem;
|
|
||||||
height: 2.5rem;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
border: 1px solid var(--color-border-gray-700);
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-avatar-default {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background-color: var(--color-bg-gray-700);
|
|
||||||
color: var(--color-text-gray-400);
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-name {
|
|
||||||
color: var(--color-text-white);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: 700;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-credits {
|
|
||||||
color: rgba(255, 255, 255, 0.6);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-top: 2px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar Language Switch */
|
|
||||||
.sidebar-language-switch {
|
|
||||||
padding: 0 1rem 0.75rem;
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toggle container */
|
|
||||||
.lang-toggle {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 2px;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lang-toggle-option {
|
|
||||||
flex: 1;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
padding: 4px 0;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color 0.3s ease;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lang-toggle-option.active {
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lang-toggle-option:hover:not(.active) {
|
|
||||||
color: rgba(255, 255, 255, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sliding indicator */
|
|
||||||
.lang-toggle-slider {
|
|
||||||
position: absolute;
|
|
||||||
top: 2px;
|
|
||||||
bottom: 2px;
|
|
||||||
width: calc(50% - 2px);
|
|
||||||
background: rgba(255, 255, 255, 0.15);
|
|
||||||
border-radius: 999px;
|
|
||||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lang-toggle-slider.left {
|
|
||||||
transform: translateX(2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.lang-toggle-slider.right {
|
|
||||||
transform: translateX(calc(100% + 2px));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Collapsed state - simple button */
|
|
||||||
.lang-toggle-collapsed {
|
|
||||||
padding: 4px 6px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
color: rgba(255, 255, 255, 0.5);
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lang-toggle-collapsed:hover {
|
|
||||||
color: #ffffff;
|
|
||||||
background: rgba(255, 255, 255, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar Footer Actions */
|
|
||||||
.sidebar-footer-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-footer-actions .logout-btn {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-inquiry-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
color: var(--color-text-gray-400);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: color var(--transition-normal);
|
|
||||||
white-space: nowrap;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: 700;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-inquiry-btn:hover {
|
|
||||||
color: var(--color-text-white);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-inquiry-btn.collapsed {
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Logout Button */
|
|
||||||
.logout-btn {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
color: var(--color-text-gray-400);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
transition: color var(--transition-normal);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logout-btn:hover {
|
|
||||||
color: var(--color-text-white);
|
|
||||||
}
|
|
||||||
|
|
||||||
.logout-btn.collapsed {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logout-btn-label {
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 모바일 전용 사이드바 튜토리얼 토글 */
|
|
||||||
.sidebar-tutorial-btn {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.sidebar-tutorial-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
color: var(--color-text-gray-400);
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: 500;
|
|
||||||
transition: color var(--transition-normal), background-color var(--transition-normal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-tutorial-btn:hover {
|
|
||||||
color: var(--color-text-white);
|
|
||||||
background-color: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-tutorial-btn.active {
|
|
||||||
color: var(--color-mint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-tutorial-label {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-tutorial-badge {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-tutorial-badge.on {
|
|
||||||
color: var(--color-mint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-tutorial-badge.off {
|
|
||||||
color: var(--color-text-gray-400);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile Menu Button */
|
|
||||||
.mobile-menu-btn {
|
|
||||||
position: fixed;
|
|
||||||
top: 1rem;
|
|
||||||
right: 1rem;
|
|
||||||
z-index: 40;
|
|
||||||
padding: 0.625rem;
|
|
||||||
background-color: var(--color-bg-card);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
border: 1px solid var(--color-border-white-10);
|
|
||||||
color: var(--color-text-gray-400);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-menu-btn:hover {
|
|
||||||
color: var(--color-text-white);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.mobile-menu-btn {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile Overlay */
|
|
||||||
.mobile-overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
|
||||||
z-index: 40;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.mobile-overlay {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,503 +0,0 @@
|
|||||||
/* =====================================================
|
|
||||||
Business Settings Components
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/* Settings Container */
|
|
||||||
.settings-container {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background-color: var(--color-bg-dark);
|
|
||||||
color: var(--color-text-white);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.settings-container {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.settings-container {
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Settings Header */
|
|
||||||
.settings-header {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
max-width: 42rem;
|
|
||||||
margin-left: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.settings-header {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.settings-header {
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-title {
|
|
||||||
font-size: var(--text-lg);
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.settings-title {
|
|
||||||
font-size: var(--text-xl);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.settings-title {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.settings-title {
|
|
||||||
font-size: var(--text-3xl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-description {
|
|
||||||
color: var(--color-text-gray-400);
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 300;
|
|
||||||
line-height: 1.625;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.settings-description {
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.settings-description {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Settings Card */
|
|
||||||
.settings-card {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 36rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-card-inner {
|
|
||||||
background-color: var(--color-bg-card);
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
padding: 0.75rem;
|
|
||||||
border: 1px solid var(--color-border-white-5);
|
|
||||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.settings-card-inner {
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: var(--radius-2xl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.settings-card-inner {
|
|
||||||
padding: 1.5rem;
|
|
||||||
border-radius: var(--radius-3xl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-card-title {
|
|
||||||
color: var(--color-mint);
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.settings-card-title {
|
|
||||||
font-size: 10px;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.settings-card-title {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Social Items */
|
|
||||||
.social-items {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.social-items {
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-item {
|
|
||||||
background-color: var(--color-bg-dark);
|
|
||||||
padding: 0.5rem;
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
border: 1px solid var(--color-border-white-5);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
transition: border-color var(--transition-normal);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.social-item {
|
|
||||||
padding: 0.75rem;
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-item:hover {
|
|
||||||
border-color: var(--color-border-white-10);
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-item-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.social-item-left {
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-item-icon {
|
|
||||||
width: 1.5rem;
|
|
||||||
height: 1.5rem;
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.social-item-icon {
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-item-icon-inner {
|
|
||||||
width: 0.75rem;
|
|
||||||
height: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.social-item-icon-inner {
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-item-name {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-text-gray-300);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.social-item-name {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-item-connect {
|
|
||||||
color: var(--color-mint);
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 700;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.social-item-connect {
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-item-connect:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
Footer Component
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/* Landing Footer */
|
|
||||||
.landing-footer {
|
|
||||||
width: 100%;
|
|
||||||
background: var(--Color-teal-800, #002224);
|
|
||||||
padding: 40px 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.landing-footer {
|
|
||||||
padding: 48px 5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.landing-footer {
|
|
||||||
padding: 60px 100px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2rem;
|
|
||||||
max-width: 1144px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.footer-content {
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-left {
|
|
||||||
min-width: 262px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-logo {
|
|
||||||
height: 24px;
|
|
||||||
width: auto;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-copyright {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 400;
|
|
||||||
color: #379599;
|
|
||||||
letter-spacing: -0.006em;
|
|
||||||
line-height: 1.19;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-links {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-link {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #379599;
|
|
||||||
text-decoration: none;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-link:hover {
|
|
||||||
opacity: 1;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-link-divider {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #379599;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-right {
|
|
||||||
max-width: 600px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-info {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 400;
|
|
||||||
color: #CEE5E6;
|
|
||||||
letter-spacing: -0.006em;
|
|
||||||
line-height: 1.19;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.footer-info {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
Header Component
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/* Landing Header */
|
|
||||||
.landing-header {
|
|
||||||
width: calc(100% - 2rem);
|
|
||||||
max-width: 1280px;
|
|
||||||
padding: 12px 12px 12px 24px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
position: fixed;
|
|
||||||
top: 16px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
z-index: 10100;
|
|
||||||
background: rgba(0, 34, 36, 0.3);
|
|
||||||
border: 1px solid rgba(229, 241, 242, 0.2);
|
|
||||||
border-radius: 999px;
|
|
||||||
backdrop-filter: blur(40px);
|
|
||||||
-webkit-backdrop-filter: blur(40px);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.landing-header {
|
|
||||||
width: calc(100% - 10rem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-logo {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-logo img {
|
|
||||||
height: 100%;
|
|
||||||
width: auto;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions .lang-toggle {
|
|
||||||
width: auto;
|
|
||||||
max-width: none;
|
|
||||||
padding: 2px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions .lang-toggle-option {
|
|
||||||
padding: 4px 10px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-login-btn {
|
|
||||||
padding: 10px 24px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background-color: #94FBE0;
|
|
||||||
color: #000000;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 14px;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-normal);
|
|
||||||
letter-spacing: -0.006em;
|
|
||||||
line-height: 1.19;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-login-btn:hover {
|
|
||||||
background-color: #7fe8cc;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(148, 251, 224, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-login-btn:active {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-start-btn {
|
|
||||||
padding: 10px 24px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background-color: #AE72F9;
|
|
||||||
color: #FFFFFF;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 14px;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-normal);
|
|
||||||
letter-spacing: -0.006em;
|
|
||||||
line-height: 1.19;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-start-btn:hover {
|
|
||||||
background-color: #9B5DE5;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(174, 114, 249, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-start-btn:active {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-avatar {
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid var(--color-border-gray-700);
|
|
||||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.header-avatar {
|
|
||||||
width: 2.5rem;
|
|
||||||
height: 2.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-avatar img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
Content Safe Area (for Landing Pages)
|
|
||||||
===================================================== */
|
|
||||||
.content-safe-area {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,12 +0,0 @@
|
|||||||
/* CASTAD Design System - 도메인별 CSS 파일 매니페스트 */
|
|
||||||
|
|
||||||
@import './tokens.css';
|
|
||||||
@import './base-components.css';
|
|
||||||
@import './media-components.css';
|
|
||||||
@import './generation-flow.css';
|
|
||||||
@import './landing.css';
|
|
||||||
@import './dashboard.css';
|
|
||||||
@import './business-settings.css';
|
|
||||||
@import './studio-assets.css';
|
|
||||||
@import './contents-social.css';
|
|
||||||
@import './modals-overlays.css';
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,593 +0,0 @@
|
|||||||
/* =====================================================
|
|
||||||
Player Components
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/* Player Bar */
|
|
||||||
.player-bar {
|
|
||||||
background-color: rgba(0, 0, 0, 0.4);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
padding: 0.75rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Play Button */
|
|
||||||
.play-btn {
|
|
||||||
color: var(--color-mint);
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: transform var(--transition-normal);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-btn:hover {
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-btn:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Progress Bar */
|
|
||||||
.progress-bar-container {
|
|
||||||
flex: 1;
|
|
||||||
height: 0.375rem;
|
|
||||||
background-color: #1f2937;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
position: relative;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar-container:disabled,
|
|
||||||
.progress-bar-container.disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar-fill {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
height: 100%;
|
|
||||||
background-color: var(--color-mint);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar-thumb {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%) translateX(-50%);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar-thumb-glow {
|
|
||||||
position: absolute;
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
background-color: var(--color-mint);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
opacity: 0.2;
|
|
||||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar-thumb-dot {
|
|
||||||
width: 0.75rem;
|
|
||||||
height: 0.75rem;
|
|
||||||
background-color: var(--color-mint);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
box-shadow: var(--shadow-mint-glow);
|
|
||||||
border: 1px solid rgba(18, 26, 29, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Time Display */
|
|
||||||
.time-display {
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--color-text-gray-500);
|
|
||||||
font-family: monospace;
|
|
||||||
text-align: right;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
Image Grid Components
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/* Image Grid */
|
|
||||||
.image-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-grid-4 {
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Image Item */
|
|
||||||
.image-item {
|
|
||||||
aspect-ratio: 1;
|
|
||||||
background-color: rgba(18, 26, 29, 0.5);
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
border: 1px solid var(--color-border-white-5);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-item img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-item-badge {
|
|
||||||
position: absolute;
|
|
||||||
top: 0.5rem;
|
|
||||||
left: 0.5rem;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
background-color: var(--color-purple-80);
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--color-text-white);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-item-remove {
|
|
||||||
position: absolute;
|
|
||||||
top: 0.5rem;
|
|
||||||
right: 0.5rem;
|
|
||||||
width: 1.75rem;
|
|
||||||
height: 1.75rem;
|
|
||||||
background-color: rgba(0, 0, 0, 0.6);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-normal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-item-remove:hover {
|
|
||||||
background-color: rgba(239, 68, 68, 0.8);
|
|
||||||
color: var(--color-text-white);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
Upload Components
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/* Upload Zone */
|
|
||||||
.upload-zone {
|
|
||||||
flex: 1;
|
|
||||||
border: 2px dashed var(--color-border-gray-600);
|
|
||||||
border-radius: var(--radius-2xl);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 1.5rem;
|
|
||||||
text-align: center;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color var(--transition-normal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-zone:hover {
|
|
||||||
border-color: rgba(166, 255, 234, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-zone-icon {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
color: var(--color-text-gray-500);
|
|
||||||
transition: color var(--transition-normal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-zone:hover .upload-zone-icon {
|
|
||||||
color: var(--color-mint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-zone-title {
|
|
||||||
color: var(--color-text-gray-300);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1.625;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-zone-subtitle {
|
|
||||||
color: var(--color-text-gray-500);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
Tag Components
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/* Tag */
|
|
||||||
.tag {
|
|
||||||
padding: 0.625rem 1.25rem;
|
|
||||||
background-color: var(--color-bg-card-inner);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
color: #e5e7eb;
|
|
||||||
border: 1px solid var(--color-border-white-10);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tag with Dot */
|
|
||||||
.tag-dot {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--color-text-gray-400);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-dot::before {
|
|
||||||
content: '';
|
|
||||||
width: 0.375rem;
|
|
||||||
height: 0.375rem;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
background-color: var(--color-mint);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
Social Card Components
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/* Social Card */
|
|
||||||
.social-card {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
background-color: var(--color-bg-dark);
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: var(--radius-2xl);
|
|
||||||
border: 1px solid var(--color-border-white-5);
|
|
||||||
transition: all var(--transition-normal);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-card:hover {
|
|
||||||
border-color: var(--color-border-white-10);
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-card.selected {
|
|
||||||
border-color: var(--color-mint);
|
|
||||||
box-shadow: 0 0 15px rgba(166, 255, 234, 0.15);
|
|
||||||
background-color: var(--color-bg-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-icon {
|
|
||||||
width: 2.5rem;
|
|
||||||
height: 2.5rem;
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
position: relative;
|
|
||||||
transition: transform var(--transition-slow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-icon-inner {
|
|
||||||
width: 1.5rem;
|
|
||||||
height: 1.5rem;
|
|
||||||
color: var(--color-text-white);
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-check {
|
|
||||||
position: absolute;
|
|
||||||
top: -0.25rem;
|
|
||||||
right: -0.25rem;
|
|
||||||
width: 1.25rem;
|
|
||||||
height: 1.25rem;
|
|
||||||
background-color: var(--color-mint);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--color-bg-dark);
|
|
||||||
border: 2px solid var(--color-bg-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-name {
|
|
||||||
font-size: var(--text-base);
|
|
||||||
font-weight: 700;
|
|
||||||
transition: color var(--transition-normal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-card.selected .social-name {
|
|
||||||
color: var(--color-text-white);
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-card:not(.selected) .social-name {
|
|
||||||
color: var(--color-text-gray-300);
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-email {
|
|
||||||
margin-left: auto;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--color-purple);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
Video Preview Components
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/* Video Container */
|
|
||||||
.video-container {
|
|
||||||
flex: 1;
|
|
||||||
background-color: #000;
|
|
||||||
border-radius: var(--radius-2xl);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-pattern {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
opacity: 0.1;
|
|
||||||
background-image:
|
|
||||||
linear-gradient(45deg, var(--color-bg-dark) 25%, transparent 25%),
|
|
||||||
linear-gradient(-45deg, var(--color-bg-dark) 25%, transparent 25%),
|
|
||||||
linear-gradient(45deg, transparent 75%, var(--color-bg-dark) 75%),
|
|
||||||
linear-gradient(-45deg, transparent 75%, var(--color-bg-dark) 75%);
|
|
||||||
background-size: 40px 40px;
|
|
||||||
background-position: 0 0, 0 20px, 20px -20px, -20px 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-controls {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 1rem;
|
|
||||||
left: 1rem;
|
|
||||||
right: 1rem;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-controls-inner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-play-btn {
|
|
||||||
color: var(--color-text-white);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color var(--transition-normal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-play-btn:hover {
|
|
||||||
color: var(--color-mint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-progress {
|
|
||||||
flex: 1;
|
|
||||||
height: 0.375rem;
|
|
||||||
background-color: rgba(255, 255, 255, 0.2);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-progress-fill {
|
|
||||||
height: 100%;
|
|
||||||
background-color: var(--color-mint);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Video Player */
|
|
||||||
.video-player {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Video Loading State */
|
|
||||||
.video-loading {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Video Error State */
|
|
||||||
.video-error {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-error svg {
|
|
||||||
width: 4rem;
|
|
||||||
height: 4rem;
|
|
||||||
color: #ef4444;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Social Card Disabled State */
|
|
||||||
.social-card.disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
Tags Container
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
.tags-container {
|
|
||||||
margin-top: 1rem;
|
|
||||||
background-color: rgba(18, 26, 29, 0.6);
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1rem;
|
|
||||||
justify-content: center;
|
|
||||||
border: 1px solid var(--color-border-white-5);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
Status Messages
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/* Error Message */
|
|
||||||
.error-message {
|
|
||||||
margin-top: 1rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background-color: rgba(239, 68, 68, 0.1);
|
|
||||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
color: #f87171;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Status Message */
|
|
||||||
.status-message {
|
|
||||||
margin-top: 1rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background-color: var(--color-mint-10);
|
|
||||||
border: 1px solid var(--color-mint-30);
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
color: var(--color-mint);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
Utility Classes
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/* Flex utilities */
|
|
||||||
.flex-center {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flex-between {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flex-col {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flex-1 {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flex-2 {
|
|
||||||
flex: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shrink-0 {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.min-h-0 {
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Gap utilities */
|
|
||||||
.gap-2 { gap: 0.5rem; }
|
|
||||||
.gap-3 { gap: 0.75rem; }
|
|
||||||
.gap-4 { gap: 1rem; }
|
|
||||||
.gap-6 { gap: 1.5rem; }
|
|
||||||
|
|
||||||
/* Margin utilities */
|
|
||||||
.mb-1 { margin-bottom: 0.25rem; }
|
|
||||||
.mb-2 { margin-bottom: 0.5rem; }
|
|
||||||
.mb-3 { margin-bottom: 0.75rem; }
|
|
||||||
.mb-4 { margin-bottom: 1rem; }
|
|
||||||
.mb-6 { margin-bottom: 1.5rem; }
|
|
||||||
.mt-4 { margin-top: 1rem; }
|
|
||||||
.mt-6 { margin-top: 1.5rem; }
|
|
||||||
|
|
||||||
/* Text utilities */
|
|
||||||
.text-center { text-align: center; }
|
|
||||||
.text-right { text-align: right; }
|
|
||||||
.font-bold { font-weight: 700; }
|
|
||||||
.font-medium { font-weight: 500; }
|
|
||||||
|
|
||||||
/* Overflow utilities */
|
|
||||||
.overflow-hidden { overflow: hidden; }
|
|
||||||
.overflow-y-auto { overflow-y: auto; }
|
|
||||||
|
|
||||||
/* Width utilities */
|
|
||||||
.w-full { width: 100%; }
|
|
||||||
|
|
||||||
/* Animation */
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { opacity: 0.2; }
|
|
||||||
50% { opacity: 0.4; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
from { transform: rotate(0deg); }
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-spin {
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-pulse {
|
|
||||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Grid utilities */
|
|
||||||
.grid-cols-3 {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Space utilities */
|
|
||||||
.space-y-4 > * + * {
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.space-y-5 > * + * {
|
|
||||||
margin-top: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Min width */
|
|
||||||
.min-w-280 {
|
|
||||||
min-width: 280px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Scrollbar utility */
|
|
||||||
.scrollbar-hide {
|
|
||||||
scrollbar-width: none;
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scrollbar-hide::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,74 +0,0 @@
|
|||||||
/* =====================================================
|
|
||||||
CASTAD Design System - CSS Variables & Component Classes
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
CSS Variables - Design Tokens
|
|
||||||
===================================================== */
|
|
||||||
:root {
|
|
||||||
/* Primary Colors */
|
|
||||||
--color-mint: #a6ffea;
|
|
||||||
--color-mint-hover: #8affda;
|
|
||||||
--color-mint-glow: rgba(166, 255, 234, 0.6);
|
|
||||||
--color-mint-10: rgba(166, 255, 234, 0.1);
|
|
||||||
--color-mint-20: rgba(166, 255, 234, 0.2);
|
|
||||||
--color-mint-30: rgba(166, 255, 234, 0.3);
|
|
||||||
--color-mint-50: rgba(166, 255, 234, 0.5);
|
|
||||||
|
|
||||||
--color-purple: #a682ff;
|
|
||||||
--color-purple-hover: #9570f0;
|
|
||||||
--color-purple-glow: rgba(166, 130, 255, 0.2);
|
|
||||||
--color-purple-80: rgba(166, 130, 255, 0.8);
|
|
||||||
|
|
||||||
/* Background Colors - Teal-600 based */
|
|
||||||
--color-bg-dark: #002224;
|
|
||||||
--color-bg-darker: #001a1c;
|
|
||||||
--color-bg-card: #003538;
|
|
||||||
--color-bg-card-inner: #004548;
|
|
||||||
|
|
||||||
/* Text Colors */
|
|
||||||
--color-text-white: #ffffff;
|
|
||||||
--color-text-gray-300: #d1d5db;
|
|
||||||
--color-text-gray-400: #9ca3af;
|
|
||||||
--color-text-gray-500: #6b7280;
|
|
||||||
|
|
||||||
/* Border Colors */
|
|
||||||
--color-border-white-5: rgba(255, 255, 255, 0.05);
|
|
||||||
--color-border-white-10: rgba(255, 255, 255, 0.1);
|
|
||||||
--color-border-gray-600: #4b5563;
|
|
||||||
--color-border-gray-700: #374151;
|
|
||||||
|
|
||||||
/* Shadows */
|
|
||||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
|
||||||
--shadow-purple: 0 10px 15px -3px rgba(166, 130, 255, 0.2);
|
|
||||||
--shadow-mint-glow: 0 0 10px rgba(166, 255, 234, 0.6);
|
|
||||||
|
|
||||||
/* Spacing */
|
|
||||||
--spacing-page: 1.5rem;
|
|
||||||
--spacing-page-md: 2rem;
|
|
||||||
|
|
||||||
/* Border Radius */
|
|
||||||
--radius-sm: 0.5rem;
|
|
||||||
--radius-md: 0.75rem;
|
|
||||||
--radius-lg: 1rem;
|
|
||||||
--radius-xl: 1.25rem;
|
|
||||||
--radius-2xl: 1rem;
|
|
||||||
--radius-3xl: 1.5rem;
|
|
||||||
--radius-full: 9999px;
|
|
||||||
|
|
||||||
/* Font Sizes */
|
|
||||||
--text-xs: 0.75rem;
|
|
||||||
--text-sm: 0.875rem;
|
|
||||||
--text-base: 1rem;
|
|
||||||
--text-lg: 1.125rem;
|
|
||||||
--text-xl: 1.25rem;
|
|
||||||
--text-2xl: 1.5rem;
|
|
||||||
--text-3xl: 1.875rem;
|
|
||||||
--text-4xl: 2.25rem;
|
|
||||||
|
|
||||||
/* Transitions */
|
|
||||||
--transition-fast: 150ms;
|
|
||||||
--transition-normal: 200ms;
|
|
||||||
--transition-slow: 300ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -32,21 +32,14 @@ export interface MarketingAnalysis {
|
|||||||
target_keywords: string[];
|
target_keywords: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImageListItem {
|
|
||||||
preview: string; // 미리보기용 CDN 리사이즈 URL
|
|
||||||
original: string; // 업로드용 원본 URL
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CrawlingResponse {
|
export interface CrawlingResponse {
|
||||||
image_list: ImageListItem[] | null;
|
image_list: string[];
|
||||||
image_count: number;
|
image_count: number;
|
||||||
m_id: number;
|
m_id: number;
|
||||||
industry: string;
|
|
||||||
processed_info: {
|
processed_info: {
|
||||||
customer_name: string;
|
customer_name: string;
|
||||||
region: string;
|
region: string;
|
||||||
detail_region_info: string;
|
detail_region_info: string;
|
||||||
industry: string;
|
|
||||||
};
|
};
|
||||||
marketing_analysis: MarketingAnalysis;
|
marketing_analysis: MarketingAnalysis;
|
||||||
}
|
}
|
||||||
@ -54,15 +47,14 @@ export interface CrawlingResponse {
|
|||||||
// URL 이미지 (서버에서 가져온 이미지)
|
// URL 이미지 (서버에서 가져온 이미지)
|
||||||
export interface UrlImage {
|
export interface UrlImage {
|
||||||
type: 'url';
|
type: 'url';
|
||||||
url: string; // 업로드용 원본 URL
|
url: string;
|
||||||
preview_url: string; // 미리보기용 CDN 리사이즈 URL
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 업로드된 파일 이미지
|
// 업로드된 파일 이미지
|
||||||
export interface FileImage {
|
export interface FileImage {
|
||||||
type: 'file';
|
type: 'file';
|
||||||
file: File; // 업로드용 원본. 썸네일로 대체하지 않는다.
|
file: File;
|
||||||
preview: string | null; // 미리보기용 축소 썸네일 objectURL. 생성 실패 시 null.
|
preview: string; // createObjectURL로 생성된 미리보기 URL
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ImageItem = UrlImage | FileImage;
|
export type ImageItem = UrlImage | FileImage;
|
||||||
@ -75,10 +67,8 @@ export interface LyricGenerateRequest {
|
|||||||
m_id: number;
|
m_id: number;
|
||||||
region: string;
|
region: string;
|
||||||
task_id: string;
|
task_id: string;
|
||||||
industry?: string;
|
|
||||||
orientation?: 'vertical' | 'horizontal';
|
orientation?: 'vertical' | 'horizontal';
|
||||||
instrumental?: boolean;
|
instrumental?: boolean;
|
||||||
genre?: string; // 사용자가 수동으로 고른 장르. '자동 선택'이면 생략 (GPT가 추천)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 가사 생성 응답
|
// 가사 생성 응답
|
||||||
@ -108,7 +98,6 @@ export interface LyricDetailResponse {
|
|||||||
status: string;
|
status: string;
|
||||||
lyric_prompt: string;
|
lyric_prompt: string;
|
||||||
lyric_result: string;
|
lyric_result: string;
|
||||||
genre?: string | null; // 확정된 장르 (수동 선택값 또는 GPT 추천값)
|
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -218,6 +207,58 @@ export interface ImageUploadResponse {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 숏폼 생성 요청
|
||||||
|
export interface ShortformGenerateRequest {
|
||||||
|
task_id: string;
|
||||||
|
kind: string;
|
||||||
|
biz_name: string;
|
||||||
|
addr?: string;
|
||||||
|
price?: string;
|
||||||
|
selling: string;
|
||||||
|
spec?: VideoSpec;
|
||||||
|
dry_run?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 숏폼 생성 응답
|
||||||
|
export interface ShortformGenerateResponse {
|
||||||
|
task_id: string;
|
||||||
|
video_id: number;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 숏폼 상태 조회 응답
|
||||||
|
export interface ShortformStatusResponse {
|
||||||
|
task_id: string;
|
||||||
|
status: 'processing' | 'completed' | 'failed';
|
||||||
|
result_url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 숏폼 미리보기 요청
|
||||||
|
export interface ShortformPreviewRequest {
|
||||||
|
kind: string;
|
||||||
|
biz_name: string;
|
||||||
|
selling: string;
|
||||||
|
addr?: string;
|
||||||
|
price?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 숏폼 미리보기 응답 (VideoSpec)
|
||||||
|
export interface VideoSpec {
|
||||||
|
hook: {
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
selling_point: {
|
||||||
|
items: string[];
|
||||||
|
};
|
||||||
|
brand_lines: string[];
|
||||||
|
end_card: {
|
||||||
|
brand: string;
|
||||||
|
location: string;
|
||||||
|
};
|
||||||
|
caption: string;
|
||||||
|
}
|
||||||
|
|
||||||
// 언어 매핑
|
// 언어 매핑
|
||||||
export const LANGUAGE_MAP: Record<string, string> = {
|
export const LANGUAGE_MAP: Record<string, string> = {
|
||||||
'한국어': 'Korean',
|
'한국어': 'Korean',
|
||||||
@ -233,13 +274,22 @@ export interface KakaoLoginUrlResponse {
|
|||||||
auth_url: string;
|
auth_url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 카카오 콜백 응답 (JWT 토큰) — 백엔드 LoginResponse와 동일 구조
|
// 카카오 콜백 사용자 정보
|
||||||
|
export interface KakaoCallbackUser {
|
||||||
|
id: number;
|
||||||
|
nickname: string;
|
||||||
|
email: string | null;
|
||||||
|
profile_image_url: string | null;
|
||||||
|
is_new_user: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 카카오 콜백 응답 (JWT 토큰)
|
||||||
export interface KakaoCallbackResponse {
|
export interface KakaoCallbackResponse {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
refresh_token: string;
|
refresh_token: string;
|
||||||
token_type: string;
|
token_type: string;
|
||||||
expires_in: number;
|
expires_in: number;
|
||||||
is_new_user: boolean;
|
user: KakaoCallbackUser;
|
||||||
redirect_url: string;
|
redirect_url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -276,28 +326,19 @@ export interface VideoListItem {
|
|||||||
region: string;
|
region: string;
|
||||||
task_id: string;
|
task_id: string;
|
||||||
result_movie_url: string;
|
result_movie_url: string;
|
||||||
poster_url?: string | null;
|
|
||||||
thumbnail_url?: string;
|
thumbnail_url?: string;
|
||||||
title?: string | null;
|
|
||||||
description?: string | null;
|
|
||||||
hashtags?: string[] | null;
|
|
||||||
official_site_url?: string | null;
|
|
||||||
created_at: string;
|
created_at: string;
|
||||||
like_count: number;
|
like_count: number;
|
||||||
comment_count: number;
|
comment_count: number;
|
||||||
is_liked_by_me: boolean;
|
is_liked_by_me?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 비디오 상세 아이템
|
// 비디오 상세 아이템
|
||||||
export interface VideoDetailItem {
|
export interface VideoDetailItem {
|
||||||
video_id: number;
|
video_id: number;
|
||||||
result_movie_url: string;
|
result_movie_url: string;
|
||||||
poster_url?: string | null;
|
|
||||||
store_name: string;
|
store_name: string;
|
||||||
region: string;
|
region: string;
|
||||||
title?: string | null;
|
|
||||||
description?: string | null;
|
|
||||||
official_site_url?: string | null;
|
|
||||||
created_at: string;
|
created_at: string;
|
||||||
like_count: number;
|
like_count: number;
|
||||||
is_liked_by_me: boolean;
|
is_liked_by_me: boolean;
|
||||||
@ -318,7 +359,6 @@ export interface VideosListResponse {
|
|||||||
export interface CommentReply {
|
export interface CommentReply {
|
||||||
id: number;
|
id: number;
|
||||||
nickname: string;
|
nickname: string;
|
||||||
profile_image_url: string | null;
|
|
||||||
content: string | null;
|
content: string | null;
|
||||||
is_deleted: boolean;
|
is_deleted: boolean;
|
||||||
is_mine: boolean;
|
is_mine: boolean;
|
||||||
@ -329,7 +369,6 @@ export interface CommentReply {
|
|||||||
export interface CommentItem {
|
export interface CommentItem {
|
||||||
id: number;
|
id: number;
|
||||||
nickname: string;
|
nickname: string;
|
||||||
profile_image_url: string | null;
|
|
||||||
content: string | null;
|
content: string | null;
|
||||||
is_deleted: boolean;
|
is_deleted: boolean;
|
||||||
is_mine: boolean;
|
is_mine: boolean;
|
||||||
@ -401,7 +440,7 @@ export interface SocialDisconnectResponse {
|
|||||||
|
|
||||||
// 유튜브 SEO Description 자동완성 요청
|
// 유튜브 SEO Description 자동완성 요청
|
||||||
export interface YTAutoSeoRequest {
|
export interface YTAutoSeoRequest {
|
||||||
video_id: number;
|
task_id: string; // 아카이브의 비디오 ID
|
||||||
}
|
}
|
||||||
|
|
||||||
// 유튜브 SEO Description 자동완성 응답
|
// 유튜브 SEO Description 자동완성 응답
|
||||||
|
|||||||
323
src/utils/api.ts
323
src/utils/api.ts
@ -34,8 +34,12 @@ import {
|
|||||||
CommentsResponse,
|
CommentsResponse,
|
||||||
CommentItem,
|
CommentItem,
|
||||||
LikeToggleResponse,
|
LikeToggleResponse,
|
||||||
|
ShortformGenerateRequest,
|
||||||
|
ShortformGenerateResponse,
|
||||||
|
ShortformStatusResponse,
|
||||||
|
ShortformPreviewRequest,
|
||||||
|
VideoSpec,
|
||||||
} from '../types/api';
|
} from '../types/api';
|
||||||
import { uploadImagesSequentially } from './imageUpload.ts';
|
|
||||||
|
|
||||||
export const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44';
|
export const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44';
|
||||||
console.log('[API] API_URL:', API_URL);
|
console.log('[API] API_URL:', API_URL);
|
||||||
@ -310,183 +314,6 @@ export async function generateVideo(taskId: string, orientation: 'vertical' | 'h
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Meta 전환 추적
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
// Meta 픽셀이 심는 1st-party 쿠키 값 읽기 (_fbc/_fbp)
|
|
||||||
function getCookieValue(name: string): string | null {
|
|
||||||
const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
|
|
||||||
return match ? decodeURIComponent(match[1]) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// URL의 fbclid로 조립해둔 fbc 폴백 값을 보관하는 localStorage 키
|
|
||||||
// UTM과 분리해 두어야 가입 완료 후 UTM을 정리해도 이후 이벤트에서 계속 쓸 수 있다.
|
|
||||||
const FBC_STORAGE_KEY = 'castad_fbc';
|
|
||||||
|
|
||||||
// Meta 클릭 ID(fbc) 조회
|
|
||||||
// 픽셀이 심은 _fbc 쿠키를 우선 사용하고, 없으면 랜딩 시 fbclid로 조립해둔 값을 쓴다.
|
|
||||||
// 애드블로커·쿠키 정리 등으로 쿠키가 유실돼도 광고 클릭 기여를 잃지 않기 위한 폴백이다.
|
|
||||||
function getFbc(): string | null {
|
|
||||||
const cookie = getCookieValue('_fbc');
|
|
||||||
if (cookie) return cookie;
|
|
||||||
|
|
||||||
try {
|
|
||||||
return localStorage.getItem(FBC_STORAGE_KEY);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 첫 영상 생성 완료(FirstVideoCreated) 전환 이벤트 발화 API
|
|
||||||
// 서버가 first_video_created_at null 여부로 계정당 최초 1회를 판정하고,
|
|
||||||
// fired=true일 때만 브라우저 픽셀(fbq)을 동일 event_id로 발화해 중복제거를 보장한다.
|
|
||||||
export async function trackFirstVideoCreated(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const response = await authenticatedFetch(`${API_URL}/tracking/meta/first-video-created`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
fbc: getFbc(),
|
|
||||||
fbp: getCookieValue('_fbp'),
|
|
||||||
event_source_url: window.location.href,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) return;
|
|
||||||
|
|
||||||
const data: { fired: boolean; event_id: string | null } = await response.json();
|
|
||||||
|
|
||||||
const fbq = (window as unknown as { fbq?: (...args: unknown[]) => void }).fbq;
|
|
||||||
if (data.fired && data.event_id && typeof fbq === 'function') {
|
|
||||||
// 서버 CAPI 이벤트와 동일한 event_id로 발화 → Meta가 중복제거
|
|
||||||
fbq('trackCustom', 'FirstVideoCreated', {}, { eventID: data.event_id });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// 전환 추적 실패는 서비스 기능에 영향을 주지 않도록 무시
|
|
||||||
console.error('[Tracking] FirstVideoCreated failed:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// UTM 파라미터 localStorage 키 (first-touch 보존)
|
|
||||||
const UTM_STORAGE_KEY = 'castad_utm';
|
|
||||||
const UTM_PARAM_NAMES = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'] as const;
|
|
||||||
|
|
||||||
// 이벤트 중복제거용 UUID 생성
|
|
||||||
// crypto.randomUUID는 보안 컨텍스트(https/localhost) 전용이라 미지원 환경 폴백 포함
|
|
||||||
function generateEventId(): string {
|
|
||||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
||||||
return crypto.randomUUID();
|
|
||||||
}
|
|
||||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
||||||
const r = (Math.random() * 16) | 0;
|
|
||||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
||||||
return v.toString(16);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// fbq 전역 함수 안전 접근
|
|
||||||
function getFbq(): ((...args: unknown[]) => void) | null {
|
|
||||||
const fbq = (window as unknown as { fbq?: (...args: unknown[]) => void }).fbq;
|
|
||||||
return typeof fbq === 'function' ? fbq : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 랜딩 진입 URL에서 광고 유입 정보(UTM, fbclid)를 localStorage에 보관한다.
|
|
||||||
export function storeUtmFromUrl(): void {
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
|
|
||||||
// UTM: 이미 저장된 값이 있으면 덮어쓰지 않아 최초 유입 경로(first-touch)를 보존
|
|
||||||
if (!localStorage.getItem(UTM_STORAGE_KEY)) {
|
|
||||||
const utm: Record<string, string> = {};
|
|
||||||
for (const name of UTM_PARAM_NAMES) {
|
|
||||||
const value = params.get(name);
|
|
||||||
if (value) utm[name] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(utm).length > 0) {
|
|
||||||
localStorage.setItem(UTM_STORAGE_KEY, JSON.stringify(utm));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// fbclid: 광고 클릭 기여는 최신 클릭이 가져가므로 새 값이 오면 덮어쓴다
|
|
||||||
// (Meta 픽셀의 _fbc 쿠키도 동일하게 last-touch로 동작)
|
|
||||||
// 형식: fb.{subdomainIndex}.{creationTime(ms)}.{fbclid}
|
|
||||||
const fbclid = params.get('fbclid');
|
|
||||||
if (fbclid) {
|
|
||||||
localStorage.setItem(FBC_STORAGE_KEY, `fb.1.${Date.now()}.${fbclid}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Tracking] storeUtmFromUrl failed:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 랜딩 진입(ViewContent) 이벤트 발화 — 비로그인 시점이므로 브라우저 픽셀 단독
|
|
||||||
export function trackViewContent(): void {
|
|
||||||
try {
|
|
||||||
const fbq = getFbq();
|
|
||||||
if (fbq) {
|
|
||||||
fbq('track', 'ViewContent', {}, { eventID: generateEventId() });
|
|
||||||
console.log('[Tracking] ViewContent fired');
|
|
||||||
} else {
|
|
||||||
console.warn('[Tracking] ViewContent skipped: fbq not loaded');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const detail = error instanceof Error ? `${error.name}: ${error.message}\n${error.stack}` : String(error);
|
|
||||||
console.error('[Tracking] ViewContent failed:', detail);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 회원가입 완료(CompleteRegistration) 전환 이벤트 발화 API
|
|
||||||
// 서버가 registration_tracked_at null 여부로 계정당 최초 1회를 판정하고 UTM을 함께 기록한다.
|
|
||||||
// fired=true일 때만 브라우저 픽셀(fbq)을 동일 event_id로 발화해 중복제거를 보장한다.
|
|
||||||
export async function trackCompleteRegistration(): Promise<void> {
|
|
||||||
try {
|
|
||||||
let utm: Record<string, string> = {};
|
|
||||||
try {
|
|
||||||
utm = JSON.parse(localStorage.getItem(UTM_STORAGE_KEY) ?? '{}');
|
|
||||||
} catch {
|
|
||||||
utm = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await authenticatedFetch(`${API_URL}/tracking/meta/complete-registration`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
fbc: getFbc(),
|
|
||||||
fbp: getCookieValue('_fbp'),
|
|
||||||
event_source_url: window.location.href,
|
|
||||||
utm_source: utm.utm_source ?? null,
|
|
||||||
utm_medium: utm.utm_medium ?? null,
|
|
||||||
utm_campaign: utm.utm_campaign ?? null,
|
|
||||||
utm_content: utm.utm_content ?? null,
|
|
||||||
utm_term: utm.utm_term ?? null,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) return;
|
|
||||||
|
|
||||||
const data: { fired: boolean; event_id: string | null } = await response.json();
|
|
||||||
|
|
||||||
if (data.fired && data.event_id) {
|
|
||||||
const fbq = getFbq();
|
|
||||||
if (fbq) {
|
|
||||||
// 서버 CAPI 이벤트와 동일한 event_id로 발화 → Meta가 중복제거
|
|
||||||
fbq('track', 'CompleteRegistration', {}, { eventID: data.event_id });
|
|
||||||
}
|
|
||||||
// 가입 어트리뷰션 저장 완료 → 보관하던 UTM 제거
|
|
||||||
localStorage.removeItem(UTM_STORAGE_KEY);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// 전환 추적 실패는 서비스 기능에 영향을 주지 않도록 무시
|
|
||||||
console.error('[Tracking] CompleteRegistration failed:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 영상 상태 확인 API
|
// 영상 상태 확인 API
|
||||||
export async function getVideoStatus(taskId: string): Promise<VideoStatusResponse> {
|
export async function getVideoStatus(taskId: string): Promise<VideoStatusResponse> {
|
||||||
const response = await authenticatedFetch(`${API_URL}/video/status/${taskId}`, {
|
const response = await authenticatedFetch(`${API_URL}/video/status/${taskId}`, {
|
||||||
@ -609,12 +436,12 @@ export async function getVideoComments(videoId: string, page: number = 1, pageSi
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 댓글 작성 (작성자 닉네임/프로필은 서버가 로그인된 카카오 정보로 채움)
|
// 댓글 작성
|
||||||
export async function postVideoComment(videoId: string, content: string, parentId?: number): Promise<CommentItem> {
|
export async function postVideoComment(videoId: string, content: string, nickname?: string, parentId?: number): Promise<CommentItem> {
|
||||||
const response = await authenticatedFetch(`${API_URL}/comment/video/${videoId}`, {
|
const response = await authenticatedFetch(`${API_URL}/comment/video/${videoId}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content, parent_id: parentId ?? null }),
|
body: JSON.stringify({ content, nickname: nickname || '익명', parent_id: parentId ?? null }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@ -656,10 +483,18 @@ export async function uploadImages(
|
|||||||
imageUrls: ImageUrlItem[],
|
imageUrls: ImageUrlItem[],
|
||||||
files: File[]
|
files: File[]
|
||||||
): Promise<ImageUploadResponse> {
|
): Promise<ImageUploadResponse> {
|
||||||
return uploadImagesSequentially(imageUrls, files, postImageUpload);
|
const formData = new FormData();
|
||||||
|
|
||||||
|
// URL 이미지들을 images_json으로 전달
|
||||||
|
if (imageUrls.length > 0) {
|
||||||
|
formData.append('images_json', JSON.stringify(imageUrls));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postImageUpload(formData: FormData): Promise<ImageUploadResponse> {
|
// 파일들을 files로 전달
|
||||||
|
files.forEach((file) => {
|
||||||
|
formData.append('files', file);
|
||||||
|
});
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), IMAGE_UPLOAD_TIMEOUT);
|
const timeoutId = setTimeout(() => controller.abort(), IMAGE_UPLOAD_TIMEOUT);
|
||||||
|
|
||||||
@ -670,21 +505,19 @@ async function postImageUpload(formData: FormData): Promise<ImageUploadResponse>
|
|||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (response.status === 413) {
|
|
||||||
throw new Error('이미지 파일이 너무 커서 업로드할 수 없습니다. 더 작은 이미지를 선택해주세요.');
|
|
||||||
}
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
if (error instanceof Error && error.name === 'AbortError') {
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
throw new Error('이미지 업로드 시간이 초과되었습니다. 다시 시도해주세요.');
|
throw new Error('이미지 업로드 시간이 초과되었습니다. 다시 시도해주세요.');
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -729,6 +562,85 @@ export async function waitForVideoComplete(
|
|||||||
return poll();
|
return poll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 숏폼 생성 API
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// 숏폼 생성 요청 (POST /shortform/generate, 202 Accepted)
|
||||||
|
export async function generateShortform(req: ShortformGenerateRequest): Promise<ShortformGenerateResponse> {
|
||||||
|
const response = await authenticatedFetch(`${API_URL}/shortform/generate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(req),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 숏폼 상태 조회 (GET /shortform/status/{task_id})
|
||||||
|
export async function getShortformStatus(taskId: string): Promise<ShortformStatusResponse> {
|
||||||
|
const response = await authenticatedFetch(`${API_URL}/shortform/status/${taskId}`, {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 숏폼 완료까지 폴링 (10분 타임아웃, 3초 간격)
|
||||||
|
const SHORTFORM_POLL_TIMEOUT = 10 * 60 * 1000;
|
||||||
|
const SHORTFORM_POLL_INTERVAL = 3000;
|
||||||
|
|
||||||
|
export async function waitForShortformComplete(
|
||||||
|
taskId: string,
|
||||||
|
onStatusChange?: (status: string) => void
|
||||||
|
): Promise<ShortformStatusResponse> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
const poll = async (): Promise<ShortformStatusResponse> => {
|
||||||
|
if (Date.now() - startTime > SHORTFORM_POLL_TIMEOUT) {
|
||||||
|
throw new Error('TIMEOUT');
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusResponse = await getShortformStatus(taskId);
|
||||||
|
onStatusChange?.(statusResponse.status);
|
||||||
|
|
||||||
|
if (statusResponse.status === 'completed') {
|
||||||
|
return statusResponse;
|
||||||
|
} else if (statusResponse.status === 'failed') {
|
||||||
|
throw new Error('Shortform generation failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, SHORTFORM_POLL_INTERVAL));
|
||||||
|
return poll();
|
||||||
|
};
|
||||||
|
|
||||||
|
return poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 숏폼 자막 미리보기 (POST /shortform/preview)
|
||||||
|
export async function previewShortform(req: ShortformPreviewRequest): Promise<VideoSpec> {
|
||||||
|
const response = await authenticatedFetch(`${API_URL}/shortform/preview`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(req),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json().catch(() => ({}));
|
||||||
|
throw new Error((err as { detail?: string }).detail || '미리보기 생성에 실패했습니다.');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 카카오 인증 API
|
// 카카오 인증 API
|
||||||
// ============================================
|
// ============================================
|
||||||
@ -936,27 +848,17 @@ function clearAllLocalData() {
|
|||||||
|
|
||||||
// 로그아웃
|
// 로그아웃
|
||||||
export async function logout(): Promise<void> {
|
export async function logout(): Promise<void> {
|
||||||
const refreshToken = getRefreshToken();
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (refreshToken) {
|
|
||||||
const response = await authenticatedFetch(`${API_URL}/user/auth/logout`, {
|
const response = await authenticatedFetch(`${API_URL}/user/auth/logout`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 응답과 관계없이 로컬 데이터 전체 삭제
|
||||||
|
clearAllLocalData();
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
// 응답과 관계없이 로컬 데이터 전체 삭제
|
|
||||||
clearAllLocalData();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 모든 기기에서 로그아웃
|
// 모든 기기에서 로그아웃
|
||||||
export async function logoutAll(): Promise<void> {
|
export async function logoutAll(): Promise<void> {
|
||||||
@ -1026,16 +928,6 @@ export interface AccommodationSearchItem {
|
|||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 네이버 검색 API는 title에 <b> 태그와 & 같은 HTML 엔티티를 함께 내려주므로
|
|
||||||
// 태그 제거 후 엔티티까지 디코딩해야 "&" 같은 원문이 그대로 노출되지 않는다.
|
|
||||||
// textarea는 DOM에 붙이지 않고 content model상 스크립트를 실행하지 않으므로 엔티티 디코딩 용도로 안전하다.
|
|
||||||
export const cleanSearchTitle = (title: string): string => {
|
|
||||||
const withoutTags = title.replace(/<[^>]*>/g, '');
|
|
||||||
const textarea = document.createElement('textarea');
|
|
||||||
textarea.innerHTML = withoutTags;
|
|
||||||
return textarea.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface AccommodationSearchResponse {
|
export interface AccommodationSearchResponse {
|
||||||
count: number;
|
count: number;
|
||||||
items: AccommodationSearchItem[];
|
items: AccommodationSearchItem[];
|
||||||
@ -1093,7 +985,7 @@ export async function autocomplete(request: AutocompleteRequest): Promise<Crawli
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 업체명·주소 직접 입력으로 마케팅 분석
|
// 업체명·주소 직접 입력으로 마케팅 분석
|
||||||
export async function marketingAnalysis(storeName: string, address: string, category = '', officialSiteUrl?: string): Promise<CrawlingResponse> {
|
export async function marketingAnalysis(storeName: string, address: string): Promise<CrawlingResponse> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), CRAWL_TIMEOUT);
|
const timeoutId = setTimeout(() => controller.abort(), CRAWL_TIMEOUT);
|
||||||
|
|
||||||
@ -1103,12 +995,7 @@ export async function marketingAnalysis(storeName: string, address: string, cate
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ store_name: storeName, address }),
|
||||||
store_name: storeName,
|
|
||||||
address,
|
|
||||||
category,
|
|
||||||
official_site_url: officialSiteUrl?.trim() || null,
|
|
||||||
}),
|
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,305 +0,0 @@
|
|||||||
export const MAX_UPLOAD_IMAGE_BYTES = 2 * 1024 * 1024;
|
|
||||||
export const MAX_UPLOAD_IMAGE_DIMENSION = 2048;
|
|
||||||
export const MAX_FALLBACK_UPLOAD_IMAGE_BYTES = 15 * 1024 * 1024;
|
|
||||||
|
|
||||||
const OUTPUT_MIME_TYPE = 'image/jpeg';
|
|
||||||
const OUTPUT_EXTENSION = 'jpg';
|
|
||||||
const BACKEND_IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp', 'heic', 'heif']);
|
|
||||||
const BACKEND_IMAGE_MIME_TYPES = new Set([
|
|
||||||
'image/jpeg',
|
|
||||||
'image/png',
|
|
||||||
'image/webp',
|
|
||||||
'image/heic',
|
|
||||||
'image/heif',
|
|
||||||
'image/heic-sequence',
|
|
||||||
'image/heif-sequence',
|
|
||||||
]);
|
|
||||||
|
|
||||||
interface ImageDimensions {
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DecodedImage extends ImageDimensions {
|
|
||||||
source: CanvasImageSource;
|
|
||||||
dispose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CompressionAttempt {
|
|
||||||
scale: number;
|
|
||||||
quality: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const COMPRESSION_ATTEMPTS: CompressionAttempt[] = [
|
|
||||||
{ scale: 1, quality: 0.86 },
|
|
||||||
{ scale: 1, quality: 0.74 },
|
|
||||||
{ scale: 1, quality: 0.62 },
|
|
||||||
{ scale: 1, quality: 0.5 },
|
|
||||||
{ scale: 0.85, quality: 0.7 },
|
|
||||||
{ scale: 0.7, quality: 0.65 },
|
|
||||||
{ scale: 0.55, quality: 0.6 },
|
|
||||||
{ scale: 0.4, quality: 0.55 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function calculateTargetDimensions(
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
maxDimension = MAX_UPLOAD_IMAGE_DIMENSION
|
|
||||||
): ImageDimensions {
|
|
||||||
if (
|
|
||||||
!Number.isFinite(width) ||
|
|
||||||
!Number.isFinite(height) ||
|
|
||||||
!Number.isFinite(maxDimension) ||
|
|
||||||
width <= 0 ||
|
|
||||||
height <= 0 ||
|
|
||||||
maxDimension <= 0
|
|
||||||
) {
|
|
||||||
throw new Error('Image dimensions must be positive finite numbers.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const scale = Math.min(1, maxDimension / Math.max(width, height));
|
|
||||||
|
|
||||||
return {
|
|
||||||
width: Math.max(1, Math.round(width * scale)),
|
|
||||||
height: Math.max(1, Math.round(height * scale)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldCompressImage(
|
|
||||||
fileSize: number,
|
|
||||||
dimensions: ImageDimensions,
|
|
||||||
maxBytes = MAX_UPLOAD_IMAGE_BYTES,
|
|
||||||
maxDimension = MAX_UPLOAD_IMAGE_DIMENSION
|
|
||||||
): boolean {
|
|
||||||
return (
|
|
||||||
fileSize > maxBytes ||
|
|
||||||
dimensions.width > maxDimension ||
|
|
||||||
dimensions.height > maxDimension
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildCompressedFileName(fileName: string): string {
|
|
||||||
const extensionIndex = fileName.lastIndexOf('.');
|
|
||||||
const baseName = extensionIndex > 0 ? fileName.slice(0, extensionIndex) : fileName;
|
|
||||||
return `${baseName || 'image'}.${OUTPUT_EXTENSION}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFileExtension(fileName: string): string {
|
|
||||||
const extensionIndex = fileName.lastIndexOf('.');
|
|
||||||
return extensionIndex >= 0 ? fileName.slice(extensionIndex + 1).toLowerCase() : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isImageInputFile(file: Pick<File, 'name' | 'type'>): boolean {
|
|
||||||
return file.type.startsWith('image/') || BACKEND_IMAGE_EXTENSIONS.has(getFileExtension(file.name));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function canUploadOriginalImage(file: Pick<File, 'name' | 'type'>): boolean {
|
|
||||||
const hasAllowedExtension = BACKEND_IMAGE_EXTENSIONS.has(getFileExtension(file.name));
|
|
||||||
const hasAllowedMimeType = file.type === '' || BACKEND_IMAGE_MIME_TYPES.has(file.type.toLowerCase());
|
|
||||||
return hasAllowedExtension && hasAllowedMimeType;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function canUploadOriginalAfterCompressionFailure(
|
|
||||||
fileSize: number,
|
|
||||||
maxBytes = MAX_FALLBACK_UPLOAD_IMAGE_BYTES
|
|
||||||
): boolean {
|
|
||||||
return fileSize <= maxBytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
function useOriginalOrThrow(file: File, cause: unknown): File {
|
|
||||||
if (!canUploadOriginalImage(file)) {
|
|
||||||
throw new Error(
|
|
||||||
`${file.name} 파일을 지원하는 이미지 형식으로 변환할 수 없습니다. ` +
|
|
||||||
'JPEG, PNG 또는 WebP로 변환한 뒤 다시 시도해주세요.',
|
|
||||||
{ cause }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!canUploadOriginalAfterCompressionFailure(file.size)) {
|
|
||||||
throw new Error(
|
|
||||||
`${file.name} 파일은 15 MB보다 크고 브라우저에서 크기를 줄일 수 없습니다. ` +
|
|
||||||
'JPEG나 PNG로 변환하거나 더 작은 이미지를 선택해주세요.',
|
|
||||||
{ cause }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.warn(`Image compression was unavailable for ${file.name}; uploading the original.`, cause);
|
|
||||||
return file;
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeWithImageElement(file: File): Promise<DecodedImage> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const objectUrl = URL.createObjectURL(file);
|
|
||||||
const image = new Image();
|
|
||||||
|
|
||||||
image.onload = () => {
|
|
||||||
resolve({
|
|
||||||
source: image,
|
|
||||||
width: image.naturalWidth,
|
|
||||||
height: image.naturalHeight,
|
|
||||||
dispose: () => URL.revokeObjectURL(objectUrl),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
image.onerror = () => {
|
|
||||||
URL.revokeObjectURL(objectUrl);
|
|
||||||
reject(new Error(`Unable to decode image: ${file.name}`));
|
|
||||||
};
|
|
||||||
image.src = objectUrl;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function decodeImage(file: File): Promise<DecodedImage> {
|
|
||||||
if (typeof createImageBitmap === 'function') {
|
|
||||||
const bitmap = await createImageBitmap(file);
|
|
||||||
return {
|
|
||||||
source: bitmap,
|
|
||||||
width: bitmap.width,
|
|
||||||
height: bitmap.height,
|
|
||||||
dispose: () => bitmap.close(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return decodeWithImageElement(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
function canvasToBlob(canvas: HTMLCanvasElement, quality: number): Promise<Blob> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
canvas.toBlob(
|
|
||||||
(blob) => {
|
|
||||||
if (blob) {
|
|
||||||
resolve(blob);
|
|
||||||
} else {
|
|
||||||
reject(new Error('The browser could not compress the image.'));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
OUTPUT_MIME_TYPE,
|
|
||||||
quality
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function compressImageForUpload(file: File): Promise<File> {
|
|
||||||
if (!isImageInputFile(file)) {
|
|
||||||
throw new Error(`지원하지 않는 이미지 파일입니다: ${file.name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let decoded: DecodedImage;
|
|
||||||
try {
|
|
||||||
decoded = await decodeImage(file);
|
|
||||||
} catch (error) {
|
|
||||||
return useOriginalOrThrow(file, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (
|
|
||||||
canUploadOriginalImage(file) &&
|
|
||||||
!shouldCompressImage(file.size, {
|
|
||||||
width: decoded.width,
|
|
||||||
height: decoded.height,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return file;
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseDimensions = calculateTargetDimensions(decoded.width, decoded.height);
|
|
||||||
let smallestBlob: Blob | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
const context = canvas.getContext('2d');
|
|
||||||
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('The browser does not support image compression.');
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const attempt of COMPRESSION_ATTEMPTS) {
|
|
||||||
canvas.width = Math.max(1, Math.round(baseDimensions.width * attempt.scale));
|
|
||||||
canvas.height = Math.max(1, Math.round(baseDimensions.height * attempt.scale));
|
|
||||||
|
|
||||||
// JPEG has no alpha channel. A white background avoids transparent pixels
|
|
||||||
// becoming black when PNG/WebP images are converted.
|
|
||||||
context.fillStyle = '#ffffff';
|
|
||||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
||||||
context.drawImage(decoded.source, 0, 0, canvas.width, canvas.height);
|
|
||||||
|
|
||||||
const blob = await canvasToBlob(canvas, attempt.quality);
|
|
||||||
if (!smallestBlob || blob.size < smallestBlob.size) {
|
|
||||||
smallestBlob = blob;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (blob.size <= MAX_UPLOAD_IMAGE_BYTES) {
|
|
||||||
return new File([blob], buildCompressedFileName(file.name), {
|
|
||||||
type: blob.type || OUTPUT_MIME_TYPE,
|
|
||||||
lastModified: file.lastModified,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
return useOriginalOrThrow(file, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const measuredSize = smallestBlob?.size ?? file.size;
|
|
||||||
throw new Error(
|
|
||||||
`${file.name} 파일을 ${Math.round(MAX_UPLOAD_IMAGE_BYTES / (1024 * 1024))} MB 이하로 ` +
|
|
||||||
`압축할 수 없습니다(압축 결과: ${Math.ceil(measuredSize / (1024 * 1024))} MB).`
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
decoded.dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MAX_PREVIEW_DIMENSION = 400;
|
|
||||||
export const PREVIEW_QUALITY = 0.7;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 그리드 미리보기 전용 축소 썸네일을 만든다.
|
|
||||||
*
|
|
||||||
* 업로드에는 원본 File 을 그대로 쓰고, 여기서 만든 objectURL 은 <img> 에만 쓰인다.
|
|
||||||
* 핵심은 finally 의 dispose() 로, createImageBitmap 경로에서 원본 비트맵을 즉시
|
|
||||||
* 해제해 메모리에 원본 크기 비트맵이 남지 않게 한다.
|
|
||||||
*
|
|
||||||
* 실패해도 예외를 던지지 않고 null 을 돌려준다. 호출부가 자리표시자를 그린다.
|
|
||||||
*/
|
|
||||||
export async function createPreviewThumbnail(file: File): Promise<string | null> {
|
|
||||||
if (!isImageInputFile(file)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let decoded: DecodedImage;
|
|
||||||
try {
|
|
||||||
decoded = await decodeImage(file);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`미리보기 썸네일 디코딩 실패: ${file.name}`, error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { width, height } = calculateTargetDimensions(
|
|
||||||
decoded.width,
|
|
||||||
decoded.height,
|
|
||||||
MAX_PREVIEW_DIMENSION
|
|
||||||
);
|
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
canvas.width = width;
|
|
||||||
canvas.height = height;
|
|
||||||
|
|
||||||
const context = canvas.getContext('2d');
|
|
||||||
if (!context) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// JPEG 에는 알파 채널이 없다. 투명 픽셀이 검게 나오지 않도록 흰 배경을 깐다.
|
|
||||||
context.fillStyle = '#ffffff';
|
|
||||||
context.fillRect(0, 0, width, height);
|
|
||||||
context.drawImage(decoded.source, 0, 0, width, height);
|
|
||||||
|
|
||||||
const blob = await canvasToBlob(canvas, PREVIEW_QUALITY);
|
|
||||||
return URL.createObjectURL(blob);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`미리보기 썸네일 생성 실패: ${file.name}`, error);
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
decoded.dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
import type { ImageItem } from '../types/api';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 같은 파일을 가리키는지 판별하기 위한 키.
|
|
||||||
*
|
|
||||||
* 브라우저는 동일한 파일에 대해 name/size/lastModified 를 항상 동일하게 주므로,
|
|
||||||
* 내용 해시를 계산하지 않고도 비용 없이 중복을 걸러낼 수 있다.
|
|
||||||
*/
|
|
||||||
export function getFileIdentity(file: Pick<File, 'name' | 'size' | 'lastModified'>): string {
|
|
||||||
return `${file.name}|${file.size}|${file.lastModified}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FileDedupResult {
|
|
||||||
/** 목록에 새로 넣어야 할 파일 */
|
|
||||||
newFiles: File[];
|
|
||||||
/** 중복이라 제외된 파일 수 */
|
|
||||||
duplicateCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이미 목록에 있는 파일, 그리고 이번 배치 안에서 서로 겹치는 파일을 걸러낸다.
|
|
||||||
*
|
|
||||||
* url 타입 이미지(크롤링으로 가져온 원격 이미지)는 로컬 파일과 비교할 수단이
|
|
||||||
* 없으므로 비교 대상에서 제외한다.
|
|
||||||
*/
|
|
||||||
export function splitDuplicateFiles(existing: ImageItem[], incoming: File[]): FileDedupResult {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
|
|
||||||
for (const item of existing) {
|
|
||||||
if (item.type === 'file') {
|
|
||||||
seen.add(getFileIdentity(item.file));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const newFiles: File[] = [];
|
|
||||||
let duplicateCount = 0;
|
|
||||||
|
|
||||||
for (const file of incoming) {
|
|
||||||
const identity = getFileIdentity(file);
|
|
||||||
if (seen.has(identity)) {
|
|
||||||
duplicateCount += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
seen.add(identity);
|
|
||||||
newFiles.push(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { newFiles, duplicateCount };
|
|
||||||
}
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
import type { ImageUploadResponse, ImageUrlItem } from '../types/api';
|
|
||||||
import { compressImageForUpload } from './imageCompression.ts';
|
|
||||||
|
|
||||||
export const MAX_IMAGES_PER_UPLOAD_TASK = 100;
|
|
||||||
|
|
||||||
export type ImageUploadRequest = (formData: FormData) => Promise<ImageUploadResponse>;
|
|
||||||
export type ImageCompressor = (file: File) => Promise<File>;
|
|
||||||
|
|
||||||
interface ImageUploadFormOptions {
|
|
||||||
imageUrls?: ImageUrlItem[];
|
|
||||||
file?: File;
|
|
||||||
taskId?: string;
|
|
||||||
finalize?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createImageUploadFormData({
|
|
||||||
imageUrls = [],
|
|
||||||
file,
|
|
||||||
taskId,
|
|
||||||
finalize,
|
|
||||||
}: ImageUploadFormOptions): FormData {
|
|
||||||
const formData = new FormData();
|
|
||||||
|
|
||||||
if (imageUrls.length > 0) {
|
|
||||||
formData.append('images_json', JSON.stringify(imageUrls));
|
|
||||||
}
|
|
||||||
if (taskId) {
|
|
||||||
formData.append('task_id', taskId);
|
|
||||||
}
|
|
||||||
if (finalize !== undefined) {
|
|
||||||
formData.append('finalize', String(finalize));
|
|
||||||
}
|
|
||||||
if (file) {
|
|
||||||
formData.append('files', file);
|
|
||||||
}
|
|
||||||
|
|
||||||
return formData;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function uploadImagesSequentially(
|
|
||||||
imageUrls: ImageUrlItem[],
|
|
||||||
files: File[],
|
|
||||||
sendRequest: ImageUploadRequest,
|
|
||||||
compressFile: ImageCompressor = compressImageForUpload
|
|
||||||
): Promise<ImageUploadResponse> {
|
|
||||||
const totalImageCount = imageUrls.length + files.length;
|
|
||||||
if (totalImageCount > MAX_IMAGES_PER_UPLOAD_TASK) {
|
|
||||||
throw new Error(`이미지는 최대 ${MAX_IMAGES_PER_UPLOAD_TASK}장까지 업로드할 수 있습니다.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (files.length === 0) {
|
|
||||||
return sendRequest(createImageUploadFormData({ imageUrls }));
|
|
||||||
}
|
|
||||||
|
|
||||||
let taskId: string | undefined;
|
|
||||||
let finalResponse: ImageUploadResponse | undefined;
|
|
||||||
|
|
||||||
for (let index = 0; index < files.length; index += 1) {
|
|
||||||
// Compress and upload one source at a time. This prevents both decoded
|
|
||||||
// image buffers and multipart request bodies from accumulating in memory.
|
|
||||||
const compressedFile = await compressFile(files[index]);
|
|
||||||
const isFirstRequest = index === 0;
|
|
||||||
const isLastRequest = index === files.length - 1;
|
|
||||||
const formData = createImageUploadFormData({
|
|
||||||
imageUrls: isFirstRequest ? imageUrls : [],
|
|
||||||
file: compressedFile,
|
|
||||||
taskId,
|
|
||||||
finalize: isLastRequest,
|
|
||||||
});
|
|
||||||
|
|
||||||
finalResponse = await sendRequest(formData);
|
|
||||||
if (!finalResponse.task_id) {
|
|
||||||
throw new Error('이미지 업로드 응답에 작업 ID가 없습니다.');
|
|
||||||
}
|
|
||||||
if (taskId && finalResponse.task_id !== taskId) {
|
|
||||||
throw new Error('이미지 업로드 작업 ID가 요청 사이에 변경되었습니다. 다시 시도해주세요.');
|
|
||||||
}
|
|
||||||
taskId = finalResponse.task_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// files.length > 0 guarantees that the loop produced a response.
|
|
||||||
return finalResponse as ImageUploadResponse;
|
|
||||||
}
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
/**
|
|
||||||
* 기기의 네이티브 공유 시트를 열고 요청을 처리했는지 반환합니다.
|
|
||||||
*
|
|
||||||
* 긴 `text`를 넣으면 카카오 등이 URL을 미리보기가 아니라 본문 텍스트로만 보냅니다.
|
|
||||||
* 제목·설명은 OG 페이지(`og:title`, `og:description`)에 두고, 여기에는 url(과 짧은 title)만 넘깁니다.
|
|
||||||
*
|
|
||||||
* 사용자가 공유 시트를 닫은 경우도 정상적으로 처리된 것으로 간주합니다.
|
|
||||||
*/
|
|
||||||
export async function tryNativeShare(data: ShareData): Promise<boolean> {
|
|
||||||
if (typeof navigator.share !== 'function') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await navigator.share(data);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error && error.name === 'AbortError') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** API 서버가 제공하는 영상별 Open Graph 공유 페이지 URL을 만듭니다. */
|
|
||||||
export function buildVideoShareUrl(apiBaseUrl: string, videoId: number | string): string {
|
|
||||||
return `${apiBaseUrl.replace(/\/$/, '')}/video/share/${encodeURIComponent(String(videoId))}`;
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user