o2o-castad-frontend/src/pages/Ssulbox/SsulDetailContent.tsx
김성경 4fe065378f Merge branch 'main' into feature-ssulbox
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 15:15:53 +09:00

546 lines
22 KiB
TypeScript

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<SsulDetailContentProps> = ({
contentId,
isModal = false,
onClose,
}) => {
const { t } = useTranslation();
const authed = isLoggedIn();
const [content, setContent] = useState<SsulDetailItem | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<CommentItem[]>([]);
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<HTMLTextAreaElement>(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<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 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 (
<span style={{ color: '#6B9EA0', fontStyle: 'italic' }}>
{t('videoDetail.deletedComment')}
</span>
);
}
return content_;
};
return (
<div className={isModal ? 'video-detail-modal-content' : 'video-detail-page-content'}>
{/* 헤더 */}
<div className="video-detail-header">
{isModal ? (
<>
{/* 제목이 없으면 space-between 인 헤더에서 닫기 버튼이 왼쪽으로 붙는다.
문구는 ADO2 상세와 같은 키를 쓴다 — 통합 목록에서 열리는 같은
모달이므로 콘텐츠 종류에 따라 제목이 바뀌면 오히려 어색하다. */}
<h2 className="video-detail-modal-title">{t('sidebar.ado2Contents')}</h2>
<button className="video-detail-close-btn" onClick={handleHeaderAction} aria-label={t('videoDetail.closeAriaLabel')}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</>
) : (
<button className="video-detail-back-btn" onClick={handleHeaderAction}>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M13 4l-6 6 6 6" />
</svg>
{t('sidebar.ado2Contents')}
</button>
)}
</div>
<div className="video-detail-body">
{loading ? (
<div className="ado2-contents-loading">
<div className="loading-spinner"></div>
<p>{t('ado2Contents.loading')}</p>
</div>
) : error ? (
<div className="ado2-contents-error"><p>{error}</p></div>
) : content ? (
<div className={`video-detail-content ${isLandscape ? 'landscape' : ''}`}>
<video
src={content.video_url}
controls
autoPlay
playsInline
controlsList="nodownload"
onContextMenu={(e) => e.preventDefault()}
className="video-detail-player"
onLoadedMetadata={(e) => {
const v = e.currentTarget;
setIsLandscape(v.videoWidth > v.videoHeight);
}}
/>
<div className="video-detail-info">
<h2 className="video-detail-store">
{content.store_name || t('ssulbox.viewer.untitled')}
</h2>
<p className="video-detail-date">{formatDate(content.created_at)}</p>
{/* 좋아요 + 공유 */}
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button
className={`video-detail-like-btn ${isLiked ? 'liked' : ''}`}
onClick={handleLike}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill={isLiked ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
{likeCount}
</button>
<div style={{ position: 'relative' }} ref={shareMenuRef}>
<button
className="video-detail-copy-btn"
onClick={handleToggleShareMenu}
>
<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" />
<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>
{copied ? t('videoDetail.copied') : t('videoDetail.share')}
</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 className="video-detail-comments">
<div className="video-detail-comments-header">
<h3 className="video-detail-comments-title">{t('videoDetail.commentsTitle')}</h3>
<span className="video-detail-comments-count">{commentsTotal}</span>
</div>
{authed && (
<div className="video-detail-comment-profile">
<img
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${commentAvatarSeed}`}
alt={t('videoDetail.changeAvatarTitle')}
className="video-detail-comment-avatar"
onClick={handleChangeAvatar}
style={{ cursor: 'pointer' }}
title={t('videoDetail.changeAvatarTitle')}
/>
<input
className="video-detail-nickname-input"
type="text"
placeholder={t('videoDetail.nicknamePlaceholder')}
value={commentNickname}
onChange={(e) => setCommentNickname(e.target.value)}
maxLength={20}
/>
</div>
)}
<div className="video-detail-comment-input-wrap">
<textarea
ref={commentTextareaRef}
className="video-detail-comment-input"
placeholder={authed ? t('videoDetail.commentPlaceholder') : t('videoDetail.commentLoginRequired')}
maxLength={500}
rows={1}
value={commentInput}
onChange={(e) => {
setCommentInput(e.target.value);
e.target.style.height = 'auto';
e.target.style.height = `${e.target.scrollHeight}px`;
}}
onFocus={handleCommentFocus}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleCommentSubmit();
}
}}
disabled={!authed}
/>
<button
className="video-detail-comment-submit"
onClick={handleCommentSubmit}
disabled={!authed || !commentInput.trim() || commentSubmitting}
>
{commentSubmitting ? t('videoDetail.commentSubmitting') : t('videoDetail.commentSubmit')}
</button>
</div>
{comments.length === 0 && !commentsLoading ? (
<p className="video-detail-comments-empty">{t('videoDetail.noComments')}</p>
) : (
<ul className="video-detail-comment-list">
{comments.map((c) => (
<li key={c.id} className="video-detail-comment-item">
<img
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${c.id}`}
alt="avatar"
className="video-detail-comment-avatar"
/>
<div className="video-detail-comment-body">
<span className="video-detail-comment-nickname">
{c.nickname || t('videoDetail.anonymous')}
</span>
<p className="video-detail-comment-text">
{renderCommentContent(c.content, c.is_deleted)}
</p>
<div className="video-detail-comment-bottom">
<span className="video-detail-comment-date">{formatCommentDate(c.created_at)}</span>
{c.is_mine && !c.is_deleted && (
<button
className="video-detail-comment-delete"
onClick={() => handleDeleteComment(c.id)}
>
{t('videoDetail.deleteComment')}
</button>
)}
</div>
{c.replies && c.replies.length > 0 && (
<ul className="video-detail-reply-list">
{c.replies.map((r) => (
<li key={r.id} className="video-detail-reply-item">
<img
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${r.id}`}
alt="avatar"
className="video-detail-comment-avatar small"
/>
<div className="video-detail-comment-body">
<span className="video-detail-comment-nickname">
{r.nickname || t('videoDetail.anonymous')}
</span>
<p className="video-detail-comment-text">
{renderCommentContent(r.content, r.is_deleted)}
</p>
<div className="video-detail-comment-bottom">
<span className="video-detail-comment-date">{formatCommentDate(r.created_at)}</span>
{r.is_mine && !r.is_deleted && (
<button
className="video-detail-comment-delete"
onClick={() => handleDeleteComment(r.id)}
>
{t('videoDetail.deleteComment')}
</button>
)}
</div>
</div>
</li>
))}
</ul>
)}
</div>
</li>
))}
</ul>
)}
{commentsHasNext && (
<button
className="video-detail-comments-more"
onClick={() => fetchComments(commentsPage + 1, true)}
disabled={commentsLoading}
>
{commentsLoading ? t('videoDetail.loadingComments') : t('videoDetail.loadMoreComments')}
</button>
)}
</div>
</div>
</div>
) : null}
</div>
{showLoginModal && (
<LoginPromptModal onClose={() => setShowLoginModal(false)} />
)}
</div>
);
};
export default SsulDetailContent;