feat(ssulbox): 썰박스 상세에 공식 링크 오버레이 추가
This commit is contained in:
parent
3bcca23e97
commit
94b39a44bd
@ -13,16 +13,7 @@ import {
|
|||||||
import { VideoDetailItem, CommentItem, UserMeResponse } from '../types/api';
|
import { VideoDetailItem, CommentItem, UserMeResponse } from '../types/api';
|
||||||
import { buildVideoShareUrl, tryNativeShare } from '../utils/nativeShare';
|
import { buildVideoShareUrl, tryNativeShare } from '../utils/nativeShare';
|
||||||
import LoginPromptModal from './LoginPromptModal';
|
import LoginPromptModal from './LoginPromptModal';
|
||||||
|
import { isSafeHttpUrl } from '../utils/url';
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
interface VideoDetailContentProps {
|
interface VideoDetailContentProps {
|
||||||
videoId: string;
|
videoId: string;
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import type { SsulDetailItem } from '../../utils/api';
|
|||||||
import { CommentItem, UserMeResponse } from '../../types/api';
|
import { CommentItem, UserMeResponse } from '../../types/api';
|
||||||
import { buildContentShareUrl, tryNativeShare } from '../../utils/nativeShare';
|
import { buildContentShareUrl, tryNativeShare } from '../../utils/nativeShare';
|
||||||
import LoginPromptModal from '../../components/LoginPromptModal';
|
import LoginPromptModal from '../../components/LoginPromptModal';
|
||||||
|
import { isSafeHttpUrl } from '../../utils/url';
|
||||||
|
|
||||||
interface SsulDetailContentProps {
|
interface SsulDetailContentProps {
|
||||||
contentId: string;
|
contentId: string;
|
||||||
@ -48,6 +49,7 @@ const SsulDetailContent: React.FC<SsulDetailContentProps> = ({
|
|||||||
|
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [isLandscape, setIsLandscape] = useState(false);
|
const [isLandscape, setIsLandscape] = useState(false);
|
||||||
|
const [showSiteOverlay, setShowSiteOverlay] = useState(false);
|
||||||
const [showLoginModal, setShowLoginModal] = useState(false);
|
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||||
|
|
||||||
const [comments, setComments] = useState<CommentItem[]>([]);
|
const [comments, setComments] = useState<CommentItem[]>([]);
|
||||||
@ -86,6 +88,7 @@ const SsulDetailContent: React.FC<SsulDetailContentProps> = ({
|
|||||||
const fetchContent = async () => {
|
const fetchContent = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setShowSiteOverlay(false);
|
||||||
try {
|
try {
|
||||||
const data = await getSsulContentById(contentId);
|
const data = await getSsulContentById(contentId);
|
||||||
setContent(data);
|
setContent(data);
|
||||||
@ -265,19 +268,47 @@ const SsulDetailContent: React.FC<SsulDetailContentProps> = ({
|
|||||||
<div className="ado2-contents-error"><p>{error}</p></div>
|
<div className="ado2-contents-error"><p>{error}</p></div>
|
||||||
) : content ? (
|
) : content ? (
|
||||||
<div className={`video-detail-content ${isLandscape ? 'landscape' : ''}`}>
|
<div className={`video-detail-content ${isLandscape ? 'landscape' : ''}`}>
|
||||||
<video
|
<div className="video-detail-player-wrap">
|
||||||
src={content.video_url}
|
<video
|
||||||
controls
|
src={content.video_url}
|
||||||
autoPlay
|
controls
|
||||||
playsInline
|
autoPlay
|
||||||
controlsList="nodownload"
|
playsInline
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
controlsList="nodownload"
|
||||||
className="video-detail-player"
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
onLoadedMetadata={(e) => {
|
className="video-detail-player"
|
||||||
const v = e.currentTarget;
|
onLoadedMetadata={(e) => {
|
||||||
setIsLandscape(v.videoWidth > v.videoHeight);
|
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">
|
<div className="video-detail-info">
|
||||||
<h2 className="video-detail-store">
|
<h2 className="video-detail-store">
|
||||||
|
|||||||
@ -800,7 +800,7 @@
|
|||||||
|
|
||||||
.video-detail-content {
|
.video-detail-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 24px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@ -605,6 +605,8 @@ export interface SsulDetailItem {
|
|||||||
video_url: string;
|
video_url: string;
|
||||||
store_name: string | null;
|
store_name: string | null;
|
||||||
region: string | null;
|
region: string | null;
|
||||||
|
/** 업체 공식 링크. 영상 종료 3초 전 오버레이에 쓰며, 미확보 시 null */
|
||||||
|
official_site_url?: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
like_count: number;
|
like_count: number;
|
||||||
is_liked_by_me: boolean;
|
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