feat(ssulbox): 썰박스 상세에 공식 링크 오버레이 추가
This commit is contained in:
parent
3bcca23e97
commit
94b39a44bd
@ -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;
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -800,7 +800,7 @@
|
||||
|
||||
.video-detail-content {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
|
||||
@ -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
15
src/utils/url.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user