From 741539a023551a1900419f901eae9917d481f06a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Tue, 11 Aug 2026 14:57:34 +0900 Subject: [PATCH] =?UTF-8?q?feat(ssulbox):=20=EC=83=81=EC=84=B8=C2=B7?= =?UTF-8?q?=EA=B3=B5=EC=9C=A0=20=ED=99=94=EB=A9=B4=EA=B3=BC=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=C2=B7=EB=8C=93=EA=B8=80=C2=B7=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ContentCardSocialActions.tsx | 25 +- src/components/SocialPostingModal.tsx | 96 ++-- src/components/VideoDetailContent.tsx | 13 +- src/components/VideoDetailModal.tsx | 5 +- src/pages/Dashboard/ADO2ContentsPage.tsx | 35 +- src/pages/Dashboard/MyContentsPage.tsx | 137 +++-- src/pages/Ssulbox/SsulDetailContent.tsx | 524 ++++++++++++++++++++ src/pages/Ssulbox/SsulDetailPage.tsx | 25 + src/pages/Ssulbox/SsulViewerModal.tsx | 81 +-- src/styles/contents-social.css | 25 +- src/types/api.ts | 11 +- src/utils/api.ts | 152 +++--- 12 files changed, 836 insertions(+), 293 deletions(-) create mode 100644 src/pages/Ssulbox/SsulDetailContent.tsx create mode 100644 src/pages/Ssulbox/SsulDetailPage.tsx diff --git a/src/components/ContentCardSocialActions.tsx b/src/components/ContentCardSocialActions.tsx index b67690a..12baddf 100644 --- a/src/components/ContentCardSocialActions.tsx +++ b/src/components/ContentCardSocialActions.tsx @@ -1,10 +1,17 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; -import { toggleVideoLike } from '../utils/api'; +import { toggleVideoLike, isLoggedIn } from '../utils/api'; +import { ContentType } from '../types/api'; +import LoginPromptModal from './LoginPromptModal'; interface ContentCardSocialActionsProps { videoId: number; + /** + * 콘텐츠 종류. video.id 와 ssul_content.id 가 겹치므로 좋아요 API 와 + * 공유 URL(/video/{id} vs /ssul/{id}) 분기에 반드시 필요하다. + */ + contentType?: ContentType; storeName: string; region?: string; commentCount: number; @@ -14,6 +21,7 @@ interface ContentCardSocialActionsProps { const ContentCardSocialActions: React.FC = ({ videoId, + contentType = 'video', storeName, region, commentCount, @@ -24,6 +32,7 @@ const ContentCardSocialActions: React.FC = ({ const [likeCount, setLikeCount] = useState(initialLikeCount); const [isLiked, setIsLiked] = useState(initialIsLiked); + const [showLoginModal, setShowLoginModal] = useState(false); const [copied, setCopied] = useState(false); const [shareMenuOpen, setShareMenuOpen] = useState(false); const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null); @@ -33,7 +42,8 @@ const ContentCardSocialActions: React.FC = ({ const shareMenuRef = useRef(null); const likeDebounceRef = useRef | null>(null); - const shareUrl = `${window.location.origin}/video/${videoId}`; + // 종류별 공개 페이지가 다르다 — 합치면 id 가 겹치는 다른 콘텐츠가 열린다 + const shareUrl = `${window.location.origin}/${contentType === 'ssul' ? 'ssul' : 'video'}/${videoId}`; useEffect(() => { if (!shareMenuOpen) return; @@ -124,6 +134,11 @@ const ContentCardSocialActions: React.FC = ({ const handleLikeClick = (e: React.MouseEvent) => { e.stopPropagation(); + if (!isLoggedIn()) { + setShowLoginModal(true); + return; + } + const prevLiked = isLiked; const prevCount = likeCount; @@ -133,7 +148,7 @@ const ContentCardSocialActions: React.FC = ({ if (likeDebounceRef.current) clearTimeout(likeDebounceRef.current); likeDebounceRef.current = setTimeout(async () => { try { - const res = await toggleVideoLike(String(videoId)); + const res = await toggleVideoLike(String(videoId), contentType); setIsLiked(res.is_liked); setLikeCount(res.like_count); } catch (err) { @@ -221,6 +236,10 @@ const ContentCardSocialActions: React.FC = ({ , document.body )} + + {showLoginModal && ( + setShowLoginModal(false)} /> + )} ); }; diff --git a/src/components/SocialPostingModal.tsx b/src/components/SocialPostingModal.tsx index 501c977..ee26052 100644 --- a/src/components/SocialPostingModal.tsx +++ b/src/components/SocialPostingModal.tsx @@ -4,9 +4,7 @@ import { useTranslation } from 'react-i18next'; import { getSocialAccounts, uploadToSocial, waitForUploadComplete, TokenExpiredError, handleSocialReconnect, getAutoSeoYoutube } from '../utils/api'; import { SocialAccount, VideoListItem, SocialUploadStatusResponse } from '../types/api'; import UploadProgressModal, { UploadStatus } from './UploadProgressModal'; -import { useTutorial } from './Tutorial/useTutorial'; -import { TUTORIAL_KEYS } from './Tutorial/tutorialSteps'; -import TutorialOverlay from './Tutorial/TutorialOverlay'; +import { useOverlayClose } from '../hooks/useOverlayClose'; interface SocialPostingModalProps { isOpen: boolean; @@ -121,7 +119,6 @@ const SocialPostingModal: React.FC = ({ onGoToCalendar, }) => { const { t } = useTranslation(); - const tutorial = useTutorial(); const [socialAccounts, setSocialAccounts] = useState([]); const [selectedChannel, setSelectedChannel] = useState(''); const [title, setTitle] = useState(''); @@ -141,7 +138,6 @@ const SocialPostingModal: React.FC = ({ const [videoMeta, setVideoMeta] = useState<{ width: number; height: number; duration: number } | null>(null); 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()); @@ -184,32 +180,6 @@ const SocialPostingModal: React.FC = ({ return () => { document.body.style.overflow = ''; }; }, [isOpen]); - // 모달 오픈/닫힘 시 튜토리얼 트리거 - useEffect(() => { - if (isOpen) { - hasBeenOpenedRef.current = true; - if (!tutorial.hasSeen(TUTORIAL_KEYS.UPLOAD_MODAL)) { - const timer = setTimeout(() => { - tutorial.startTutorial(TUTORIAL_KEYS.UPLOAD_MODAL); - }, 400); - return () => clearTimeout(timer); - } - } else if (hasBeenOpenedRef.current && !tutorial.hasSeen(TUTORIAL_KEYS.FEEDBACK)) { - hasBeenOpenedRef.current = false; - tutorial.startTutorial(TUTORIAL_KEYS.FEEDBACK); - } - }, [isOpen]); - - // SEO 생성 완료 시 UPLOAD_FORM 튜토리얼 트리거 - useEffect(() => { - if (!isLoadingAutoDescription && isOpen && !tutorial.hasSeen(TUTORIAL_KEYS.UPLOAD_FORM)) { - const timer = setTimeout(() => { - tutorial.startTutorial(TUTORIAL_KEYS.UPLOAD_FORM); - }, 400); - return () => clearTimeout(timer); - } - }, [isLoadingAutoDescription]); - // 소셜 계정 로드 useEffect(() => { if (!isOpen) return; @@ -218,22 +188,27 @@ const SocialPostingModal: React.FC = ({ loadSocialAccounts(); - const taskId = video?.task_id ?? null; + // SEO 자동 채움 키. ADO2 는 task_id, 썰박스는 task_id 가 없어(빈 문자열) + // (type, video_id) 로 키를 만든다 — 없으면 썰박스는 자동 채움이 통째로 스킵됐다. + const seoKey = + video?.type === 'ssul' + ? `ssul:${video.video_id}` + : video?.task_id || null; const expired = now - loadedAtRef.current > SEO_CACHE_TTL; - if (taskId && (taskId !== loadedForTaskIdRef.current || expired)) { - loadedForTaskIdRef.current = taskId; + if (seoKey && (seoKey !== loadedForTaskIdRef.current || expired)) { + loadedForTaskIdRef.current = seoKey; loadedAtRef.current = now; - loadAutocomplete(); - } else if (taskId) { - const cached = seoCache.current.get(taskId); + loadAutocomplete(seoKey); + } else if (seoKey) { + const cached = seoCache.current.get(seoKey); if (cached) { setTitle(cached.title); setDescription(cached.description); setTags(cached.tags); } } - }, [isOpen, video?.task_id]); + }, [isOpen, video?.task_id, video?.type, video?.video_id]); const loadSocialAccounts = async () => { setIsLoadingAccounts(true); @@ -258,13 +233,23 @@ const SocialPostingModal: React.FC = ({ } }; - const loadAutocomplete = async () => { - if (!video?.task_id) return; + /** + * 제목·설명·태그 자동 채움. + * + * `seoKey` 는 호출부가 만든 캐시 키다(ADO2=task_id, 썰박스=`ssul:{id}`). + * 백엔드에 보낼 식별자는 종류마다 다르다 — ADO2 는 task_id(UUID), 썰박스는 + * ssul_content.id 문자열이며, 서버가 content_type 으로 어느 테이블을 볼지 가른다. + */ + const loadAutocomplete = async (seoKey: string) => { + if (!video) return; + const isSsul = video.type === 'ssul'; + if (!isSsul && !video.task_id) return; setIsLoadingAutoDescription(true); try { const requestPayload = { - task_id : video.task_id, + content_type: video.type, + task_id: isSsul ? String(video.video_id) : video.task_id, }; // Call autoSEO API console.log('[Upload] Request payload:', requestPayload); @@ -274,7 +259,7 @@ const SocialPostingModal: React.FC = ({ 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, { + seoCache.current.set(seoKey, { title: autoSeoResponse.title || '', description: autoSeoResponse.description || '', tags: autoSeoResponse.keywords?.join(',') || '', @@ -351,6 +336,9 @@ const SocialPostingModal: React.FC = ({ } const requestPayload = { + // 종류를 함께 보낸다 — 없으면 백엔드가 video 로 간주해, 썰박스 항목에서는 + // id 가 겹치는 엉뚱한 ADO2 영상이 업로드된다. + content_type: video.type, video_id: video.video_id, social_account_id: selectedAcc.id, title: title.trim(), @@ -442,6 +430,8 @@ const SocialPostingModal: React.FC = ({ resetForm(); }; + const overlayCloseHandlers = useOverlayClose(handleClose); + const selectedAccount = socialAccounts.find(acc => acc.platform_user_id === selectedChannel); const privacyOptions = [ @@ -473,23 +463,13 @@ const SocialPostingModal: React.FC = ({ return ( <> {showUploadProgress && uploadProgressModalElement} - {tutorial.isActive && ( - - )} ); } return ( <> -
+
e.stopPropagation()}> {/* Header */}
@@ -859,16 +839,6 @@ const SocialPostingModal: React.FC = ({
{uploadProgressModalElement} - {tutorial.isActive && ( - - )} ); }; diff --git a/src/components/VideoDetailContent.tsx b/src/components/VideoDetailContent.tsx index da836fa..97244b0 100644 --- a/src/components/VideoDetailContent.tsx +++ b/src/components/VideoDetailContent.tsx @@ -231,11 +231,14 @@ const VideoDetailContent: React.FC = ({ videoId, isModa {/* 헤더 */}
{isModal ? ( - + <> +

{t('sidebar.ado2Contents')}

+ + ) : ( - )} + {/* SocialPostingModal 이 content_type 을 함께 보내므로 + 썰박스 항목도 같은 모달로 업로드한다 (social_upload 병합). */} + - {/* ⚠️ 삭제는 파괴적이다. `DELETE /archive/videos/{id}` 는 `Video.id` 로 - 지우므로 썰박스 id 를 넘기면 **id 가 겹치는 ADO2 영상이 삭제된다.** - 소유권 검증도 통과한다(같은 사용자가 양쪽을 다 가진 경우). - 썰박스 삭제 API 가 붙기 전까지 절대 노출하지 않는다. */} - {video.type === 'video' && ( - - )} + {/* 삭제는 종류별 API 로 분기한다(handleDeleteConfirm). + 아이템 전체를 넘겨야 종류를 잃지 않는다. */} +
@@ -379,7 +376,7 @@ const MyContentsPage: React.FC = ({ onNavigate }) => { {/* 삭제 확인 모달 */} {deleteModalOpen && ( -
+
e.stopPropagation()}>

{t('ado2Contents.deleteConfirmTitle')}

{t('ado2Contents.deleteConfirmDesc')}

diff --git a/src/pages/Ssulbox/SsulDetailContent.tsx b/src/pages/Ssulbox/SsulDetailContent.tsx new file mode 100644 index 0000000..a6ec8e4 --- /dev/null +++ b/src/pages/Ssulbox/SsulDetailContent.tsx @@ -0,0 +1,524 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + deleteComment, + getSsulContentById, + getVideoComments, + isLoggedIn, + postVideoComment, + toggleVideoLike, +} from '../../utils/api'; +import type { SsulDetailItem } from '../../utils/api'; +import { CommentItem } from '../../types/api'; +import LoginPromptModal from '../../components/LoginPromptModal'; + +interface SsulDetailContentProps { + contentId: string; + isModal?: boolean; + onClose?: () => void; +} + +/** + * 썰박스 콘텐츠 상세 — castad `VideoDetailContent` 와 같은 3층 구조의 내용부. + * (SsulViewerModal 이 모달로, SsulDetailPage 가 공유 링크 페이지로 감싼다) + * + * UI·동작(좋아요 낙관 갱신·댓글·대댓글·공유 메뉴·비로그인 게이트)은 + * `VideoDetailContent` 와 동일하고, 다른 점은 셋뿐이다: + * 1. 상세 조회가 `GET /ssul/{id}` (video 조회를 쓰면 id 가 겹치는 남의 영상이 열린다) + * 2. 좋아요·댓글 API 에 `contentType='ssul'` 을 얹는다 + * 3. 공유 URL 이 `/ssul/{id}` 다 + */ +const SsulDetailContent: React.FC = ({ + contentId, + isModal = false, + onClose, +}) => { + const { t } = useTranslation(); + const authed = isLoggedIn(); + + const [content, setContent] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [likeCount, setLikeCount] = useState(0); + 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); + + const [comments, setComments] = useState([]); + const [commentsTotal, setCommentsTotal] = useState(0); + const [commentsPage, setCommentsPage] = useState(1); + const [commentsHasNext, setCommentsHasNext] = useState(false); + const [commentsLoading, setCommentsLoading] = useState(false); + const [commentInput, setCommentInput] = useState(''); + const [commentSubmitting, setCommentSubmitting] = useState(false); + const commentTextareaRef = useRef(null); + + const [commentNickname, setCommentNickname] = useState(''); + 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( + async (page: number, append = false) => { + setCommentsLoading(true); + try { + const res = await getVideoComments(contentId, page, 20, 'ssul'); + const sorted = [...res.items].sort( + (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime() + ); + setComments((prev) => (append ? [...prev, ...sorted] : sorted)); + setCommentsTotal(res.total); + setCommentsHasNext(res.has_next); + setCommentsPage(page); + } catch (err) { + console.error('Failed to fetch ssul comments:', err); + } finally { + setCommentsLoading(false); + } + }, + [contentId] + ); + + useEffect(() => { + const fetchContent = async () => { + setLoading(true); + setError(null); + try { + const data = await getSsulContentById(contentId); + setContent(data); + setLikeCount(data.like_count); + setIsLiked(data.is_liked_by_me); + } catch (err) { + console.error('Failed to fetch ssul content:', err); + setError(t('ado2Contents.loadFailed')); + } finally { + setLoading(false); + } + }; + + setIsLandscape(false); + fetchContent(); + fetchComments(1); + }, [contentId, fetchComments, t]); + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return t('videoDetail.dateFormat', { + year: date.getFullYear(), + month: date.getMonth() + 1, + day: date.getDate(), + }); + }; + + const formatCommentDate = (dateString: string) => { + const date = new Date(dateString); + return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String( + date.getDate() + ).padStart(2, '0')}`; + }; + + // 썰박스 공개 페이지 경로. /video/{id} 를 쓰면 id 가 겹치는 남의 영상이 열린다. + const shareUrl = `${window.location.origin}/ssul/${contentId}`; + + 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: content?.store_name ?? t('videoDetail.kakaoDefaultTitle'), + description: t('videoDetail.kakaoDescription', { region: content?.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 = useRef(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 = useRef | null>(null); + + const handleLike = () => { + if (!authed) { + setShowLoginModal(true); + return; + } + + setIsLiked((prev) => !prev); + setLikeCount((prev) => (isLiked ? prev - 1 : prev + 1)); + + if (likeDebounceRef.current) clearTimeout(likeDebounceRef.current); + likeDebounceRef.current = setTimeout(async () => { + const prevLiked = isLiked; + const prevCount = likeCount; + try { + await toggleVideoLike(contentId, 'ssul'); + } catch (err) { + console.error('Failed to toggle ssul like:', err); + setIsLiked(prevLiked); + setLikeCount(prevCount); + } + }, 500); + }; + + const handleHeaderAction = () => { + if (!isModal && !authed) { + setShowLoginModal(true); + return; + } + onClose?.(); + }; + + const handleCommentFocus = () => { + if (!authed) setShowLoginModal(true); + }; + + const handleCommentSubmit = async () => { + if (!authed) { + setShowLoginModal(true); + return; + } + if (!commentInput.trim() || commentSubmitting) return; + setCommentSubmitting(true); + try { + await postVideoComment(contentId, commentInput.trim(), commentNickname, undefined, 'ssul'); + 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 ssul comment:', err); + } finally { + setCommentSubmitting(false); + } + }; + + const handleDeleteComment = async (commentId: number) => { + try { + await deleteComment(commentId); + await fetchComments(commentsPage); + } catch (err) { + console.error('Failed to delete ssul comment:', err); + } + }; + + const renderCommentContent = (content_: string | null, isDeleted: boolean) => { + if (isDeleted) { + return ( + + {t('videoDetail.deletedComment')} + + ); + } + return content_; + }; + + return ( +
+ {/* 헤더 */} +
+ {isModal ? ( + <> + {/* 제목이 없으면 space-between 인 헤더에서 닫기 버튼이 왼쪽으로 붙는다. + 문구는 ADO2 상세와 같은 키를 쓴다 — 통합 목록에서 열리는 같은 + 모달이므로 콘텐츠 종류에 따라 제목이 바뀌면 오히려 어색하다. */} +

{t('sidebar.ado2Contents')}

+ + + ) : ( + + )} +
+ + {loading ? ( +
+
+

{t('ado2Contents.loading')}

+
+ ) : error ? ( +

{error}

+ ) : content ? ( +
+