feat(ssulbox): 썰박스 상세에 공식 링크 오버레이 추가

This commit is contained in:
김성경 2026-08-24 13:45:16 +09:00
parent 3bcca23e97
commit 94b39a44bd
5 changed files with 63 additions and 24 deletions

View File

@ -13,16 +13,7 @@ import {
import { VideoDetailItem, CommentItem, UserMeResponse } from '../types/api';
import { buildVideoShareUrl, tryNativeShare } from '../utils/nativeShare';
import LoginPromptModal from './LoginPromptModal';
const isSafeHttpUrl = (url?: string | null): boolean => {
if (!url) return false;
try {
const parsed = new URL(url);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
};
import { isSafeHttpUrl } from '../utils/url';
interface VideoDetailContentProps {
videoId: string;

View File

@ -14,6 +14,7 @@ import type { SsulDetailItem } from '../../utils/api';
import { CommentItem, UserMeResponse } from '../../types/api';
import { buildContentShareUrl, tryNativeShare } from '../../utils/nativeShare';
import LoginPromptModal from '../../components/LoginPromptModal';
import { isSafeHttpUrl } from '../../utils/url';
interface SsulDetailContentProps {
contentId: string;
@ -48,6 +49,7 @@ const SsulDetailContent: React.FC<SsulDetailContentProps> = ({
const [copied, setCopied] = useState(false);
const [isLandscape, setIsLandscape] = useState(false);
const [showSiteOverlay, setShowSiteOverlay] = useState(false);
const [showLoginModal, setShowLoginModal] = useState(false);
const [comments, setComments] = useState<CommentItem[]>([]);
@ -86,6 +88,7 @@ const SsulDetailContent: React.FC<SsulDetailContentProps> = ({
const fetchContent = async () => {
setLoading(true);
setError(null);
setShowSiteOverlay(false);
try {
const data = await getSsulContentById(contentId);
setContent(data);
@ -265,19 +268,47 @@ const SsulDetailContent: React.FC<SsulDetailContentProps> = ({
<div className="ado2-contents-error"><p>{error}</p></div>
) : content ? (
<div className={`video-detail-content ${isLandscape ? 'landscape' : ''}`}>
<video
src={content.video_url}
controls
autoPlay
playsInline
controlsList="nodownload"
onContextMenu={(e) => e.preventDefault()}
className="video-detail-player"
onLoadedMetadata={(e) => {
const v = e.currentTarget;
setIsLandscape(v.videoWidth > v.videoHeight);
}}
/>
<div className="video-detail-player-wrap">
<video
src={content.video_url}
controls
autoPlay
playsInline
controlsList="nodownload"
onContextMenu={(e) => e.preventDefault()}
className="video-detail-player"
onLoadedMetadata={(e) => {
const v = e.currentTarget;
setIsLandscape(v.videoWidth > v.videoHeight);
}}
onTimeUpdate={(e) => {
if (!content.official_site_url) return;
const v = e.currentTarget;
// 영상 종료 3초 전부터 공식 홈페이지 오버레이 노출
setShowSiteOverlay(
Number.isFinite(v.duration) && v.duration - v.currentTime <= 3
);
}}
/>
{showSiteOverlay && isSafeHttpUrl(content.official_site_url) && (
<a
href={content.official_site_url as string}
target="_blank"
rel="noopener noreferrer"
className="video-site-overlay"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
<span className="video-site-overlay-text">
<strong>{content.store_name}</strong>
{t('videoDetail.siteOverlayLabel')}
</span>
</a>
)}
</div>
<div className="video-detail-info">
<h2 className="video-detail-store">

View File

@ -800,7 +800,7 @@
.video-detail-content {
display: flex;
gap: 16px;
gap: 24px;
flex-wrap: wrap;
align-items: center;
flex: 1;

View File

@ -605,6 +605,8 @@ export interface SsulDetailItem {
video_url: string;
store_name: string | null;
region: string | null;
/** 업체 공식 링크. 영상 종료 3초 전 오버레이에 쓰며, 미확보 시 null */
official_site_url?: string | null;
created_at: string;
like_count: number;
is_liked_by_me: boolean;

15
src/utils/url.ts Normal file
View File

@ -0,0 +1,15 @@
/**
* · URL을 .
*
* `official_site_url`
* `javascript:` href에 XSS가 . http/https만 .
*/
export const isSafeHttpUrl = (url?: string | null): boolean => {
if (!url) return false;
try {
const parsed = new URL(url);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
};