o2o-castad-frontend/src/components/VideoDetailContent.tsx

413 lines
16 KiB
TypeScript

import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import {
getVideoById,
getVideoComments,
postVideoComment,
deleteComment,
toggleVideoLike,
isLoggedIn,
getUserMe,
API_URL,
} from '../utils/api';
import { VideoDetailItem, CommentItem, UserMeResponse } from '../types/api';
import { buildVideoShareUrl, tryNativeShare } from '../utils/nativeShare';
import LoginPromptModal from './LoginPromptModal';
interface VideoDetailContentProps {
videoId: string;
isModal?: boolean;
onClose?: () => void;
}
const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModal = false, onClose }) => {
const { t } = useTranslation();
const authed = isLoggedIn();
const [video, setVideo] = useState<VideoDetailItem | 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 [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 [currentUser, setCurrentUser] = useState<UserMeResponse | null>(null);
const fetchComments = useCallback(async (page: number, append = false) => {
setCommentsLoading(true);
try {
const res = await getVideoComments(videoId, page, 20);
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 comments:', err);
} finally {
setCommentsLoading(false);
}
}, [videoId]);
useEffect(() => {
const fetchVideo = async () => {
setLoading(true);
setError(null);
try {
const data = await getVideoById(videoId);
setVideo(data);
setLikeCount(data.like_count);
setIsLiked(data.is_liked_by_me);
} catch (err) {
console.error('Failed to fetch video:', err);
setError(t('ado2Contents.loadFailed'));
} finally {
setLoading(false);
}
};
fetchVideo();
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() });
};
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')}`;
};
const shareUrl = buildVideoShareUrl(API_URL, videoId);
const shareTitle = video?.title || video?.store_name || t('videoDetail.kakaoDefaultTitle');
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(shareUrl);
} catch {
// clipboard API 미지원 환경에서는 무시
}
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleShareButtonClick = async () => {
const handled = await tryNativeShare({
title: shareTitle,
url: shareUrl,
});
if (handled) {
return;
}
await handleCopyLink();
};
const likeDebounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const handleLike = () => {
if (!authed) { setShowLoginModal(true); return; }
// 1. UI 즉시 업데이트 (Optimistic)
setIsLiked(prev => !prev);
setLikeCount(prev => isLiked ? prev - 1 : prev + 1);
// 2. 기존 debounce 타이머 취소 후 재설정
if (likeDebounceRef.current) clearTimeout(likeDebounceRef.current);
likeDebounceRef.current = setTimeout(async () => {
const prevLiked = isLiked;
const prevCount = likeCount;
try {
await toggleVideoLike(videoId);
} catch (err) {
// 3. 실패 시 롤백
console.error('Failed to toggle 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(videoId, commentInput.trim());
setCommentInput('');
if (commentTextareaRef.current) {
commentTextareaRef.current.style.height = 'auto';
}
await fetchComments(1);
} catch (err) {
console.error('Failed to post comment:', err);
} finally {
setCommentSubmitting(false);
}
};
const handleDeleteComment = async (commentId: number) => {
try {
await deleteComment(commentId);
await fetchComments(commentsPage);
} catch (err) {
console.error('Failed to delete 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 ? (
<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>
{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>
) : video ? (
<div className={`video-detail-content ${isLandscape ? 'landscape' : ''}`}>
<video
src={video.result_movie_url}
controls
autoPlay
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">{video.store_name}</h2>
<p className="video-detail-date">{formatDate(video.created_at)}</p>
{/* 좋아요 + 링크 복사 */}
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button
className={`video-detail-like-btn ${isLiked ? 'liked' : ''}`}
onClick={handleLike}
disabled={false}
>
<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>
<button
className="video-detail-copy-btn"
onClick={handleShareButtonClick}
title={t('videoDetail.share')}
>
<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>
</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 && currentUser && (
<div className="video-detail-comment-profile">
{currentUser.profile_image_url && (
<img
src={currentUser.profile_image_url}
alt={currentUser.nickname}
className="video-detail-comment-avatar"
/>
)}
<span className="video-detail-comment-nickname">{currentUser.nickname}</span>
</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">
{c.profile_image_url && (
<img
src={c.profile_image_url}
alt={c.nickname || t('videoDetail.anonymous')}
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">
{r.profile_image_url && (
<img
src={r.profile_image_url}
alt={r.nickname || t('videoDetail.anonymous')}
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}
{showLoginModal && (
<LoginPromptModal onClose={() => setShowLoginModal(false)} />
)}
</div>
);
};
export default VideoDetailContent;