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 [shareMenuUpward, setShareMenuUpward] = 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]); // 모달 내부는 스크롤 영역(.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 | 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(), 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 ? (