fix: 공유하기 및 UI 수정
This commit is contained in:
parent
4dff60c1ba
commit
17319e950e
@ -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
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.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",
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
{/* 댓글 섹션 */}
|
{/* 댓글 섹션 */}
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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": "차감된 크레딧은 환불되었습니다.",
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
{/* 연결된 계정 목록 */}
|
{/* 연결된 계정 목록 */}
|
||||||
|
|||||||
@ -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')}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user