|
@@ -4,15 +4,17 @@ import { useState, useEffect, useRef } from 'react';
|
|
|
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 } from '@/lib/api';
|
|
|
|
|
|
|
+import { shareLinksApi, ShareLinkVerify, GuestComment } from '@/lib/api';
|
|
|
import { formatTimecode } from '@/lib/format';
|
|
import { formatTimecode } from '@/lib/format';
|
|
|
|
|
|
|
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
|
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
|
|
|
|
|
+const GUEST_NAME_KEY = (token: string) => `vidreview_guest_name_${token}`;
|
|
|
|
|
+const REFRESH_INTERVAL_MS = 30_000;
|
|
|
|
|
|
|
|
export default function SharePage() {
|
|
export default function SharePage() {
|
|
|
const params = useParams();
|
|
const params = useParams();
|
|
|
const router = useRouter();
|
|
const router = useRouter();
|
|
|
- const { user, token } = useAuth();
|
|
|
|
|
|
|
+ const { user, token: authToken } = useAuth();
|
|
|
const tokenParam = params.token as string;
|
|
const tokenParam = params.token as string;
|
|
|
|
|
|
|
|
const [state, setState] = useState<'loading' | 'password' | 'ready' | 'expired' | 'error'>('loading');
|
|
const [state, setState] = useState<'loading' | 'password' | 'ready' | 'expired' | 'error'>('loading');
|
|
@@ -21,19 +23,40 @@ export default function SharePage() {
|
|
|
const [passwordError, setPasswordError] = useState<string | null>(null);
|
|
const [passwordError, setPasswordError] = useState<string | null>(null);
|
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
const [streamUrl, setStreamUrl] = useState<string | null>(null);
|
|
const [streamUrl, setStreamUrl] = useState<string | null>(null);
|
|
|
|
|
+ const [has4K, setHas4K] = useState(false);
|
|
|
|
|
+ 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 [guestNameSubmitted, setGuestNameSubmitted] = useState(false);
|
|
|
|
|
+ const [comments, setComments] = useState<GuestComment[]>([]);
|
|
|
|
|
+ const [commentsTotal, setCommentsTotal] = useState(0);
|
|
|
|
|
+ const [commentLoading, setCommentLoading] = useState(false);
|
|
|
|
|
+ const [commentText, setCommentText] = useState('');
|
|
|
|
|
+ const [commentSubmitting, setCommentSubmitting] = useState(false);
|
|
|
|
|
+ const [commentError, setCommentError] = useState<string | null>(null);
|
|
|
|
|
+
|
|
|
|
|
+ // ── Load guest name from localStorage ───────────────────────────────────────
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ const saved = localStorage.getItem(GUEST_NAME_KEY(tokenParam));
|
|
|
|
|
+ if (saved) {
|
|
|
|
|
+ setGuestName(saved);
|
|
|
|
|
+ setGuestNameSubmitted(true);
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [tokenParam]);
|
|
|
|
|
+
|
|
|
|
|
+ // ── Verify share link ───────────────────────────────────────────────────────
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
async function verify() {
|
|
async function verify() {
|
|
|
try {
|
|
try {
|
|
|
const info = await shareLinksApi.verify(tokenParam);
|
|
const info = await shareLinksApi.verify(tokenParam);
|
|
|
setLinkInfo(info);
|
|
setLinkInfo(info);
|
|
|
|
|
|
|
|
- // If user is logged in, redirect to review page
|
|
|
|
|
- if (user && token) {
|
|
|
|
|
|
|
+ if (user && authToken) {
|
|
|
try {
|
|
try {
|
|
|
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 ${token}` },
|
|
|
|
|
|
|
+ headers: { Authorization: `Bearer ${authToken}` },
|
|
|
});
|
|
});
|
|
|
if (assetRes.ok) {
|
|
if (assetRes.ok) {
|
|
|
router.replace(`/review/${info.asset.id}`);
|
|
router.replace(`/review/${info.asset.id}`);
|
|
@@ -58,7 +81,30 @@ export default function SharePage() {
|
|
|
}
|
|
}
|
|
|
verify();
|
|
verify();
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
- }, [tokenParam, user, token]);
|
|
|
|
|
|
|
+ }, [tokenParam, user, authToken]);
|
|
|
|
|
+
|
|
|
|
|
+ // ── Fetch comments when ready ────────────────────────────────────────────────
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (state !== 'ready' || !linkInfo?.allowUnregisteredComments) return;
|
|
|
|
|
+ loadComments();
|
|
|
|
|
+ const interval = setInterval(loadComments, REFRESH_INTERVAL_MS);
|
|
|
|
|
+ return () => clearInterval(interval);
|
|
|
|
|
+ // eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
+ }, [state, tokenParam, linkInfo?.allowUnregisteredComments]);
|
|
|
|
|
+
|
|
|
|
|
+ async function loadComments() {
|
|
|
|
|
+ if (!linkInfo?.allowUnregisteredComments) return;
|
|
|
|
|
+ setCommentLoading(true);
|
|
|
|
|
+ try {
|
|
|
|
|
+ const data = await shareLinksApi.getComments(tokenParam);
|
|
|
|
|
+ setComments(data.comments);
|
|
|
|
|
+ setCommentsTotal(data.total);
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // Silently fail — comments are non-critical
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setCommentLoading(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
async function fetchAccess(pwd?: string) {
|
|
async function fetchAccess(pwd?: string) {
|
|
|
setSubmitting(true);
|
|
setSubmitting(true);
|
|
@@ -66,6 +112,10 @@ export default function SharePage() {
|
|
|
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);
|
|
|
|
|
+ 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 : '';
|
|
@@ -84,42 +134,116 @@ export default function SharePage() {
|
|
|
fetchAccess(password);
|
|
fetchAccess(password);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ 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);
|
|
|
|
|
+ try {
|
|
|
|
|
+ const data = await shareLinksApi.postGuestComment(tokenParam, {
|
|
|
|
|
+ guestName: guestName.trim(),
|
|
|
|
|
+ content: commentText.trim(),
|
|
|
|
|
+ });
|
|
|
|
|
+ setComments(prev => [...prev, data.comment]);
|
|
|
|
|
+ setCommentsTotal(prev => prev + 1);
|
|
|
|
|
+ setCommentText('');
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ setCommentError(err instanceof Error ? err.message : 'Failed to post comment');
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setCommentSubmitting(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
function goToLogin() {
|
|
function goToLogin() {
|
|
|
- // Redirect to review page after login so user can comment
|
|
|
|
|
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 isHls = !!streamUrl?.endsWith('.m3u8');
|
|
const isHls = !!streamUrl?.endsWith('.m3u8');
|
|
|
|
|
|
|
|
- // Load HLS if needed
|
|
|
|
|
|
|
+ // Load HLS
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
if (!streamUrl || !isHls || !videoRef.current) return;
|
|
if (!streamUrl || !isHls || !videoRef.current) return;
|
|
|
- const video = videoRef.current;
|
|
|
|
|
-
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
|
const Hls = require('hls.js');
|
|
const Hls = require('hls.js');
|
|
|
if (Hls.isSupported()) {
|
|
if (Hls.isSupported()) {
|
|
|
- const hls = new Hls({
|
|
|
|
|
- enableWorker: false,
|
|
|
|
|
- backBufferLength: 30,
|
|
|
|
|
- maxBufferLength: 30,
|
|
|
|
|
- maxBufferSize: 50 * 1024 * 1024,
|
|
|
|
|
- maxMaxBufferSize: 100 * 1024 * 1024,
|
|
|
|
|
- startLevel: -1,
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ const hls = new Hls({ enableWorker: false, backBufferLength: 30, maxBufferLength: 30, maxBufferSize: 50 * 1024 * 1024, maxMaxBufferSize: 100 * 1024 * 1024, startLevel: -1 });
|
|
|
hls.loadSource(streamUrl);
|
|
hls.loadSource(streamUrl);
|
|
|
- hls.attachMedia(video);
|
|
|
|
|
- hls.on(Hls.Events.ERROR, (_event: string, data: { fatal: boolean; details: string }) => {
|
|
|
|
|
|
|
+ hls.attachMedia(videoRef.current);
|
|
|
|
|
+ hls.on(Hls.Events.ERROR, (_: string, data: { fatal: boolean; details: string }) => {
|
|
|
if (data.fatal) console.error('[HLS] Fatal:', data.details);
|
|
if (data.fatal) console.error('[HLS] Fatal:', data.details);
|
|
|
});
|
|
});
|
|
|
return () => { hls.destroy(); };
|
|
return () => { hls.destroy(); };
|
|
|
- } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
|
|
|
|
- video.src = streamUrl;
|
|
|
|
|
|
|
+ } else if (videoRef.current.canPlayType('application/vnd.apple.mpegurl')) {
|
|
|
|
|
+ videoRef.current.src = streamUrl;
|
|
|
}
|
|
}
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
}, [streamUrl, isHls]);
|
|
}, [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 (
|
|
return (
|
|
|
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
|
|
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
|
|
@@ -158,9 +282,7 @@ export default function SharePage() {
|
|
|
</div>
|
|
</div>
|
|
|
<h1 className="text-lg font-semibold mb-2" style={{ color: 'var(--text)' }}>Link Expired</h1>
|
|
<h1 className="text-lg font-semibold mb-2" style={{ color: 'var(--text)' }}>Link Expired</h1>
|
|
|
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>
|
|
<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.'}
|
|
|
|
|
|
|
+ {(linkInfo?.maxViews ?? 0) > 0 ? `This link has reached its view limit (${linkInfo?.maxViews} views).` : 'This share link has expired.'}
|
|
|
</p>
|
|
</p>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
@@ -172,39 +294,17 @@ export default function SharePage() {
|
|
|
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
|
|
<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">
|
|
<div className="text-center max-w-sm w-full px-4">
|
|
|
{linkInfo?.asset.thumbnail && (
|
|
{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 }}
|
|
|
|
|
- />
|
|
|
|
|
|
|
+ <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>
|
|
|
|
|
|
|
+ <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">
|
|
<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'}
|
|
|
|
|
|
|
+ <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>
|
|
</button>
|
|
|
</form>
|
|
</form>
|
|
|
</div>
|
|
</div>
|
|
@@ -212,37 +312,75 @@ export default function SharePage() {
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // ready — full width video with info below
|
|
|
|
|
|
|
+ // ── Ready state ──────────────────────────────────────────────────────────────
|
|
|
return (
|
|
return (
|
|
|
- <div className="min-h-screen" style={{ background: 'var(--bg)' }}>
|
|
|
|
|
|
|
+ <div className="min-h-screen flex flex-col" 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>
|
|
|
|
|
- {linkInfo?.allowDownload && streamUrl && (
|
|
|
|
|
- <a
|
|
|
|
|
- href={streamUrl}
|
|
|
|
|
- download
|
|
|
|
|
|
|
+ <h1 className="text-xs font-medium truncate flex-1" style={{ color: 'var(--text)' }}>{linkInfo?.asset.title}</h1>
|
|
|
|
|
+
|
|
|
|
|
+ {/* 4K badge */}
|
|
|
|
|
+ {has4K && (
|
|
|
|
|
+ <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>
|
|
|
|
|
+ 4K
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* Download */}
|
|
|
|
|
+ {(linkInfo?.allowDownload && streamUrl) && (
|
|
|
|
|
+ <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}>
|
|
<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" />
|
|
<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>
|
|
|
- Download
|
|
|
|
|
|
|
+ {has4K ? 'Download 4K' : 'Download'}
|
|
|
</a>
|
|
</a>
|
|
|
)}
|
|
)}
|
|
|
- {!user && (
|
|
|
|
|
- <button
|
|
|
|
|
- onClick={goToLogin}
|
|
|
|
|
|
|
+
|
|
|
|
|
+ {/* Guest name input or badge */}
|
|
|
|
|
+ {linkInfo?.allowUnregisteredComments && !user && (
|
|
|
|
|
+ guestNameSubmitted ? (
|
|
|
|
|
+ <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>
|
|
|
|
|
+ {guestName}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <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' }}
|
|
|
|
|
+ />
|
|
|
|
|
+ <button type="submit" disabled={!guestName.trim()}
|
|
|
|
|
+ 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>
|
|
|
|
|
+ </form>
|
|
|
|
|
+ )
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* Login link */}
|
|
|
|
|
+ {!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"
|
|
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}>
|
|
<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" />
|
|
<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>
|
|
@@ -251,55 +389,89 @@ export default function SharePage() {
|
|
|
)}
|
|
)}
|
|
|
</header>
|
|
</header>
|
|
|
|
|
|
|
|
- {/* Video — full width, maximize space */}
|
|
|
|
|
- <div className="w-full" style={{ background: '#000' }}>
|
|
|
|
|
- {streamUrl ? (
|
|
|
|
|
- <div className="max-w-6xl mx-auto" style={{ aspectRatio: '16/9', maxHeight: '85vh' }}>
|
|
|
|
|
- {isHls ? (
|
|
|
|
|
- <video
|
|
|
|
|
- ref={videoRef}
|
|
|
|
|
- className="w-full h-full"
|
|
|
|
|
- controls
|
|
|
|
|
- playsInline
|
|
|
|
|
- />
|
|
|
|
|
- ) : (
|
|
|
|
|
- <video
|
|
|
|
|
- ref={videoRef}
|
|
|
|
|
- src={streamUrl}
|
|
|
|
|
- className="w-full h-full"
|
|
|
|
|
- controls
|
|
|
|
|
- playsInline
|
|
|
|
|
- />
|
|
|
|
|
- )}
|
|
|
|
|
- </div>
|
|
|
|
|
- ) : !linkInfo?.asset.videoReady ? (
|
|
|
|
|
- <div className="w-full flex items-center justify-center" style={{ aspectRatio: '16/9', maxHeight: '85vh' }}>
|
|
|
|
|
- <div className="text-center">
|
|
|
|
|
- <div className="w-8 h-8 rounded-full animate-spin mx-auto mb-3" style={{ borderColor: '#6366F1', borderTopColor: 'transparent', borderWidth: '2px' }} />
|
|
|
|
|
|
|
+ {/* 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 }}>
|
|
|
|
|
+ {streamUrl ? (
|
|
|
|
|
+ <div className="w-full max-w-6xl mx-auto" style={{ aspectRatio: '16/9', maxHeight: '85vh' }}>
|
|
|
|
|
+ {isHls ? (
|
|
|
|
|
+ <video ref={videoRef} className="w-full h-full" controls playsInline />
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <video ref={videoRef} src={streamUrl} className="w-full h-full" controls playsInline />
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : !linkInfo?.asset.videoReady ? (
|
|
|
|
|
+ <div className="flex flex-col items-center justify-center" style={{ aspectRatio: '16/9', maxHeight: '85vh' }}>
|
|
|
|
|
+ <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>
|
|
|
|
|
+ ) : null}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Comment panel */}
|
|
|
|
|
+ {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>}
|
|
|
|
|
+ <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>
|
|
</div>
|
|
|
- ) : null}
|
|
|
|
|
|
|
+ )}
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- {/* Info below video */}
|
|
|
|
|
- <div className="max-w-6xl mx-auto px-4 pt-4 pb-8">
|
|
|
|
|
- <div className="flex items-center gap-4 text-xs" style={{ color: 'var(--text-muted)' }}>
|
|
|
|
|
- {linkInfo?.asset.duration && (
|
|
|
|
|
- <span>{formatTimecode(linkInfo.asset.duration, linkInfo.asset.fps ?? 30, linkInfo.asset.duration ?? 0)}</span>
|
|
|
|
|
- )}
|
|
|
|
|
- <span>·</span>
|
|
|
|
|
- <span>
|
|
|
|
|
- {linkInfo?.viewCount} / {linkInfo?.maxViews && linkInfo.maxViews > 0 ? linkInfo.maxViews : '∞'} views
|
|
|
|
|
- </span>
|
|
|
|
|
- {!linkInfo?.allowDownload && (
|
|
|
|
|
- <>
|
|
|
|
|
- <span>·</span>
|
|
|
|
|
- <span>Download disabled</span>
|
|
|
|
|
- </>
|
|
|
|
|
- )}
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ {/* Info 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)' }}>
|
|
|
|
|
+ {linkInfo?.asset.duration && (
|
|
|
|
|
+ <span>{formatTimecode(linkInfo.asset.duration, linkInfo.asset.fps ?? 30, linkInfo.asset.duration ?? 0)}</span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ <span>·</span>
|
|
|
|
|
+ <span>{linkInfo?.viewCount} / {linkInfo?.maxViews && linkInfo.maxViews > 0 ? linkInfo.maxViews : '∞'} views</span>
|
|
|
|
|
+ {!linkInfo?.allowDownload && <><span>·</span><span>Download disabled</span></>}
|
|
|
|
|
+ {linkInfo?.allowUnregisteredComments && !user && (
|
|
|
|
|
+ <><span>·</span><span style={{ color: '#A78BFA' }}>Guest comments enabled</span></>
|
|
|
|
|
+ )}
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
);
|
|
);
|
|
|
-}
|
|
|
|
|
|
|
+}
|