feat(ssulbox): 상세·공유 화면과 좋아요·댓글·업로드 연동
This commit is contained in:
parent
7b1e691715
commit
741539a023
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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={{
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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>
|
||||
|
||||
524
src/pages/Ssulbox/SsulDetailContent.tsx
Normal file
524
src/pages/Ssulbox/SsulDetailContent.tsx
Normal 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;
|
||||
25
src/pages/Ssulbox/SsulDetailPage.tsx
Normal file
25
src/pages/Ssulbox/SsulDetailPage.tsx
Normal 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;
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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자
|
||||
|
||||
152
src/utils/api.ts
152
src/utils/api.ts
@ -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 와 동일하게 맞춘다 */
|
||||
|
||||
Loading…
Reference in New Issue
Block a user