feat(ssulbox): 상세·공유 화면과 좋아요·댓글·업로드 연동

This commit is contained in:
김성경 2026-08-11 14:57:34 +09:00
parent 7b1e691715
commit 741539a023
12 changed files with 836 additions and 293 deletions

View File

@ -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<ContentCardSocialActionsProps> = ({
videoId,
contentType = 'video',
storeName,
region,
commentCount,
@ -24,6 +32,7 @@ const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
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<ContentCardSocialActionsProps> = ({
const shareMenuRef = useRef<HTMLDivElement>(null);
const likeDebounceRef = useRef<ReturnType<typeof setTimeout> | 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<ContentCardSocialActionsProps> = ({
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<ContentCardSocialActionsProps> = ({
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<ContentCardSocialActionsProps> = ({
</div>,
document.body
)}
{showLoginModal && (
<LoginPromptModal onClose={() => setShowLoginModal(false)} />
)}
</div>
);
};

View File

@ -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<SocialPostingModalProps> = ({
onGoToCalendar,
}) => {
const { t } = useTranslation();
const tutorial = useTutorial();
const [socialAccounts, setSocialAccounts] = useState<SocialAccount[]>([]);
const [selectedChannel, setSelectedChannel] = useState<string>('');
const [title, setTitle] = useState('');
@ -141,7 +138,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
const [videoMeta, setVideoMeta] = useState<{ width: number; height: number; duration: number } | null>(null);
const channelDropdownRef = useRef<HTMLDivElement>(null);
const privacyDropdownRef = useRef<HTMLDivElement>(null);
const hasBeenOpenedRef = useRef(false);
const loadedForTaskIdRef = useRef<string | null>(null);
const loadedAtRef = useRef<number>(0);
const seoCache = useRef<Map<string, { title: string; description: string; tags: string }>>(new Map());
@ -184,32 +180,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
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<SocialPostingModalProps> = ({
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<SocialPostingModalProps> = ({
}
};
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<SocialPostingModalProps> = ({
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<SocialPostingModalProps> = ({
}
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<SocialPostingModalProps> = ({
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<SocialPostingModalProps> = ({
return (
<>
{showUploadProgress && uploadProgressModalElement}
{tutorial.isActive && (
<TutorialOverlay
hints={tutorial.hints}
currentIndex={tutorial.currentHintIndex}
onNext={tutorial.nextHint}
onPrev={tutorial.prevHint}
onSkip={tutorial.skipTutorial}
groupProgress={tutorial.groupProgress}
/>
)}
</>
);
}
return (
<>
<div className="social-posting-overlay" onClick={handleClose}>
<div className="social-posting-overlay" {...overlayCloseHandlers}>
<div className="social-posting-modal" onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div className="social-posting-header">
@ -859,16 +839,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
</div>
</div>
{uploadProgressModalElement}
{tutorial.isActive && (
<TutorialOverlay
hints={tutorial.hints}
currentIndex={tutorial.currentHintIndex}
onNext={tutorial.nextHint}
onPrev={tutorial.prevHint}
onSkip={tutorial.skipTutorial}
groupProgress={tutorial.groupProgress}
/>
)}
</>
);
};

View File

@ -231,11 +231,14 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
{/* 헤더 */}
<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>
<>
<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">

View File

@ -1,5 +1,6 @@
import React, { useEffect } from 'react';
import VideoDetailContent from './VideoDetailContent';
import { useOverlayClose } from '../hooks/useOverlayClose';
interface VideoDetailModalProps {
videoId: string;
@ -23,6 +24,8 @@ const VideoDetailModal: React.FC<VideoDetailModalProps> = ({ videoId, onClose })
};
}, [onClose]);
const overlayCloseHandlers = useOverlayClose(onClose);
return (
<div
style={{
@ -34,7 +37,7 @@ const VideoDetailModal: React.FC<VideoDetailModalProps> = ({ videoId, onClose })
overflowY: 'auto',
padding: '24px 16px',
}}
onClick={onClose}
{...overlayCloseHandlers}
>
<div
style={{

View File

@ -1,8 +1,7 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { getAllVideos, isLoggedIn } from '../../utils/api';
import { getAllVideos } from '../../utils/api';
import { VideoListItem } from '../../types/api';
import LoginPromptModal from '../../components/LoginPromptModal';
import VideoDetailModal from '../../components/VideoDetailModal';
import CitySelectModal from '../../components/CitySelectModal';
import ContentCardSocialActions from '../../components/ContentCardSocialActions';
@ -14,14 +13,13 @@ interface ADO2ContentsPageProps {
const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
const { t } = useTranslation();
const authed = isLoggedIn();
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null);
// 썰박스는 별도 뷰어를 쓴다 — VideoDetailModal 은 video_id 로 조회하므로
// 썰박스 id 를 넘기면 id 가 겹치는 다른 영상이 열린다.
const [selectedSsul, setSelectedSsul] = useState<VideoListItem | null>(null);
const [videos, setVideos] = useState<VideoListItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(authed);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [hasNext, setHasNext] = useState(false);
@ -37,7 +35,6 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
const [showCityModal, setShowCityModal] = useState(false);
useEffect(() => {
if (!authed) return;
fetchVideos();
}, [page, sortBy, order, storeName, region]);
@ -197,19 +194,17 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
<h3 className="content-card-title">{video.store_name}</h3>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<p className="content-card-date">{formatDate(video.created_at)}</p>
{/* 좋아요·댓글은 castad API 를 video_id 로 호출한다.
썰박스 id 를 넘기면 id 가 겹치는 다른 영상에 반영되므로
`/ssul/*` 반응 API 가 붙기 전까지 노출하지 않는다. */}
{video.type === 'video' && (
<ContentCardSocialActions
videoId={video.video_id}
storeName={video.store_name}
region={video.region}
commentCount={video.comment_count ?? 0}
initialLikeCount={video.like_count ?? 0}
initialIsLiked={video.is_liked_by_me}
/>
)}
{/* 좋아요·댓글 API 가 type 을 받으므로 썰박스에도 노출한다.
contentType 이 없으면 id 가 겹치는 다른 영상에 반영된다. */}
<ContentCardSocialActions
videoId={video.video_id}
contentType={video.type}
storeName={video.store_name}
region={video.region}
commentCount={video.comment_count ?? 0}
initialLikeCount={video.like_count ?? 0}
initialIsLiked={video.is_liked_by_me}
/>
</div>
</div>
</div>
@ -237,10 +232,6 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
</>
)}
{!authed && (
<LoginPromptModal onClose={() => { window.location.href = '/'; }} />
)}
<SsulViewerModal item={selectedSsul} onClose={() => setSelectedSsul(null)} />
{selectedVideoId !== null && (

View File

@ -1,14 +1,13 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { getVideosList, deleteVideo } from '../../utils/api';
import { getVideosList, deleteVideo, deleteSsulContent } from '../../utils/api';
import { VideoListItem } from '../../types/api';
import SocialPostingModal from '../../components/SocialPostingModal';
import VideoDetailModal from '../../components/VideoDetailModal';
import ContentCardSocialActions from '../../components/ContentCardSocialActions';
import SsulViewerModal from '../Ssulbox/SsulViewerModal';
import { useTutorial } from '../../components/Tutorial/useTutorial';
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
import { useOverlayClose } from '../../hooks/useOverlayClose';
const VideoPreviewCard: React.FC<{ src: string; className?: string }> = ({ src, className }) => {
const videoRef = useRef<HTMLVideoElement>(null);
@ -90,11 +89,12 @@ const VideoPreviewCard: React.FC<{ src: string; className?: string }> = ({ src,
interface MyContentsPageProps {
onBack?: () => void;
onNavigate?: (item: string) => void;
/** 내 정보 탭 안에 임베드될 때: 탭 라벨과 중복되는 자체 제목을 숨긴다 */
embedded?: boolean;
}
const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate, embedded }) => {
const { t } = useTranslation();
const tutorial = useTutorial();
const [videos, setVideos] = useState<VideoListItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
@ -104,7 +104,7 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
const [hasPrev, setHasPrev] = useState(false);
const [totalPages, setTotalPages] = useState(1);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<number | null>(null);
const [deleteTarget, setDeleteTarget] = useState<VideoListItem | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [uploadModalOpen, setUploadModalOpen] = useState(false);
const [uploadTargetVideo, setUploadTargetVideo] = useState<VideoListItem | null>(null);
@ -119,12 +119,6 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
fetchVideos();
}, [page]);
useEffect(() => {
if (!tutorial.hasSeen(TUTORIAL_KEYS.ADO2_CONTENTS)) {
tutorial.startTutorial(TUTORIAL_KEYS.ADO2_CONTENTS);
}
}, []);
const fetchVideos = async () => {
setLoading(true);
setError(null);
@ -149,9 +143,7 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}.${month}.${day}・${hours}:${minutes}`;
return `${year}.${month}.${day}`;
};
const formatTitle = (storeName: string, dateString: string) => {
@ -181,8 +173,10 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
}
};
const handleDeleteClick = (videoId: number) => {
setDeleteTargetId(videoId);
// 아이템 전체를 저장한다 — id 만 들면 종류를 잃어 어느 삭제 API 를 부를지 알 수 없다
// (video.id 와 ssul_content.id 는 값이 겹치는 독립 시퀀스).
const handleDeleteClick = (video: VideoListItem) => {
setDeleteTarget(video);
setDeleteModalOpen(true);
};
@ -198,18 +192,30 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
const handleDeleteCancel = () => {
setDeleteModalOpen(false);
setDeleteTargetId(null);
setDeleteTarget(null);
};
const deleteModalOverlayHandlers = useOverlayClose(handleDeleteCancel);
const handleDeleteConfirm = async () => {
if (!deleteTargetId) return;
if (!deleteTarget) return;
setIsDeleting(true);
try {
await deleteVideo(deleteTargetId);
setVideos(prev => prev.filter(video => video.video_id !== deleteTargetId));
// 종류별 삭제 API 가 다르다. deleteVideo 는 Video.id 로 지우므로
// 썰박스 id 를 넘기면 id 가 겹치는 엉뚱한 ADO2 영상이 삭제된다.
if (deleteTarget.type === 'ssul') {
await deleteSsulContent(deleteTarget.video_id);
} else {
await deleteVideo(deleteTarget.video_id);
}
setVideos(prev =>
prev.filter(
video =>
!(video.type === deleteTarget.type && video.video_id === deleteTarget.video_id)
)
);
setTotal(prev => Math.max(0, prev - 1));
setDeleteModalOpen(false);
setDeleteTargetId(null);
setDeleteTarget(null);
} catch (err) {
console.error('Delete failed:', err);
alert(t('ado2Contents.deleteFailed'));
@ -219,10 +225,10 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
};
return (
<div className="ado2-contents-page">
<div className={`ado2-contents-page ${embedded ? 'ado2-contents-page--embedded' : ''}`}>
{/* Header */}
<div className="ado2-contents-header">
<h1 className="ado2-contents-title">{t('sidebar.myContents')}</h1>
{!embedded && <h1 className="ado2-contents-title">{t('sidebar.myContents')}</h1>}
<span className="ado2-contents-count">{t('ado2Contents.totalCount', { count: total })}</span>
</div>
@ -289,40 +295,35 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
<p className="content-card-date">
{formatDate(video.created_at)}
</p>
{/* 좋아요·댓글은 castad API 를 video_id 로 호출한다.
썰박스 id 를 넘기면 id 가 겹치는 다른 영상에 반영되므로
`/ssul/*` 반응 API 가 붙기 전까지 노출하지 않는다. */}
{video.type === 'video' && (
<ContentCardSocialActions
videoId={video.video_id}
storeName={video.store_name}
region={video.region}
commentCount={video.comment_count ?? 0}
initialLikeCount={video.like_count ?? 0}
initialIsLiked={video.is_liked_by_me}
/>
)}
{/* 좋아요·댓글 API 가 type 을 받으므로 썰박스에도 노출한다.
contentType 이 없으면 id 가 겹치는 다른 영상에 반영된다. */}
<ContentCardSocialActions
videoId={video.video_id}
contentType={video.type}
storeName={video.store_name}
region={video.region}
commentCount={video.comment_count ?? 0}
initialLikeCount={video.like_count ?? 0}
initialIsLiked={video.is_liked_by_me}
/>
</div>
</div>
{/* Action Buttons */}
<div className="content-card-actions">
{/* SNS 업로드는 SocialPostingModal 이 video_id 로 업로드 API 를 부른다.
썰박스 id 를 넘기면 **엉뚱한 ADO2 영상이 업로드된다.**
`/ssul/upload/*` 가 붙기 전까지 노출하지 않는다. */}
{video.type === 'video' && (
<button
className="content-download-btn"
onClick={() => handleUploadClick(video)}
disabled={!video.result_movie_url}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M10 13V3M10 3l-4 4M10 3l4 4"/>
<path d="M3 15v2h14v-2"/>
</svg>
<span>{t('ado2Contents.uploadToSocial')}</span>
</button>
)}
{/* SocialPostingModal 이 content_type 을 함께 보내므로
썰박스 항목도 같은 모달로 업로드한다 (social_upload 병합). */}
<button
className="content-download-btn"
onClick={() => handleUploadClick(video)}
disabled={!video.result_movie_url}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M10 13V3M10 3l-4 4M10 3l4 4"/>
<path d="M3 15v2h14v-2"/>
</svg>
<span>{t('ado2Contents.uploadToSocial')}</span>
</button>
<button
className="content-upload-btn"
onClick={() => handleDownload(video.result_movie_url, video.store_name)}
@ -334,22 +335,18 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
<path d="M3 15v2h14v-2"/>
</svg>
</button>
{/* ⚠️ 삭제는 파괴적이다. `DELETE /archive/videos/{id}` 는 `Video.id` 로
지우므로 썰박스 id 를 넘기면 **id 가 겹치는 ADO2 영상이 삭제된다.**
소유권 검증도 통과한다(같은 사용자가 양쪽을 다 가진 경우).
썰박스 삭제 API 가 붙기 전까지 절대 노출하지 않는다. */}
{video.type === 'video' && (
<button
className="content-delete-btn"
onClick={() => handleDeleteClick(video.video_id)}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M3 5h14M8 5V3h4v2M6 5v12h8V5"/>
<line x1="8" y1="8" x2="8" y2="14"/>
<line x1="12" y1="8" x2="12" y2="14"/>
</svg>
</button>
)}
{/* 삭제는 종류별 API 로 분기한다(handleDeleteConfirm).
아이템 전체를 넘겨야 종류를 잃지 않는다. */}
<button
className="content-delete-btn"
onClick={() => handleDeleteClick(video)}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M3 5h14M8 5V3h4v2M6 5v12h8V5"/>
<line x1="8" y1="8" x2="8" y2="14"/>
<line x1="12" y1="8" x2="12" y2="14"/>
</svg>
</button>
</div>
</div>
</div>
@ -379,7 +376,7 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
{/* 삭제 확인 모달 */}
{deleteModalOpen && (
<div className="delete-modal-overlay" onClick={handleDeleteCancel}>
<div className="delete-modal-overlay" {...deleteModalOverlayHandlers}>
<div className="delete-modal" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<h2 className="delete-modal-title">{t('ado2Contents.deleteConfirmTitle')}</h2>
<p className="delete-modal-description">{t('ado2Contents.deleteConfirmDesc')}</p>

View File

@ -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<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 [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]);
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(), 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 (
<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>
{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
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={() => setShareMenuOpen((v) => !v)}
>
<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">
<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}
{showLoginModal && (
<LoginPromptModal onClose={() => setShowLoginModal(false)} />
)}
</div>
);
};
export default SsulDetailContent;

View File

@ -0,0 +1,25 @@
import React from 'react';
import SsulDetailContent from './SsulDetailContent';
interface SsulDetailPageProps {
contentId: string;
}
/**
* 썰박스 공유 링크 페이지 (`/ssul/{id}`) — castad `VideoDetailPage` 와 동일한 패턴.
* 비로그인도 볼 수 있다(백엔드 `GET /ssul/{id}` 가 인증 선택).
*/
const SsulDetailPage: React.FC<SsulDetailPageProps> = ({ contentId }) => {
const handleBack = () => {
localStorage.setItem('castad_active_item', 'ADO2 콘텐츠');
window.location.href = '/';
};
return (
<div className="video-detail-page">
<SsulDetailContent contentId={contentId} isModal={false} onClose={handleBack} />
</div>
);
};
export default SsulDetailPage;

View File

@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import React, { useEffect } from 'react';
import { VideoListItem } from '../../types/api';
import SsulDetailContent from './SsulDetailContent';
import { useOverlayClose } from '../../hooks/useOverlayClose';
interface SsulViewerModalProps {
/** null 이면 닫힘 */
@ -9,25 +10,13 @@ interface SsulViewerModalProps {
}
/**
* 썰박스 콘텐츠 뷰어.
* 썰박스 콘텐츠 모달 — castad `VideoDetailModal` 과 같은 백드롭 래퍼.
* 내용은 `SsulDetailContent` 가 그린다(공유 링크 페이지 `SsulDetailPage` 와 공용).
*
* **castad `VideoDetailModal` 과 같은 형식**을 쓴다 — 백드롭·패널 인라인 스타일과
* `video-detail-*` 클래스를 그대로 재사용하므로 통합 목록에서 어떤 카드를 열든
* 같은 모양이 나온다. 신규 CSS 를 만들지 않는다.
*
* 컴포넌트를 그대로 못 쓰는 이유: `VideoDetailModal` 은 `videoId` 로 `/video/{id}` 를
* 조회하는데, 썰박스 id 를 넘기면 **id 가 겹치는 다른 ADO2 영상**이 열린다
* (`video.id` 와 `ssul_content.id` 는 각각 1부터 시작하는 독립 시퀀스).
* 목록이 이미 재생에 필요한 정보를 갖고 있어 추가 조회도 필요 없다.
*
* 좋아요·댓글·공유는 castad API 를 video_id 로 호출하므로 여기서는 빼 뒀다.
* `/ssul/*` 반응 API 가 붙으면 추가한다.
* `VideoDetailModal` 을 그대로 못 쓰는 이유: `videoId` 로 `/video/{id}` 를
* 조회하는데, 썰박스 id 를 넘기면 **id 가 겹치는 다른 ADO2 영상**이 열린다.
*/
const SsulViewerModal: React.FC<SsulViewerModalProps> = ({ item, onClose }) => {
const { t } = useTranslation();
// 가로 영상이면 castad 와 동일하게 landscape 레이아웃으로 전환한다
const [isLandscape, setIsLandscape] = useState(false);
useEffect(() => {
if (!item) return;
// 모달 열릴 때 배경 스크롤 잠금
@ -45,21 +34,10 @@ const SsulViewerModal: React.FC<SsulViewerModalProps> = ({ item, onClose }) => {
};
}, [item, onClose]);
// 다른 콘텐츠를 열면 방향 판정을 초기화한다(이전 값이 남으면 레이아웃이 틀어진다)
useEffect(() => {
setIsLandscape(false);
}, [item?.type, item?.video_id]);
const overlayCloseHandlers = useOverlayClose(onClose);
if (!item) return null;
const formatDate = (value?: string) => {
if (!value) return '';
const d = new Date(value);
return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(
d.getDate()
).padStart(2, '0')}`;
};
return (
<div
style={{
@ -71,7 +49,7 @@ const SsulViewerModal: React.FC<SsulViewerModalProps> = ({ item, onClose }) => {
overflowY: 'auto',
padding: '24px 16px',
}}
onClick={onClose}
{...overlayCloseHandlers}
>
<div
style={{
@ -84,42 +62,11 @@ const SsulViewerModal: React.FC<SsulViewerModalProps> = ({ item, onClose }) => {
}}
onClick={(e) => e.stopPropagation()}
>
<div className="video-detail-modal-content">
{/* 헤더 */}
<div className="video-detail-header">
<button
className="video-detail-close-btn"
onClick={onClose}
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>
</div>
<div className={`video-detail-content ${isLandscape ? 'landscape' : ''}`}>
<video
src={item.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">
{item.store_name || t('ssulbox.viewer.untitled')}
</h2>
<p className="video-detail-date">{formatDate(item.created_at)}</p>
</div>
</div>
</div>
<SsulDetailContent
contentId={String(item.video_id)}
isModal={true}
onClose={onClose}
/>
</div>
</div>
);

View File

@ -690,7 +690,16 @@
}
.video-detail-header {
/* margin-bottom: 24px; */
display: flex;
align-items: center;
justify-content: space-between;
}
.video-detail-modal-title {
font-size: 20px;
font-weight: 600;
color: #FFFFFF;
margin: 0;
}
.video-detail-back-btn {
@ -1498,6 +1507,18 @@
background-color: var(--color-bg-darker);
}
/* 내 콘텐츠/캘린더 탭은 그리드형 화면이라 900px 폭 제한을 해제한다 */
.myinfo-page--wide {
max-width: none;
}
/* 내 정보 탭에 임베드된 내 콘텐츠: 단독 페이지용 여백을 제거해 이중 여백 방지 */
.ado2-contents-page--embedded {
padding: 0;
min-height: auto;
background-color: transparent;
}
.myinfo-title {
font-size: 1.75rem;
font-weight: 700;
@ -1606,7 +1627,7 @@
.myinfo-credits-desc {
color: rgba(255, 255, 255, 0.4);
font-size: 0.85rem;
margin: 0;
margin-right: 5px;
flex: 1;
}

View File

@ -401,7 +401,9 @@ export interface SocialDisconnectResponse {
// 유튜브 SEO Description 자동완성 요청
export interface YTAutoSeoRequest {
task_id: string; // 아카이브의 비디오 ID
/** 썰박스면 'ssul' + task_id 자리에 ssul_content.id 문자열. 생략 시 video */
content_type?: ContentType;
task_id: string; // ADO2 는 task_id, 썰박스는 콘텐츠 id 문자열
}
// 유튜브 SEO Description 자동완성 응답
@ -413,7 +415,12 @@ export interface YTAutoSeoResponse {
// 소셜 업로드 요청
export interface SocialUploadRequest {
video_id: number; // 아카이브의 비디오 ID
/**
* 콘텐츠 종류. video.id 와 ssul_content.id 가 겹치므로 생략(=video)한 채
* 썰박스 id 를 보내면 **엉뚱한 ADO2 영상이 업로드된다.**
*/
content_type?: ContentType;
video_id: number; // 콘텐츠 ID (content_type 안에서만 유일)
social_account_id: number; // 선택된 채널 ID
title: string; // 최대 100자
description: string; // 최대 5000자

View File

@ -34,6 +34,7 @@ import {
VideoDetailItem,
CommentsResponse,
CommentItem,
ContentType,
LikeToggleResponse,
} from '../types/api';
@ -574,7 +575,7 @@ export async function getAllVideos(
if (region.trim()) params.set('region', region.trim());
const response = await authenticatedFetch(`${API_URL}/video/all?${params.toString()}`, {
method: 'GET',
});
}, { redirectOnAuthFail: false });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
@ -587,7 +588,7 @@ export async function getAllVideos(
export async function getVideoById(videoId: string): Promise<VideoDetailItem> {
const response = await authenticatedFetch(`${API_URL}/video/${videoId}`, {
method: 'GET',
});
}, { redirectOnAuthFail: false });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
@ -596,22 +597,75 @@ export async function getVideoById(videoId: string): Promise<VideoDetailItem> {
return response.json();
}
// 댓글 목록 조회
export async function getVideoComments(videoId: string, page: number = 1, pageSize: number = 20): Promise<CommentsResponse> {
const response = await authenticatedFetch(`${API_URL}/comment/video/${videoId}?page=${page}&page_size=${pageSize}`, {
// 썰박스 콘텐츠 공개 상세 (비로그인 허용 — 공유 링크 /ssul/{id} 가 사용)
export interface SsulDetailItem {
content_id: number;
scenario: string;
video_url: string;
store_name: string | null;
region: string | null;
created_at: string;
like_count: number;
is_liked_by_me: boolean;
}
export async function getSsulContentById(contentId: string): Promise<SsulDetailItem> {
const response = await authenticatedFetch(`${API_URL}/ssul/${contentId}`, {
method: 'GET',
}, { redirectOnAuthFail: false });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
// 썰박스 콘텐츠 소프트 삭제.
// ⚠️ deleteVideo(= /archive/videos/{id}, Video.id 기준)와 절대 혼용하지 말 것 —
// id 가 겹치므로 엉뚱한 ADO2 영상이 지워진다. 썰박스는 반드시 이 함수로.
export async function deleteSsulContent(contentId: number): Promise<void> {
const response = await authenticatedFetch(`${API_URL}/ssul/${contentId}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
}
// 댓글 목록 조회
// contentType: video.id 와 ssul_content.id 가 겹치므로 종류를 함께 보낸다 (기본 'video')
export async function getVideoComments(
videoId: string,
page: number = 1,
pageSize: number = 20,
contentType: ContentType = 'video'
): Promise<CommentsResponse> {
const response = await authenticatedFetch(
`${API_URL}/comment/video/${videoId}?page=${page}&page_size=${pageSize}&type=${contentType}`,
{
method: 'GET',
},
{ redirectOnAuthFail: false }
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
// 댓글 작성
export async function postVideoComment(videoId: string, content: string, nickname?: string, parentId?: number): Promise<CommentItem> {
const response = await authenticatedFetch(`${API_URL}/comment/video/${videoId}`, {
export async function postVideoComment(
videoId: string,
content: string,
nickname?: string,
parentId?: number,
contentType: ContentType = 'video'
): Promise<CommentItem> {
const response = await authenticatedFetch(`${API_URL}/comment/video/${videoId}?type=${contentType}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, nickname: nickname || '익명', parent_id: parentId ?? null }),
@ -636,10 +690,18 @@ export async function deleteComment(commentId: number): Promise<void> {
}
// 좋아요 토글
export async function toggleVideoLike(videoId: string): Promise<LikeToggleResponse> {
const response = await authenticatedFetch(`${API_URL}/video/${videoId}/like`, {
method: 'POST',
});
export async function toggleVideoLike(
videoId: string,
// video.id 와 ssul_content.id 가 겹치므로 종류를 함께 보내야 한다.
// 기본값 'video' 라 기존 호출부는 수정이 없다.
contentType: ContentType = 'video'
): Promise<LikeToggleResponse> {
const response = await authenticatedFetch(
`${API_URL}/video/${videoId}/like?type=${contentType}`,
{
method: 'POST',
}
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
@ -786,7 +848,8 @@ function redirectToLogin() {
// 401 에러 시 자동으로 토큰 갱신 후 재요청하는 래퍼 함수
export async function authenticatedFetch(
url: string,
options: RequestInit = {}
options: RequestInit = {},
{ redirectOnAuthFail = true }: { redirectOnAuthFail?: boolean } = {}
): Promise<Response> {
// 인증 헤더 + 캐시 방지 헤더 추가
const headers: HeadersInit = {
@ -803,9 +866,14 @@ export async function authenticatedFetch(
const errorBody = await response.json().catch(() => null);
const errorCode = errorBody?.detail?.code;
// 애초에 토큰이 없던 익명 요청(공개 화면)까지 튕기면 안 되므로 별도 처리
const isMissingToken = errorCode === 'MISSING_TOKEN';
if (errorCode !== 'TOKEN_EXPIRED') {
// INVALID_TOKEN 등 갱신으로 해결 불가한 경우 즉시 로그인 이동
redirectToLogin();
if (redirectOnAuthFail && !isMissingToken) {
redirectToLogin();
}
throw new Error(errorCode ?? 'Unauthorized');
}
@ -828,7 +896,9 @@ export async function authenticatedFetch(
response = await fetch(url, { ...options, headers: newHeaders });
} catch (refreshError) {
console.error('Token refresh failed:', refreshError);
redirectToLogin();
if (redirectOnAuthFail) {
redirectToLogin();
}
throw refreshError;
}
}
@ -1331,34 +1401,19 @@ export async function retryUpload(uploadId: number): Promise<{ success: boolean;
// 썰박스 (Ssulbox)
// ============================================================================
/**
* 썰박스 장소 검색 결과.
*
* castad `/search/accommodation`(네이버 검색 API)과 달리 **`place_url` 을 준다.**
* generator 가 네이버 지도 place 페이지를 크롤링하므로 이 URL 이 반드시 필요해서
* 별도 엔드포인트를 쓴다.
*/
export interface SsulPlaceItem {
title: string;
category: string;
address: string;
roadAddress: string;
place_url: string;
}
export interface SsulCreateRequest {
scenario: string;
/** 네이버 지도 place URL 또는 업장명 */
/**
* 업장명 또는 네이버 지도 링크.
*
* 자동완성으로 고른 경우 **업장명**을 보낸다 — place URL 해석은 서버가
* ADO2 와 동일한 경로(NvMapPwScraper)로 처리한다. 링크를 직접 붙여넣었으면
* 그대로 보낸다.
*/
input: string;
scenes?: number;
seconds?: number;
/**
* 검색으로 업장을 고른 경우에만 함께 보낸다.
*
* 통합 콘텐츠 목록의 업장명 표시와 `store_name`/`region` 필터가 이 값에 의존한다.
* 링크를 직접 붙여넣은 경우에는 보낼 값이 없고, 그때 업장명은 백엔드가 생성
* 로그에서 뒤늦게 채운다(주소가 없어 지역은 채우지 못한다).
*/
/** 자동완성으로 고른 경우에만. 목록 표시·필터·place URL 해석에 쓰인다 */
store_name?: string;
/** 지역 추출용(도로명). 백엔드는 이 값을 저장하지 않는다 */
road_address?: string;
@ -1369,29 +1424,9 @@ export interface SsulCreateRequest {
export interface SsulCreateResponse {
id: number;
status: string;
poll_interval_seconds?: number;
}
/**
* 업장 검색. 실패해도 빈 배열을 돌려준다 —
* 사용자는 네이버 링크를 직접 붙여넣는 우회 경로가 있으므로 흐름을 막지 않는다.
*/
export async function searchSsulPlace(query: string): Promise<SsulPlaceItem[]> {
try {
const response = await authenticatedFetch(
`${API_URL}/ssul/search/place?query=${encodeURIComponent(query)}`
);
if (!response.ok) return [];
const data = await response.json();
return data.items ?? [];
} catch {
return [];
}
}
/**
* 썰박스 생성 요청. **요청 시점에 크레딧이 선차감된다.**
* 잔액이 부족하면 402 를 던지므로 호출부가 충전 화면으로 유도해야 한다.
*/
export async function createSsulJob(
request: SsulCreateRequest
): Promise<SsulCreateResponse> {
@ -1426,6 +1461,7 @@ export interface SsulTaskStatus {
step: number;
error: string | null;
video_url: string | null;
created_at: string;
}
/** 폴링 간격. castad 의 waitForVideoComplete 와 동일하게 맞춘다 */