fix: 공유하기 및 UI 수정

This commit is contained in:
김성경 2026-08-20 10:34:14 +09:00
parent 4dff60c1ba
commit 17319e950e
14 changed files with 169 additions and 274 deletions

View File

@ -50,7 +50,6 @@
} }
} }
</script> </script>
<script src="https://t1.kakaocdn.net/kakao_js_sdk/2.7.4/kakao.min.js" crossorigin="anonymous"></script>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&family=Playfair+Display:ital,wght@0,700;1,700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&family=Playfair+Display:ital,wght@0,700;1,700&display=swap" rel="stylesheet">

43
package-lock.json generated
View File

@ -16,6 +16,8 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2", "typescript": "~5.8.2",
"vite": "^6.2.0" "vite": "^6.2.0"
@ -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.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
}
},
"node_modules/@types/use-sync-external-store": { "node_modules/@types/use-sync-external-store": {
"version": "0.0.6", "version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
@ -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",

View File

@ -14,6 +14,8 @@ interface ContentCardSocialActionsProps {
contentType?: ContentType; contentType?: ContentType;
storeName: string; storeName: string;
region?: string; region?: string;
title?: string | null;
description?: string | null;
commentCount: number; commentCount: number;
initialLikeCount: number; initialLikeCount: number;
initialIsLiked?: boolean; initialIsLiked?: boolean;
@ -24,6 +26,8 @@ const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
contentType = 'video', contentType = 'video',
storeName, storeName,
region, region,
title,
description,
commentCount, commentCount,
initialLikeCount, initialLikeCount,
initialIsLiked = false, initialIsLiked = false,
@ -36,8 +40,8 @@ const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
const likeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); const likeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const shareUrl = buildContentShareUrl(API_URL, videoId, contentType); const shareUrl = buildContentShareUrl(API_URL, videoId, contentType);
const shareTitle = storeName || t('videoDetail.kakaoDefaultTitle'); const shareTitle = title || storeName || t('videoDetail.kakaoDefaultTitle');
const shareDescription = t('videoDetail.kakaoDescription', { region: region ?? '' }); const shareDescription = description || t('videoDetail.kakaoDescription', { region: region ?? '' });
const handleShareBtnClick = async (e: React.MouseEvent) => { const handleShareBtnClick = async (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();

View File

@ -32,7 +32,6 @@ 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 [showLoginModal, setShowLoginModal] = useState(false); const [showLoginModal, setShowLoginModal] = useState(false);
@ -104,8 +103,8 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
}; };
const shareUrl = buildVideoShareUrl(API_URL, videoId); const shareUrl = buildVideoShareUrl(API_URL, videoId);
const shareTitle = video?.store_name ?? t('videoDetail.kakaoDefaultTitle'); const shareTitle = video?.title || video?.store_name || t('videoDetail.kakaoDefaultTitle');
const shareDescription = t('videoDetail.kakaoDescription', { region: video?.region ?? '' }); const shareDescription = video?.description || t('videoDetail.kakaoDescription', { region: video?.region ?? '' });
const handleCopyLink = async () => { const handleCopyLink = async () => {
try { try {
@ -126,53 +125,9 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
if (handled) { if (handled) {
return; return;
} }
setShareMenuOpen(v => !v); await handleCopyLink();
}; };
const handleKakaoShare = () => {
const kakao = window.Kakao;
if (kakao?.Share) {
kakao.Share.sendDefault({
objectType: 'feed',
content: {
title: video?.store_name ?? t('videoDetail.kakaoDefaultTitle'),
description: t('videoDetail.kakaoDescription', { region: video?.region ?? '' }),
imageUrl: 'https://ado2.o2osolution.ai/favicon_48.svg',
link: { mobileWebUrl: shareUrl, webUrl: shareUrl },
},
buttons: [{ title: t('videoDetail.kakaoButtonTitle'), link: { mobileWebUrl: shareUrl, webUrl: shareUrl } }],
});
} else if (navigator.share) {
navigator.share({ url: shareUrl }).catch(() => {});
} else {
handleCopyLink();
}
setShareMenuOpen(false);
};
const handleFacebookShare = () => {
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,width=600,height=600');
setShareMenuOpen(false);
};
const handleTwitterShare = () => {
window.open(`https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,width=600,height=600');
setShareMenuOpen(false);
};
const shareMenuRef = React.useRef<HTMLDivElement>(null);
useEffect(() => {
if (!shareMenuOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (shareMenuRef.current && !shareMenuRef.current.contains(e.target as Node)) {
setShareMenuOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [shareMenuOpen]);
const likeDebounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null); const likeDebounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const handleLike = () => { const handleLike = () => {
@ -302,52 +257,17 @@ 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={handleShareButtonClick} 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 ? t('videoDetail.copied') : t('videoDetail.share')}
</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>
{t('videoDetail.shareKakao')}
</button>
{/* 페이스북 */}
<button className="video-detail-share-item" onClick={handleFacebookShare}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="#1877F2">
<path d="M24 12.073C24 5.405 18.627 0 12 0S0 5.405 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.41c0-3.025 1.792-4.697 4.533-4.697 1.312 0 2.686.235 2.686.235v2.97h-1.513c-1.491 0-1.956.93-1.956 1.887v2.268h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z"/>
</svg>
{t('videoDetail.shareFacebook')}
</button>
{/* X (트위터) */}
<button className="video-detail-share-item" onClick={handleTwitterShare}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
{t('videoDetail.shareTwitter')}
</button>
{/* URL 복사 */}
<button className="video-detail-share-item" onClick={() => { handleCopyLink(); setShareMenuOpen(false); }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="9" width="13" height="13" rx="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
{copied ? t('videoDetail.copied') : t('videoDetail.copyUrl')}
</button>
</div>
)}
</div>
</div> </div>
{/* 댓글 섹션 */} {/* 댓글 섹션 */}

View File

@ -5,17 +5,6 @@ import './i18n';
import './styles/index.css'; import './styles/index.css';
import App from './App'; import App from './App';
declare global {
interface Window {
Kakao: any;
}
}
const kakaoKey = import.meta.env.VITE_KAKAO_JS_KEY as string | undefined;
if (kakaoKey && window.Kakao && !window.Kakao.isInitialized()) {
window.Kakao.init(kakaoKey);
}
const rootElement = document.getElementById('root'); const rootElement = document.getElementById('root');
if (!rootElement) { if (!rootElement) {
throw new Error("Could not find root element to mount to"); throw new Error("Could not find root element to mount to");

View File

@ -575,10 +575,6 @@
"closeAriaLabel": "Close", "closeAriaLabel": "Close",
"share": "Share", "share": "Share",
"copied": "Copied!", "copied": "Copied!",
"shareKakao": "KakaoTalk",
"shareFacebook": "Facebook",
"shareTwitter": "X (Twitter)",
"copyUrl": "Copy URL",
"commentsTitle": "Comments", "commentsTitle": "Comments",
"commentPlaceholder": "Write a comment...", "commentPlaceholder": "Write a comment...",
"commentLoginRequired": "Please log in to write a comment", "commentLoginRequired": "Please log in to write a comment",

View File

@ -575,10 +575,6 @@
"closeAriaLabel": "닫기", "closeAriaLabel": "닫기",
"share": "공유하기", "share": "공유하기",
"copied": "복사됨!", "copied": "복사됨!",
"shareKakao": "카카오톡",
"shareFacebook": "페이스북",
"shareTwitter": "X (트위터)",
"copyUrl": "URL 복사",
"commentsTitle": "댓글", "commentsTitle": "댓글",
"commentPlaceholder": "댓글을 입력하세요...", "commentPlaceholder": "댓글을 입력하세요...",
"commentLoginRequired": "로그인 후 댓글을 작성할 수 있습니다", "commentLoginRequired": "로그인 후 댓글을 작성할 수 있습니다",
@ -620,7 +616,7 @@
"searching": "검색 중", "searching": "검색 중",
"placeLoading": "가게를 찾는 중… (몇 초 걸려요)", "placeLoading": "가게를 찾는 중… (몇 초 걸려요)",
"hintUrlOk": "네이버 링크로 이 가게를 직접 크롤링합니다.", "hintUrlOk": "네이버 링크로 이 가게를 직접 크롤링합니다.",
"hintDefault": "가게를 선택하면 그 가게의 사진과 정보를 크롤링해 썰박스에 씁니다.", "hintDefault": "업체의 사진과 정보를 크롤링해 썰박스에 사용합니다.",
"hintScenario": "{{name}} 시나리오로 약 5~6분 소요됩니다.", "hintScenario": "{{name}} 시나리오로 약 5~6분 소요됩니다.",
"noResult": "검색 결과가 없어요. 이름을 바꾸거나 네이버 지도 링크를 붙여넣어 주세요.", "noResult": "검색 결과가 없어요. 이름을 바꾸거나 네이버 지도 링크를 붙여넣어 주세요.",
"cost": "생성 비용", "cost": "생성 비용",
@ -634,7 +630,7 @@
"step2": "스토리보드 구성 중", "step2": "스토리보드 구성 중",
"step3": "이미지·목소리 생성 중", "step3": "이미지·목소리 생성 중",
"step4": "영상 합성 중", "step4": "영상 합성 중",
"takesTime": "약 5~6분 소요됩니다. 다른 작업을 하셔도 계속 진행됩니다.", "takesTime": "약 5~6분 소요됩니다.",
"failTitle": "생성에 실패했어요", "failTitle": "생성에 실패했어요",
"failDefault": "잠시 후 다시 시도해주세요.", "failDefault": "잠시 후 다시 시도해주세요.",
"refunded": "차감된 크레딧은 환불되었습니다.", "refunded": "차감된 크레딧은 환불되었습니다.",

View File

@ -200,7 +200,7 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
<div className="content-card-info"> <div className="content-card-info">
<div className="content-card-text"> <div className="content-card-text">
<h3 className="content-card-title">{video.store_name}</h3> <h3 className="content-card-title">{video.store_name}</h3>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div className="content-card-meta">
<p className="content-card-date">{formatDate(video.created_at)}</p> <p className="content-card-date">{formatDate(video.created_at)}</p>
{/* 좋아요·댓글 API 가 type 을 받으므로 썰박스에도 노출한다. {/* 좋아요·댓글 API 가 type 을 받으므로 썰박스에도 노출한다.
contentType 이 없으면 id 가 겹치는 다른 영상에 반영된다. */} contentType 이 없으면 id 가 겹치는 다른 영상에 반영된다. */}
@ -209,6 +209,8 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
contentType={video.type} contentType={video.type}
storeName={video.store_name} storeName={video.store_name}
region={video.region} region={video.region}
title={video.title}
description={video.description}
commentCount={video.comment_count ?? 0} commentCount={video.comment_count ?? 0}
initialLikeCount={video.like_count ?? 0} initialLikeCount={video.like_count ?? 0}
initialIsLiked={video.is_liked_by_me} initialIsLiked={video.is_liked_by_me}

View File

@ -299,7 +299,7 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate, embedded })
<h3 className="content-card-title"> <h3 className="content-card-title">
{formatTitle(video.store_name, video.created_at)} {formatTitle(video.store_name, video.created_at)}
</h3> </h3>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div className="content-card-meta">
<p className="content-card-date"> <p className="content-card-date">
{formatDate(video.created_at)} {formatDate(video.created_at)}
</p> </p>
@ -310,6 +310,8 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate, embedded })
contentType={video.type} contentType={video.type}
storeName={video.store_name} storeName={video.store_name}
region={video.region} region={video.region}
title={video.title}
description={video.description}
commentCount={video.comment_count ?? 0} commentCount={video.comment_count ?? 0}
initialLikeCount={video.like_count ?? 0} initialLikeCount={video.like_count ?? 0}
initialIsLiked={video.is_liked_by_me} initialIsLiked={video.is_liked_by_me}

View File

@ -298,13 +298,13 @@ const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab, onNavigate })
<span>{isConnecting === 'youtube' ? t('myInfo.youtubeConnecting') : t('myInfo.youtubeConnect')}</span> <span>{isConnecting === 'youtube' ? t('myInfo.youtubeConnecting') : t('myInfo.youtubeConnect')}</span>
</button> </button>
<button {/* <button
disabled disabled
className="myinfo-social-btn disabled" className="myinfo-social-btn disabled"
> >
<img src="/assets/images/social-instagram.png" alt="Instagram" className="myinfo-social-btn-icon" /> <img src="/assets/images/social-instagram.png" alt="Instagram" className="myinfo-social-btn-icon" />
<span>{t('myInfo.instagramConnect')}</span> <span>{t('myInfo.instagramConnect')}</span>
</button> </button> */}
</div> </div>
{/* 연결된 계정 목록 */} {/* 연결된 계정 목록 */}

View File

@ -1,15 +1,18 @@
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 { import {
API_URL,
deleteComment, deleteComment,
getSsulContentById, getSsulContentById,
getUserMe,
getVideoComments, getVideoComments,
isLoggedIn, isLoggedIn,
postVideoComment, postVideoComment,
toggleVideoLike, toggleVideoLike,
} from '../../utils/api'; } from '../../utils/api';
import type { SsulDetailItem } from '../../utils/api'; import type { SsulDetailItem } from '../../utils/api';
import { CommentItem } from '../../types/api'; import { CommentItem, UserMeResponse } from '../../types/api';
import { buildContentShareUrl, tryNativeShare } from '../../utils/nativeShare';
import LoginPromptModal from '../../components/LoginPromptModal'; import LoginPromptModal from '../../components/LoginPromptModal';
interface SsulDetailContentProps { interface SsulDetailContentProps {
@ -22,11 +25,11 @@ interface SsulDetailContentProps {
* 썰박스 콘텐츠 상세 — castad `VideoDetailContent` 와 같은 3층 구조의 내용부. * 썰박스 콘텐츠 상세 — castad `VideoDetailContent` 와 같은 3층 구조의 내용부.
* (SsulViewerModal 이 모달로, SsulDetailPage 가 공유 링크 페이지로 감싼다) * (SsulViewerModal 이 모달로, SsulDetailPage 가 공유 링크 페이지로 감싼다)
* *
* UI·동작(좋아요 낙관 갱신·댓글·대댓글·공유 메뉴·비로그인 게이트)은 * UI·동작(좋아요 낙관 갱신·댓글·대댓글·공유·비로그인 게이트)은
* `VideoDetailContent` 와 동일하고, 다른 점은 셋뿐이다: * `VideoDetailContent` 와 동일하고, 다른 점은 셋뿐이다:
* 1. 상세 조회가 `GET /ssul/{id}` (video 조회를 쓰면 id 가 겹치는 남의 영상이 열린다) * 1. 상세 조회가 `GET /ssul/{id}` (video 조회를 쓰면 id 가 겹치는 남의 영상이 열린다)
* 2. 좋아요·댓글 API 에 `contentType='ssul'` 을 얹는다 * 2. 좋아요·댓글 API 에 `contentType='ssul'` 을 얹는다
* 3. 공유 URL 이 `/ssul/{id}` 다 * 3. 공유 URL 이 `/ssul/share/{id}` 다
*/ */
const SsulDetailContent: React.FC<SsulDetailContentProps> = ({ const SsulDetailContent: React.FC<SsulDetailContentProps> = ({
contentId, contentId,
@ -44,8 +47,6 @@ const SsulDetailContent: React.FC<SsulDetailContentProps> = ({
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 [shareMenuUpward, setShareMenuUpward] = useState(false);
const [isLandscape, setIsLandscape] = useState(false); const [isLandscape, setIsLandscape] = useState(false);
const [showLoginModal, setShowLoginModal] = useState(false); const [showLoginModal, setShowLoginModal] = useState(false);
@ -58,16 +59,7 @@ const SsulDetailContent: React.FC<SsulDetailContentProps> = ({
const [commentSubmitting, setCommentSubmitting] = useState(false); const [commentSubmitting, setCommentSubmitting] = useState(false);
const commentTextareaRef = useRef<HTMLTextAreaElement>(null); const commentTextareaRef = useRef<HTMLTextAreaElement>(null);
const [commentNickname, setCommentNickname] = useState(''); const [currentUser, setCurrentUser] = useState<UserMeResponse | null>(null);
const [commentAvatarSeedIdx, setCommentAvatarSeedIdx] = useState(0);
// castad 와 동일한 고정 seed 목록 — 브라우저가 캐싱해 중복 요청이 없다
const AVATAR_SEEDS = ['42', '77', '123', '256', '512', '888', '1024', '2048', '3141', '9999'];
const commentAvatarSeed = AVATAR_SEEDS[commentAvatarSeedIdx % AVATAR_SEEDS.length];
const handleChangeAvatar = useCallback(() => {
setCommentAvatarSeedIdx((prev) => (prev + 1) % AVATAR_SEEDS.length);
}, []);
const fetchComments = useCallback( const fetchComments = useCallback(
async (page: number, append = false) => { async (page: number, append = false) => {
@ -112,6 +104,13 @@ const SsulDetailContent: React.FC<SsulDetailContentProps> = ({
fetchComments(1); fetchComments(1);
}, [contentId, fetchComments, t]); }, [contentId, fetchComments, t]);
useEffect(() => {
if (!authed) return;
getUserMe().then(setCurrentUser).catch((err) => {
console.error('Failed to fetch current user:', err);
});
}, [authed]);
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
const date = new Date(dateString); const date = new Date(dateString);
return t('videoDetail.dateFormat', { return t('videoDetail.dateFormat', {
@ -129,7 +128,7 @@ const SsulDetailContent: React.FC<SsulDetailContentProps> = ({
}; };
// 썰박스 공개 페이지 경로. /video/{id} 를 쓰면 id 가 겹치는 남의 영상이 열린다. // 썰박스 공개 페이지 경로. /video/{id} 를 쓰면 id 가 겹치는 남의 영상이 열린다.
const shareUrl = `${window.location.origin}/ssul/${contentId}`; const shareUrl = buildContentShareUrl(API_URL, contentId, 'ssul');
const handleCopyLink = async () => { const handleCopyLink = async () => {
try { try {
@ -141,65 +140,16 @@ const SsulDetailContent: React.FC<SsulDetailContentProps> = ({
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
}; };
const handleKakaoShare = () => { const handleShareButtonClick = async () => {
const kakao = window.Kakao; const handled = await tryNativeShare({
if (kakao?.Share) { title: content?.store_name ?? t('videoDetail.kakaoDefaultTitle'),
kakao.Share.sendDefault({ text: t('videoDetail.kakaoDescription', { region: content?.region ?? '' }),
objectType: 'feed', url: shareUrl,
content: { });
title: content?.store_name ?? t('videoDetail.kakaoDefaultTitle'), if (handled) {
description: t('videoDetail.kakaoDescription', { region: content?.region ?? '' }), return;
imageUrl: 'https://ado2.o2osolution.ai/favicon_48.svg',
link: { mobileWebUrl: shareUrl, webUrl: shareUrl },
},
buttons: [{ title: t('videoDetail.kakaoButtonTitle'), link: { mobileWebUrl: shareUrl, webUrl: shareUrl } }],
});
} else if (navigator.share) {
navigator.share({ url: shareUrl }).catch(() => {});
} else {
handleCopyLink();
} }
setShareMenuOpen(false); await handleCopyLink();
};
const handleFacebookShare = () => {
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,width=600,height=600');
setShareMenuOpen(false);
};
const handleTwitterShare = () => {
window.open(`https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,width=600,height=600');
setShareMenuOpen(false);
};
const shareMenuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!shareMenuOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (shareMenuRef.current && !shareMenuRef.current.contains(e.target as Node)) {
setShareMenuOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [shareMenuOpen]);
// 모달 내부는 스크롤 영역(.video-detail-body)이 곧 화면 경계라, 버튼이 그 하단에
// 가까우면 아래로 펼치는 메뉴가 잘려서 안 보인다. 열기 직전 남은 공간을 재서 뒤집는다.
const handleToggleShareMenu = () => {
if (!shareMenuOpen) {
const btnRect = shareMenuRef.current?.getBoundingClientRect();
if (btnRect) {
const scrollBox = shareMenuRef.current?.closest('.video-detail-body');
const boundaryBottom = scrollBox
? scrollBox.getBoundingClientRect().bottom
: window.innerHeight;
const ESTIMATED_MENU_HEIGHT = 220;
setShareMenuUpward(btnRect.bottom + ESTIMATED_MENU_HEIGHT > boundaryBottom);
}
}
setShareMenuOpen(v => !v);
}; };
const likeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); const likeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -252,8 +202,6 @@ const SsulDetailContent: React.FC<SsulDetailContentProps> = ({
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 ssul comment:', err); console.error('Failed to post ssul comment:', err);
@ -349,48 +297,17 @@ const SsulDetailContent: React.FC<SsulDetailContentProps> = ({
</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={handleToggleShareMenu} 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 ? t('videoDetail.copied') : t('videoDetail.share')}
</button> </button>
{shareMenuOpen && (
<div className={`video-detail-share-menu ${shareMenuUpward ? 'upward' : ''}`}>
<button className="video-detail-share-item" onClick={handleKakaoShare}>
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="20" height="20" rx="4" fill="#FEE500" />
<path fillRule="evenodd" clipRule="evenodd" d="M10 3.5C6.134 3.5 3 6.01 3 9.1c0 1.98 1.2 3.72 3.01 4.76l-.74 2.75a.19.19 0 0 0 .28.21l3.37-2.23c.34.04.69.06 1.06.06 3.866 0 7-2.51 7-5.6S13.866 3.5 10 3.5z" fill="#3C1E1E" />
</svg>
{t('videoDetail.shareKakao')}
</button>
<button className="video-detail-share-item" onClick={handleFacebookShare}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="#1877F2">
<path d="M24 12.073C24 5.405 18.627 0 12 0S0 5.405 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.41c0-3.025 1.792-4.697 4.533-4.697 1.312 0 2.686.235 2.686.235v2.97h-1.513c-1.491 0-1.956.93-1.956 1.887v2.268h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z" />
</svg>
{t('videoDetail.shareFacebook')}
</button>
<button className="video-detail-share-item" onClick={handleTwitterShare}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
{t('videoDetail.shareTwitter')}
</button>
<button className="video-detail-share-item" onClick={() => { handleCopyLink(); setShareMenuOpen(false); }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
{copied ? t('videoDetail.copied') : t('videoDetail.copyUrl')}
</button>
</div>
)}
</div>
</div> </div>
{/* 댓글 섹션 */} {/* 댓글 섹션 */}
@ -400,24 +317,16 @@ const SsulDetailContent: React.FC<SsulDetailContentProps> = ({
<span className="video-detail-comments-count">{commentsTotal}</span> <span className="video-detail-comments-count">{commentsTotal}</span>
</div> </div>
{authed && ( {authed && currentUser && (
<div className="video-detail-comment-profile"> <div className="video-detail-comment-profile">
<img {currentUser.profile_image_url && (
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${commentAvatarSeed}`} <img
alt={t('videoDetail.changeAvatarTitle')} src={currentUser.profile_image_url}
className="video-detail-comment-avatar" alt={currentUser.nickname}
onClick={handleChangeAvatar} className="video-detail-comment-avatar"
style={{ cursor: 'pointer' }} />
title={t('videoDetail.changeAvatarTitle')} )}
/> <span className="video-detail-comment-nickname">{currentUser.nickname}</span>
<input
className="video-detail-nickname-input"
type="text"
placeholder={t('videoDetail.nicknamePlaceholder')}
value={commentNickname}
onChange={(e) => setCommentNickname(e.target.value)}
maxLength={20}
/>
</div> </div>
)} )}
@ -458,11 +367,13 @@ const SsulDetailContent: React.FC<SsulDetailContentProps> = ({
<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">
<img {c.profile_image_url && (
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${c.id}`} <img
alt="avatar" src={c.profile_image_url}
className="video-detail-comment-avatar" alt={c.nickname || t('videoDetail.anonymous')}
/> 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 || t('videoDetail.anonymous')}
@ -486,11 +397,13 @@ const SsulDetailContent: React.FC<SsulDetailContentProps> = ({
<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">
<img {r.profile_image_url && (
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${r.id}`} <img
alt="avatar" src={r.profile_image_url}
className="video-detail-comment-avatar small" alt={r.nickname || t('videoDetail.anonymous')}
/> 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 || t('videoDetail.anonymous')}

View File

@ -359,6 +359,7 @@
border-radius: 20px; border-radius: 20px;
overflow: hidden; overflow: hidden;
background-color: #034A4D; background-color: #034A4D;
min-width: 0;
} }
.content-card-thumbnail { .content-card-thumbnail {
@ -435,6 +436,16 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
min-width: 0;
}
.content-card-meta {
display: flex;
justify-content: space-between;
align-items: center;
gap: 4px 8px;
min-width: 0;
flex-wrap: wrap;
} }
.content-card-title { .content-card-title {
@ -458,7 +469,7 @@
color: #9BCACC; color: #9BCACC;
white-space: nowrap; white-space: nowrap;
flex-shrink: 0; flex-shrink: 0;
margin-left: 8px; margin-left: auto;
} }
.content-card-like-btn { .content-card-like-btn {
@ -696,6 +707,26 @@
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
padding: 0 32px 24px; padding: 0 32px 24px;
color-scheme: dark;
scrollbar-width: thin;
scrollbar-color: #046266 transparent;
}
.video-detail-modal-content > .video-detail-body::-webkit-scrollbar {
width: 6px;
}
.video-detail-modal-content > .video-detail-body::-webkit-scrollbar-track {
background: transparent;
}
.video-detail-modal-content > .video-detail-body::-webkit-scrollbar-thumb {
background: #046266;
border-radius: 3px;
}
.video-detail-modal-content > .video-detail-body::-webkit-scrollbar-thumb:hover {
background: #067C80;
} }
.video-detail-page-content { .video-detail-page-content {

View File

@ -305,6 +305,8 @@ export interface VideoDetailItem {
poster_url?: string | null; poster_url?: string | null;
store_name: string; store_name: string;
region: string; region: string;
title?: string | null;
description?: string | null;
created_at: string; created_at: string;
like_count: number; like_count: number;
is_liked_by_me: boolean; is_liked_by_me: boolean;

View File

@ -20,20 +20,20 @@ export async function tryNativeShare(data: ShareData): Promise<boolean> {
} }
} }
/** API 서버가 제공하는 영상별 Open Graph 공유 페이지 URL을 만듭니다. */ /** API 서버의 영상 Open Graph 공유 페이지 URL을 만듭니다. */
export function buildVideoShareUrl(apiBaseUrl: string, videoId: number | string): string { export function buildVideoShareUrl(apiBaseUrl: string, videoId: number | string): string {
return `${apiBaseUrl.replace(/\/$/, '')}/video/share/${encodeURIComponent(String(videoId))}`; return `${apiBaseUrl.replace(/\/$/, '')}/video/share/${encodeURIComponent(String(videoId))}`;
} }
/** 콘텐츠 종류별 공개 공유 URL. video.id 와 ssul_content.id 가 겹치므로 경로를 갈라야 한다. */ /** 콘텐츠 종류별 공유 URL. video.id 와 ssul_content.id 가 겹치므로 경로를 갈라야 한다. */
export function buildContentShareUrl( export function buildContentShareUrl(
apiBaseUrl: string, apiBaseUrl: string,
contentId: number | string, contentId: number | string,
contentType: 'video' | 'ssul' = 'video', contentType: 'video' | 'ssul' = 'video',
): string { ): string {
const base = apiBaseUrl.replace(/\/$/, '');
if (contentType === 'ssul') { if (contentType === 'ssul') {
const origin = typeof window !== 'undefined' ? window.location.origin : ''; return `${base}/ssul/share/${encodeURIComponent(String(contentId))}`;
return `${origin}/ssul/${encodeURIComponent(String(contentId))}`;
} }
return buildVideoShareUrl(apiBaseUrl, contentId); return buildVideoShareUrl(base, contentId);
} }