From 9c0c3de35f3394adbb081a553af8054cf46c25bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Fri, 14 Aug 2026 15:29:39 +0900 Subject: [PATCH 1/5] =?UTF-8?q?UI=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/styles/generation-flow.css | 1 + src/styles/studio-assets.css | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/styles/generation-flow.css b/src/styles/generation-flow.css index df9f3db..f5529cb 100644 --- a/src/styles/generation-flow.css +++ b/src/styles/generation-flow.css @@ -2805,6 +2805,7 @@ .wizard-stepper { padding: 2rem; max-width: 100%; + margin-top: 30px; } .wizard-stepper-node { diff --git a/src/styles/studio-assets.css b/src/styles/studio-assets.css index 24a8dea..5d1e225 100644 --- a/src/styles/studio-assets.css +++ b/src/styles/studio-assets.css @@ -1146,6 +1146,36 @@ } } +/* 모바일: 제목은 한 줄로 유지하고, 권장 수량/카운트는 그 아래 줄로 내린다 */ +@media (max-width: 1023px) { + .asset-section-header { + align-items: flex-start; + } + + .asset-section-header-left { + flex: 1; + min-width: 0; + flex-wrap: wrap; + row-gap: 4px; + } + + .asset-section-header-left .asset-section-title { + order: 1; + white-space: nowrap; + } + + .asset-section-header-left .asset-section-count { + order: 2; + white-space: nowrap; + } + + .asset-section-header-left .asset-section-subtitle { + order: 3; + width: 100%; + white-space: nowrap; + } +} + /* Asset Image List */ .asset-image-list { background-color: #002224; From 3566c1f7bcb755add8e9bcb2f74b1b8e04b3fa90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Tue, 18 Aug 2026 11:02:29 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=EC=98=81=EC=83=81=20=EA=B3=B5?= =?UTF-8?q?=EC=9C=A0=C2=B7=ED=8F=AC=EC=8A=A4=ED=84=B0=20=EC=8D=B8=EB=84=A4?= =?UTF-8?q?=EC=9D=BC=C2=B7=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=84=9C=EB=B2=84=20=EC=97=B0=EB=8F=99=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ContentCardSocialActions.tsx | 36 +++++-- src/components/VideoDetailContent.tsx | 113 +++++++++++--------- src/locales/en.json | 2 - src/locales/ko.json | 2 - src/pages/Dashboard/ADO2ContentsPage.tsx | 11 +- src/pages/Dashboard/CompletionContent.tsx | 1 + src/pages/Dashboard/MyContentsPage.tsx | 11 +- src/types/api.ts | 6 +- src/utils/api.ts | 6 +- src/utils/nativeShare.ts | 27 +++++ 10 files changed, 146 insertions(+), 69 deletions(-) create mode 100644 src/utils/nativeShare.ts diff --git a/src/components/ContentCardSocialActions.tsx b/src/components/ContentCardSocialActions.tsx index b67690a..adfd36e 100644 --- a/src/components/ContentCardSocialActions.tsx +++ b/src/components/ContentCardSocialActions.tsx @@ -1,12 +1,14 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; -import { toggleVideoLike } from '../utils/api'; +import { API_URL, toggleVideoLike } from '../utils/api'; +import { buildVideoShareUrl, tryNativeShare } from '../utils/nativeShare'; interface ContentCardSocialActionsProps { videoId: number; storeName: string; region?: string; + posterUrl?: string | null; commentCount: number; initialLikeCount: number; initialIsLiked?: boolean; @@ -16,6 +18,7 @@ const ContentCardSocialActions: React.FC = ({ videoId, storeName, region, + posterUrl, commentCount, initialLikeCount, initialIsLiked = false, @@ -33,7 +36,11 @@ const ContentCardSocialActions: React.FC = ({ const shareMenuRef = useRef(null); const likeDebounceRef = useRef | null>(null); - const shareUrl = `${window.location.origin}/video/${videoId}`; + const videoPageUrl = `${window.location.origin}/video/${videoId}`; + const shareUrl = buildVideoShareUrl(API_URL, videoId); + const shareTitle = storeName || t('videoDetail.kakaoDefaultTitle'); + const shareDescription = t('videoDetail.kakaoDescription', { region: region ?? '' }); + const shareImageUrl = posterUrl || `${window.location.origin}/assets/images/hero-background.png`; useEffect(() => { if (!shareMenuOpen) return; @@ -96,15 +103,15 @@ const ContentCardSocialActions: React.FC = ({ kakao.Share.sendDefault({ objectType: 'feed', content: { - title: storeName || t('videoDetail.kakaoDefaultTitle'), - description: t('videoDetail.kakaoDescription', { region: region ?? '' }), - imageUrl: 'https://ado2.o2osolution.ai/favicon_48.svg', - link: { mobileWebUrl: shareUrl, webUrl: shareUrl }, + title: shareTitle, + description: shareDescription, + imageUrl: shareImageUrl, + link: { mobileWebUrl: videoPageUrl, webUrl: videoPageUrl }, }, - buttons: [{ title: t('videoDetail.kakaoButtonTitle'), link: { mobileWebUrl: shareUrl, webUrl: shareUrl } }], + buttons: [{ title: t('videoDetail.kakaoButtonTitle'), link: { mobileWebUrl: videoPageUrl, webUrl: videoPageUrl } }], }); } else if (navigator.share) { - navigator.share({ url: shareUrl }).catch(() => {}); + navigator.share({ title: shareTitle, text: shareDescription, url: shareUrl }).catch(() => {}); } else { handleCopyLink(); } @@ -144,8 +151,19 @@ const ContentCardSocialActions: React.FC = ({ }, 500); }; - const handleShareBtnClick = (e: React.MouseEvent) => { + const handleShareBtnClick = async (e: React.MouseEvent) => { e.stopPropagation(); + + const handled = await tryNativeShare({ + title: shareTitle, + text: shareDescription, + url: shareUrl, + }); + if (handled) { + setShareMenuOpen(false); + return; + } + const rect = shareBtnRef.current?.getBoundingClientRect(); if (rect) { // 초기 추정 위치(측정 전 첫 렌더용) — useLayoutEffect가 실제 크기 기준으로 다시 보정한다 diff --git a/src/components/VideoDetailContent.tsx b/src/components/VideoDetailContent.tsx index da836fa..1494b56 100644 --- a/src/components/VideoDetailContent.tsx +++ b/src/components/VideoDetailContent.tsx @@ -7,8 +7,11 @@ import { deleteComment, toggleVideoLike, isLoggedIn, + getUserMe, + API_URL, } from '../utils/api'; -import { VideoDetailItem, CommentItem } from '../types/api'; +import { VideoDetailItem, CommentItem, UserMeResponse } from '../types/api'; +import { buildVideoShareUrl, tryNativeShare } from '../utils/nativeShare'; import LoginPromptModal from './LoginPromptModal'; interface VideoDetailContentProps { @@ -42,16 +45,7 @@ const VideoDetailContent: React.FC = ({ videoId, isModa const [commentSubmitting, setCommentSubmitting] = useState(false); const commentTextareaRef = useRef(null); - const [commentNickname, setCommentNickname] = useState(''); - const [commentAvatarSeedIdx, setCommentAvatarSeedIdx] = useState(0); - - // 고정 seed 목록: 브라우저가 캐싱하여 중복 요청 없음 - const AVATAR_SEEDS = ['42', '77', '123', '256', '512', '888', '1024', '2048', '3141', '9999']; - const commentAvatarSeed = AVATAR_SEEDS[commentAvatarSeedIdx % AVATAR_SEEDS.length]; - - const handleChangeAvatar = useCallback(() => { - setCommentAvatarSeedIdx(prev => (prev + 1) % AVATAR_SEEDS.length); - }, []); + const [currentUser, setCurrentUser] = useState(null); const fetchComments = useCallback(async (page: number, append = false) => { setCommentsLoading(true); @@ -92,6 +86,13 @@ const VideoDetailContent: React.FC = ({ videoId, isModa fetchComments(1); }, [videoId, fetchComments]); + useEffect(() => { + if (!authed) return; + getUserMe().then(setCurrentUser).catch((err) => { + console.error('Failed to fetch current user:', err); + }); + }, [authed]); + const formatDate = (dateString: string) => { const date = new Date(dateString); return t('videoDetail.dateFormat', { year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate() }); @@ -102,7 +103,11 @@ const VideoDetailContent: React.FC = ({ videoId, isModa return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`; }; - const shareUrl = `${window.location.origin}/video/${videoId}`; + const videoPageUrl = `${window.location.origin}/video/${videoId}`; + const shareUrl = buildVideoShareUrl(API_URL, videoId); + const shareTitle = video?.store_name ?? t('videoDetail.kakaoDefaultTitle'); + const shareDescription = t('videoDetail.kakaoDescription', { region: video?.region ?? '' }); + const shareImageUrl = video?.poster_url || `${window.location.origin}/assets/images/hero-background.png`; const handleCopyLink = async () => { try { @@ -120,15 +125,15 @@ const VideoDetailContent: React.FC = ({ videoId, isModa 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 }, + title: shareTitle, + description: shareDescription, + imageUrl: shareImageUrl, + link: { mobileWebUrl: videoPageUrl, webUrl: videoPageUrl }, }, - buttons: [{ title: t('videoDetail.kakaoButtonTitle'), link: { mobileWebUrl: shareUrl, webUrl: shareUrl } }], + buttons: [{ title: t('videoDetail.kakaoButtonTitle'), link: { mobileWebUrl: videoPageUrl, webUrl: videoPageUrl } }], }); } else if (navigator.share) { - navigator.share({ url: shareUrl }).catch(() => {}); + navigator.share({ title: shareTitle, text: shareDescription, url: shareUrl }).catch(() => {}); } else { handleCopyLink(); } @@ -147,6 +152,20 @@ const VideoDetailContent: React.FC = ({ videoId, isModa const shareMenuRef = React.useRef(null); + const handleShareButtonClick = async () => { + const handled = await tryNativeShare({ + title: shareTitle, + text: shareDescription, + url: shareUrl, + }); + if (handled) { + setShareMenuOpen(false); + return; + } + + setShareMenuOpen((open) => !open); + }; + useEffect(() => { if (!shareMenuOpen) return; const handleClickOutside = (e: MouseEvent) => { @@ -197,13 +216,11 @@ const VideoDetailContent: React.FC = ({ videoId, isModa if (!commentInput.trim() || commentSubmitting) return; setCommentSubmitting(true); try { - await postVideoComment(videoId, commentInput.trim(), commentNickname); + await postVideoComment(videoId, commentInput.trim()); setCommentInput(''); if (commentTextareaRef.current) { commentTextareaRef.current.style.height = 'auto'; } - setCommentNickname(''); - setCommentAvatarSeedIdx(prev => (prev + 1) % AVATAR_SEEDS.length); await fetchComments(1); } catch (err) { console.error('Failed to post comment:', err); @@ -287,7 +304,7 @@ const VideoDetailContent: React.FC = ({ videoId, isModa
- {/* 댓글 작성자 프로필 선택 */} - {authed && ( + {/* 댓글 작성자 프로필 (카카오 로그인 정보) */} + {authed && currentUser && (
- {t('videoDetail.changeAvatarTitle')} - setCommentNickname(e.target.value)} - maxLength={20} - /> + {currentUser.profile_image_url && ( + {currentUser.nickname} + )} + {currentUser.nickname}
)} @@ -398,11 +407,13 @@ const VideoDetailContent: React.FC = ({ videoId, isModa
    {comments.map((c) => (
  • - avatar + {c.profile_image_url && ( + {c.nickname + )}
    {c.nickname || t('videoDetail.anonymous')} @@ -427,11 +438,13 @@ const VideoDetailContent: React.FC = ({ videoId, isModa
      {c.replies.map((r) => (
    • - avatar + {r.profile_image_url && ( + {r.nickname + )}
      {r.nickname || t('videoDetail.anonymous')} diff --git a/src/locales/en.json b/src/locales/en.json index b649a24..bf52e42 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -630,8 +630,6 @@ "shareTwitter": "X (Twitter)", "copyUrl": "Copy URL", "commentsTitle": "Comments", - "changeAvatarTitle": "Click to change avatar", - "nicknamePlaceholder": "Author name", "commentPlaceholder": "Write a comment...", "commentLoginRequired": "Please log in to write a comment", "commentSubmitting": "Submitting", diff --git a/src/locales/ko.json b/src/locales/ko.json index 25270d1..d83cfbd 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -629,8 +629,6 @@ "shareTwitter": "X (트위터)", "copyUrl": "URL 복사", "commentsTitle": "댓글", - "changeAvatarTitle": "클릭하여 아바타 변경", - "nicknamePlaceholder": "작성자 이름", "commentPlaceholder": "댓글을 입력하세요...", "commentLoginRequired": "로그인 후 댓글을 작성할 수 있습니다", "commentSubmitting": "작성 중", diff --git a/src/pages/Dashboard/ADO2ContentsPage.tsx b/src/pages/Dashboard/ADO2ContentsPage.tsx index 002e9a5..249f912 100644 --- a/src/pages/Dashboard/ADO2ContentsPage.tsx +++ b/src/pages/Dashboard/ADO2ContentsPage.tsx @@ -151,7 +151,15 @@ const ADO2ContentsPage: React.FC = () => { onKeyDown={(e) => e.key === 'Enter' && handleCardClick(video.video_id)} >
      - {video.thumbnail_url ? ( + {video.poster_url ? ( + {video.store_name} + ) : video.thumbnail_url ? ( {video.store_name} = () => { videoId={video.video_id} storeName={video.store_name} region={video.region} + posterUrl={video.poster_url} commentCount={video.comment_count ?? 0} initialLikeCount={video.like_count ?? 0} initialIsLiked={video.is_liked_by_me} diff --git a/src/pages/Dashboard/CompletionContent.tsx b/src/pages/Dashboard/CompletionContent.tsx index ab95013..245dec4 100755 --- a/src/pages/Dashboard/CompletionContent.tsx +++ b/src/pages/Dashboard/CompletionContent.tsx @@ -563,6 +563,7 @@ const CompletionContent: React.FC = ({ created_at: new Date().toISOString(), like_count: 0, comment_count: 0, + is_liked_by_me: false, } : null} /> diff --git a/src/pages/Dashboard/MyContentsPage.tsx b/src/pages/Dashboard/MyContentsPage.tsx index b479c41..3212aae 100644 --- a/src/pages/Dashboard/MyContentsPage.tsx +++ b/src/pages/Dashboard/MyContentsPage.tsx @@ -248,7 +248,15 @@ const MyContentsPage: React.FC = ({ onNavigate }) => { style={{ cursor: 'pointer' }} onClick={() => setSelectedVideoId(video.video_id)} > - {video.result_movie_url ? ( + {video.poster_url ? ( + {video.store_name} + ) : video.result_movie_url ? ( = ({ onNavigate }) => { videoId={video.video_id} storeName={video.store_name} region={video.region} + posterUrl={video.poster_url} commentCount={video.comment_count ?? 0} initialLikeCount={video.like_count ?? 0} initialIsLiked={video.is_liked_by_me} diff --git a/src/types/api.ts b/src/types/api.ts index 253befc..e75525c 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -276,17 +276,19 @@ export interface VideoListItem { region: string; task_id: string; result_movie_url: string; + poster_url?: string | null; thumbnail_url?: string; created_at: string; like_count: number; comment_count: number; - is_liked_by_me?: boolean; + is_liked_by_me: boolean; } // 비디오 상세 아이템 export interface VideoDetailItem { video_id: number; result_movie_url: string; + poster_url?: string | null; store_name: string; region: string; created_at: string; @@ -309,6 +311,7 @@ export interface VideosListResponse { export interface CommentReply { id: number; nickname: string; + profile_image_url: string | null; content: string | null; is_deleted: boolean; is_mine: boolean; @@ -319,6 +322,7 @@ export interface CommentReply { export interface CommentItem { id: number; nickname: string; + profile_image_url: string | null; content: string | null; is_deleted: boolean; is_mine: boolean; diff --git a/src/utils/api.ts b/src/utils/api.ts index 80bdb3a..4fbd0e3 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -609,12 +609,12 @@ export async function getVideoComments(videoId: string, page: number = 1, pageSi return response.json(); } -// 댓글 작성 -export async function postVideoComment(videoId: string, content: string, nickname?: string, parentId?: number): Promise { +// 댓글 작성 (작성자 닉네임/프로필은 서버가 로그인된 카카오 정보로 채움) +export async function postVideoComment(videoId: string, content: string, parentId?: number): Promise { const response = await authenticatedFetch(`${API_URL}/comment/video/${videoId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content, nickname: nickname || '익명', parent_id: parentId ?? null }), + body: JSON.stringify({ content, parent_id: parentId ?? null }), }); if (!response.ok) { diff --git a/src/utils/nativeShare.ts b/src/utils/nativeShare.ts new file mode 100644 index 0000000..7946064 --- /dev/null +++ b/src/utils/nativeShare.ts @@ -0,0 +1,27 @@ +/** + * 기기의 네이티브 공유 시트를 열고 요청을 처리했는지 반환합니다. + * + * 사용자가 공유 시트를 닫은 경우도 정상적으로 처리된 것으로 간주해 + * 별도의 공유 메뉴가 뒤이어 열리지 않도록 합니다. + */ +export async function tryNativeShare(data: ShareData): Promise { + if (typeof navigator.share !== 'function') { + return false; + } + + try { + await navigator.share(data); + return true; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return true; + } + + return false; + } +} + +/** API 서버가 제공하는 영상별 Open Graph 공유 페이지 URL을 만듭니다. */ +export function buildVideoShareUrl(apiBaseUrl: string, videoId: number | string): string { + return `${apiBaseUrl.replace(/\/$/, '')}/video/share/${encodeURIComponent(String(videoId))}`; +} From e400884eb72efe2bc85ac1e61bd684d2c37ca284 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Tue, 18 Aug 2026 14:56:38 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20=EA=B3=B5=ED=86=B5=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EA=B3=B5=EC=9C=A0=ED=95=98=EA=B8=B0=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/assets/images/ado2_image.png | Bin 0 -> 8886 bytes src/components/ContentCardSocialActions.tsx | 17 +++-------------- src/components/VideoDetailContent.tsx | 15 +++------------ src/locales/en.json | 1 - src/locales/ko.json | 1 - src/pages/Dashboard/ADO2ContentsPage.tsx | 1 - src/pages/Dashboard/MyContentsPage.tsx | 1 - 7 files changed, 6 insertions(+), 30 deletions(-) create mode 100644 public/assets/images/ado2_image.png diff --git a/public/assets/images/ado2_image.png b/public/assets/images/ado2_image.png new file mode 100644 index 0000000000000000000000000000000000000000..ed58869f171e835c764ae07ec55ccdc74db68b78 GIT binary patch literal 8886 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 literal 0 HcmV?d00001 diff --git a/src/components/ContentCardSocialActions.tsx b/src/components/ContentCardSocialActions.tsx index adfd36e..5172b92 100644 --- a/src/components/ContentCardSocialActions.tsx +++ b/src/components/ContentCardSocialActions.tsx @@ -8,7 +8,6 @@ interface ContentCardSocialActionsProps { videoId: number; storeName: string; region?: string; - posterUrl?: string | null; commentCount: number; initialLikeCount: number; initialIsLiked?: boolean; @@ -18,7 +17,6 @@ const ContentCardSocialActions: React.FC = ({ videoId, storeName, region, - posterUrl, commentCount, initialLikeCount, initialIsLiked = false, @@ -36,11 +34,9 @@ const ContentCardSocialActions: React.FC = ({ const shareMenuRef = useRef(null); const likeDebounceRef = useRef | null>(null); - const videoPageUrl = `${window.location.origin}/video/${videoId}`; const shareUrl = buildVideoShareUrl(API_URL, videoId); const shareTitle = storeName || t('videoDetail.kakaoDefaultTitle'); const shareDescription = t('videoDetail.kakaoDescription', { region: region ?? '' }); - const shareImageUrl = posterUrl || `${window.location.origin}/assets/images/hero-background.png`; useEffect(() => { if (!shareMenuOpen) return; @@ -100,16 +96,9 @@ const ContentCardSocialActions: React.FC = ({ const handleKakaoShare = () => { const kakao = window.Kakao; if (kakao?.Share) { - kakao.Share.sendDefault({ - objectType: 'feed', - content: { - title: shareTitle, - description: shareDescription, - imageUrl: shareImageUrl, - link: { mobileWebUrl: videoPageUrl, webUrl: videoPageUrl }, - }, - buttons: [{ title: t('videoDetail.kakaoButtonTitle'), link: { mobileWebUrl: videoPageUrl, webUrl: videoPageUrl } }], - }); + // 공유 URL(백엔드 OG 페이지)을 스크랩해 제목/설명/이미지를 자동으로 채운다. + // 다른 채널과 동일한 shareUrl을 쓰므로 카카오만을 위한 별도 콘텐츠 지정이 필요 없다. + kakao.Share.sendScrap({ requestUrl: shareUrl }); } else if (navigator.share) { navigator.share({ title: shareTitle, text: shareDescription, url: shareUrl }).catch(() => {}); } else { diff --git a/src/components/VideoDetailContent.tsx b/src/components/VideoDetailContent.tsx index 1494b56..d39cfc2 100644 --- a/src/components/VideoDetailContent.tsx +++ b/src/components/VideoDetailContent.tsx @@ -103,11 +103,9 @@ const VideoDetailContent: React.FC = ({ videoId, isModa return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`; }; - const videoPageUrl = `${window.location.origin}/video/${videoId}`; const shareUrl = buildVideoShareUrl(API_URL, videoId); const shareTitle = video?.store_name ?? t('videoDetail.kakaoDefaultTitle'); const shareDescription = t('videoDetail.kakaoDescription', { region: video?.region ?? '' }); - const shareImageUrl = video?.poster_url || `${window.location.origin}/assets/images/hero-background.png`; const handleCopyLink = async () => { try { @@ -122,16 +120,9 @@ const VideoDetailContent: React.FC = ({ videoId, isModa const handleKakaoShare = () => { const kakao = window.Kakao; if (kakao?.Share) { - kakao.Share.sendDefault({ - objectType: 'feed', - content: { - title: shareTitle, - description: shareDescription, - imageUrl: shareImageUrl, - link: { mobileWebUrl: videoPageUrl, webUrl: videoPageUrl }, - }, - buttons: [{ title: t('videoDetail.kakaoButtonTitle'), link: { mobileWebUrl: videoPageUrl, webUrl: videoPageUrl } }], - }); + // 공유 URL(백엔드 OG 페이지)을 스크랩해 제목/설명/이미지를 자동으로 채운다. + // 다른 채널과 동일한 shareUrl을 쓰므로 카카오만을 위한 별도 콘텐츠 지정이 필요 없다. + kakao.Share.sendScrap({ requestUrl: shareUrl }); } else if (navigator.share) { navigator.share({ title: shareTitle, text: shareDescription, url: shareUrl }).catch(() => {}); } else { diff --git a/src/locales/en.json b/src/locales/en.json index bf52e42..ba649a4 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -620,7 +620,6 @@ "dateFormat": "{{month}}/{{day}}/{{year}}", "kakaoDefaultTitle": "ADO2 Video", "kakaoDescription": "{{region}} · ADO2 AI Marketing Video", - "kakaoButtonTitle": "Watch Video", "deletedComment": "(This comment has been deleted.)", "closeAriaLabel": "Close", "share": "Share", diff --git a/src/locales/ko.json b/src/locales/ko.json index d83cfbd..9098eb9 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -619,7 +619,6 @@ "dateFormat": "{{year}}년 {{month}}월 {{day}}일", "kakaoDefaultTitle": "ADO2 영상", "kakaoDescription": "{{region}} · ADO2 AI 마케팅 영상", - "kakaoButtonTitle": "영상 보기", "deletedComment": "(삭제된 댓글입니다.)", "closeAriaLabel": "닫기", "share": "공유하기", diff --git a/src/pages/Dashboard/ADO2ContentsPage.tsx b/src/pages/Dashboard/ADO2ContentsPage.tsx index 249f912..3eb26c4 100644 --- a/src/pages/Dashboard/ADO2ContentsPage.tsx +++ b/src/pages/Dashboard/ADO2ContentsPage.tsx @@ -194,7 +194,6 @@ const ADO2ContentsPage: React.FC = () => { videoId={video.video_id} storeName={video.store_name} region={video.region} - posterUrl={video.poster_url} commentCount={video.comment_count ?? 0} initialLikeCount={video.like_count ?? 0} initialIsLiked={video.is_liked_by_me} diff --git a/src/pages/Dashboard/MyContentsPage.tsx b/src/pages/Dashboard/MyContentsPage.tsx index 3212aae..c78413f 100644 --- a/src/pages/Dashboard/MyContentsPage.tsx +++ b/src/pages/Dashboard/MyContentsPage.tsx @@ -291,7 +291,6 @@ const MyContentsPage: React.FC = ({ onNavigate }) => { videoId={video.video_id} storeName={video.store_name} region={video.region} - posterUrl={video.poster_url} commentCount={video.comment_count ?? 0} initialLikeCount={video.like_count ?? 0} initialIsLiked={video.is_liked_by_me} From 63d60caabda0f6976e82150195366dd0146f5b51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Tue, 18 Aug 2026 17:23:42 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20=EC=9D=B4=EC=A0=84=20=EA=B3=B5?= =?UTF-8?q?=EC=9C=A0=ED=95=98=EA=B8=B0=20=EA=B8=B0=EB=8A=A5=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ContentCardSocialActions.tsx | 147 ++------------------ src/components/VideoDetailContent.tsx | 113 +++------------ src/utils/nativeShare.ts | 3 +- 3 files changed, 28 insertions(+), 235 deletions(-) diff --git a/src/components/ContentCardSocialActions.tsx b/src/components/ContentCardSocialActions.tsx index 5172b92..52d16bb 100644 --- a/src/components/ContentCardSocialActions.tsx +++ b/src/components/ContentCardSocialActions.tsx @@ -1,5 +1,4 @@ -import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { createPortal } from 'react-dom'; +import React, { useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { API_URL, toggleVideoLike } from '../utils/api'; import { buildVideoShareUrl, tryNativeShare } from '../utils/nativeShare'; @@ -25,96 +24,29 @@ const ContentCardSocialActions: React.FC = ({ 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 = buildVideoShareUrl(API_URL, videoId); const shareTitle = storeName || t('videoDetail.kakaoDefaultTitle'); const shareDescription = t('videoDetail.kakaoDescription', { region: region ?? '' }); - useEffect(() => { - if (!shareMenuOpen) return; + const handleShareBtnClick = async (e: React.MouseEvent) => { + e.stopPropagation(); - 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; + const handled = await tryNativeShare({ + title: shareTitle, + text: shareDescription, + url: shareUrl, + }); + if (handled) { + return; } - 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) { - // 공유 URL(백엔드 OG 페이지)을 스크랩해 제목/설명/이미지를 자동으로 채운다. - // 다른 채널과 동일한 shareUrl을 쓰므로 카카오만을 위한 별도 콘텐츠 지정이 필요 없다. - kakao.Share.sendScrap({ requestUrl: shareUrl }); - } else if (navigator.share) { - navigator.share({ title: shareTitle, text: shareDescription, 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) => { @@ -140,28 +72,6 @@ const ContentCardSocialActions: React.FC = ({ }, 500); }; - const handleShareBtnClick = async (e: React.MouseEvent) => { - e.stopPropagation(); - - const handled = await tryNativeShare({ - title: shareTitle, - text: shareDescription, - url: shareUrl, - }); - if (handled) { - setShareMenuOpen(false); - return; - } - - 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()}> - - {shareMenuOpen && menuPos && createPortal( -
      - - - - -
      , - document.body - )}
      ); }; diff --git a/src/components/VideoDetailContent.tsx b/src/components/VideoDetailContent.tsx index d39cfc2..f7c27f2 100644 --- a/src/components/VideoDetailContent.tsx +++ b/src/components/VideoDetailContent.tsx @@ -32,7 +32,6 @@ const VideoDetailContent: React.FC = ({ videoId, isModa const [isLiked, setIsLiked] = useState(false); const [copied, setCopied] = useState(false); - const [shareMenuOpen, setShareMenuOpen] = useState(false); const [isLandscape, setIsLandscape] = useState(false); const [showLoginModal, setShowLoginModal] = useState(false); @@ -107,42 +106,6 @@ const VideoDetailContent: React.FC = ({ videoId, isModa const shareTitle = video?.store_name ?? t('videoDetail.kakaoDefaultTitle'); const shareDescription = t('videoDetail.kakaoDescription', { region: video?.region ?? '' }); - 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) { - // 공유 URL(백엔드 OG 페이지)을 스크랩해 제목/설명/이미지를 자동으로 채운다. - // 다른 채널과 동일한 shareUrl을 쓰므로 카카오만을 위한 별도 콘텐츠 지정이 필요 없다. - kakao.Share.sendScrap({ requestUrl: shareUrl }); - } else if (navigator.share) { - navigator.share({ title: shareTitle, text: shareDescription, 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(null); - const handleShareButtonClick = async () => { const handled = await tryNativeShare({ title: shareTitle, @@ -150,24 +113,18 @@ const VideoDetailContent: React.FC = ({ videoId, isModa url: shareUrl, }); if (handled) { - setShareMenuOpen(false); return; } - setShareMenuOpen((open) => !open); + try { + await navigator.clipboard.writeText(shareUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // clipboard API 미지원 환경에서는 무시 + } }; - 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 | null>(null); const handleLike = () => { @@ -292,52 +249,16 @@ const VideoDetailContent: React.FC = ({ videoId, isModa {likeCount} -
      - - {shareMenuOpen && ( -
      - {/* 카카오톡 */} - - {/* 페이스북 */} - - {/* X (트위터) */} - - {/* URL 복사 */} - -
      - )} -
      +
      {/* 댓글 섹션 */} diff --git a/src/utils/nativeShare.ts b/src/utils/nativeShare.ts index 7946064..4278804 100644 --- a/src/utils/nativeShare.ts +++ b/src/utils/nativeShare.ts @@ -1,8 +1,7 @@ /** * 기기의 네이티브 공유 시트를 열고 요청을 처리했는지 반환합니다. * - * 사용자가 공유 시트를 닫은 경우도 정상적으로 처리된 것으로 간주해 - * 별도의 공유 메뉴가 뒤이어 열리지 않도록 합니다. + * 사용자가 공유 시트를 닫은 경우도 정상적으로 처리된 것으로 간주합니다. */ export async function tryNativeShare(data: ShareData): Promise { if (typeof navigator.share !== 'function') { From bdb581459d7f9d474abf0cef40d98aba882843c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Wed, 19 Aug 2026 10:19:54 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat(social):=20=EC=97=85=EB=A1=9C=EB=93=9C?= =?UTF-8?q?=20=ED=8F=BC=EC=97=90=20=EC=A0=80=EC=9E=A5=EB=90=9C=20=EC=98=81?= =?UTF-8?q?=EC=83=81=20SEO=20=EB=A9=94=ED=83=80=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=EB=A5=BC=20=ED=91=9C=EC=8B=9C=20=EA=B0=92=EC=9D=B4=20=EC=9E=88?= =?UTF-8?q?=EC=9C=BC=EB=A9=B4=20DB=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20?= =?UTF-8?q?=EB=B0=94=EB=A1=9C=20=EC=B1=84=EC=9A=B0=EA=B3=A0,=20=EC=97=86?= =?UTF-8?q?=EC=9C=BC=EB=A9=B4=20video=5Fid=20=EA=B8=B0=EC=A4=80=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=ED=95=9C=20=EB=B2=88=EB=A7=8C=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=ED=95=9C=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/SocialPostingModal.tsx | 56 +++++++++++---------------- src/types/api.ts | 5 ++- 2 files changed, 27 insertions(+), 34 deletions(-) diff --git a/src/components/SocialPostingModal.tsx b/src/components/SocialPostingModal.tsx index 501c977..50e8cd8 100644 --- a/src/components/SocialPostingModal.tsx +++ b/src/components/SocialPostingModal.tsx @@ -142,10 +142,7 @@ const SocialPostingModal: React.FC = ({ const channelDropdownRef = useRef(null); const privacyDropdownRef = useRef(null); const hasBeenOpenedRef = useRef(false); - const loadedForTaskIdRef = useRef(null); - const loadedAtRef = useRef(0); - const seoCache = useRef>(new Map()); - const SEO_CACHE_TTL = 50 * 60 * 1000; + const loadedForVideoIdRef = useRef(null); // Upload progress modal state const [showUploadProgress, setShowUploadProgress] = useState(false); @@ -212,28 +209,30 @@ const SocialPostingModal: React.FC = ({ // 소셜 계정 로드 useEffect(() => { - if (!isOpen) return; - - const now = Date.now(); + if (!isOpen) { + loadedForVideoIdRef.current = null; + return; + } loadSocialAccounts(); - const taskId = video?.task_id ?? null; - const expired = now - loadedAtRef.current > SEO_CACHE_TTL; - - if (taskId && (taskId !== loadedForTaskIdRef.current || expired)) { - loadedForTaskIdRef.current = taskId; - loadedAtRef.current = now; - loadAutocomplete(); - } else if (taskId) { - const cached = seoCache.current.get(taskId); - if (cached) { - setTitle(cached.title); - setDescription(cached.description); - setTags(cached.tags); - } + const videoId = video?.video_id ?? null; + if (!videoId || videoId === loadedForVideoIdRef.current) { + return; } - }, [isOpen, video?.task_id]); + + loadedForVideoIdRef.current = videoId; + + if (video?.title) { + setTitle(video.title); + setDescription(video.description || ''); + setTags((video.hashtags || []).join(',')); + setIsLoadingAutoDescription(false); + return; + } + + loadAutocomplete(); + }, [isOpen, video?.video_id, video?.title, video?.description, video?.hashtags]); const loadSocialAccounts = async () => { setIsLoadingAccounts(true); @@ -259,29 +258,20 @@ const SocialPostingModal: React.FC = ({ }; const loadAutocomplete = async () => { - if (!video?.task_id) return; + if (!video?.video_id) return; setIsLoadingAutoDescription(true); try { const requestPayload = { - task_id : video.task_id, + video_id: video.video_id, }; - // Call autoSEO API - console.log('[Upload] Request payload:', requestPayload); const autoSeoResponse = await getAutoSeoYoutube(requestPayload); - // 각 필드가 있을 때만 덮어씌움 (기존 값 보호) if (autoSeoResponse.title) setTitle(autoSeoResponse.title); if (autoSeoResponse.description) setDescription(autoSeoResponse.description); if (autoSeoResponse.keywords) setTags(autoSeoResponse.keywords.join(',')); - seoCache.current.set(video.task_id, { - title: autoSeoResponse.title || '', - description: autoSeoResponse.description || '', - tags: autoSeoResponse.keywords?.join(',') || '', - }); } catch (error) { console.error('Failed to load autocomplete:', error); - // 실패해도 사용자에게 별도 알림 없이 조용히 처리 } finally { setIsLoadingAutoDescription(false); } diff --git a/src/types/api.ts b/src/types/api.ts index e75525c..854442e 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -278,6 +278,9 @@ export interface VideoListItem { result_movie_url: string; poster_url?: string | null; thumbnail_url?: string; + title?: string | null; + description?: string | null; + hashtags?: string[] | null; created_at: string; like_count: number; comment_count: number; @@ -394,7 +397,7 @@ export interface SocialDisconnectResponse { // 유튜브 SEO Description 자동완성 요청 export interface YTAutoSeoRequest { - task_id: string; // 아카이브의 비디오 ID + video_id: number; } // 유튜브 SEO Description 자동완성 응답