feat(ssulbox): 상세·공유 화면과 좋아요·댓글·업로드 연동
This commit is contained in:
parent
7b1e691715
commit
741539a023
@ -1,10 +1,17 @@
|
|||||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 {
|
interface ContentCardSocialActionsProps {
|
||||||
videoId: number;
|
videoId: number;
|
||||||
|
/**
|
||||||
|
* 콘텐츠 종류. video.id 와 ssul_content.id 가 겹치므로 좋아요 API 와
|
||||||
|
* 공유 URL(/video/{id} vs /ssul/{id}) 분기에 반드시 필요하다.
|
||||||
|
*/
|
||||||
|
contentType?: ContentType;
|
||||||
storeName: string;
|
storeName: string;
|
||||||
region?: string;
|
region?: string;
|
||||||
commentCount: number;
|
commentCount: number;
|
||||||
@ -14,6 +21,7 @@ interface ContentCardSocialActionsProps {
|
|||||||
|
|
||||||
const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
|
const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
|
||||||
videoId,
|
videoId,
|
||||||
|
contentType = 'video',
|
||||||
storeName,
|
storeName,
|
||||||
region,
|
region,
|
||||||
commentCount,
|
commentCount,
|
||||||
@ -24,6 +32,7 @@ const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
|
|||||||
|
|
||||||
const [likeCount, setLikeCount] = useState(initialLikeCount);
|
const [likeCount, setLikeCount] = useState(initialLikeCount);
|
||||||
const [isLiked, setIsLiked] = useState(initialIsLiked);
|
const [isLiked, setIsLiked] = useState(initialIsLiked);
|
||||||
|
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [shareMenuOpen, setShareMenuOpen] = useState(false);
|
const [shareMenuOpen, setShareMenuOpen] = useState(false);
|
||||||
const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null);
|
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 shareMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const likeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(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(() => {
|
useEffect(() => {
|
||||||
if (!shareMenuOpen) return;
|
if (!shareMenuOpen) return;
|
||||||
@ -124,6 +134,11 @@ const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
|
|||||||
const handleLikeClick = (e: React.MouseEvent) => {
|
const handleLikeClick = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (!isLoggedIn()) {
|
||||||
|
setShowLoginModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const prevLiked = isLiked;
|
const prevLiked = isLiked;
|
||||||
const prevCount = likeCount;
|
const prevCount = likeCount;
|
||||||
|
|
||||||
@ -133,7 +148,7 @@ const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
|
|||||||
if (likeDebounceRef.current) clearTimeout(likeDebounceRef.current);
|
if (likeDebounceRef.current) clearTimeout(likeDebounceRef.current);
|
||||||
likeDebounceRef.current = setTimeout(async () => {
|
likeDebounceRef.current = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await toggleVideoLike(String(videoId));
|
const res = await toggleVideoLike(String(videoId), contentType);
|
||||||
setIsLiked(res.is_liked);
|
setIsLiked(res.is_liked);
|
||||||
setLikeCount(res.like_count);
|
setLikeCount(res.like_count);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -221,6 +236,10 @@ const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
|
|||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showLoginModal && (
|
||||||
|
<LoginPromptModal onClose={() => setShowLoginModal(false)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -4,9 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { getSocialAccounts, uploadToSocial, waitForUploadComplete, TokenExpiredError, handleSocialReconnect, getAutoSeoYoutube } from '../utils/api';
|
import { getSocialAccounts, uploadToSocial, waitForUploadComplete, TokenExpiredError, handleSocialReconnect, getAutoSeoYoutube } from '../utils/api';
|
||||||
import { SocialAccount, VideoListItem, SocialUploadStatusResponse } from '../types/api';
|
import { SocialAccount, VideoListItem, SocialUploadStatusResponse } from '../types/api';
|
||||||
import UploadProgressModal, { UploadStatus } from './UploadProgressModal';
|
import UploadProgressModal, { UploadStatus } from './UploadProgressModal';
|
||||||
import { useTutorial } from './Tutorial/useTutorial';
|
import { useOverlayClose } from '../hooks/useOverlayClose';
|
||||||
import { TUTORIAL_KEYS } from './Tutorial/tutorialSteps';
|
|
||||||
import TutorialOverlay from './Tutorial/TutorialOverlay';
|
|
||||||
|
|
||||||
interface SocialPostingModalProps {
|
interface SocialPostingModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@ -121,7 +119,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||||||
onGoToCalendar,
|
onGoToCalendar,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const tutorial = useTutorial();
|
|
||||||
const [socialAccounts, setSocialAccounts] = useState<SocialAccount[]>([]);
|
const [socialAccounts, setSocialAccounts] = useState<SocialAccount[]>([]);
|
||||||
const [selectedChannel, setSelectedChannel] = useState<string>('');
|
const [selectedChannel, setSelectedChannel] = useState<string>('');
|
||||||
const [title, setTitle] = useState('');
|
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 [videoMeta, setVideoMeta] = useState<{ width: number; height: number; duration: number } | null>(null);
|
||||||
const channelDropdownRef = useRef<HTMLDivElement>(null);
|
const channelDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const privacyDropdownRef = useRef<HTMLDivElement>(null);
|
const privacyDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const hasBeenOpenedRef = useRef(false);
|
|
||||||
const loadedForTaskIdRef = useRef<string | null>(null);
|
const loadedForTaskIdRef = useRef<string | null>(null);
|
||||||
const loadedAtRef = useRef<number>(0);
|
const loadedAtRef = useRef<number>(0);
|
||||||
const seoCache = useRef<Map<string, { title: string; description: string; tags: string }>>(new Map());
|
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 = ''; };
|
return () => { document.body.style.overflow = ''; };
|
||||||
}, [isOpen]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
@ -218,22 +188,27 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||||||
|
|
||||||
loadSocialAccounts();
|
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;
|
const expired = now - loadedAtRef.current > SEO_CACHE_TTL;
|
||||||
|
|
||||||
if (taskId && (taskId !== loadedForTaskIdRef.current || expired)) {
|
if (seoKey && (seoKey !== loadedForTaskIdRef.current || expired)) {
|
||||||
loadedForTaskIdRef.current = taskId;
|
loadedForTaskIdRef.current = seoKey;
|
||||||
loadedAtRef.current = now;
|
loadedAtRef.current = now;
|
||||||
loadAutocomplete();
|
loadAutocomplete(seoKey);
|
||||||
} else if (taskId) {
|
} else if (seoKey) {
|
||||||
const cached = seoCache.current.get(taskId);
|
const cached = seoCache.current.get(seoKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
setTitle(cached.title);
|
setTitle(cached.title);
|
||||||
setDescription(cached.description);
|
setDescription(cached.description);
|
||||||
setTags(cached.tags);
|
setTags(cached.tags);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isOpen, video?.task_id]);
|
}, [isOpen, video?.task_id, video?.type, video?.video_id]);
|
||||||
|
|
||||||
const loadSocialAccounts = async () => {
|
const loadSocialAccounts = async () => {
|
||||||
setIsLoadingAccounts(true);
|
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);
|
setIsLoadingAutoDescription(true);
|
||||||
try {
|
try {
|
||||||
const requestPayload = {
|
const requestPayload = {
|
||||||
task_id : video.task_id,
|
content_type: video.type,
|
||||||
|
task_id: isSsul ? String(video.video_id) : video.task_id,
|
||||||
};
|
};
|
||||||
// Call autoSEO API
|
// Call autoSEO API
|
||||||
console.log('[Upload] Request payload:', requestPayload);
|
console.log('[Upload] Request payload:', requestPayload);
|
||||||
@ -274,7 +259,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||||||
if (autoSeoResponse.title) setTitle(autoSeoResponse.title);
|
if (autoSeoResponse.title) setTitle(autoSeoResponse.title);
|
||||||
if (autoSeoResponse.description) setDescription(autoSeoResponse.description);
|
if (autoSeoResponse.description) setDescription(autoSeoResponse.description);
|
||||||
if (autoSeoResponse.keywords) setTags(autoSeoResponse.keywords.join(','));
|
if (autoSeoResponse.keywords) setTags(autoSeoResponse.keywords.join(','));
|
||||||
seoCache.current.set(video.task_id, {
|
seoCache.current.set(seoKey, {
|
||||||
title: autoSeoResponse.title || '',
|
title: autoSeoResponse.title || '',
|
||||||
description: autoSeoResponse.description || '',
|
description: autoSeoResponse.description || '',
|
||||||
tags: autoSeoResponse.keywords?.join(',') || '',
|
tags: autoSeoResponse.keywords?.join(',') || '',
|
||||||
@ -351,6 +336,9 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const requestPayload = {
|
const requestPayload = {
|
||||||
|
// 종류를 함께 보낸다 — 없으면 백엔드가 video 로 간주해, 썰박스 항목에서는
|
||||||
|
// id 가 겹치는 엉뚱한 ADO2 영상이 업로드된다.
|
||||||
|
content_type: video.type,
|
||||||
video_id: video.video_id,
|
video_id: video.video_id,
|
||||||
social_account_id: selectedAcc.id,
|
social_account_id: selectedAcc.id,
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
@ -442,6 +430,8 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||||||
resetForm();
|
resetForm();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const overlayCloseHandlers = useOverlayClose(handleClose);
|
||||||
|
|
||||||
const selectedAccount = socialAccounts.find(acc => acc.platform_user_id === selectedChannel);
|
const selectedAccount = socialAccounts.find(acc => acc.platform_user_id === selectedChannel);
|
||||||
|
|
||||||
const privacyOptions = [
|
const privacyOptions = [
|
||||||
@ -473,23 +463,13 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{showUploadProgress && uploadProgressModalElement}
|
{showUploadProgress && uploadProgressModalElement}
|
||||||
{tutorial.isActive && (
|
|
||||||
<TutorialOverlay
|
|
||||||
hints={tutorial.hints}
|
|
||||||
currentIndex={tutorial.currentHintIndex}
|
|
||||||
onNext={tutorial.nextHint}
|
|
||||||
onPrev={tutorial.prevHint}
|
|
||||||
onSkip={tutorial.skipTutorial}
|
|
||||||
groupProgress={tutorial.groupProgress}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="social-posting-overlay" onClick={handleClose}>
|
<div className="social-posting-overlay" {...overlayCloseHandlers}>
|
||||||
<div className="social-posting-modal" onClick={(e) => e.stopPropagation()}>
|
<div className="social-posting-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="social-posting-header">
|
<div className="social-posting-header">
|
||||||
@ -859,16 +839,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{uploadProgressModalElement}
|
{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">
|
<div className="video-detail-header">
|
||||||
{isModal ? (
|
{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">
|
<h2 className="video-detail-modal-title">{t('sidebar.ado2Contents')}</h2>
|
||||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
<button className="video-detail-close-btn" onClick={handleHeaderAction} aria-label={t('videoDetail.closeAriaLabel')}>
|
||||||
</svg>
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
</button>
|
<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}>
|
<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">
|
<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 React, { useEffect } from 'react';
|
||||||
import VideoDetailContent from './VideoDetailContent';
|
import VideoDetailContent from './VideoDetailContent';
|
||||||
|
import { useOverlayClose } from '../hooks/useOverlayClose';
|
||||||
|
|
||||||
interface VideoDetailModalProps {
|
interface VideoDetailModalProps {
|
||||||
videoId: string;
|
videoId: string;
|
||||||
@ -23,6 +24,8 @@ const VideoDetailModal: React.FC<VideoDetailModalProps> = ({ videoId, onClose })
|
|||||||
};
|
};
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
|
const overlayCloseHandlers = useOverlayClose(onClose);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@ -34,7 +37,7 @@ const VideoDetailModal: React.FC<VideoDetailModalProps> = ({ videoId, onClose })
|
|||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
padding: '24px 16px',
|
padding: '24px 16px',
|
||||||
}}
|
}}
|
||||||
onClick={onClose}
|
{...overlayCloseHandlers}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { getAllVideos, isLoggedIn } from '../../utils/api';
|
import { getAllVideos } from '../../utils/api';
|
||||||
import { VideoListItem } from '../../types/api';
|
import { VideoListItem } from '../../types/api';
|
||||||
import LoginPromptModal from '../../components/LoginPromptModal';
|
|
||||||
import VideoDetailModal from '../../components/VideoDetailModal';
|
import VideoDetailModal from '../../components/VideoDetailModal';
|
||||||
import CitySelectModal from '../../components/CitySelectModal';
|
import CitySelectModal from '../../components/CitySelectModal';
|
||||||
import ContentCardSocialActions from '../../components/ContentCardSocialActions';
|
import ContentCardSocialActions from '../../components/ContentCardSocialActions';
|
||||||
@ -14,14 +13,13 @@ interface ADO2ContentsPageProps {
|
|||||||
|
|
||||||
const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
|
const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const authed = isLoggedIn();
|
|
||||||
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null);
|
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null);
|
||||||
// 썰박스는 별도 뷰어를 쓴다 — VideoDetailModal 은 video_id 로 조회하므로
|
// 썰박스는 별도 뷰어를 쓴다 — VideoDetailModal 은 video_id 로 조회하므로
|
||||||
// 썰박스 id 를 넘기면 id 가 겹치는 다른 영상이 열린다.
|
// 썰박스 id 를 넘기면 id 가 겹치는 다른 영상이 열린다.
|
||||||
const [selectedSsul, setSelectedSsul] = useState<VideoListItem | null>(null);
|
const [selectedSsul, setSelectedSsul] = useState<VideoListItem | null>(null);
|
||||||
const [videos, setVideos] = useState<VideoListItem[]>([]);
|
const [videos, setVideos] = useState<VideoListItem[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(authed);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [hasNext, setHasNext] = useState(false);
|
const [hasNext, setHasNext] = useState(false);
|
||||||
@ -37,7 +35,6 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
|
|||||||
const [showCityModal, setShowCityModal] = useState(false);
|
const [showCityModal, setShowCityModal] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authed) return;
|
|
||||||
fetchVideos();
|
fetchVideos();
|
||||||
}, [page, sortBy, order, storeName, region]);
|
}, [page, sortBy, order, storeName, region]);
|
||||||
|
|
||||||
@ -197,19 +194,17 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
|
|||||||
<h3 className="content-card-title">{video.store_name}</h3>
|
<h3 className="content-card-title">{video.store_name}</h3>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<p className="content-card-date">{formatDate(video.created_at)}</p>
|
<p className="content-card-date">{formatDate(video.created_at)}</p>
|
||||||
{/* 좋아요·댓글은 castad API 를 video_id 로 호출한다.
|
{/* 좋아요·댓글 API 가 type 을 받으므로 썰박스에도 노출한다.
|
||||||
썰박스 id 를 넘기면 id 가 겹치는 다른 영상에 반영되므로
|
contentType 이 없으면 id 가 겹치는 다른 영상에 반영된다. */}
|
||||||
`/ssul/*` 반응 API 가 붙기 전까지 노출하지 않는다. */}
|
<ContentCardSocialActions
|
||||||
{video.type === 'video' && (
|
videoId={video.video_id}
|
||||||
<ContentCardSocialActions
|
contentType={video.type}
|
||||||
videoId={video.video_id}
|
storeName={video.store_name}
|
||||||
storeName={video.store_name}
|
region={video.region}
|
||||||
region={video.region}
|
commentCount={video.comment_count ?? 0}
|
||||||
commentCount={video.comment_count ?? 0}
|
initialLikeCount={video.like_count ?? 0}
|
||||||
initialLikeCount={video.like_count ?? 0}
|
initialIsLiked={video.is_liked_by_me}
|
||||||
initialIsLiked={video.is_liked_by_me}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -237,10 +232,6 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!authed && (
|
|
||||||
<LoginPromptModal onClose={() => { window.location.href = '/'; }} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SsulViewerModal item={selectedSsul} onClose={() => setSelectedSsul(null)} />
|
<SsulViewerModal item={selectedSsul} onClose={() => setSelectedSsul(null)} />
|
||||||
|
|
||||||
{selectedVideoId !== null && (
|
{selectedVideoId !== null && (
|
||||||
|
|||||||
@ -1,14 +1,13 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { getVideosList, deleteVideo } from '../../utils/api';
|
import { getVideosList, deleteVideo, deleteSsulContent } from '../../utils/api';
|
||||||
import { VideoListItem } from '../../types/api';
|
import { VideoListItem } from '../../types/api';
|
||||||
import SocialPostingModal from '../../components/SocialPostingModal';
|
import SocialPostingModal from '../../components/SocialPostingModal';
|
||||||
import VideoDetailModal from '../../components/VideoDetailModal';
|
import VideoDetailModal from '../../components/VideoDetailModal';
|
||||||
import ContentCardSocialActions from '../../components/ContentCardSocialActions';
|
import ContentCardSocialActions from '../../components/ContentCardSocialActions';
|
||||||
import SsulViewerModal from '../Ssulbox/SsulViewerModal';
|
import SsulViewerModal from '../Ssulbox/SsulViewerModal';
|
||||||
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
import { useOverlayClose } from '../../hooks/useOverlayClose';
|
||||||
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
|
||||||
|
|
||||||
const VideoPreviewCard: React.FC<{ src: string; className?: string }> = ({ src, className }) => {
|
const VideoPreviewCard: React.FC<{ src: string; className?: string }> = ({ src, className }) => {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
@ -90,11 +89,12 @@ const VideoPreviewCard: React.FC<{ src: string; className?: string }> = ({ src,
|
|||||||
interface MyContentsPageProps {
|
interface MyContentsPageProps {
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
onNavigate?: (item: string) => void;
|
onNavigate?: (item: string) => void;
|
||||||
|
/** 내 정보 탭 안에 임베드될 때: 탭 라벨과 중복되는 자체 제목을 숨긴다 */
|
||||||
|
embedded?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
|
const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate, embedded }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const tutorial = useTutorial();
|
|
||||||
const [videos, setVideos] = useState<VideoListItem[]>([]);
|
const [videos, setVideos] = useState<VideoListItem[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -104,7 +104,7 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
|
|||||||
const [hasPrev, setHasPrev] = useState(false);
|
const [hasPrev, setHasPrev] = useState(false);
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
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 [isDeleting, setIsDeleting] = useState(false);
|
||||||
const [uploadModalOpen, setUploadModalOpen] = useState(false);
|
const [uploadModalOpen, setUploadModalOpen] = useState(false);
|
||||||
const [uploadTargetVideo, setUploadTargetVideo] = useState<VideoListItem | null>(null);
|
const [uploadTargetVideo, setUploadTargetVideo] = useState<VideoListItem | null>(null);
|
||||||
@ -119,12 +119,6 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
|
|||||||
fetchVideos();
|
fetchVideos();
|
||||||
}, [page]);
|
}, [page]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!tutorial.hasSeen(TUTORIAL_KEYS.ADO2_CONTENTS)) {
|
|
||||||
tutorial.startTutorial(TUTORIAL_KEYS.ADO2_CONTENTS);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchVideos = async () => {
|
const fetchVideos = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@ -149,9 +143,7 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
|
|||||||
const year = date.getFullYear();
|
const year = date.getFullYear();
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
const hours = String(date.getHours()).padStart(2, '0');
|
return `${year}.${month}.${day}`;
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
||||||
return `${year}.${month}.${day}・${hours}:${minutes}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTitle = (storeName: string, dateString: string) => {
|
const formatTitle = (storeName: string, dateString: string) => {
|
||||||
@ -181,8 +173,10 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteClick = (videoId: number) => {
|
// 아이템 전체를 저장한다 — id 만 들면 종류를 잃어 어느 삭제 API 를 부를지 알 수 없다
|
||||||
setDeleteTargetId(videoId);
|
// (video.id 와 ssul_content.id 는 값이 겹치는 독립 시퀀스).
|
||||||
|
const handleDeleteClick = (video: VideoListItem) => {
|
||||||
|
setDeleteTarget(video);
|
||||||
setDeleteModalOpen(true);
|
setDeleteModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -198,18 +192,30 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
|
|||||||
|
|
||||||
const handleDeleteCancel = () => {
|
const handleDeleteCancel = () => {
|
||||||
setDeleteModalOpen(false);
|
setDeleteModalOpen(false);
|
||||||
setDeleteTargetId(null);
|
setDeleteTarget(null);
|
||||||
};
|
};
|
||||||
|
const deleteModalOverlayHandlers = useOverlayClose(handleDeleteCancel);
|
||||||
|
|
||||||
const handleDeleteConfirm = async () => {
|
const handleDeleteConfirm = async () => {
|
||||||
if (!deleteTargetId) return;
|
if (!deleteTarget) return;
|
||||||
setIsDeleting(true);
|
setIsDeleting(true);
|
||||||
try {
|
try {
|
||||||
await deleteVideo(deleteTargetId);
|
// 종류별 삭제 API 가 다르다. deleteVideo 는 Video.id 로 지우므로
|
||||||
setVideos(prev => prev.filter(video => video.video_id !== deleteTargetId));
|
// 썰박스 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));
|
setTotal(prev => Math.max(0, prev - 1));
|
||||||
setDeleteModalOpen(false);
|
setDeleteModalOpen(false);
|
||||||
setDeleteTargetId(null);
|
setDeleteTarget(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Delete failed:', err);
|
console.error('Delete failed:', err);
|
||||||
alert(t('ado2Contents.deleteFailed'));
|
alert(t('ado2Contents.deleteFailed'));
|
||||||
@ -219,10 +225,10 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ado2-contents-page">
|
<div className={`ado2-contents-page ${embedded ? 'ado2-contents-page--embedded' : ''}`}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="ado2-contents-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>
|
<span className="ado2-contents-count">{t('ado2Contents.totalCount', { count: total })}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -289,40 +295,35 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
|
|||||||
<p className="content-card-date">
|
<p className="content-card-date">
|
||||||
{formatDate(video.created_at)}
|
{formatDate(video.created_at)}
|
||||||
</p>
|
</p>
|
||||||
{/* 좋아요·댓글은 castad API 를 video_id 로 호출한다.
|
{/* 좋아요·댓글 API 가 type 을 받으므로 썰박스에도 노출한다.
|
||||||
썰박스 id 를 넘기면 id 가 겹치는 다른 영상에 반영되므로
|
contentType 이 없으면 id 가 겹치는 다른 영상에 반영된다. */}
|
||||||
`/ssul/*` 반응 API 가 붙기 전까지 노출하지 않는다. */}
|
<ContentCardSocialActions
|
||||||
{video.type === 'video' && (
|
videoId={video.video_id}
|
||||||
<ContentCardSocialActions
|
contentType={video.type}
|
||||||
videoId={video.video_id}
|
storeName={video.store_name}
|
||||||
storeName={video.store_name}
|
region={video.region}
|
||||||
region={video.region}
|
commentCount={video.comment_count ?? 0}
|
||||||
commentCount={video.comment_count ?? 0}
|
initialLikeCount={video.like_count ?? 0}
|
||||||
initialLikeCount={video.like_count ?? 0}
|
initialIsLiked={video.is_liked_by_me}
|
||||||
initialIsLiked={video.is_liked_by_me}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="content-card-actions">
|
<div className="content-card-actions">
|
||||||
{/* SNS 업로드는 SocialPostingModal 이 video_id 로 업로드 API 를 부른다.
|
{/* SocialPostingModal 이 content_type 을 함께 보내므로
|
||||||
썰박스 id 를 넘기면 **엉뚱한 ADO2 영상이 업로드된다.**
|
썰박스 항목도 같은 모달로 업로드한다 (social_upload 병합). */}
|
||||||
`/ssul/upload/*` 가 붙기 전까지 노출하지 않는다. */}
|
<button
|
||||||
{video.type === 'video' && (
|
className="content-download-btn"
|
||||||
<button
|
onClick={() => handleUploadClick(video)}
|
||||||
className="content-download-btn"
|
disabled={!video.result_movie_url}
|
||||||
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"/>
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
<path d="M3 15v2h14v-2"/>
|
||||||
<path d="M10 13V3M10 3l-4 4M10 3l4 4"/>
|
</svg>
|
||||||
<path d="M3 15v2h14v-2"/>
|
<span>{t('ado2Contents.uploadToSocial')}</span>
|
||||||
</svg>
|
</button>
|
||||||
<span>{t('ado2Contents.uploadToSocial')}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
className="content-upload-btn"
|
className="content-upload-btn"
|
||||||
onClick={() => handleDownload(video.result_movie_url, video.store_name)}
|
onClick={() => handleDownload(video.result_movie_url, video.store_name)}
|
||||||
@ -334,22 +335,18 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
|
|||||||
<path d="M3 15v2h14v-2"/>
|
<path d="M3 15v2h14v-2"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/* ⚠️ 삭제는 파괴적이다. `DELETE /archive/videos/{id}` 는 `Video.id` 로
|
{/* 삭제는 종류별 API 로 분기한다(handleDeleteConfirm).
|
||||||
지우므로 썰박스 id 를 넘기면 **id 가 겹치는 ADO2 영상이 삭제된다.**
|
아이템 전체를 넘겨야 종류를 잃지 않는다. */}
|
||||||
소유권 검증도 통과한다(같은 사용자가 양쪽을 다 가진 경우).
|
<button
|
||||||
썰박스 삭제 API 가 붙기 전까지 절대 노출하지 않는다. */}
|
className="content-delete-btn"
|
||||||
{video.type === 'video' && (
|
onClick={() => handleDeleteClick(video)}
|
||||||
<button
|
>
|
||||||
className="content-delete-btn"
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
onClick={() => handleDeleteClick(video.video_id)}
|
<path d="M3 5h14M8 5V3h4v2M6 5v12h8V5"/>
|
||||||
>
|
<line x1="8" y1="8" x2="8" y2="14"/>
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
<line x1="12" y1="8" x2="12" y2="14"/>
|
||||||
<path d="M3 5h14M8 5V3h4v2M6 5v12h8V5"/>
|
</svg>
|
||||||
<line x1="8" y1="8" x2="8" y2="14"/>
|
</button>
|
||||||
<line x1="12" y1="8" x2="12" y2="14"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -379,7 +376,7 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
|
|||||||
|
|
||||||
{/* 삭제 확인 모달 */}
|
{/* 삭제 확인 모달 */}
|
||||||
{deleteModalOpen && (
|
{deleteModalOpen && (
|
||||||
<div className="delete-modal-overlay" onClick={handleDeleteCancel}>
|
<div className="delete-modal-overlay" {...deleteModalOverlayHandlers}>
|
||||||
<div className="delete-modal" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
<div className="delete-modal" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||||
<h2 className="delete-modal-title">{t('ado2Contents.deleteConfirmTitle')}</h2>
|
<h2 className="delete-modal-title">{t('ado2Contents.deleteConfirmTitle')}</h2>
|
||||||
<p className="delete-modal-description">{t('ado2Contents.deleteConfirmDesc')}</p>
|
<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 React, { useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { VideoListItem } from '../../types/api';
|
import { VideoListItem } from '../../types/api';
|
||||||
|
import SsulDetailContent from './SsulDetailContent';
|
||||||
|
import { useOverlayClose } from '../../hooks/useOverlayClose';
|
||||||
|
|
||||||
interface SsulViewerModalProps {
|
interface SsulViewerModalProps {
|
||||||
/** null 이면 닫힘 */
|
/** null 이면 닫힘 */
|
||||||
@ -9,25 +10,13 @@ interface SsulViewerModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 썰박스 콘텐츠 뷰어.
|
* 썰박스 콘텐츠 모달 — castad `VideoDetailModal` 과 같은 백드롭 래퍼.
|
||||||
|
* 내용은 `SsulDetailContent` 가 그린다(공유 링크 페이지 `SsulDetailPage` 와 공용).
|
||||||
*
|
*
|
||||||
* **castad `VideoDetailModal` 과 같은 형식**을 쓴다 — 백드롭·패널 인라인 스타일과
|
* `VideoDetailModal` 을 그대로 못 쓰는 이유: `videoId` 로 `/video/{id}` 를
|
||||||
* `video-detail-*` 클래스를 그대로 재사용하므로 통합 목록에서 어떤 카드를 열든
|
* 조회하는데, 썰박스 id 를 넘기면 **id 가 겹치는 다른 ADO2 영상**이 열린다.
|
||||||
* 같은 모양이 나온다. 신규 CSS 를 만들지 않는다.
|
|
||||||
*
|
|
||||||
* 컴포넌트를 그대로 못 쓰는 이유: `VideoDetailModal` 은 `videoId` 로 `/video/{id}` 를
|
|
||||||
* 조회하는데, 썰박스 id 를 넘기면 **id 가 겹치는 다른 ADO2 영상**이 열린다
|
|
||||||
* (`video.id` 와 `ssul_content.id` 는 각각 1부터 시작하는 독립 시퀀스).
|
|
||||||
* 목록이 이미 재생에 필요한 정보를 갖고 있어 추가 조회도 필요 없다.
|
|
||||||
*
|
|
||||||
* 좋아요·댓글·공유는 castad API 를 video_id 로 호출하므로 여기서는 빼 뒀다.
|
|
||||||
* `/ssul/*` 반응 API 가 붙으면 추가한다.
|
|
||||||
*/
|
*/
|
||||||
const SsulViewerModal: React.FC<SsulViewerModalProps> = ({ item, onClose }) => {
|
const SsulViewerModal: React.FC<SsulViewerModalProps> = ({ item, onClose }) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
// 가로 영상이면 castad 와 동일하게 landscape 레이아웃으로 전환한다
|
|
||||||
const [isLandscape, setIsLandscape] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
// 모달 열릴 때 배경 스크롤 잠금
|
// 모달 열릴 때 배경 스크롤 잠금
|
||||||
@ -45,21 +34,10 @@ const SsulViewerModal: React.FC<SsulViewerModalProps> = ({ item, onClose }) => {
|
|||||||
};
|
};
|
||||||
}, [item, onClose]);
|
}, [item, onClose]);
|
||||||
|
|
||||||
// 다른 콘텐츠를 열면 방향 판정을 초기화한다(이전 값이 남으면 레이아웃이 틀어진다)
|
const overlayCloseHandlers = useOverlayClose(onClose);
|
||||||
useEffect(() => {
|
|
||||||
setIsLandscape(false);
|
|
||||||
}, [item?.type, item?.video_id]);
|
|
||||||
|
|
||||||
if (!item) return null;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@ -71,7 +49,7 @@ const SsulViewerModal: React.FC<SsulViewerModalProps> = ({ item, onClose }) => {
|
|||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
padding: '24px 16px',
|
padding: '24px 16px',
|
||||||
}}
|
}}
|
||||||
onClick={onClose}
|
{...overlayCloseHandlers}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@ -84,42 +62,11 @@ const SsulViewerModal: React.FC<SsulViewerModalProps> = ({ item, onClose }) => {
|
|||||||
}}
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="video-detail-modal-content">
|
<SsulDetailContent
|
||||||
{/* 헤더 */}
|
contentId={String(item.video_id)}
|
||||||
<div className="video-detail-header">
|
isModal={true}
|
||||||
<button
|
onClose={onClose}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -690,7 +690,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.video-detail-header {
|
.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 {
|
.video-detail-back-btn {
|
||||||
@ -1498,6 +1507,18 @@
|
|||||||
background-color: var(--color-bg-darker);
|
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 {
|
.myinfo-title {
|
||||||
font-size: 1.75rem;
|
font-size: 1.75rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@ -1606,7 +1627,7 @@
|
|||||||
.myinfo-credits-desc {
|
.myinfo-credits-desc {
|
||||||
color: rgba(255, 255, 255, 0.4);
|
color: rgba(255, 255, 255, 0.4);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
margin: 0;
|
margin-right: 5px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -401,7 +401,9 @@ export interface SocialDisconnectResponse {
|
|||||||
|
|
||||||
// 유튜브 SEO Description 자동완성 요청
|
// 유튜브 SEO Description 자동완성 요청
|
||||||
export interface YTAutoSeoRequest {
|
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 자동완성 응답
|
// 유튜브 SEO Description 자동완성 응답
|
||||||
@ -413,7 +415,12 @@ export interface YTAutoSeoResponse {
|
|||||||
|
|
||||||
// 소셜 업로드 요청
|
// 소셜 업로드 요청
|
||||||
export interface SocialUploadRequest {
|
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
|
social_account_id: number; // 선택된 채널 ID
|
||||||
title: string; // 최대 100자
|
title: string; // 최대 100자
|
||||||
description: string; // 최대 5000자
|
description: string; // 최대 5000자
|
||||||
|
|||||||
152
src/utils/api.ts
152
src/utils/api.ts
@ -34,6 +34,7 @@ import {
|
|||||||
VideoDetailItem,
|
VideoDetailItem,
|
||||||
CommentsResponse,
|
CommentsResponse,
|
||||||
CommentItem,
|
CommentItem,
|
||||||
|
ContentType,
|
||||||
LikeToggleResponse,
|
LikeToggleResponse,
|
||||||
} from '../types/api';
|
} from '../types/api';
|
||||||
|
|
||||||
@ -574,7 +575,7 @@ export async function getAllVideos(
|
|||||||
if (region.trim()) params.set('region', region.trim());
|
if (region.trim()) params.set('region', region.trim());
|
||||||
const response = await authenticatedFetch(`${API_URL}/video/all?${params.toString()}`, {
|
const response = await authenticatedFetch(`${API_URL}/video/all?${params.toString()}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
});
|
}, { redirectOnAuthFail: false });
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
@ -587,7 +588,7 @@ export async function getAllVideos(
|
|||||||
export async function getVideoById(videoId: string): Promise<VideoDetailItem> {
|
export async function getVideoById(videoId: string): Promise<VideoDetailItem> {
|
||||||
const response = await authenticatedFetch(`${API_URL}/video/${videoId}`, {
|
const response = await authenticatedFetch(`${API_URL}/video/${videoId}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
});
|
}, { redirectOnAuthFail: false });
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
@ -596,22 +597,75 @@ export async function getVideoById(videoId: string): Promise<VideoDetailItem> {
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 댓글 목록 조회
|
// 썰박스 콘텐츠 공개 상세 (비로그인 허용 — 공유 링크 /ssul/{id} 가 사용)
|
||||||
export async function getVideoComments(videoId: string, page: number = 1, pageSize: number = 20): Promise<CommentsResponse> {
|
export interface SsulDetailItem {
|
||||||
const response = await authenticatedFetch(`${API_URL}/comment/video/${videoId}?page=${page}&page_size=${pageSize}`, {
|
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',
|
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) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
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();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 댓글 작성
|
// 댓글 작성
|
||||||
export async function postVideoComment(videoId: string, content: string, nickname?: string, parentId?: number): Promise<CommentItem> {
|
export async function postVideoComment(
|
||||||
const response = await authenticatedFetch(`${API_URL}/comment/video/${videoId}`, {
|
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',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content, nickname: nickname || '익명', parent_id: parentId ?? null }),
|
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> {
|
export async function toggleVideoLike(
|
||||||
const response = await authenticatedFetch(`${API_URL}/video/${videoId}/like`, {
|
videoId: string,
|
||||||
method: 'POST',
|
// 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) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
@ -786,7 +848,8 @@ function redirectToLogin() {
|
|||||||
// 401 에러 시 자동으로 토큰 갱신 후 재요청하는 래퍼 함수
|
// 401 에러 시 자동으로 토큰 갱신 후 재요청하는 래퍼 함수
|
||||||
export async function authenticatedFetch(
|
export async function authenticatedFetch(
|
||||||
url: string,
|
url: string,
|
||||||
options: RequestInit = {}
|
options: RequestInit = {},
|
||||||
|
{ redirectOnAuthFail = true }: { redirectOnAuthFail?: boolean } = {}
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
// 인증 헤더 + 캐시 방지 헤더 추가
|
// 인증 헤더 + 캐시 방지 헤더 추가
|
||||||
const headers: HeadersInit = {
|
const headers: HeadersInit = {
|
||||||
@ -803,9 +866,14 @@ export async function authenticatedFetch(
|
|||||||
const errorBody = await response.json().catch(() => null);
|
const errorBody = await response.json().catch(() => null);
|
||||||
const errorCode = errorBody?.detail?.code;
|
const errorCode = errorBody?.detail?.code;
|
||||||
|
|
||||||
|
// 애초에 토큰이 없던 익명 요청(공개 화면)까지 튕기면 안 되므로 별도 처리
|
||||||
|
const isMissingToken = errorCode === 'MISSING_TOKEN';
|
||||||
|
|
||||||
if (errorCode !== 'TOKEN_EXPIRED') {
|
if (errorCode !== 'TOKEN_EXPIRED') {
|
||||||
// INVALID_TOKEN 등 갱신으로 해결 불가한 경우 즉시 로그인 이동
|
// INVALID_TOKEN 등 갱신으로 해결 불가한 경우 즉시 로그인 이동
|
||||||
redirectToLogin();
|
if (redirectOnAuthFail && !isMissingToken) {
|
||||||
|
redirectToLogin();
|
||||||
|
}
|
||||||
throw new Error(errorCode ?? 'Unauthorized');
|
throw new Error(errorCode ?? 'Unauthorized');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -828,7 +896,9 @@ export async function authenticatedFetch(
|
|||||||
response = await fetch(url, { ...options, headers: newHeaders });
|
response = await fetch(url, { ...options, headers: newHeaders });
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
console.error('Token refresh failed:', refreshError);
|
console.error('Token refresh failed:', refreshError);
|
||||||
redirectToLogin();
|
if (redirectOnAuthFail) {
|
||||||
|
redirectToLogin();
|
||||||
|
}
|
||||||
throw refreshError;
|
throw refreshError;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1331,34 +1401,19 @@ export async function retryUpload(uploadId: number): Promise<{ success: boolean;
|
|||||||
// 썰박스 (Ssulbox)
|
// 썰박스 (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 {
|
export interface SsulCreateRequest {
|
||||||
scenario: string;
|
scenario: string;
|
||||||
/** 네이버 지도 place URL 또는 업장명 */
|
/**
|
||||||
|
* 업장명 또는 네이버 지도 링크.
|
||||||
|
*
|
||||||
|
* 자동완성으로 고른 경우 **업장명**을 보낸다 — place URL 해석은 서버가
|
||||||
|
* ADO2 와 동일한 경로(NvMapPwScraper)로 처리한다. 링크를 직접 붙여넣었으면
|
||||||
|
* 그대로 보낸다.
|
||||||
|
*/
|
||||||
input: string;
|
input: string;
|
||||||
scenes?: number;
|
scenes?: number;
|
||||||
seconds?: number;
|
seconds?: number;
|
||||||
/**
|
/** 자동완성으로 고른 경우에만. 목록 표시·필터·place URL 해석에 쓰인다 */
|
||||||
* 검색으로 업장을 고른 경우에만 함께 보낸다.
|
|
||||||
*
|
|
||||||
* 통합 콘텐츠 목록의 업장명 표시와 `store_name`/`region` 필터가 이 값에 의존한다.
|
|
||||||
* 링크를 직접 붙여넣은 경우에는 보낼 값이 없고, 그때 업장명은 백엔드가 생성
|
|
||||||
* 로그에서 뒤늦게 채운다(주소가 없어 지역은 채우지 못한다).
|
|
||||||
*/
|
|
||||||
store_name?: string;
|
store_name?: string;
|
||||||
/** 지역 추출용(도로명). 백엔드는 이 값을 저장하지 않는다 */
|
/** 지역 추출용(도로명). 백엔드는 이 값을 저장하지 않는다 */
|
||||||
road_address?: string;
|
road_address?: string;
|
||||||
@ -1369,29 +1424,9 @@ export interface SsulCreateRequest {
|
|||||||
export interface SsulCreateResponse {
|
export interface SsulCreateResponse {
|
||||||
id: number;
|
id: number;
|
||||||
status: string;
|
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(
|
export async function createSsulJob(
|
||||||
request: SsulCreateRequest
|
request: SsulCreateRequest
|
||||||
): Promise<SsulCreateResponse> {
|
): Promise<SsulCreateResponse> {
|
||||||
@ -1426,6 +1461,7 @@ export interface SsulTaskStatus {
|
|||||||
step: number;
|
step: number;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
video_url: string | null;
|
video_url: string | null;
|
||||||
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 폴링 간격. castad 의 waitForVideoComplete 와 동일하게 맞춘다 */
|
/** 폴링 간격. castad 의 waitForVideoComplete 와 동일하게 맞춘다 */
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user