mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat(share): add video-level secure share links with password unlock and session-based watch/comment access
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface ShareLinkBootstrapProps {
|
||||
videoId: string;
|
||||
shareToken: string;
|
||||
}
|
||||
|
||||
export function ShareLinkBootstrap({ videoId, shareToken }: ShareLinkBootstrapProps) {
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
async function establishSession() {
|
||||
try {
|
||||
const response = await fetch(`/watch/${videoId}/session`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ shareToken }),
|
||||
});
|
||||
|
||||
if (isCancelled) return;
|
||||
|
||||
if (response.ok) {
|
||||
router.replace(`/watch/${videoId}`);
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as { requiresPassword?: boolean; error?: string } | null;
|
||||
if (payload?.requiresPassword) {
|
||||
router.replace(`/watch/${videoId}?unlock=1`);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(payload?.error || 'Invalid or expired share link');
|
||||
} catch {
|
||||
if (!isCancelled) {
|
||||
setError('Failed to open share link');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void establishSession();
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [router, shareToken, videoId]);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-background px-4">
|
||||
<div className="w-full max-w-sm rounded-xl border bg-card p-6 shadow-sm text-center space-y-3">
|
||||
<div className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||||
</div>
|
||||
<h1 className="text-lg font-semibold">Opening shared video</h1>
|
||||
<p className="text-sm text-muted-foreground">{error || 'Verifying link access...'}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Lock, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
interface ShareLinkUnlockProps {
|
||||
videoId: string;
|
||||
}
|
||||
|
||||
export function ShareLinkUnlock({ videoId }: ShareLinkUnlockProps) {
|
||||
const router = useRouter();
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const submitPassword = async () => {
|
||||
if (!password.trim()) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/watch/${videoId}/session`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||
setError(payload?.error || 'Invalid password');
|
||||
return;
|
||||
}
|
||||
|
||||
router.replace(`/watch/${videoId}`);
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError('Failed to verify password');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-background px-4">
|
||||
<div className="w-full max-w-sm rounded-xl border bg-card p-6 shadow-sm">
|
||||
<div className="text-center mb-5">
|
||||
<div className="inline-flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 mb-3">
|
||||
<Lock className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold">Password Required</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Enter the password to continue to the shared video.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
void submitPassword();
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<Button className="w-full" disabled={isSubmitting} onClick={() => void submitPassword()}>
|
||||
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Continue'}
|
||||
</Button>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground text-center mt-4">
|
||||
Or <Link href="/login" className="underline hover:text-foreground">sign in</Link> with your account
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
Link as LinkIcon,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Share2,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
@@ -245,16 +249,25 @@ export function VideoCard({ video, projectId, onDeleted }: VideoCardProps) {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/projects/${projectId}/videos/${video.id}/share`}>
|
||||
<Share2 className="mr-2 h-4 w-4" />
|
||||
Share
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setShowEditDialog(true)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setShowVersionDialog(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Version
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onSelect={() => setShowDeleteDialog(true)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
Image as ImageIcon,
|
||||
Download,
|
||||
FileText,
|
||||
Share2,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -386,6 +387,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
}, [mode]);
|
||||
|
||||
const isGuest = video ? !video.isAuthenticated : false;
|
||||
const canInitializePlayer = mode !== 'watch' || !isGuest || guestNameConfirmed;
|
||||
|
||||
const [showVersionDialog, setShowVersionDialog] = useState(false);
|
||||
const [newVersionUrl, setNewVersionUrl] = useState('');
|
||||
@@ -505,7 +507,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
|
||||
const apiBasePath = mode === 'dashboard'
|
||||
? `/api/projects/${propProjectId}/videos/${videoId}`
|
||||
: `/api/watch/${videoId}`;
|
||||
: `/api/watch/${videoId}?includeComments=true`;
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchVideo() {
|
||||
@@ -641,7 +643,10 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
const embedUrl = useMemo(() => {
|
||||
if (!activeVersion) return '';
|
||||
if (activeVersion.providerId === 'youtube') {
|
||||
return `https://www.youtube.com/embed/${activeVersion.videoId}?enablejsapi=1&rel=0&modestbranding=1&controls=0&showinfo=0&iv_load_policy=3&disablekb=1`;
|
||||
const base = `https://www.youtube.com/embed/${activeVersion.videoId}?enablejsapi=1&rel=0&modestbranding=1&controls=0&showinfo=0&iv_load_policy=3&disablekb=1`;
|
||||
if (typeof window === 'undefined') return base;
|
||||
const origin = window.location.origin;
|
||||
return `${base}&origin=${encodeURIComponent(origin)}`;
|
||||
}
|
||||
if (activeVersion.providerId === 'bunny') {
|
||||
return `https://${BUNNY_PULL_ZONE_HOSTNAME}/${activeVersion.videoId}/playlist.m3u8`;
|
||||
@@ -703,6 +708,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
}, [isApiLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canInitializePlayer) return;
|
||||
if (!activeProviderId) return;
|
||||
const isYoutube = activeProviderId === 'youtube';
|
||||
const isBunny = activeProviderId === 'bunny';
|
||||
@@ -1041,7 +1047,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
bunnyRetryTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [activeProviderId, activeVersionId, embedUrl, isApiLoaded, video?.isAuthenticated, videoId]);
|
||||
}, [activeProviderId, activeVersionId, embedUrl, isApiLoaded, video?.isAuthenticated, videoId, canInitializePlayer]);
|
||||
|
||||
// Save detected duration to DB if the version doesn't have one stored
|
||||
useEffect(() => {
|
||||
@@ -2884,6 +2890,12 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
|
||||
{mode === 'dashboard' && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/projects/${projectId}/videos/${videoId}/share`}>
|
||||
<Share2 className="h-4 w-4 mr-1" />
|
||||
Share Video
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="hidden sm:flex items-center gap-2">
|
||||
<Dialog open={showVersionDialog} onOpenChange={setShowVersionDialog}>
|
||||
<DialogTrigger asChild>
|
||||
@@ -3138,6 +3150,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="absolute inset-0 w-full h-full border-0"
|
||||
referrerPolicy="origin-when-cross-origin"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user