From e7d6d89fe5cf838a7c2825dcd1c685e40f565269 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Wed, 8 Jul 2026 10:50:13 +0900 Subject: [PATCH] =?UTF-8?q?=EC=98=81=EC=83=81=EC=B9=B4=EB=93=9C=EC=97=90?= =?UTF-8?q?=20=EA=B3=B5=EC=9C=A0=ED=95=98=EA=B8=B0=20=EC=A2=8B=EC=95=84?= =?UTF-8?q?=EC=9A=94=20=ED=81=B4=EB=A6=AD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ContentCardSocialActions.tsx | 228 ++++++++++++++++++++ src/locales/en.json | 2 +- src/locales/ko.json | 2 +- src/pages/Dashboard/ADO2ContentsPage.tsx | 19 +- src/pages/Dashboard/MyContentsPage.tsx | 19 +- src/styles/contents-social.css | 87 +++++++- src/styles/generation-flow.css | 2 +- src/styles/studio-assets.css | 2 +- 8 files changed, 335 insertions(+), 26 deletions(-) create mode 100644 src/components/ContentCardSocialActions.tsx diff --git a/src/components/ContentCardSocialActions.tsx b/src/components/ContentCardSocialActions.tsx new file mode 100644 index 0000000..58608f7 --- /dev/null +++ b/src/components/ContentCardSocialActions.tsx @@ -0,0 +1,228 @@ +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; +import { toggleVideoLike } from '../utils/api'; + +interface ContentCardSocialActionsProps { + videoId: number; + storeName: string; + region?: string; + commentCount: number; + initialLikeCount: number; + initialIsLiked?: boolean; +} + +const ContentCardSocialActions: React.FC = ({ + videoId, + storeName, + region, + commentCount, + initialLikeCount, + initialIsLiked = false, +}) => { + const { t } = useTranslation(); + + const [likeCount, setLikeCount] = useState(initialLikeCount); + const [isLiked, setIsLiked] = useState(initialIsLiked); + const [copied, setCopied] = useState(false); + const [shareMenuOpen, setShareMenuOpen] = useState(false); + const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null); + const [anchorRect, setAnchorRect] = useState<{ top: number; bottom: number; left: number } | null>(null); + + const shareBtnRef = useRef(null); + const shareMenuRef = useRef(null); + const likeDebounceRef = useRef | null>(null); + + const shareUrl = `${window.location.origin}/video/${videoId}`; + + useEffect(() => { + if (!shareMenuOpen) return; + + const closeMenu = () => setShareMenuOpen(false); + + const handleClickOutside = (e: MouseEvent) => { + if ( + shareMenuRef.current && !shareMenuRef.current.contains(e.target as Node) && + shareBtnRef.current && !shareBtnRef.current.contains(e.target as Node) + ) { + closeMenu(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + window.addEventListener('scroll', closeMenu, true); + window.addEventListener('resize', closeMenu); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + window.removeEventListener('scroll', closeMenu, true); + window.removeEventListener('resize', closeMenu); + }; + }, [shareMenuOpen]); + + // 메뉴가 뷰포트 밖으로 넘어가지 않도록 실제 렌더된 크기 기준으로 위치 보정 + useLayoutEffect(() => { + if (!shareMenuOpen || !anchorRect || !shareMenuRef.current) return; + const margin = 8; + const menuRect = shareMenuRef.current.getBoundingClientRect(); + + let left = anchorRect.left; + if (left + menuRect.width > window.innerWidth - margin) { + left = window.innerWidth - menuRect.width - margin; + } + left = Math.max(margin, left); + + let top = anchorRect.bottom + 6; + if (top + menuRect.height > window.innerHeight - margin) { + top = anchorRect.top - menuRect.height - 6; + } + top = Math.max(margin, top); + + setMenuPos((prev) => (prev && prev.top === top && prev.left === left ? prev : { top, left })); + }, [shareMenuOpen, anchorRect]); + + const handleCopyLink = async () => { + try { + await navigator.clipboard.writeText(shareUrl); + } catch { + // clipboard API 미지원 환경에서는 무시 + } + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const handleKakaoShare = () => { + const kakao = window.Kakao; + if (kakao?.Share) { + kakao.Share.sendDefault({ + objectType: 'feed', + content: { + title: storeName || t('videoDetail.kakaoDefaultTitle'), + description: t('videoDetail.kakaoDescription', { region: region ?? '' }), + imageUrl: 'https://demo.castad.net/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 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); + }; + + const handleShareBtnClick = (e: React.MouseEvent) => { + e.stopPropagation(); + const rect = shareBtnRef.current?.getBoundingClientRect(); + if (rect) { + // 초기 추정 위치(측정 전 첫 렌더용) — useLayoutEffect가 실제 크기 기준으로 다시 보정한다 + setAnchorRect({ top: rect.top, bottom: rect.bottom, left: rect.left }); + setMenuPos({ top: rect.bottom + 6, left: rect.left }); + } + setShareMenuOpen((v) => !v); + }; + + return ( +
e.stopPropagation()}> + + + + + + + {commentCount} + + + + + {shareMenuOpen && menuPos && createPortal( +
+ + + + +
, + document.body + )} +
+ ); +}; + +export default ContentCardSocialActions; diff --git a/src/locales/en.json b/src/locales/en.json index 1696f0b..a97fb04 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -524,7 +524,7 @@ "delete": "Delete", "previous": "Previous", "next": "Next", - "uploadToSocial": "Upload to social media", + "uploadToSocial": "Upload to social", "sortLatest": "Latest", "sortOldest": "Oldest", "sortLikes": "Most Liked", diff --git a/src/locales/ko.json b/src/locales/ko.json index 0221333..b38192b 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -523,7 +523,7 @@ "delete": "삭제", "previous": "이전", "next": "다음", - "uploadToSocial": "소셜 미디어에 업로드", + "uploadToSocial": "SNS 업로드", "sortLatest": "최신순", "sortOldest": "오래된순", "sortLikes": "좋아요 많은순", diff --git a/src/pages/Dashboard/ADO2ContentsPage.tsx b/src/pages/Dashboard/ADO2ContentsPage.tsx index f1307b9..002e9a5 100644 --- a/src/pages/Dashboard/ADO2ContentsPage.tsx +++ b/src/pages/Dashboard/ADO2ContentsPage.tsx @@ -5,6 +5,7 @@ import { VideoListItem } from '../../types/api'; import LoginPromptModal from '../../components/LoginPromptModal'; import VideoDetailModal from '../../components/VideoDetailModal'; import CitySelectModal from '../../components/CitySelectModal'; +import ContentCardSocialActions from '../../components/ContentCardSocialActions'; interface ADO2ContentsPageProps { onBack?: () => void; @@ -181,16 +182,14 @@ const ADO2ContentsPage: React.FC = () => {

{video.store_name}

{formatDate(video.created_at)}

- - - - - {video.like_count ?? 0} - - - - {video.comment_count ?? 0} - +
diff --git a/src/pages/Dashboard/MyContentsPage.tsx b/src/pages/Dashboard/MyContentsPage.tsx index aa06855..b479c41 100644 --- a/src/pages/Dashboard/MyContentsPage.tsx +++ b/src/pages/Dashboard/MyContentsPage.tsx @@ -5,6 +5,7 @@ import { getVideosList, deleteVideo } from '../../utils/api'; import { VideoListItem } from '../../types/api'; import SocialPostingModal from '../../components/SocialPostingModal'; import VideoDetailModal from '../../components/VideoDetailModal'; +import ContentCardSocialActions from '../../components/ContentCardSocialActions'; import { useTutorial } from '../../components/Tutorial/useTutorial'; import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps'; @@ -278,16 +279,14 @@ const MyContentsPage: React.FC = ({ onNavigate }) => {

{formatDate(video.created_at)}

- - - - - {video.like_count ?? 0} - - - - {video.comment_count ?? 0} - + diff --git a/src/styles/contents-social.css b/src/styles/contents-social.css index e9cec4a..e5382cc 100644 --- a/src/styles/contents-social.css +++ b/src/styles/contents-social.css @@ -448,11 +448,11 @@ text-overflow: ellipsis; } -.content-card-like { +.content-card-social { display: inline-flex; align-items: center; justify-content: center; - gap: 4px; + gap: 10px; font-size: 15px; font-weight: 500; color: #9BCACC; @@ -461,6 +461,49 @@ margin-left: 8px; } +.content-card-like-btn { + display: inline-flex; + align-items: center; + gap: 4px; + background: none; + border: none; + padding: 0; + font: inherit; + color: #9BCACC; + cursor: pointer; + transition: color 0.2s; +} + +.content-card-like-btn:hover { + color: #fff; +} + +.content-card-like-btn.liked { + color: #ff6b6b; +} + +.content-card-comment { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.content-card-share-btn { + display: inline-flex; + align-items: center; + justify-content: center; + background: none; + border: none; + padding: 0; + color: #9BCACC; + cursor: pointer; + transition: color 0.2s; +} + +.content-card-share-btn:hover { + color: #fff; +} + .content-card-date { font-size: 14px; font-weight: 400; @@ -1157,6 +1200,46 @@ } } +@media (max-width: 480px) { + .ado2-contents-page { + padding: 12px; + } + + .ado2-contents-grid { + grid-template-columns: repeat(2, 1fr); + gap: 8px; + } + + .content-card-info { + padding: 10px; + gap: 8px; + } + + .content-card-social { + gap: 6px; + } + + .content-card-actions { + gap: 4px; + } + + .content-download-btn { + padding: 8px 4px; + } + + .content-download-btn span { + overflow: hidden; + text-overflow: ellipsis; + } + + .content-delete-btn, + .content-upload-btn { + width: 30px; + height: 30px; + flex-shrink: 0; + } +} + /* ===================================================== Delete Confirmation Modal ===================================================== */ diff --git a/src/styles/generation-flow.css b/src/styles/generation-flow.css index 4051182..df9f3db 100644 --- a/src/styles/generation-flow.css +++ b/src/styles/generation-flow.css @@ -511,7 +511,7 @@ .comp2-page { height: 100%; - background: #002224; + background: var(--color-bg-darker); display: flex; flex-direction: column; overflow: hidden; diff --git a/src/styles/studio-assets.css b/src/styles/studio-assets.css index 96820e9..47ea6f5 100644 --- a/src/styles/studio-assets.css +++ b/src/styles/studio-assets.css @@ -5,7 +5,7 @@ /* Sound Studio Page */ .sound-studio-page { height: 100%; - background-color: #002224; + background-color: var(--color-bg-darker); overflow-y: auto; overflow-x: hidden; padding: 30px 1rem 120px;