|
@@ -1,17 +1,54 @@
|
|
|
'use client';
|
|
'use client';
|
|
|
|
|
|
|
|
import { useState, useEffect, useRef } from 'react';
|
|
import { useState, useEffect, useRef } from 'react';
|
|
|
-import { AnnotationCanvas, COLORS, Tool } from '@/components/video-player/AnnotationCanvas';
|
|
|
|
|
-import { AnnotationData } from '@/lib/api';
|
|
|
|
|
import { useRouter } from 'next/navigation';
|
|
import { useRouter } from 'next/navigation';
|
|
|
import { useAuth } from '@/lib/auth-context';
|
|
import { useAuth } from '@/lib/auth-context';
|
|
|
import { useParams } from 'next/navigation';
|
|
import { useParams } from 'next/navigation';
|
|
|
-import { shareLinksApi, ShareLinkVerify, GuestComment } from '@/lib/api';
|
|
|
|
|
-import { formatTimecode } from '@/lib/format';
|
|
|
|
|
|
|
+import { shareLinksApi, ShareLinkVerify, GuestComment, AnnotationData } from '@/lib/api';
|
|
|
|
|
+import { VideoPlayer } from '@/components/video-player/VideoPlayer';
|
|
|
|
|
+import { CommentPanel } from '@/components/comments/CommentPanel';
|
|
|
|
|
+import { Tool } from '@/components/video-player/AnnotationCanvas';
|
|
|
|
|
|
|
|
-const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
|
|
|
|
|
const GUEST_NAME_KEY = (token: string) => `vidreview_guest_name_${token}`;
|
|
const GUEST_NAME_KEY = (token: string) => `vidreview_guest_name_${token}`;
|
|
|
const REFRESH_INTERVAL_MS = 30_000;
|
|
const REFRESH_INTERVAL_MS = 30_000;
|
|
|
|
|
+const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
|
|
|
|
|
+
|
|
|
|
|
+// GuestComment from the API → shape that CommentPanel expects
|
|
|
|
|
+interface PanelComment {
|
|
|
|
|
+ id: string;
|
|
|
|
|
+ userId: string;
|
|
|
|
|
+ user: { id: string; name: string; email: string; avatarUrl: string | null };
|
|
|
|
|
+ guestName: string | null;
|
|
|
|
|
+ content: string;
|
|
|
|
|
+ timestamp: number | null;
|
|
|
|
|
+ annotations: AnnotationData[] | null;
|
|
|
|
|
+ resolved: boolean;
|
|
|
|
|
+ resolveStatus: 'UNRESOLVED' | 'PENDING_APPROVAL' | 'RESOLVED';
|
|
|
|
|
+ parentId: string | null;
|
|
|
|
|
+ deleted: boolean;
|
|
|
|
|
+ createdAt: string;
|
|
|
|
|
+ replies: PanelComment[];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function toPanelComment(c: GuestComment): PanelComment {
|
|
|
|
|
+ return {
|
|
|
|
|
+ id: c.id,
|
|
|
|
|
+ userId: c.userId ?? 'guest',
|
|
|
|
|
+ user: c.userId
|
|
|
|
|
+ ? (c.user ?? { id: c.userId, name: 'Unknown', email: '', avatarUrl: null })
|
|
|
|
|
+ : { id: 'guest', name: c.guestName ?? 'Guest', email: '', avatarUrl: null },
|
|
|
|
|
+ guestName: c.guestName,
|
|
|
|
|
+ content: c.content,
|
|
|
|
|
+ timestamp: c.timestamp ?? null,
|
|
|
|
|
+ annotations: (c.annotations as AnnotationData[] | null) ?? null,
|
|
|
|
|
+ resolved: c.resolved,
|
|
|
|
|
+ resolveStatus: (c.resolveStatus as PanelComment['resolveStatus']) ?? 'UNRESOLVED',
|
|
|
|
|
+ parentId: c.parentId ?? null,
|
|
|
|
|
+ deleted: c.deleted,
|
|
|
|
|
+ createdAt: c.createdAt,
|
|
|
|
|
+ replies: (c.replies ?? []).map(r => toPanelComment(r)),
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
export default function SharePage() {
|
|
export default function SharePage() {
|
|
|
const params = useParams();
|
|
const params = useParams();
|
|
@@ -29,44 +66,28 @@ export default function SharePage() {
|
|
|
const [originalDownloadUrl, setOriginalDownloadUrl] = useState<string | null>(null);
|
|
const [originalDownloadUrl, setOriginalDownloadUrl] = useState<string | null>(null);
|
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
|
|
|
|
|
|
- // ── Guest comment state ──────────────────────────────────────────────────────
|
|
|
|
|
const [guestName, setGuestName] = useState('');
|
|
const [guestName, setGuestName] = useState('');
|
|
|
const [guestNameSubmitted, setGuestNameSubmitted] = useState(false);
|
|
const [guestNameSubmitted, setGuestNameSubmitted] = useState(false);
|
|
|
- const [comments, setComments] = useState<GuestComment[]>([]);
|
|
|
|
|
|
|
+ const [comments, setComments] = useState<PanelComment[]>([]);
|
|
|
const [commentsTotal, setCommentsTotal] = useState(0);
|
|
const [commentsTotal, setCommentsTotal] = useState(0);
|
|
|
const [commentLoading, setCommentLoading] = useState(false);
|
|
const [commentLoading, setCommentLoading] = useState(false);
|
|
|
- const [commentText, setCommentText] = useState('');
|
|
|
|
|
- const [commentSubmitting, setCommentSubmitting] = useState(false);
|
|
|
|
|
- const [commentError, setCommentError] = useState<string | null>(null);
|
|
|
|
|
- const [currentTime, setCurrentTime] = useState(0);
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // Drawing state
|
|
|
const [drawMode, setDrawMode] = useState(false);
|
|
const [drawMode, setDrawMode] = useState(false);
|
|
|
const [drawTool, setDrawTool] = useState<Tool>('arrow');
|
|
const [drawTool, setDrawTool] = useState<Tool>('arrow');
|
|
|
const [drawColor, setDrawColor] = useState('#ef4444');
|
|
const [drawColor, setDrawColor] = useState('#ef4444');
|
|
|
const [pendingStrokes, setPendingStrokes] = useState<AnnotationData[]>([]);
|
|
const [pendingStrokes, setPendingStrokes] = useState<AnnotationData[]>([]);
|
|
|
- const videoContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
- const [videoDims, setVideoDims] = useState({ width: 0, height: 0 });
|
|
|
|
|
|
|
|
|
|
- // ── Video container resize observer ───────────────────────────────────────
|
|
|
|
|
- useEffect(() => {
|
|
|
|
|
- const el = videoContainerRef.current;
|
|
|
|
|
- if (!el) return;
|
|
|
|
|
- const update = () => setVideoDims({ width: el.offsetWidth, height: el.offsetHeight });
|
|
|
|
|
- update();
|
|
|
|
|
- const obs = new ResizeObserver(update);
|
|
|
|
|
- obs.observe(el);
|
|
|
|
|
- return () => obs.disconnect();
|
|
|
|
|
- }, []);
|
|
|
|
|
-
|
|
|
|
|
- // ── Load guest name from localStorage ───────────────────────────────────────
|
|
|
|
|
|
|
+ // Playback state
|
|
|
|
|
+ const [currentTime, setCurrentTime] = useState(0);
|
|
|
|
|
+
|
|
|
|
|
+ // Load guest name from localStorage
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
const saved = localStorage.getItem(GUEST_NAME_KEY(tokenParam));
|
|
const saved = localStorage.getItem(GUEST_NAME_KEY(tokenParam));
|
|
|
- if (saved) {
|
|
|
|
|
- setGuestName(saved);
|
|
|
|
|
- setGuestNameSubmitted(true);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ if (saved) { setGuestName(saved); setGuestNameSubmitted(true); }
|
|
|
}, [tokenParam]);
|
|
}, [tokenParam]);
|
|
|
|
|
|
|
|
- // ── Verify share link ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
+ // Verify share link
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
async function verify() {
|
|
async function verify() {
|
|
|
try {
|
|
try {
|
|
@@ -78,32 +99,23 @@ export default function SharePage() {
|
|
|
const assetRes = await fetch(`${API_BASE}/api/assets/${info.asset.id}`, {
|
|
const assetRes = await fetch(`${API_BASE}/api/assets/${info.asset.id}`, {
|
|
|
headers: { Authorization: `Bearer ${authToken}` },
|
|
headers: { Authorization: `Bearer ${authToken}` },
|
|
|
});
|
|
});
|
|
|
- if (assetRes.ok) {
|
|
|
|
|
- router.replace(`/review/${info.asset.id}`);
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
- } catch { /* fall through to public view */ }
|
|
|
|
|
|
|
+ if (assetRes.ok) { router.replace(`/review/${info.asset.id}`); return; }
|
|
|
|
|
+ } catch { /* fall through */ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- if (info.hasPassword) {
|
|
|
|
|
- setState('password');
|
|
|
|
|
- } else {
|
|
|
|
|
- await fetchAccess();
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ if (info.hasPassword) { setState('password'); }
|
|
|
|
|
+ else { await fetchAccess(); }
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
|
const msg = e instanceof Error ? e.message : '';
|
|
const msg = e instanceof Error ? e.message : '';
|
|
|
- if (msg.includes('410') || msg.includes('View limit')) {
|
|
|
|
|
- setState('expired');
|
|
|
|
|
- } else {
|
|
|
|
|
- setState('error');
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ if (msg.includes('410') || msg.includes('View limit')) { setState('expired'); }
|
|
|
|
|
+ else { setState('error'); }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
verify();
|
|
verify();
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
}, [tokenParam, user, authToken]);
|
|
}, [tokenParam, user, authToken]);
|
|
|
|
|
|
|
|
- // ── Fetch comments when ready ────────────────────────────────────────────────
|
|
|
|
|
|
|
+ // Fetch comments
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
if (state !== 'ready' || !linkInfo?.allowUnregisteredComments) return;
|
|
if (state !== 'ready' || !linkInfo?.allowUnregisteredComments) return;
|
|
|
loadComments();
|
|
loadComments();
|
|
@@ -117,364 +129,237 @@ export default function SharePage() {
|
|
|
setCommentLoading(true);
|
|
setCommentLoading(true);
|
|
|
try {
|
|
try {
|
|
|
const data = await shareLinksApi.getComments(tokenParam);
|
|
const data = await shareLinksApi.getComments(tokenParam);
|
|
|
- setComments(data.comments);
|
|
|
|
|
|
|
+ setComments(data.comments.map(toPanelComment));
|
|
|
setCommentsTotal(data.total);
|
|
setCommentsTotal(data.total);
|
|
|
- } catch {
|
|
|
|
|
- // Silently fail — comments are non-critical
|
|
|
|
|
- } finally {
|
|
|
|
|
- setCommentLoading(false);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ } catch { /* silent */ }
|
|
|
|
|
+ finally { setCommentLoading(false); }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function fetchAccess(pwd?: string) {
|
|
async function fetchAccess(pwd?: string) {
|
|
|
- setSubmitting(true);
|
|
|
|
|
- setPasswordError(null);
|
|
|
|
|
|
|
+ setSubmitting(true); setPasswordError(null);
|
|
|
try {
|
|
try {
|
|
|
const data = await shareLinksApi.access(tokenParam, pwd);
|
|
const data = await shareLinksApi.access(tokenParam, pwd);
|
|
|
setStreamUrl(`${API_BASE}${data.streamUrl}`);
|
|
setStreamUrl(`${API_BASE}${data.streamUrl}`);
|
|
|
setHas4K(data.has4K);
|
|
setHas4K(data.has4K);
|
|
|
- if (data.originalDownloadUrl) {
|
|
|
|
|
- setOriginalDownloadUrl(`${API_BASE}${data.originalDownloadUrl}`);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ if (data.originalDownloadUrl) setOriginalDownloadUrl(`${API_BASE}${data.originalDownloadUrl}`);
|
|
|
setState('ready');
|
|
setState('ready');
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
|
const msg = e instanceof Error ? e.message : '';
|
|
const msg = e instanceof Error ? e.message : '';
|
|
|
- if (msg.includes('401')) {
|
|
|
|
|
- setPasswordError('Incorrect password');
|
|
|
|
|
- } else {
|
|
|
|
|
- setPasswordError(msg || 'Access denied');
|
|
|
|
|
- }
|
|
|
|
|
- } finally {
|
|
|
|
|
- setSubmitting(false);
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- function handlePasswordSubmit(e: React.FormEvent) {
|
|
|
|
|
- e.preventDefault();
|
|
|
|
|
- fetchAccess(password);
|
|
|
|
|
|
|
+ setPasswordError(msg.includes('401') ? 'Incorrect password' : (msg || 'Access denied'));
|
|
|
|
|
+ } finally { setSubmitting(false); }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- function handleGuestNameSubmit(e: React.FormEvent) {
|
|
|
|
|
- e.preventDefault();
|
|
|
|
|
- const trimmed = guestName.trim();
|
|
|
|
|
- if (!trimmed) return;
|
|
|
|
|
- localStorage.setItem(GUEST_NAME_KEY(tokenParam), trimmed);
|
|
|
|
|
- setGuestNameSubmitted(true);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- async function handleCommentSubmit(e: React.FormEvent) {
|
|
|
|
|
- e.preventDefault();
|
|
|
|
|
- if (!commentText.trim() || commentSubmitting) return;
|
|
|
|
|
- setCommentSubmitting(true);
|
|
|
|
|
- setCommentError(null);
|
|
|
|
|
|
|
+ async function handleAddComment(payload: { content: string; timestamp?: number; parentId?: string }) {
|
|
|
|
|
+ if (!payload.content.trim() || !guestName.trim()) return;
|
|
|
|
|
+ setCommentSubmitting(true); setCommentError(null);
|
|
|
try {
|
|
try {
|
|
|
- const data = await shareLinksApi.postGuestComment(tokenParam, {
|
|
|
|
|
|
|
+ const res = await shareLinksApi.postGuestComment(tokenParam, {
|
|
|
guestName: guestName.trim(),
|
|
guestName: guestName.trim(),
|
|
|
- content: commentText.trim(),
|
|
|
|
|
- timestamp: currentTime > 0 ? currentTime : undefined,
|
|
|
|
|
|
|
+ content: payload.content,
|
|
|
|
|
+ timestamp: payload.timestamp ?? (currentTime > 0 ? currentTime : undefined),
|
|
|
annotations: pendingStrokes.length > 0 ? pendingStrokes : undefined,
|
|
annotations: pendingStrokes.length > 0 ? pendingStrokes : undefined,
|
|
|
|
|
+ parentId: payload.parentId,
|
|
|
});
|
|
});
|
|
|
setPendingStrokes([]);
|
|
setPendingStrokes([]);
|
|
|
- setComments(prev => [...prev, data.comment]);
|
|
|
|
|
|
|
+ setComments(prev => [...prev, toPanelComment(res.comment)]);
|
|
|
setCommentsTotal(prev => prev + 1);
|
|
setCommentsTotal(prev => prev + 1);
|
|
|
setCommentText('');
|
|
setCommentText('');
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
|
setCommentError(err instanceof Error ? err.message : 'Failed to post comment');
|
|
setCommentError(err instanceof Error ? err.message : 'Failed to post comment');
|
|
|
- } finally {
|
|
|
|
|
- setCommentSubmitting(false);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ } finally { setCommentSubmitting(false); }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ function handleGuestNameSubmit(e: React.FormEvent) {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ const trimmed = guestName.trim();
|
|
|
|
|
+ if (!trimmed) return;
|
|
|
|
|
+ localStorage.setItem(GUEST_NAME_KEY(tokenParam), trimmed);
|
|
|
|
|
+ setGuestNameSubmitted(true);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const [commentText, setCommentText] = useState('');
|
|
|
|
|
+ const [commentSubmitting, setCommentSubmitting] = useState(false);
|
|
|
|
|
+ const [commentError, setCommentError] = useState<string | null>(null);
|
|
|
|
|
+
|
|
|
function goToLogin() {
|
|
function goToLogin() {
|
|
|
const returnUrl = linkInfo ? `/review/${linkInfo.asset.id}` : '/login';
|
|
const returnUrl = linkInfo ? `/review/${linkInfo.asset.id}` : '/login';
|
|
|
router.push(`/login?redirect=${encodeURIComponent(returnUrl)}`);
|
|
router.push(`/login?redirect=${encodeURIComponent(returnUrl)}`);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ const showGuestComments = linkInfo?.allowUnregisteredComments && guestNameSubmitted;
|
|
|
const isHls = !!streamUrl?.endsWith('.m3u8');
|
|
const isHls = !!streamUrl?.endsWith('.m3u8');
|
|
|
-
|
|
|
|
|
- // Load HLS
|
|
|
|
|
- useEffect(() => {
|
|
|
|
|
- if (!streamUrl || !isHls || !videoRef.current) return;
|
|
|
|
|
- // eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
|
|
|
- const Hls = require('hls.js');
|
|
|
|
|
- if (Hls.isSupported()) {
|
|
|
|
|
- const hls = new Hls({ enableWorker: false, backBufferLength: 30, maxBufferLength: 30, maxBufferSize: 50 * 1024 * 1024, maxMaxBufferSize: 100 * 1024 * 1024, startLevel: -1 });
|
|
|
|
|
- hls.loadSource(streamUrl);
|
|
|
|
|
- hls.attachMedia(videoRef.current);
|
|
|
|
|
- hls.on(Hls.Events.ERROR, (_: string, data: { fatal: boolean; details: string }) => {
|
|
|
|
|
- if (data.fatal) console.error('[HLS] Fatal:', data.details);
|
|
|
|
|
- });
|
|
|
|
|
- return () => { hls.destroy(); };
|
|
|
|
|
- } else if (videoRef.current.canPlayType('application/vnd.apple.mpegurl')) {
|
|
|
|
|
- videoRef.current.src = streamUrl;
|
|
|
|
|
|
|
+ const fps = linkInfo?.asset.fps ?? 30;
|
|
|
|
|
+
|
|
|
|
|
+ // Compute visible annotations at current time
|
|
|
|
|
+ const visibleAnnotations: { annotation: AnnotationData; timestamp: number }[] = [];
|
|
|
|
|
+ if (currentTime > 0) {
|
|
|
|
|
+ const frameRange = 3 / fps;
|
|
|
|
|
+ for (const c of comments) {
|
|
|
|
|
+ if (c.annotations && c.timestamp != null && Math.abs(currentTime - c.timestamp) <= frameRange) {
|
|
|
|
|
+ for (const ann of c.annotations) {
|
|
|
|
|
+ visibleAnnotations.push({ annotation: ann, timestamp: c.timestamp });
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
- // eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
- }, [streamUrl, isHls]);
|
|
|
|
|
-
|
|
|
|
|
- // ── Render helpers ───────────────────────────────────────────────────────────
|
|
|
|
|
- const showGuestComments = linkInfo?.allowUnregisteredComments && guestNameSubmitted;
|
|
|
|
|
- const canComment = showGuestComments && !user;
|
|
|
|
|
-
|
|
|
|
|
- function CommentItem({ comment }: { comment: GuestComment }) {
|
|
|
|
|
- const isGuest = comment.userId === null;
|
|
|
|
|
- const displayName = isGuest ? (comment.guestName ?? 'Anonymous') : (comment.user?.name ?? 'Unknown');
|
|
|
|
|
- const avatarColor = isGuest ? '#A78BFA' : '#6366F1';
|
|
|
|
|
-
|
|
|
|
|
- return (
|
|
|
|
|
- <div className="flex gap-3 py-3" style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
|
|
|
|
|
- {/* Avatar */}
|
|
|
|
|
- <div
|
|
|
|
|
- className="w-7 h-7 rounded-full shrink-0 flex items-center justify-center text-xs font-semibold"
|
|
|
|
|
- style={{ background: `${avatarColor}22`, color: avatarColor }}
|
|
|
|
|
- >
|
|
|
|
|
- {isGuest ? (
|
|
|
|
|
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
|
|
- <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
|
|
|
|
- </svg>
|
|
|
|
|
- ) : (
|
|
|
|
|
- displayName[0]?.toUpperCase() ?? '?'
|
|
|
|
|
- )}
|
|
|
|
|
- </div>
|
|
|
|
|
- <div className="flex-1 min-w-0">
|
|
|
|
|
- <div className="flex items-center gap-2 mb-0.5">
|
|
|
|
|
- <span className="text-xs font-medium" style={{ color: isGuest ? '#A78BFA' : 'var(--text)' }}>
|
|
|
|
|
- {displayName}
|
|
|
|
|
- </span>
|
|
|
|
|
- {isGuest && (
|
|
|
|
|
- <span className="text-xs px-1.5 py-0.5 rounded" style={{ background: 'rgba(167,139,250,0.12)', color: '#A78BFA' }}>
|
|
|
|
|
- Guest
|
|
|
|
|
- </span>
|
|
|
|
|
- )}
|
|
|
|
|
- {comment.timestamp != null && (
|
|
|
|
|
- <span className="text-xs" style={{ color: 'var(--text-muted)' }}>
|
|
|
|
|
- {formatTimecode(comment.timestamp, 30, comment.timestamp)}
|
|
|
|
|
- </span>
|
|
|
|
|
- )}
|
|
|
|
|
- </div>
|
|
|
|
|
- <p className="text-xs leading-relaxed" style={{ color: 'var(--text)' }}>{comment.content}</p>
|
|
|
|
|
-
|
|
|
|
|
- {/* Replies */}
|
|
|
|
|
- {comment.replies?.length > 0 && (
|
|
|
|
|
- <div className="mt-2 pl-3" style={{ borderLeft: '2px solid rgba(255,255,255,0.08)' }}>
|
|
|
|
|
- {comment.replies.map(reply => (
|
|
|
|
|
- <CommentItem key={reply.id} comment={reply} />
|
|
|
|
|
- ))}
|
|
|
|
|
- </div>
|
|
|
|
|
- )}
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- );
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // ── Loading / Error / Expired / Password states ─────────────────────────────
|
|
|
|
|
if (state === 'loading') {
|
|
if (state === 'loading') {
|
|
|
- return (
|
|
|
|
|
- <div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
|
|
|
|
|
- <div className="flex items-center gap-3" style={{ color: 'var(--text-muted)' }}>
|
|
|
|
|
- <div className="w-5 h-5 rounded-full animate-spin" style={{ borderColor: '#6366F1', borderTopColor: 'transparent', borderWidth: '2px' }} />
|
|
|
|
|
- <span>Loading shared video…</span>
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ return <div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
|
|
|
|
|
+ <div className="flex items-center gap-3" style={{ color: 'var(--text-muted)' }}>
|
|
|
|
|
+ <div className="w-5 h-5 rounded-full animate-spin" style={{ borderColor: '#6366F1', borderTopColor: 'transparent', borderWidth: '2px' }} />
|
|
|
|
|
+ <span>Loading shared video…</span>
|
|
|
</div>
|
|
</div>
|
|
|
- );
|
|
|
|
|
|
|
+ </div>;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
if (state === 'error') {
|
|
if (state === 'error') {
|
|
|
- return (
|
|
|
|
|
- <div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
|
|
|
|
|
- <div className="text-center max-w-sm">
|
|
|
|
|
- <div className="w-12 h-12 rounded-2xl mx-auto mb-4 flex items-center justify-center" style={{ background: 'rgba(239,68,68,0.10)', border: '1px solid rgba(239,68,68,0.20)' }}>
|
|
|
|
|
- <svg className="w-6 h-6" style={{ color: '#F87171' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
|
|
- <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
|
|
|
|
- </svg>
|
|
|
|
|
- </div>
|
|
|
|
|
- <h1 className="text-lg font-semibold mb-2" style={{ color: 'var(--text)' }}>Link Not Found</h1>
|
|
|
|
|
- <p className="text-sm" style={{ color: 'var(--text-muted)' }}>This share link is invalid or has been revoked.</p>
|
|
|
|
|
|
|
+ return <div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
|
|
|
|
|
+ <div className="text-center max-w-sm">
|
|
|
|
|
+ <div className="w-12 h-12 rounded-2xl mx-auto mb-4 flex items-center justify-center" style={{ background: 'rgba(239,68,68,0.10)', border: '1px solid rgba(239,68,68,0.20)' }}>
|
|
|
|
|
+ <svg className="w-6 h-6" style={{ color: '#F87171' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
|
|
|
|
+ </svg>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+ <h1 className="text-lg font-semibold mb-2" style={{ color: 'var(--text)' }}>Link Not Found</h1>
|
|
|
|
|
+ <p className="text-sm" style={{ color: 'var(--text-muted)' }}>This share link is invalid or has been revoked.</p>
|
|
|
</div>
|
|
</div>
|
|
|
- );
|
|
|
|
|
|
|
+ </div>;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
if (state === 'expired') {
|
|
if (state === 'expired') {
|
|
|
- return (
|
|
|
|
|
- <div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
|
|
|
|
|
- <div className="text-center max-w-sm">
|
|
|
|
|
- <div className="w-12 h-12 rounded-2xl mx-auto mb-4 flex items-center justify-center" style={{ background: 'rgba(251,191,36,0.10)', border: '1px solid rgba(251,191,36,0.20)' }}>
|
|
|
|
|
- <svg className="w-6 h-6" style={{ color: '#FBBF24' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
|
|
- <path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
|
|
|
- </svg>
|
|
|
|
|
- </div>
|
|
|
|
|
- <h1 className="text-lg font-semibold mb-2" style={{ color: 'var(--text)' }}>Link Expired</h1>
|
|
|
|
|
- <p className="text-sm" style={{ color: 'var(--text-muted)' }}>
|
|
|
|
|
- {(linkInfo?.maxViews ?? 0) > 0 ? `This link has reached its view limit (${linkInfo?.maxViews} views).` : 'This share link has expired.'}
|
|
|
|
|
- </p>
|
|
|
|
|
|
|
+ return <div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
|
|
|
|
|
+ <div className="text-center max-w-sm">
|
|
|
|
|
+ <div className="w-12 h-12 rounded-2xl mx-auto mb-4 flex items-center justify-center" style={{ background: 'rgba(251,191,36,0.10)', border: '1px solid rgba(251,191,36,0.20)' }}>
|
|
|
|
|
+ <svg className="w-6 h-6" style={{ color: '#FBBF24' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
|
|
|
+ </svg>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+ <h1 className="text-lg font-semibold mb-2" style={{ color: 'var(--text)' }}>Link Expired</h1>
|
|
|
|
|
+ <p className="text-sm" style={{ color: 'var(--text-muted)' }}>
|
|
|
|
|
+ {(linkInfo?.maxViews ?? 0) > 0 ? `This link has reached its view limit (${linkInfo?.maxViews} views).` : 'This share link has expired.'}
|
|
|
|
|
+ </p>
|
|
|
</div>
|
|
</div>
|
|
|
- );
|
|
|
|
|
|
|
+ </div>;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
if (state === 'password') {
|
|
if (state === 'password') {
|
|
|
- return (
|
|
|
|
|
- <div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
|
|
|
|
|
- <div className="text-center max-w-sm w-full px-4">
|
|
|
|
|
- {linkInfo?.asset.thumbnail && (
|
|
|
|
|
- <img src={`${API_BASE}/uploads/${linkInfo.asset.thumbnail}`} alt={linkInfo.asset.title}
|
|
|
|
|
- className="w-full rounded-xl mb-6 aspect-video object-cover" style={{ maxHeight: 200 }} />
|
|
|
|
|
- )}
|
|
|
|
|
- <h1 className="text-lg font-semibold mb-2" style={{ color: 'var(--text)' }}>{linkInfo?.asset.title}</h1>
|
|
|
|
|
- <p className="text-sm mb-6" style={{ color: 'var(--text-muted)' }}>This video is password protected.</p>
|
|
|
|
|
- <form onSubmit={handlePasswordSubmit} className="space-y-3">
|
|
|
|
|
- <input type="password" value={password} onChange={e => { setPassword(e.target.value); setPasswordError(null); }}
|
|
|
|
|
- placeholder="Enter password" className="input w-full" autoFocus />
|
|
|
|
|
- {passwordError && <p className="text-xs" style={{ color: '#F87171' }}>{passwordError}</p>}
|
|
|
|
|
- <button type="submit" disabled={submitting || !password} className="btn btn-primary btn-md w-full">
|
|
|
|
|
- {submitting ? <div className="w-4 h-4 rounded-full animate-spin" style={{ borderColor: '#fff', borderTopColor: 'transparent', borderWidth: '2px' }} /> : 'View Video'}
|
|
|
|
|
- </button>
|
|
|
|
|
- </form>
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ return <div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
|
|
|
|
|
+ <div className="text-center max-w-sm w-full px-4">
|
|
|
|
|
+ {linkInfo?.asset.thumbnail && (
|
|
|
|
|
+ <img src={`${API_BASE}/uploads/${linkInfo.asset.thumbnail}`} alt={linkInfo.asset.title}
|
|
|
|
|
+ className="w-full rounded-xl mb-6 aspect-video object-cover" style={{ maxHeight: 200 }} />
|
|
|
|
|
+ )}
|
|
|
|
|
+ <h1 className="text-lg font-semibold mb-2" style={{ color: 'var(--text)' }}>{linkInfo?.asset.title}</h1>
|
|
|
|
|
+ <p className="text-sm mb-6" style={{ color: 'var(--text-muted)' }}>This video is password protected.</p>
|
|
|
|
|
+ <form onSubmit={e => { e.preventDefault(); fetchAccess(password); }} className="space-y-3">
|
|
|
|
|
+ <input type="password" value={password} onChange={e => { setPassword(e.target.value); setPasswordError(null); }}
|
|
|
|
|
+ placeholder="Enter password" className="input w-full" autoFocus />
|
|
|
|
|
+ {passwordError && <p className="text-xs" style={{ color: '#F87171' }}>{passwordError}</p>}
|
|
|
|
|
+ <button type="submit" disabled={submitting || !password} className="btn btn-primary btn-md w-full">
|
|
|
|
|
+ {submitting ? <div className="w-4 h-4 rounded-full animate-spin" style={{ borderColor: '#fff', borderTopColor: 'transparent', borderWidth: '2px' }} /> : 'View Video'}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </form>
|
|
|
</div>
|
|
</div>
|
|
|
- );
|
|
|
|
|
|
|
+ </div>;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // ── Ready state ──────────────────────────────────────────────────────────────
|
|
|
|
|
return (
|
|
return (
|
|
|
- <div className="min-h-screen flex flex-col" style={{ background: 'var(--bg)' }}>
|
|
|
|
|
|
|
+ <div className="flex flex-col h-screen" style={{ background: 'var(--bg)' }}>
|
|
|
{/* Header */}
|
|
{/* Header */}
|
|
|
<header className="h-12 flex items-center px-4 gap-3 shrink-0"
|
|
<header className="h-12 flex items-center px-4 gap-3 shrink-0"
|
|
|
- style={{ background: 'rgba(10,11,20,0.95)', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
|
|
|
|
|
|
+ style={{ background: 'rgba(10,11,20,0.95)', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
|
|
<svg className="w-4 h-4 shrink-0" style={{ color: '#A78BFA' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<svg className="w-4 h-4 shrink-0" style={{ color: '#A78BFA' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
|
|
</svg>
|
|
</svg>
|
|
|
<h1 className="text-xs font-medium truncate flex-1" style={{ color: 'var(--text)' }}>{linkInfo?.asset.title}</h1>
|
|
<h1 className="text-xs font-medium truncate flex-1" style={{ color: 'var(--text)' }}>{linkInfo?.asset.title}</h1>
|
|
|
|
|
|
|
|
- {/* 4K badge */}
|
|
|
|
|
{has4K && (
|
|
{has4K && (
|
|
|
<span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded shrink-0"
|
|
<span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded shrink-0"
|
|
|
- style={{ background: 'rgba(251,191,36,0.10)', color: '#FBBF24', border: '1px solid rgba(251,191,36,0.20)' }}>
|
|
|
|
|
- <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
|
|
- <path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 01-1.043 3.296 3.745 3.745 0 01-3.296 1.043A3.745 3.745 0 0112 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 01-3.296-1.043 3.745 3.745 0 01-1.043-3.296A3.745 3.745 0 013 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 011.043-3.296 3.746 3.746 0 013.296-1.043A3.746 3.746 0 0112 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 013.296 1.043 3.746 3.746 0 011.043 3.296A3.745 3.745 0 0121 12z" />
|
|
|
|
|
- </svg>
|
|
|
|
|
|
|
+ style={{ background: 'rgba(251,191,36,0.10)', color: '#FBBF24', border: '1px solid rgba(251,191,36,0.20)' }}>
|
|
|
|
|
+ <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 01-1.043 3.296 3.745 3.745 0 01-3.296 1.043A3.745 3.745 0 0112 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 01-3.296-1.043 3.745 3.745 0 01-1.043-3.296A3.745 3.745 0 013 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 011.043-3.296 3.746 3.746 0 013.296-1.043A3.746 3.746 0 0112 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 013.296 1.043 3.746 3.746 0 011.043 3.296A3.745 3.745 0 0121 12z" /></svg>
|
|
|
4K
|
|
4K
|
|
|
</span>
|
|
</span>
|
|
|
)}
|
|
)}
|
|
|
|
|
|
|
|
- {/* Download */}
|
|
|
|
|
{(linkInfo?.allowDownload && streamUrl) && (
|
|
{(linkInfo?.allowDownload && streamUrl) && (
|
|
|
<a href={originalDownloadUrl ?? streamUrl} download
|
|
<a href={originalDownloadUrl ?? streamUrl} download
|
|
|
className="flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-md transition-all shrink-0"
|
|
className="flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-md transition-all shrink-0"
|
|
|
style={{ color: '#60A5FA', background: 'rgba(96,165,250,0.08)' }}>
|
|
style={{ color: '#60A5FA', background: 'rgba(96,165,250,0.08)' }}>
|
|
|
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
|
|
- <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
|
|
|
|
- </svg>
|
|
|
|
|
|
|
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" /></svg>
|
|
|
{has4K ? 'Download 4K' : 'Download'}
|
|
{has4K ? 'Download 4K' : 'Download'}
|
|
|
</a>
|
|
</a>
|
|
|
)}
|
|
)}
|
|
|
|
|
|
|
|
- {/* Guest name input or badge */}
|
|
|
|
|
{linkInfo?.allowUnregisteredComments && !user && (
|
|
{linkInfo?.allowUnregisteredComments && !user && (
|
|
|
guestNameSubmitted ? (
|
|
guestNameSubmitted ? (
|
|
|
<div className="flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-md shrink-0"
|
|
<div className="flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-md shrink-0"
|
|
|
- style={{ color: '#A78BFA', background: 'rgba(167,139,250,0.10)' }}>
|
|
|
|
|
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
|
|
- <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
|
|
|
|
- </svg>
|
|
|
|
|
|
|
+ style={{ color: '#A78BFA', background: 'rgba(167,139,250,0.10)' }}>
|
|
|
|
|
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" /></svg>
|
|
|
{guestName}
|
|
{guestName}
|
|
|
</div>
|
|
</div>
|
|
|
) : (
|
|
) : (
|
|
|
<form onSubmit={handleGuestNameSubmit} className="flex items-center gap-2 shrink-0">
|
|
<form onSubmit={handleGuestNameSubmit} className="flex items-center gap-2 shrink-0">
|
|
|
- <input
|
|
|
|
|
- type="text"
|
|
|
|
|
- value={guestName}
|
|
|
|
|
- onChange={e => setGuestName(e.target.value)}
|
|
|
|
|
- placeholder="Your name"
|
|
|
|
|
- maxLength={50}
|
|
|
|
|
- className="w-24 text-xs px-2 py-1 rounded-md"
|
|
|
|
|
- style={{ background: 'rgba(167,139,250,0.10)', color: '#A78BFA', border: '1px solid rgba(167,139,250,0.30)', outline: 'none' }}
|
|
|
|
|
- />
|
|
|
|
|
|
|
+ <input type="text" value={guestName} onChange={e => setGuestName(e.target.value)}
|
|
|
|
|
+ placeholder="Your name" maxLength={50} className="w-24 text-xs px-2 py-1 rounded-md"
|
|
|
|
|
+ style={{ background: 'rgba(167,139,250,0.10)', color: '#A78BFA', border: '1px solid rgba(167,139,250,0.30)', outline: 'none' }} />
|
|
|
<button type="submit" disabled={!guestName.trim()}
|
|
<button type="submit" disabled={!guestName.trim()}
|
|
|
className="text-xs px-2 py-1 rounded-md shrink-0 transition-all"
|
|
className="text-xs px-2 py-1 rounded-md shrink-0 transition-all"
|
|
|
- style={{ background: '#A78BFA', color: '#fff', opacity: guestName.trim() ? 1 : 0.5 }}>
|
|
|
|
|
- Comment
|
|
|
|
|
- </button>
|
|
|
|
|
|
|
+ style={{ background: '#A78BFA', color: '#fff', opacity: guestName.trim() ? 1 : 0.5 }}>Comment</button>
|
|
|
</form>
|
|
</form>
|
|
|
)
|
|
)
|
|
|
)}
|
|
)}
|
|
|
|
|
|
|
|
- {/* Login link */}
|
|
|
|
|
{!user && !linkInfo?.allowUnregisteredComments && (
|
|
{!user && !linkInfo?.allowUnregisteredComments && (
|
|
|
- <button onClick={goToLogin}
|
|
|
|
|
- className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-md transition-all shrink-0"
|
|
|
|
|
|
|
+ <button onClick={goToLogin} className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-md transition-all shrink-0"
|
|
|
style={{ color: '#A78BFA', background: 'rgba(167,139,250,0.10)' }}>
|
|
style={{ color: '#A78BFA', background: 'rgba(167,139,250,0.10)' }}>
|
|
|
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
|
|
- <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
|
|
|
|
|
- </svg>
|
|
|
|
|
|
|
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" /></svg>
|
|
|
Login to comment
|
|
Login to comment
|
|
|
</button>
|
|
</button>
|
|
|
)}
|
|
)}
|
|
|
</header>
|
|
</header>
|
|
|
|
|
|
|
|
{/* Main content */}
|
|
{/* Main content */}
|
|
|
- <div className="flex-1 flex flex-col lg:flex-row">
|
|
|
|
|
- {/* Video area */}
|
|
|
|
|
- <div className="flex-1 flex items-center justify-center" style={{ background: '#000', minHeight: 0 }}>
|
|
|
|
|
|
|
+ <div className="flex-1 flex overflow-hidden">
|
|
|
|
|
+ {/* Video */}
|
|
|
|
|
+ <div className="flex-1 flex items-center justify-center overflow-hidden" style={{ background: '#000', minWidth: 0 }}>
|
|
|
{streamUrl ? (
|
|
{streamUrl ? (
|
|
|
- <div className="w-full max-w-6xl mx-auto" style={{ aspectRatio: '16/9', maxHeight: '85vh' }}>
|
|
|
|
|
- <div ref={videoContainerRef} className="relative w-full h-full">
|
|
|
|
|
- {isHls ? (
|
|
|
|
|
- <video ref={videoRef} className="w-full h-full" controls playsInline
|
|
|
|
|
- onTimeUpdate={e => setCurrentTime(e.currentTarget.currentTime)} />
|
|
|
|
|
- ) : (
|
|
|
|
|
- <video ref={videoRef} src={streamUrl} className="w-full h-full" controls playsInline
|
|
|
|
|
- onTimeUpdate={e => setCurrentTime(e.currentTarget.currentTime)} />
|
|
|
|
|
- )}
|
|
|
|
|
- {/* Annotation drawing canvas */}
|
|
|
|
|
- <AnnotationCanvas
|
|
|
|
|
- isActive={drawMode}
|
|
|
|
|
- tool={drawTool}
|
|
|
|
|
- color={drawColor}
|
|
|
|
|
- width={videoDims.width}
|
|
|
|
|
- height={videoDims.height}
|
|
|
|
|
- pendingStrokes={pendingStrokes}
|
|
|
|
|
- onStrokeComplete={(stroke) => setPendingStrokes(prev => [...prev, stroke])}
|
|
|
|
|
- />
|
|
|
|
|
- {/* Draw toolbar */}
|
|
|
|
|
- {drawMode && (
|
|
|
|
|
- <div className="absolute top-2 right-2 z-30 flex flex-wrap gap-1.5 p-2 rounded-xl"
|
|
|
|
|
- style={{ background: 'rgba(15,17,28,0.97)', border: '1px solid rgba(167,139,250,0.3)', backdropFilter: 'blur(12px)' }}>
|
|
|
|
|
- {(['pen', 'arrow', 'rect', 'ellipse'] as Tool[]).map(t => (
|
|
|
|
|
- <button key={t} onClick={() => setDrawTool(t)}
|
|
|
|
|
- className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
|
|
|
|
|
- drawTool === t ? 'bg-indigo-600 text-white' : 'bg-white/10 text-white/80 hover:bg-white/20'
|
|
|
|
|
- }`}>{t.charAt(0).toUpperCase() + t.slice(1)}</button>
|
|
|
|
|
- ))}
|
|
|
|
|
- <div className="w-px h-5 bg-white/10" />
|
|
|
|
|
- {COLORS.map(c => (
|
|
|
|
|
- <button key={c.value} onClick={() => setDrawColor(c.value)}
|
|
|
|
|
- className={`w-5 h-5 rounded-full border-2 transition-transform hover:scale-125 ${
|
|
|
|
|
- drawColor === c.value ? 'border-white scale-125' : 'border-transparent'
|
|
|
|
|
- }`} style={{ backgroundColor: c.value }} />
|
|
|
|
|
- ))}
|
|
|
|
|
- <div className="w-px h-5 bg-white/10" />
|
|
|
|
|
- <button onClick={() => { setDrawMode(false); setPendingStrokes([]); }}
|
|
|
|
|
- className="text-xs text-white/60 hover:text-white px-2 py-1 rounded bg-white/10 hover:bg-white/20 transition-colors">Clear</button>
|
|
|
|
|
- <button onClick={() => setDrawMode(false)}
|
|
|
|
|
- className="text-xs text-white/60 hover:text-white px-2 py-1 rounded bg-white/10 hover:bg-white/20 transition-colors">Done</button>
|
|
|
|
|
- </div>
|
|
|
|
|
- )}
|
|
|
|
|
- {/* Draw mode toggle */}
|
|
|
|
|
- {!drawMode && (
|
|
|
|
|
- <button onClick={() => { videoRef.current?.pause(); setDrawMode(true); }}
|
|
|
|
|
- className="absolute top-2 right-2 z-20 p-1.5 rounded-lg transition-colors"
|
|
|
|
|
- style={{ background: 'rgba(0,0,0,0.5)', color: 'rgba(255,255,255,0.8)' }}
|
|
|
|
|
- title="Draw annotation">
|
|
|
|
|
- <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
|
|
- <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
|
|
|
|
- </svg>
|
|
|
|
|
- </button>
|
|
|
|
|
- )}
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ <VideoPlayer
|
|
|
|
|
+ src={streamUrl}
|
|
|
|
|
+ mimeType="video/mp4"
|
|
|
|
|
+ fps={fps}
|
|
|
|
|
+ comments={comments as any}
|
|
|
|
|
+ visibleAnnotations={visibleAnnotations}
|
|
|
|
|
+ drawMode={drawMode}
|
|
|
|
|
+ drawTool={drawTool}
|
|
|
|
|
+ drawColor={drawColor}
|
|
|
|
|
+ onDrawModeChange={setDrawMode}
|
|
|
|
|
+ onDrawToolChange={setDrawTool}
|
|
|
|
|
+ onDrawColorChange={setDrawColor}
|
|
|
|
|
+ pendingStrokes={pendingStrokes}
|
|
|
|
|
+ onStrokeComplete={stroke => setPendingStrokes(prev => [...prev, stroke])}
|
|
|
|
|
+ onTimeUpdate={setCurrentTime}
|
|
|
|
|
+ onCommentClick={(comment) => {
|
|
|
|
|
+ if (comment.timestamp != null && videoRef.current) {
|
|
|
|
|
+ videoRef.current.currentTime = comment.timestamp;
|
|
|
|
|
+ }
|
|
|
|
|
+ }}
|
|
|
|
|
+ onPrevComment={() => {
|
|
|
|
|
+ const tsComments = comments.filter(c => c.timestamp != null);
|
|
|
|
|
+ const prev = [...tsComments].reverse().find(c => (c.timestamp ?? 0) < currentTime - 0.5 / fps);
|
|
|
|
|
+ if (prev && prev.timestamp != null && videoRef.current) {
|
|
|
|
|
+ videoRef.current.currentTime = prev.timestamp;
|
|
|
|
|
+ }
|
|
|
|
|
+ }}
|
|
|
|
|
+ onNextComment={() => {
|
|
|
|
|
+ const next = comments.find(c => (c.timestamp ?? 0) > currentTime + 0.5 / fps);
|
|
|
|
|
+ if (next && next.timestamp != null && videoRef.current) {
|
|
|
|
|
+ videoRef.current.currentTime = next.timestamp;
|
|
|
|
|
+ }
|
|
|
|
|
+ }}
|
|
|
|
|
+ videoRef={videoRef}
|
|
|
|
|
+ />
|
|
|
) : !linkInfo?.asset.videoReady ? (
|
|
) : !linkInfo?.asset.videoReady ? (
|
|
|
- <div className="flex flex-col items-center justify-center" style={{ aspectRatio: '16/9', maxHeight: '85vh' }}>
|
|
|
|
|
|
|
+ <div className="flex flex-col items-center justify-center">
|
|
|
<div className="w-8 h-8 rounded-full animate-spin mb-3" style={{ borderColor: '#6366F1', borderTopColor: 'transparent', borderWidth: '2px' }} />
|
|
<div className="w-8 h-8 rounded-full animate-spin mb-3" style={{ borderColor: '#6366F1', borderTopColor: 'transparent', borderWidth: '2px' }} />
|
|
|
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>Video is being processed…</p>
|
|
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>Video is being processed…</p>
|
|
|
</div>
|
|
</div>
|
|
@@ -483,75 +368,37 @@ export default function SharePage() {
|
|
|
|
|
|
|
|
{/* Comment panel */}
|
|
{/* Comment panel */}
|
|
|
{showGuestComments && (
|
|
{showGuestComments && (
|
|
|
- <div className="lg:w-96 w-full shrink-0 flex flex-col"
|
|
|
|
|
- style={{ background: '#13141F', borderTop: '1px solid rgba(255,255,255,0.06)', maxHeight: '60vh', borderLeft: '1px solid rgba(255,255,255,0.06)' }}>
|
|
|
|
|
- {/* Comment form */}
|
|
|
|
|
- <form onSubmit={handleCommentSubmit} className="p-3 shrink-0" style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
|
|
|
|
- <textarea
|
|
|
|
|
- value={commentText}
|
|
|
|
|
- onChange={e => setCommentText(e.target.value)}
|
|
|
|
|
- placeholder="Write a comment…"
|
|
|
|
|
- rows={3}
|
|
|
|
|
- maxLength={5000}
|
|
|
|
|
- className="w-full text-xs rounded-lg px-3 py-2 resize-none"
|
|
|
|
|
- style={{ background: 'rgba(255,255,255,0.05)', color: 'var(--text)', border: '1px solid rgba(255,255,255,0.08)', outline: 'none' }}
|
|
|
|
|
- />
|
|
|
|
|
- {commentError && <p className="text-xs mt-1" style={{ color: '#F87171' }}>{commentError}</p>}
|
|
|
|
|
- {currentTime > 0 && (
|
|
|
|
|
- <div className="text-xs mb-2 flex items-center gap-1" style={{ color: '#A78BFA' }}>
|
|
|
|
|
- <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
|
|
- <path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
|
|
|
- </svg>
|
|
|
|
|
- Comment at {formatTimecode(currentTime, 30, currentTime)}{pendingStrokes.length > 0 && ` (${pendingStrokes.length} annotation${pendingStrokes.length > 1 ? 's' : ''})`}
|
|
|
|
|
- </div>
|
|
|
|
|
- )}
|
|
|
|
|
- <div className="flex justify-end mt-2">
|
|
|
|
|
- <button type="submit" disabled={commentSubmitting || !commentText.trim()}
|
|
|
|
|
- className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg transition-all"
|
|
|
|
|
- style={{ background: '#A78BFA', color: '#fff', opacity: commentText.trim() && !commentSubmitting ? 1 : 0.5 }}>
|
|
|
|
|
- {commentSubmitting ? (
|
|
|
|
|
- <div className="w-3 h-3 rounded-full animate-spin" style={{ borderColor: '#fff', borderTopColor: 'transparent', borderWidth: '2px' }} />
|
|
|
|
|
- ) : (
|
|
|
|
|
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
|
|
- <path strokeLinecap="round" strokeLinejoin="round" d="M6 12H3m12 0h9m-9 0a9 9 0 01-9-9M3 12a9 9 0 019-9m0 0a9 9 0 019 9m-9-9v6" />
|
|
|
|
|
- </svg>
|
|
|
|
|
- )}
|
|
|
|
|
- Post
|
|
|
|
|
- </button>
|
|
|
|
|
- </div>
|
|
|
|
|
- </form>
|
|
|
|
|
-
|
|
|
|
|
- {/* Comment list */}
|
|
|
|
|
- <div className="flex-1 overflow-y-auto px-3">
|
|
|
|
|
- <div className="flex items-center justify-between py-2">
|
|
|
|
|
- <span className="text-xs font-medium" style={{ color: 'var(--text-muted)' }}>
|
|
|
|
|
- {commentsTotal} comment{commentsTotal !== 1 ? 's' : ''}
|
|
|
|
|
- </span>
|
|
|
|
|
- {commentLoading && (
|
|
|
|
|
- <div className="w-3 h-3 rounded-full animate-spin" style={{ borderColor: '#6366F1', borderTopColor: 'transparent', borderWidth: '1.5px' }} />
|
|
|
|
|
- )}
|
|
|
|
|
- </div>
|
|
|
|
|
- {comments.length === 0 && !commentLoading && (
|
|
|
|
|
- <p className="text-xs text-center py-6" style={{ color: 'var(--text-muted)' }}>No comments yet. Be the first!</p>
|
|
|
|
|
- )}
|
|
|
|
|
- {comments.map(c => <CommentItem key={c.id} comment={c} />)}
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ <div className="w-96 shrink-0 overflow-hidden"
|
|
|
|
|
+ style={{ background: '#13141F', borderLeft: '1px solid rgba(255,255,255,0.06)' }}>
|
|
|
|
|
+ <CommentPanel
|
|
|
|
|
+ comments={comments as any}
|
|
|
|
|
+ currentUserId="guest"
|
|
|
|
|
+ currentUserRole="EDITOR"
|
|
|
|
|
+ isProjectAdmin={false}
|
|
|
|
|
+ currentTime={currentTime}
|
|
|
|
|
+ pendingAnnotation={pendingStrokes.length > 0 ? pendingStrokes : undefined}
|
|
|
|
|
+ onAddComment={handleAddComment}
|
|
|
|
|
+ onResolve={async () => {}}
|
|
|
|
|
+ onRequestResolve={async () => {}}
|
|
|
|
|
+ onDelete={async () => {}}
|
|
|
|
|
+ onCommentClick={(comment) => {
|
|
|
|
|
+ if (comment.timestamp != null && videoRef.current) {
|
|
|
|
|
+ videoRef.current.currentTime = comment.timestamp;
|
|
|
|
|
+ }
|
|
|
|
|
+ }}
|
|
|
|
|
+ />
|
|
|
</div>
|
|
</div>
|
|
|
)}
|
|
)}
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- {/* Info footer */}
|
|
|
|
|
|
|
+ {/* Footer */}
|
|
|
<div className="shrink-0 px-4 py-3 flex items-center gap-4 text-xs" style={{ color: 'var(--text-muted)', borderTop: '1px solid rgba(255,255,255,0.04)' }}>
|
|
<div className="shrink-0 px-4 py-3 flex items-center gap-4 text-xs" style={{ color: 'var(--text-muted)', borderTop: '1px solid rgba(255,255,255,0.04)' }}>
|
|
|
- {linkInfo?.asset.duration && (
|
|
|
|
|
- <span>{formatTimecode(linkInfo.asset.duration, linkInfo.asset.fps ?? 30, linkInfo.asset.duration ?? 0)}</span>
|
|
|
|
|
- )}
|
|
|
|
|
|
|
+ {linkInfo?.asset.duration && <span>{linkInfo.asset.duration}</span>}
|
|
|
<span>·</span>
|
|
<span>·</span>
|
|
|
<span>{linkInfo?.viewCount} / {linkInfo?.maxViews && linkInfo.maxViews > 0 ? linkInfo.maxViews : '∞'} views</span>
|
|
<span>{linkInfo?.viewCount} / {linkInfo?.maxViews && linkInfo.maxViews > 0 ? linkInfo.maxViews : '∞'} views</span>
|
|
|
{!linkInfo?.allowDownload && <><span>·</span><span>Download disabled</span></>}
|
|
{!linkInfo?.allowDownload && <><span>·</span><span>Download disabled</span></>}
|
|
|
- {linkInfo?.allowUnregisteredComments && !user && (
|
|
|
|
|
- <><span>·</span><span style={{ color: '#A78BFA' }}>Guest comments enabled</span></>
|
|
|
|
|
- )}
|
|
|
|
|
|
|
+ {linkInfo?.allowUnregisteredComments && !user && <><span>·</span><span style={{ color: '#A78BFA' }}>Guest comments enabled</span></>}
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
);
|
|
);
|
|
|
-}
|
|
|
|
|
|
|
+}
|