mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
refactor: eslint and prettier conflict will be resolved and formatted
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
|
||||
function VideoCardSkeleton() {
|
||||
return (
|
||||
@@ -14,7 +14,7 @@ function VideoCardSkeleton() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProjectLoading() {
|
||||
@@ -49,5 +49,5 @@ export default function ProjectLoading() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FolderX } from "lucide-react";
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FolderX } from 'lucide-react';
|
||||
|
||||
export default function ProjectNotFound() {
|
||||
return (
|
||||
@@ -9,7 +9,8 @@ export default function ProjectNotFound() {
|
||||
<FolderX className="h-12 w-12 text-muted-foreground" />
|
||||
<h1 className="text-2xl font-bold">Project Not Found</h1>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
The project you're looking for doesn't exist or you don't have access to it.
|
||||
The project you're looking for doesn't exist or you don't have access to
|
||||
it.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import Link from 'next/link';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import {
|
||||
ArrowLeft,
|
||||
} from 'lucide-react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { GuestGate } from '@/components/guest-gate';
|
||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
@@ -120,8 +118,8 @@ export default async function ProjectPage({ params, searchParams }: ProjectPageP
|
||||
},
|
||||
}),
|
||||
db.video.count({
|
||||
where: { projectId: project.id }
|
||||
})
|
||||
where: { projectId: project.id },
|
||||
}),
|
||||
]);
|
||||
|
||||
const totalPages = Math.ceil(totalVideos / pageSize);
|
||||
@@ -132,7 +130,8 @@ export default async function ProjectPage({ params, searchParams }: ProjectPageP
|
||||
return {
|
||||
id: video.id,
|
||||
title: video.title,
|
||||
thumbnailUrl: activeVersion?.thumbnailUrl || 'https://via.placeholder.com/320x180?text=No+Thumbnail',
|
||||
thumbnailUrl:
|
||||
activeVersion?.thumbnailUrl || 'https://via.placeholder.com/320x180?text=No+Thumbnail',
|
||||
currentVersion: video._count.versions,
|
||||
commentCount: activeVersion?._count.comments || 0,
|
||||
duration: formatDuration(activeVersion?.duration),
|
||||
@@ -141,7 +140,12 @@ export default async function ProjectPage({ params, searchParams }: ProjectPageP
|
||||
};
|
||||
});
|
||||
|
||||
const canEdit = access.canEdit && (isOwner || project.members[0]?.role === 'ADMIN' || workspaceRole === 'OWNER' || workspaceRole === 'ADMIN');
|
||||
const canEdit =
|
||||
access.canEdit &&
|
||||
(isOwner ||
|
||||
project.members[0]?.role === 'ADMIN' ||
|
||||
workspaceRole === 'OWNER' ||
|
||||
workspaceRole === 'ADMIN');
|
||||
const isAuthenticated = !!session?.user?.id;
|
||||
|
||||
const projectData = {
|
||||
|
||||
@@ -57,7 +57,7 @@ export function ProjectContentClient({
|
||||
canEdit,
|
||||
isOwner,
|
||||
totalPages,
|
||||
currentPage
|
||||
currentPage,
|
||||
}: ProjectContentClientProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -115,7 +115,10 @@ export function ProjectContentClient({
|
||||
<div className="flex items-center gap-2">
|
||||
{project.workspace && (
|
||||
<Link href={`/workspaces/${project.workspace.id}`}>
|
||||
<Badge variant="secondary" className="flex items-center gap-1 hover:bg-accent transition-colors">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1 hover:bg-accent transition-colors"
|
||||
>
|
||||
<Building2 className="h-3 w-3" />
|
||||
{project.workspace.name}
|
||||
</Badge>
|
||||
@@ -221,16 +224,13 @@ export function ProjectContentClient({
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-8 flex items-center justify-end space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage <= 1}
|
||||
asChild={currentPage > 1}
|
||||
>
|
||||
<Button variant="outline" size="sm" disabled={currentPage <= 1} asChild={currentPage > 1}>
|
||||
{currentPage > 1 ? (
|
||||
<Link href={`?${createQueryString('page', (currentPage - 1).toString())}`}>Previous</Link>
|
||||
<Link href={`?${createQueryString('page', (currentPage - 1).toString())}`}>
|
||||
Previous
|
||||
</Link>
|
||||
) : (
|
||||
"Previous"
|
||||
'Previous'
|
||||
)}
|
||||
</Button>
|
||||
<span className="text-sm font-medium">
|
||||
@@ -245,7 +245,7 @@ export function ProjectContentClient({
|
||||
{currentPage < totalPages ? (
|
||||
<Link href={`?${createQueryString('page', (currentPage + 1).toString())}`}>Next</Link>
|
||||
) : (
|
||||
"Next"
|
||||
'Next'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Card, CardHeader, CardContent } from "@/components/ui/card"
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/card';
|
||||
|
||||
export default function ProjectSettingsLoading() {
|
||||
return (
|
||||
@@ -63,5 +63,5 @@ export default function ProjectSettingsLoading() {
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,18 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, Copy, Check, Loader2, UserPlus, Share2, Globe, Lock, Mail, X } from 'lucide-react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Copy,
|
||||
Check,
|
||||
Loader2,
|
||||
UserPlus,
|
||||
Share2,
|
||||
Globe,
|
||||
Lock,
|
||||
Mail,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -11,323 +22,323 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
|
||||
interface ProjectMember {
|
||||
id: string;
|
||||
role: string;
|
||||
user: {
|
||||
id: string;
|
||||
role: string;
|
||||
user: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
};
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface ProjectSharePageProps {
|
||||
projectId: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export default function ProjectSharePageClient({ projectId }: ProjectSharePageProps) {
|
||||
const [projectName, setProjectName] = useState('');
|
||||
const [projectVisibility, setProjectVisibility] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [members, setMembers] = useState<ProjectMember[]>([]);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [inviteEmail, setInviteEmail] = useState('');
|
||||
const [isInviting, setIsInviting] = useState(false);
|
||||
const [inviteSuccess, setInviteSuccess] = useState('');
|
||||
const [projectName, setProjectName] = useState('');
|
||||
const [projectVisibility, setProjectVisibility] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [members, setMembers] = useState<ProjectMember[]>([]);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [inviteEmail, setInviteEmail] = useState('');
|
||||
const [isInviting, setIsInviting] = useState(false);
|
||||
const [inviteSuccess, setInviteSuccess] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/projects/${projectId}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
setError(data.error);
|
||||
} else {
|
||||
const project = data.data;
|
||||
setProjectName(project.name || '');
|
||||
setProjectVisibility(project.visibility || 'PRIVATE');
|
||||
setMembers(project.members || []);
|
||||
}
|
||||
})
|
||||
.catch(() => setError('Failed to load project'))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [projectId]);
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const getDirectLink = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return `${window.location.origin}/projects/${projectId}`;
|
||||
useEffect(() => {
|
||||
fetch(`/api/projects/${projectId}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
setError(data.error);
|
||||
} else {
|
||||
const project = data.data;
|
||||
setProjectName(project.name || '');
|
||||
setProjectVisibility(project.visibility || 'PRIVATE');
|
||||
setMembers(project.members || []);
|
||||
}
|
||||
return `/projects/${projectId}`;
|
||||
};
|
||||
})
|
||||
.catch(() => setError('Failed to load project'))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [projectId]);
|
||||
|
||||
const handleInvite = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!inviteEmail.trim()) return;
|
||||
const copyToClipboard = async (text: string) => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
setIsInviting(true);
|
||||
setError('');
|
||||
setInviteSuccess('');
|
||||
|
||||
try {
|
||||
// TODO: Implement invite API
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
setInviteSuccess(`Invitation sent to ${inviteEmail}`);
|
||||
setInviteEmail('');
|
||||
setTimeout(() => setInviteSuccess(''), 3000);
|
||||
} catch {
|
||||
setError('Failed to send invitation');
|
||||
} finally {
|
||||
setIsInviting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const VisibilityIcon = () => {
|
||||
switch (projectVisibility) {
|
||||
case 'PUBLIC':
|
||||
return <Globe className="h-5 w-5" />;
|
||||
case 'INVITE':
|
||||
return <UserPlus className="h-5 w-5" />;
|
||||
default:
|
||||
return <Lock className="h-5 w-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getVisibilityColor = () => {
|
||||
switch (projectVisibility) {
|
||||
case 'PUBLIC':
|
||||
return 'bg-green-500/10 text-green-500';
|
||||
case 'INVITE':
|
||||
return 'bg-blue-500/10 text-blue-500';
|
||||
default:
|
||||
return 'bg-orange-500/10 text-orange-500';
|
||||
}
|
||||
};
|
||||
|
||||
const getVisibilityLabel = () => {
|
||||
switch (projectVisibility) {
|
||||
case 'PUBLIC':
|
||||
return { title: 'Public', description: 'Anyone with the link can view this project' };
|
||||
case 'INVITE':
|
||||
return { title: 'Invite Only', description: 'Only people you invite can access' };
|
||||
default:
|
||||
return { title: 'Private', description: 'Only you can access this project' };
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-4rem)] flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
const getDirectLink = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return `${window.location.origin}/projects/${projectId}`;
|
||||
}
|
||||
return `/projects/${projectId}`;
|
||||
};
|
||||
|
||||
const visibilityInfo = getVisibilityLabel();
|
||||
const handleInvite = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!inviteEmail.trim()) return;
|
||||
|
||||
setIsInviting(true);
|
||||
setError('');
|
||||
setInviteSuccess('');
|
||||
|
||||
try {
|
||||
// TODO: Implement invite API
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
setInviteSuccess(`Invitation sent to ${inviteEmail}`);
|
||||
setInviteEmail('');
|
||||
setTimeout(() => setInviteSuccess(''), 3000);
|
||||
} catch {
|
||||
setError('Failed to send invitation');
|
||||
} finally {
|
||||
setIsInviting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const VisibilityIcon = () => {
|
||||
switch (projectVisibility) {
|
||||
case 'PUBLIC':
|
||||
return <Globe className="h-5 w-5" />;
|
||||
case 'INVITE':
|
||||
return <UserPlus className="h-5 w-5" />;
|
||||
default:
|
||||
return <Lock className="h-5 w-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getVisibilityColor = () => {
|
||||
switch (projectVisibility) {
|
||||
case 'PUBLIC':
|
||||
return 'bg-green-500/10 text-green-500';
|
||||
case 'INVITE':
|
||||
return 'bg-blue-500/10 text-blue-500';
|
||||
default:
|
||||
return 'bg-orange-500/10 text-orange-500';
|
||||
}
|
||||
};
|
||||
|
||||
const getVisibilityLabel = () => {
|
||||
switch (projectVisibility) {
|
||||
case 'PUBLIC':
|
||||
return { title: 'Public', description: 'Anyone with the link can view this project' };
|
||||
case 'INVITE':
|
||||
return { title: 'Invite Only', description: 'Only people you invite can access' };
|
||||
default:
|
||||
return { title: 'Private', description: 'Only you can access this project' };
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-4rem)] flex items-start justify-center py-12 px-4">
|
||||
<div className="w-full max-w-xl">
|
||||
<div className="mb-8">
|
||||
<Link
|
||||
href={`/projects/${projectId}`}
|
||||
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
Back to Project
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Header Card */}
|
||||
<Card className="border-border/50 shadow-lg">
|
||||
<CardHeader className="text-center pb-2">
|
||||
<div className="mx-auto w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center mb-4">
|
||||
<Share2 className="h-7 w-7 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Share Project</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
Share "{projectName}" with your team or clients
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
{/* Visibility Status */}
|
||||
<div className={`flex items-center gap-3 p-4 rounded-xl ${getVisibilityColor()}`}>
|
||||
<div className="w-10 h-10 rounded-lg bg-current/10 flex items-center justify-center">
|
||||
<VisibilityIcon />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{visibilityInfo.title}</div>
|
||||
<div className="text-sm opacity-80">{visibilityInfo.description}</div>
|
||||
</div>
|
||||
<Link href={`/projects/${projectId}/settings`}>
|
||||
<Button variant="ghost" size="sm" className="text-current hover:bg-current/10">
|
||||
Change
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Invite People - Only show for INVITE visibility */}
|
||||
{projectVisibility === 'INVITE' && (
|
||||
<Card className="border-border/50 shadow-lg">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Mail className="h-5 w-5 text-primary" />
|
||||
Invite People
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Send email invitations to specific people
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<form onSubmit={handleInvite} className="flex gap-2">
|
||||
<Input
|
||||
type="email"
|
||||
value={inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
placeholder="[email protected]"
|
||||
className="h-11 flex-1"
|
||||
disabled={isInviting}
|
||||
/>
|
||||
<Button type="submit" disabled={isInviting || !inviteEmail.trim()} className="h-11">
|
||||
{isInviting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<UserPlus className="h-4 w-4 mr-2" />
|
||||
Invite
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{inviteSuccess && (
|
||||
<div className="p-3 rounded-lg bg-green-500/10 border border-green-500/20 text-green-500 text-sm">
|
||||
{inviteSuccess}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current Members */}
|
||||
{members.length > 0 && (
|
||||
<div className="space-y-2 pt-2">
|
||||
<Label className="text-sm text-muted-foreground">Project Members</Label>
|
||||
<div className="space-y-2">
|
||||
{members.map((member) => (
|
||||
<div
|
||||
key={member.id}
|
||||
className="flex items-center justify-between p-3 rounded-xl border bg-card"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarFallback className="text-xs">
|
||||
{member.user.name?.charAt(0) || member.user.email?.charAt(0) || '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="font-medium text-sm">
|
||||
{member.user.name || 'Unknown'}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{member.user.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs capitalize">
|
||||
{member.role.toLowerCase()}
|
||||
</Badge>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-destructive">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{members.length === 0 && (
|
||||
<div className="text-center py-6 text-muted-foreground">
|
||||
<UserPlus className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No members yet</p>
|
||||
<p className="text-xs opacity-70">Invite people to collaborate on this project</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Public Link - Only show for PUBLIC visibility */}
|
||||
{projectVisibility === 'PUBLIC' && (
|
||||
<Card className="border-border/50 shadow-lg">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Globe className="h-5 w-5 text-primary" />
|
||||
Public Link
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Share this link with anyone
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={getDirectLink()}
|
||||
readOnly
|
||||
className="font-mono text-sm h-11 bg-muted/50"
|
||||
/>
|
||||
<Button
|
||||
variant={copied ? 'default' : 'outline'}
|
||||
size="icon"
|
||||
className="h-11 w-11 shrink-0"
|
||||
onClick={() => copyToClipboard(getDirectLink())}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Private notice */}
|
||||
{projectVisibility === 'PRIVATE' && (
|
||||
<Card className="border-border/50 shadow-lg">
|
||||
<CardContent className="py-8">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-muted/50 flex items-center justify-center mx-auto mb-4">
|
||||
<Lock className="h-8 w-8 text-muted-foreground/50" />
|
||||
</div>
|
||||
<h3 className="font-medium mb-1">This project is private</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Only you can access this project. Change visibility to share with others.
|
||||
</p>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/projects/${projectId}/settings`}>
|
||||
Change Visibility
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-4 rounded-lg bg-destructive/10 border border-destructive/20 text-destructive text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-[calc(100vh-4rem)] flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const visibilityInfo = getVisibilityLabel();
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-4rem)] flex items-start justify-center py-12 px-4">
|
||||
<div className="w-full max-w-xl">
|
||||
<div className="mb-8">
|
||||
<Link
|
||||
href={`/projects/${projectId}`}
|
||||
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
Back to Project
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Header Card */}
|
||||
<Card className="border-border/50 shadow-lg">
|
||||
<CardHeader className="text-center pb-2">
|
||||
<div className="mx-auto w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center mb-4">
|
||||
<Share2 className="h-7 w-7 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Share Project</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
Share "{projectName}" with your team or clients
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
{/* Visibility Status */}
|
||||
<div className={`flex items-center gap-3 p-4 rounded-xl ${getVisibilityColor()}`}>
|
||||
<div className="w-10 h-10 rounded-lg bg-current/10 flex items-center justify-center">
|
||||
<VisibilityIcon />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{visibilityInfo.title}</div>
|
||||
<div className="text-sm opacity-80">{visibilityInfo.description}</div>
|
||||
</div>
|
||||
<Link href={`/projects/${projectId}/settings`}>
|
||||
<Button variant="ghost" size="sm" className="text-current hover:bg-current/10">
|
||||
Change
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Invite People - Only show for INVITE visibility */}
|
||||
{projectVisibility === 'INVITE' && (
|
||||
<Card className="border-border/50 shadow-lg">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Mail className="h-5 w-5 text-primary" />
|
||||
Invite People
|
||||
</CardTitle>
|
||||
<CardDescription>Send email invitations to specific people</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<form onSubmit={handleInvite} className="flex gap-2">
|
||||
<Input
|
||||
type="email"
|
||||
value={inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
placeholder="[email protected]"
|
||||
className="h-11 flex-1"
|
||||
disabled={isInviting}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isInviting || !inviteEmail.trim()}
|
||||
className="h-11"
|
||||
>
|
||||
{isInviting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<UserPlus className="h-4 w-4 mr-2" />
|
||||
Invite
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{inviteSuccess && (
|
||||
<div className="p-3 rounded-lg bg-green-500/10 border border-green-500/20 text-green-500 text-sm">
|
||||
{inviteSuccess}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current Members */}
|
||||
{members.length > 0 && (
|
||||
<div className="space-y-2 pt-2">
|
||||
<Label className="text-sm text-muted-foreground">Project Members</Label>
|
||||
<div className="space-y-2">
|
||||
{members.map((member) => (
|
||||
<div
|
||||
key={member.id}
|
||||
className="flex items-center justify-between p-3 rounded-xl border bg-card"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarFallback className="text-xs">
|
||||
{member.user.name?.charAt(0) || member.user.email?.charAt(0) || '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="font-medium text-sm">
|
||||
{member.user.name || 'Unknown'}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{member.user.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs capitalize">
|
||||
{member.role.toLowerCase()}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{members.length === 0 && (
|
||||
<div className="text-center py-6 text-muted-foreground">
|
||||
<UserPlus className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No members yet</p>
|
||||
<p className="text-xs opacity-70">
|
||||
Invite people to collaborate on this project
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Public Link - Only show for PUBLIC visibility */}
|
||||
{projectVisibility === 'PUBLIC' && (
|
||||
<Card className="border-border/50 shadow-lg">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Globe className="h-5 w-5 text-primary" />
|
||||
Public Link
|
||||
</CardTitle>
|
||||
<CardDescription>Share this link with anyone</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={getDirectLink()}
|
||||
readOnly
|
||||
className="font-mono text-sm h-11 bg-muted/50"
|
||||
/>
|
||||
<Button
|
||||
variant={copied ? 'default' : 'outline'}
|
||||
size="icon"
|
||||
className="h-11 w-11 shrink-0"
|
||||
onClick={() => copyToClipboard(getDirectLink())}
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Private notice */}
|
||||
{projectVisibility === 'PRIVATE' && (
|
||||
<Card className="border-border/50 shadow-lg">
|
||||
<CardContent className="py-8">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-muted/50 flex items-center justify-center mx-auto mb-4">
|
||||
<Lock className="h-8 w-8 text-muted-foreground/50" />
|
||||
</div>
|
||||
<h3 className="font-medium mb-1">This project is private</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Only you can access this project. Change visibility to share with others.
|
||||
</p>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/projects/${projectId}/settings`}>Change Visibility</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-4 rounded-lg bg-destructive/10 border border-destructive/20 text-destructive text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+213
-101
@@ -100,7 +100,13 @@ const isSafeUrl = (url: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
export default function CompareVersionsPageClient({ projectId, videoId }: { projectId: string; videoId: string }) {
|
||||
export default function CompareVersionsPageClient({
|
||||
projectId,
|
||||
videoId,
|
||||
}: {
|
||||
projectId: string;
|
||||
videoId: string;
|
||||
}) {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [video, setVideo] = useState<VideoData | null>(null);
|
||||
@@ -164,7 +170,9 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
|
||||
useEffect(() => {
|
||||
async function fetchVideo() {
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}/videos/${videoId}?includeComments=false`);
|
||||
const res = await fetch(
|
||||
`/api/projects/${projectId}/videos/${videoId}?includeComments=false`
|
||||
);
|
||||
if (!res.ok) {
|
||||
setError('Failed to load video');
|
||||
setLoading(false);
|
||||
@@ -176,9 +184,9 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
|
||||
|
||||
const versionsParam = searchParams.get('versions');
|
||||
if (versionsParam) {
|
||||
const ids = versionsParam.split(',').filter((id) =>
|
||||
data.versions.some((v: Version) => v.id === id)
|
||||
);
|
||||
const ids = versionsParam
|
||||
.split(',')
|
||||
.filter((id) => data.versions.some((v: Version) => v.id === id));
|
||||
if (ids.length >= 2) {
|
||||
setPanelVersionIds(ids);
|
||||
} else {
|
||||
@@ -291,12 +299,23 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
|
||||
const playing = state === window.YT?.PlayerState?.PLAYING;
|
||||
|
||||
if (playing) {
|
||||
players.forEach((p) => { try { p.pauseVideo(); } catch { /* */ } });
|
||||
players.forEach((p) => {
|
||||
try {
|
||||
p.pauseVideo();
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
});
|
||||
setIsPlaying(false);
|
||||
} else {
|
||||
const t = firstPlayer.getCurrentTime();
|
||||
players.forEach((p) => {
|
||||
try { p.seekTo(t, true); p.playVideo(); } catch { /* */ }
|
||||
try {
|
||||
p.seekTo(t, true);
|
||||
p.playVideo();
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
});
|
||||
setIsPlaying(true);
|
||||
}
|
||||
@@ -307,36 +326,48 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
|
||||
|
||||
const handleSeek = useCallback((time: number) => {
|
||||
const players = Array.from(playersRef.current.values());
|
||||
players.forEach((p) => { try { p.seekTo(time, true); } catch { /* */ } });
|
||||
players.forEach((p) => {
|
||||
try {
|
||||
p.seekTo(time, true);
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
});
|
||||
setCurrentTime(time);
|
||||
}, []);
|
||||
|
||||
const handleTimelineMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (!timelineRef.current || durationRef.current <= 0) return;
|
||||
setIsDragging(true);
|
||||
const rect = timelineRef.current.getBoundingClientRect();
|
||||
const fraction = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const time = fraction * durationRef.current;
|
||||
currentTimeRef.current = time;
|
||||
setCurrentTime(time);
|
||||
handleSeek(time);
|
||||
}, [handleSeek]);
|
||||
const handleTimelineMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!timelineRef.current || durationRef.current <= 0) return;
|
||||
setIsDragging(true);
|
||||
const rect = timelineRef.current.getBoundingClientRect();
|
||||
const fraction = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const time = fraction * durationRef.current;
|
||||
currentTimeRef.current = time;
|
||||
setCurrentTime(time);
|
||||
handleSeek(time);
|
||||
},
|
||||
[handleSeek]
|
||||
);
|
||||
|
||||
const handleTimelineMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
if (!isDragging || !timelineRef.current || durationRef.current <= 0) return;
|
||||
const rect = timelineRef.current.getBoundingClientRect();
|
||||
const fraction = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const time = fraction * durationRef.current;
|
||||
currentTimeRef.current = time;
|
||||
setCurrentTime(time);
|
||||
// Keep DOM in sync while the RAF loop is paused during drag
|
||||
const pct = fraction * 100;
|
||||
if (progressBarRef.current) progressBarRef.current.style.width = `${pct}%`;
|
||||
if (playheadRef.current) playheadRef.current.style.left = `calc(${pct}% - 2px)`;
|
||||
if (timecodeRef.current) {
|
||||
timecodeRef.current.textContent = `${formatTime(time)} / ${formatTime(durationRef.current)}`;
|
||||
}
|
||||
}, [isDragging]);
|
||||
const handleTimelineMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!isDragging || !timelineRef.current || durationRef.current <= 0) return;
|
||||
const rect = timelineRef.current.getBoundingClientRect();
|
||||
const fraction = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const time = fraction * durationRef.current;
|
||||
currentTimeRef.current = time;
|
||||
setCurrentTime(time);
|
||||
// Keep DOM in sync while the RAF loop is paused during drag
|
||||
const pct = fraction * 100;
|
||||
if (progressBarRef.current) progressBarRef.current.style.width = `${pct}%`;
|
||||
if (playheadRef.current) playheadRef.current.style.left = `calc(${pct}% - 2px)`;
|
||||
if (timecodeRef.current) {
|
||||
timecodeRef.current.textContent = `${formatTime(time)} / ${formatTime(durationRef.current)}`;
|
||||
}
|
||||
},
|
||||
[isDragging]
|
||||
);
|
||||
|
||||
const handleTimelineMouseUp = useCallback(() => {
|
||||
if (!isDragging) return;
|
||||
@@ -399,7 +430,8 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
||||
return;
|
||||
|
||||
const players = Array.from(playersRef.current.values());
|
||||
if (players.length === 0) return;
|
||||
@@ -430,8 +462,14 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
|
||||
e.preventDefault();
|
||||
players.forEach((p) => {
|
||||
try {
|
||||
if (p.isMuted?.()) { p.unMute?.(); } else { p.mute?.(); }
|
||||
} catch { /* */ }
|
||||
if (p.isMuted?.()) {
|
||||
p.unMute?.();
|
||||
} else {
|
||||
p.mute?.();
|
||||
}
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
@@ -442,28 +480,31 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
|
||||
}, [handlePlayPause, handleSeek]);
|
||||
|
||||
// Fetch comments for a version
|
||||
const toggleComments = useCallback(async (versionId: string) => {
|
||||
if (openCommentsPanel === versionId) {
|
||||
setOpenCommentsPanel(null);
|
||||
return;
|
||||
}
|
||||
setOpenCommentsPanel(versionId);
|
||||
|
||||
if (!commentsCache.has(versionId)) {
|
||||
setCommentsLoading(versionId);
|
||||
try {
|
||||
const res = await fetch(`/api/versions/${versionId}/comments`);
|
||||
const json = await res.json();
|
||||
const data = json.data;
|
||||
const commentsList = Array.isArray(data) ? data : (data?.comments ?? []);
|
||||
setCommentsCache((prev) => new Map(prev).set(versionId, commentsList));
|
||||
} catch {
|
||||
setCommentsCache((prev) => new Map(prev).set(versionId, []));
|
||||
} finally {
|
||||
setCommentsLoading(null);
|
||||
const toggleComments = useCallback(
|
||||
async (versionId: string) => {
|
||||
if (openCommentsPanel === versionId) {
|
||||
setOpenCommentsPanel(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [openCommentsPanel, commentsCache]);
|
||||
setOpenCommentsPanel(versionId);
|
||||
|
||||
if (!commentsCache.has(versionId)) {
|
||||
setCommentsLoading(versionId);
|
||||
try {
|
||||
const res = await fetch(`/api/versions/${versionId}/comments`);
|
||||
const json = await res.json();
|
||||
const data = json.data;
|
||||
const commentsList = Array.isArray(data) ? data : (data?.comments ?? []);
|
||||
setCommentsCache((prev) => new Map(prev).set(versionId, commentsList));
|
||||
} catch {
|
||||
setCommentsCache((prev) => new Map(prev).set(versionId, []));
|
||||
} finally {
|
||||
setCommentsLoading(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[openCommentsPanel, commentsCache]
|
||||
);
|
||||
|
||||
const handleChangeVersion = useCallback((panelIndex: number, newVersionId: string) => {
|
||||
setPanelVersionIds((prev) => {
|
||||
@@ -471,7 +512,11 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
|
||||
const oldId = next[panelIndex];
|
||||
const oldPlayer = playersRef.current.get(oldId);
|
||||
if (oldPlayer) {
|
||||
try { oldPlayer.destroy(); } catch { /* */ }
|
||||
try {
|
||||
oldPlayer.destroy();
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
playersRef.current.delete(oldId);
|
||||
}
|
||||
next[panelIndex] = newVersionId;
|
||||
@@ -619,11 +664,21 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
|
||||
if (!player) return;
|
||||
const isMuted = mutedPanels.has(versionId);
|
||||
try {
|
||||
if (isMuted) { player.unMute(); } else { player.mute(); }
|
||||
} catch { /* */ }
|
||||
if (isMuted) {
|
||||
player.unMute();
|
||||
} else {
|
||||
player.mute();
|
||||
}
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
setMutedPanels((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (isMuted) { next.delete(versionId); } else { next.add(versionId); }
|
||||
if (isMuted) {
|
||||
next.delete(versionId);
|
||||
} else {
|
||||
next.add(versionId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
@@ -686,7 +741,11 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 flex items-center justify-center bg-black/20 transition-opacity duration-300 pointer-events-none',
|
||||
isPlaying ? (cursorIdle ? 'opacity-0' : 'opacity-0 group-hover:opacity-100') : 'opacity-100'
|
||||
isPlaying
|
||||
? cursorIdle
|
||||
? 'opacity-0'
|
||||
: 'opacity-0 group-hover:opacity-100'
|
||||
: 'opacity-100'
|
||||
)}
|
||||
>
|
||||
<div className="w-14 h-14 rounded-full bg-black/60 flex items-center justify-center">
|
||||
@@ -710,7 +769,12 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
|
||||
{panelComments.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setOpenCommentsPanel(null)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setOpenCommentsPanel(null)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -728,20 +792,29 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
|
||||
[...panelComments]
|
||||
.sort((a, b) => a.timestamp - b.timestamp)
|
||||
.map((comment) => {
|
||||
const authorName = comment.author?.name || comment.guestName || 'Anonymous';
|
||||
const authorName =
|
||||
comment.author?.name || comment.guestName || 'Anonymous';
|
||||
return (
|
||||
<div
|
||||
key={comment.id}
|
||||
className={cn('rounded-lg border p-2 text-xs', comment.isResolved && 'opacity-60')}
|
||||
className={cn(
|
||||
'rounded-lg border p-2 text-xs',
|
||||
comment.isResolved && 'opacity-60'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Avatar className="h-4 w-4">
|
||||
<AvatarImage src={comment.author?.image ?? undefined} />
|
||||
<AvatarFallback className="text-[8px]">{authorName.charAt(0)}</AvatarFallback>
|
||||
<AvatarFallback className="text-[8px]">
|
||||
{authorName.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="font-medium truncate">{authorName}</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleSeek(comment.timestamp); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSeek(comment.timestamp);
|
||||
}}
|
||||
className="ml-auto flex items-center gap-0.5 text-primary bg-primary/10 px-1 py-0.5 rounded text-[10px] hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<Clock className="h-2.5 w-2.5" />
|
||||
@@ -749,13 +822,18 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
|
||||
</button>
|
||||
</div>
|
||||
{comment.content && (
|
||||
<p className="text-muted-foreground leading-relaxed">{comment.content}</p>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
{comment.content}
|
||||
</p>
|
||||
)}
|
||||
{comment.tag && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="mt-1 text-[10px] px-1.5 py-0"
|
||||
style={{ borderColor: comment.tag.color, color: comment.tag.color }}
|
||||
style={{
|
||||
borderColor: comment.tag.color,
|
||||
color: comment.tag.color,
|
||||
}}
|
||||
>
|
||||
{comment.tag.name}
|
||||
</Badge>
|
||||
@@ -873,7 +951,11 @@ function YouTubePanel({
|
||||
clearTimeout(timeout);
|
||||
onUnregister(version.id);
|
||||
if (playerRef.current) {
|
||||
try { playerRef.current.destroy(); } catch { /* */ }
|
||||
try {
|
||||
playerRef.current.destroy();
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
playerRef.current = null;
|
||||
}
|
||||
};
|
||||
@@ -882,7 +964,11 @@ function YouTubePanel({
|
||||
return () => {
|
||||
onUnregister(version.id);
|
||||
if (playerRef.current) {
|
||||
try { playerRef.current.destroy(); } catch { /* */ }
|
||||
try {
|
||||
playerRef.current.destroy();
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
playerRef.current = null;
|
||||
}
|
||||
};
|
||||
@@ -979,11 +1065,8 @@ function BunnyPanel({
|
||||
}
|
||||
return cachedDuration;
|
||||
},
|
||||
getPlayerState: () => (
|
||||
isPlaying
|
||||
? (window.YT?.PlayerState?.PLAYING ?? 1)
|
||||
: (window.YT?.PlayerState?.PAUSED ?? 2)
|
||||
),
|
||||
getPlayerState: () =>
|
||||
isPlaying ? (window.YT?.PlayerState?.PLAYING ?? 1) : (window.YT?.PlayerState?.PAUSED ?? 2),
|
||||
setPlaybackRate: (rate: number) => {
|
||||
videoEl.playbackRate = rate;
|
||||
},
|
||||
@@ -997,7 +1080,11 @@ function BunnyPanel({
|
||||
videoEl.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
videoEl.removeEventListener('error', onError);
|
||||
if (hlsRef.current) {
|
||||
try { hlsRef.current.destroy(); } catch { /* ignore */ }
|
||||
try {
|
||||
hlsRef.current.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
hlsRef.current = null;
|
||||
}
|
||||
videoEl.removeAttribute('src');
|
||||
@@ -1014,10 +1101,18 @@ function BunnyPanel({
|
||||
setIsPortraitSource(videoEl.videoHeight > videoEl.videoWidth);
|
||||
}
|
||||
};
|
||||
const onTimeUpdate = () => { cachedTime = videoEl.currentTime || 0; };
|
||||
const onPlay = () => { isPlaying = true; };
|
||||
const onPause = () => { isPlaying = false; };
|
||||
const onEnded = () => { isPlaying = false; };
|
||||
const onTimeUpdate = () => {
|
||||
cachedTime = videoEl.currentTime || 0;
|
||||
};
|
||||
const onPlay = () => {
|
||||
isPlaying = true;
|
||||
};
|
||||
const onPause = () => {
|
||||
isPlaying = false;
|
||||
};
|
||||
const onEnded = () => {
|
||||
isPlaying = false;
|
||||
};
|
||||
if (!bunnyCdnHostname) {
|
||||
return;
|
||||
}
|
||||
@@ -1027,7 +1122,11 @@ function BunnyPanel({
|
||||
sourceMode = 'original';
|
||||
clearRetryTimer();
|
||||
if (hlsRef.current) {
|
||||
try { hlsRef.current.destroy(); } catch { /* ignore */ }
|
||||
try {
|
||||
hlsRef.current.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
hlsRef.current = null;
|
||||
}
|
||||
videoEl.src = getRetryUrl(originalUrl);
|
||||
@@ -1075,24 +1174,30 @@ function BunnyPanel({
|
||||
hls.on(Hls.Events.ERROR, (_, data) => {
|
||||
if (destroyed) return;
|
||||
const responseCode = (data as { response?: { code?: number } }).response?.code;
|
||||
const isManifestLoadFailure = data.details === Hls.ErrorDetails.MANIFEST_LOAD_ERROR
|
||||
|| data.details === Hls.ErrorDetails.MANIFEST_LOAD_TIMEOUT;
|
||||
const hasProcessingLikeStatus = responseCode === undefined
|
||||
|| responseCode === 0
|
||||
|| responseCode === 403
|
||||
|| responseCode === 404
|
||||
|| responseCode === 423
|
||||
|| responseCode === 429
|
||||
|| responseCode === 503;
|
||||
const isManifestLoadFailure =
|
||||
data.details === Hls.ErrorDetails.MANIFEST_LOAD_ERROR ||
|
||||
data.details === Hls.ErrorDetails.MANIFEST_LOAD_TIMEOUT;
|
||||
const hasProcessingLikeStatus =
|
||||
responseCode === undefined ||
|
||||
responseCode === 0 ||
|
||||
responseCode === 403 ||
|
||||
responseCode === 404 ||
|
||||
responseCode === 423 ||
|
||||
responseCode === 429 ||
|
||||
responseCode === 503;
|
||||
const isLikelyProcessing = isManifestLoadFailure && hasProcessingLikeStatus;
|
||||
const isNetworkPreMetadataProcessing = data.type === Hls.ErrorTypes.NETWORK_ERROR
|
||||
&& hasProcessingLikeStatus
|
||||
&& videoEl.readyState < HTMLMediaElement.HAVE_METADATA;
|
||||
const isUnknownPreMetadataProcessing = !data.details
|
||||
&& !data.type
|
||||
&& videoEl.readyState < HTMLMediaElement.HAVE_METADATA;
|
||||
const isNetworkPreMetadataProcessing =
|
||||
data.type === Hls.ErrorTypes.NETWORK_ERROR &&
|
||||
hasProcessingLikeStatus &&
|
||||
videoEl.readyState < HTMLMediaElement.HAVE_METADATA;
|
||||
const isUnknownPreMetadataProcessing =
|
||||
!data.details && !data.type && videoEl.readyState < HTMLMediaElement.HAVE_METADATA;
|
||||
|
||||
if (isLikelyProcessing || isNetworkPreMetadataProcessing || isUnknownPreMetadataProcessing) {
|
||||
if (
|
||||
isLikelyProcessing ||
|
||||
isNetworkPreMetadataProcessing ||
|
||||
isUnknownPreMetadataProcessing
|
||||
) {
|
||||
if (sourceMode === 'hls') {
|
||||
activateOriginalFallback();
|
||||
return;
|
||||
@@ -1132,13 +1237,20 @@ function BunnyPanel({
|
||||
}, [version.id, version.videoId, onRegister, onUnregister, bunnyCdnHostname]);
|
||||
|
||||
return (
|
||||
<div ref={panelRef} className="relative w-full h-full group flex items-center justify-center bg-black">
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="relative w-full h-full group flex items-center justify-center bg-black"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex items-center justify-center bg-black',
|
||||
isPortraitSource ? 'h-full overflow-hidden' : 'w-full h-full'
|
||||
)}
|
||||
style={isPortraitSource && portraitFrameWidth > 0 ? { width: `${portraitFrameWidth}px` } : undefined}
|
||||
style={
|
||||
isPortraitSource && portraitFrameWidth > 0
|
||||
? { width: `${portraitFrameWidth}px` }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
@@ -1156,5 +1268,5 @@ function BunnyPanel({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
function PlayerPanelSkeleton() {
|
||||
return (
|
||||
@@ -12,7 +12,7 @@ function PlayerPanelSkeleton() {
|
||||
<Skeleton className="h-4 w-24 mx-auto" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default function CompareLoading() {
|
||||
@@ -35,5 +35,5 @@ export default function CompareLoading() {
|
||||
<PlayerPanelSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertTriangle, Film } from "lucide-react";
|
||||
import { useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AlertTriangle, Film } from 'lucide-react';
|
||||
|
||||
export default function VideoError({
|
||||
error,
|
||||
@@ -12,7 +12,7 @@ export default function VideoError({
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error("Video player error:", error);
|
||||
console.error('Video player error:', error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
@@ -24,13 +24,10 @@ export default function VideoError({
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold">Video Player Error</h1>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
Something went wrong with the video player. This could be due to a network issue or a problem with the video file.
|
||||
Something went wrong with the video player. This could be due to a network issue or a
|
||||
problem with the video file.
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Error ID: {error.digest}
|
||||
</p>
|
||||
)}
|
||||
{error.digest && <p className="text-muted-foreground text-xs">Error ID: {error.digest}</p>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={reset} variant="default">
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
export default function VideoLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
export default function VideoLayout({ children }: { children: React.ReactNode }) {
|
||||
// This layout is empty - no header, no sidebar
|
||||
// The video page uses full screen space
|
||||
return <>{children}</>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
function CommentSkeleton() {
|
||||
return (
|
||||
@@ -14,7 +14,7 @@ function CommentSkeleton() {
|
||||
<Skeleton className="h-4 w-full mb-1" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default function VideoPlayerLoading() {
|
||||
@@ -80,5 +80,5 @@ export default function VideoPlayerLoading() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Film } from "lucide-react";
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Film } from 'lucide-react';
|
||||
|
||||
export default function VideoNotFound() {
|
||||
return (
|
||||
|
||||
+54
-15
@@ -2,7 +2,17 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, Check, Copy, Link2, Loader2, RefreshCcw, ShieldOff, Lock, ShieldCheck } from 'lucide-react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
Copy,
|
||||
Link2,
|
||||
Loader2,
|
||||
RefreshCcw,
|
||||
ShieldOff,
|
||||
Lock,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -46,7 +56,9 @@ export default function VideoSharePageClient({ projectId, videoId }: VideoShareP
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/videos/${videoId}/share`, { cache: 'no-store' });
|
||||
const response = await fetch(`/api/projects/${projectId}/videos/${videoId}/share`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
const payload = (await response.json()) as ShareResponse;
|
||||
|
||||
if (!response.ok || payload.error) {
|
||||
@@ -152,7 +164,10 @@ export default function VideoSharePageClient({ projectId, videoId }: VideoShareP
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as ShareResponse | { error?: string } | null;
|
||||
const payload = (await response.json().catch(() => null)) as
|
||||
| ShareResponse
|
||||
| { error?: string }
|
||||
| null;
|
||||
if (!response.ok || ('error' in (payload || {}) && payload?.error)) {
|
||||
setError((payload as { error?: string } | null)?.error || 'Failed to update link security');
|
||||
return;
|
||||
@@ -182,9 +197,14 @@ export default function VideoSharePageClient({ projectId, videoId }: VideoShareP
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ allowDownloads: nextAllowDownloads }),
|
||||
});
|
||||
const payload = (await response.json().catch(() => null)) as ShareResponse | { error?: string } | null;
|
||||
const payload = (await response.json().catch(() => null)) as
|
||||
| ShareResponse
|
||||
| { error?: string }
|
||||
| null;
|
||||
if (!response.ok || ('error' in (payload || {}) && payload?.error)) {
|
||||
setError((payload as { error?: string } | null)?.error || 'Failed to update download setting');
|
||||
setError(
|
||||
(payload as { error?: string } | null)?.error || 'Failed to update download setting'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const data = (payload as ShareResponse).data;
|
||||
@@ -237,18 +257,28 @@ export default function VideoSharePageClient({ projectId, videoId }: VideoShareP
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={createShareLink} disabled={submitting} variant="outline">
|
||||
{submitting ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <RefreshCcw className="h-4 w-4 mr-2" />}
|
||||
{submitting ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<RefreshCcw className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Regenerate Link
|
||||
</Button>
|
||||
<Button onClick={revokeShareLink} disabled={submitting} variant="destructive">
|
||||
{submitting ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <ShieldOff className="h-4 w-4 mr-2" />}
|
||||
{submitting ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<ShieldOff className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Revoke Link
|
||||
</Button>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3 space-y-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Video download</p>
|
||||
<p className="text-xs text-muted-foreground">Allow viewers with this link to download</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow viewers with this link to download
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
@@ -270,13 +300,19 @@ export default function VideoSharePageClient({ projectId, videoId }: VideoShareP
|
||||
|
||||
<div className="rounded-lg border p-3 space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
{hasPassword ? <ShieldCheck className="h-4 w-4 text-green-600" /> : <Lock className="h-4 w-4" />}
|
||||
{hasPassword ? (
|
||||
<ShieldCheck className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Lock className="h-4 w-4" />
|
||||
)}
|
||||
Link password
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={hasPassword ? 'Enter new password to replace current one' : 'Set a password'}
|
||||
placeholder={
|
||||
hasPassword ? 'Enter new password to replace current one' : 'Set a password'
|
||||
}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={submitting}
|
||||
@@ -302,18 +338,21 @@ export default function VideoSharePageClient({ projectId, videoId }: VideoShareP
|
||||
</div>
|
||||
) : (
|
||||
<Button onClick={createShareLink} disabled={submitting}>
|
||||
{submitting ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Link2 className="h-4 w-4 mr-2" />}
|
||||
{submitting ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Link2 className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Create Review Link
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This link allows guests to leave comments without an account. You can optionally protect it with a password.
|
||||
This link allows guests to leave comments without an account. You can optionally
|
||||
protect it with a password.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -4,14 +4,27 @@ import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { ArrowLeft, Loader2, Link as LinkIcon, AlertCircle, CheckCircle2, UploadCloud, FileVideo } from 'lucide-react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
Link as LinkIcon,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
UploadCloud,
|
||||
FileVideo,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { parseVideoUrl, fetchVideoMetadata, getThumbnailUrl, type VideoSource } from '@/lib/video-providers';
|
||||
import {
|
||||
parseVideoUrl,
|
||||
fetchVideoMetadata,
|
||||
getThumbnailUrl,
|
||||
type VideoSource,
|
||||
} from '@/lib/video-providers';
|
||||
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||
import * as tus from 'tus-js-client';
|
||||
|
||||
@@ -60,7 +73,8 @@ export default function NewVideoPageClient({
|
||||
description: '',
|
||||
});
|
||||
const isUploadingFile = isLoading && uploadMode === 'file';
|
||||
const leaveWarningMessage = 'A video upload is in progress. Leaving this page will interrupt it. Do you want to leave?';
|
||||
const leaveWarningMessage =
|
||||
'A video upload is in progress. Leaving this page will interrupt it. Do you want to leave?';
|
||||
|
||||
useEffect(() => {
|
||||
pendingBunnyVideoIdRef.current = pendingBunnyVideoId;
|
||||
@@ -70,45 +84,51 @@ export default function NewVideoPageClient({
|
||||
pendingBunnyUploadTokenRef.current = pendingBunnyUploadToken;
|
||||
}, [pendingBunnyUploadToken]);
|
||||
|
||||
const cleanupPendingBunnyVideo = useCallback(async (videoId: string, uploadToken: string, keepalive = false) => {
|
||||
try {
|
||||
await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ videoId, uploadToken }),
|
||||
keepalive,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup pending Bunny upload:', error);
|
||||
} finally {
|
||||
if (pendingBunnyVideoIdRef.current === videoId) {
|
||||
pendingBunnyVideoIdRef.current = null;
|
||||
setPendingBunnyVideoId(null);
|
||||
}
|
||||
if (pendingBunnyUploadTokenRef.current === uploadToken) {
|
||||
pendingBunnyUploadTokenRef.current = null;
|
||||
setPendingBunnyUploadToken(null);
|
||||
}
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const abortAndCleanupPendingUpload = useCallback((keepalive = false) => {
|
||||
const pendingVideoId = pendingBunnyVideoIdRef.current;
|
||||
const pendingUploadToken = pendingBunnyUploadTokenRef.current;
|
||||
if (!pendingVideoId || !pendingUploadToken) return;
|
||||
|
||||
if (activeTusUploadRef.current) {
|
||||
const cleanupPendingBunnyVideo = useCallback(
|
||||
async (videoId: string, uploadToken: string, keepalive = false) => {
|
||||
try {
|
||||
activeTusUploadRef.current.abort(true);
|
||||
} catch {
|
||||
// Ignore abort failures; we'll still attempt cleanup.
|
||||
await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ videoId, uploadToken }),
|
||||
keepalive,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup pending Bunny upload:', error);
|
||||
} finally {
|
||||
activeTusUploadRef.current = null;
|
||||
if (pendingBunnyVideoIdRef.current === videoId) {
|
||||
pendingBunnyVideoIdRef.current = null;
|
||||
setPendingBunnyVideoId(null);
|
||||
}
|
||||
if (pendingBunnyUploadTokenRef.current === uploadToken) {
|
||||
pendingBunnyUploadTokenRef.current = null;
|
||||
setPendingBunnyUploadToken(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[projectId]
|
||||
);
|
||||
|
||||
void cleanupPendingBunnyVideo(pendingVideoId, pendingUploadToken, keepalive);
|
||||
}, [cleanupPendingBunnyVideo]);
|
||||
const abortAndCleanupPendingUpload = useCallback(
|
||||
(keepalive = false) => {
|
||||
const pendingVideoId = pendingBunnyVideoIdRef.current;
|
||||
const pendingUploadToken = pendingBunnyUploadTokenRef.current;
|
||||
if (!pendingVideoId || !pendingUploadToken) return;
|
||||
|
||||
if (activeTusUploadRef.current) {
|
||||
try {
|
||||
activeTusUploadRef.current.abort(true);
|
||||
} catch {
|
||||
// Ignore abort failures; we'll still attempt cleanup.
|
||||
} finally {
|
||||
activeTusUploadRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
void cleanupPendingBunnyVideo(pendingVideoId, pendingUploadToken, keepalive);
|
||||
},
|
||||
[cleanupPendingBunnyVideo]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isUploadingFile) return;
|
||||
@@ -206,38 +226,47 @@ export default function NewVideoPageClient({
|
||||
}
|
||||
};
|
||||
|
||||
const setSelectedVideoFile = useCallback((file: File) => {
|
||||
if (!isVideoFile(file)) {
|
||||
setSubmitError('Please select a valid video file.');
|
||||
return;
|
||||
}
|
||||
const setSelectedVideoFile = useCallback(
|
||||
(file: File) => {
|
||||
if (!isVideoFile(file)) {
|
||||
setSubmitError('Please select a valid video file.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFile(file);
|
||||
setSubmitError('');
|
||||
setSelectedFile(file);
|
||||
setSubmitError('');
|
||||
|
||||
if (!formData.title) {
|
||||
const nameWithoutExt = file.name.replace(/\.[^/.]+$/, '');
|
||||
setFormData((prev) => ({ ...prev, title: nameWithoutExt }));
|
||||
}
|
||||
}, [formData.title]);
|
||||
if (!formData.title) {
|
||||
const nameWithoutExt = file.name.replace(/\.[^/.]+$/, '');
|
||||
setFormData((prev) => ({ ...prev, title: nameWithoutExt }));
|
||||
}
|
||||
},
|
||||
[formData.title]
|
||||
);
|
||||
|
||||
const handleFileDragEnter = useCallback((event: React.DragEvent<HTMLLabelElement>) => {
|
||||
event.preventDefault();
|
||||
if (isLoading) return;
|
||||
fileDragDepthRef.current += 1;
|
||||
if (Array.from(event.dataTransfer.types).includes('Files')) {
|
||||
setIsFileDragOver(true);
|
||||
}
|
||||
}, [isLoading]);
|
||||
const handleFileDragEnter = useCallback(
|
||||
(event: React.DragEvent<HTMLLabelElement>) => {
|
||||
event.preventDefault();
|
||||
if (isLoading) return;
|
||||
fileDragDepthRef.current += 1;
|
||||
if (Array.from(event.dataTransfer.types).includes('Files')) {
|
||||
setIsFileDragOver(true);
|
||||
}
|
||||
},
|
||||
[isLoading]
|
||||
);
|
||||
|
||||
const handleFileDragOver = useCallback((event: React.DragEvent<HTMLLabelElement>) => {
|
||||
event.preventDefault();
|
||||
if (isLoading) return;
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
if (Array.from(event.dataTransfer.types).includes('Files')) {
|
||||
setIsFileDragOver(true);
|
||||
}
|
||||
}, [isLoading]);
|
||||
const handleFileDragOver = useCallback(
|
||||
(event: React.DragEvent<HTMLLabelElement>) => {
|
||||
event.preventDefault();
|
||||
if (isLoading) return;
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
if (Array.from(event.dataTransfer.types).includes('Files')) {
|
||||
setIsFileDragOver(true);
|
||||
}
|
||||
},
|
||||
[isLoading]
|
||||
);
|
||||
|
||||
const handleFileDragLeave = useCallback((event: React.DragEvent<HTMLLabelElement>) => {
|
||||
event.preventDefault();
|
||||
@@ -247,26 +276,35 @@ export default function NewVideoPageClient({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleFileDrop = useCallback((event: React.DragEvent<HTMLLabelElement>) => {
|
||||
event.preventDefault();
|
||||
fileDragDepthRef.current = 0;
|
||||
setIsFileDragOver(false);
|
||||
if (isLoading) return;
|
||||
const handleFileDrop = useCallback(
|
||||
(event: React.DragEvent<HTMLLabelElement>) => {
|
||||
event.preventDefault();
|
||||
fileDragDepthRef.current = 0;
|
||||
setIsFileDragOver(false);
|
||||
if (isLoading) return;
|
||||
|
||||
const file = Array.from(event.dataTransfer.files)[0];
|
||||
if (!file) return;
|
||||
setSelectedVideoFile(file);
|
||||
}, [isLoading, setSelectedVideoFile]);
|
||||
const file = Array.from(event.dataTransfer.files)[0];
|
||||
if (!file) return;
|
||||
setSelectedVideoFile(file);
|
||||
},
|
||||
[isLoading, setSelectedVideoFile]
|
||||
);
|
||||
|
||||
const uploadToBunny = async (
|
||||
file: File
|
||||
): Promise<{ videoId: string; libraryId: string; providerId: string; url: string; uploadToken: string }> => {
|
||||
): Promise<{
|
||||
videoId: string;
|
||||
libraryId: string;
|
||||
providerId: string;
|
||||
url: string;
|
||||
uploadToken: string;
|
||||
}> => {
|
||||
// 1. Initialize Bunny Stream upload (creates video & gets signature)
|
||||
setUploadStatus('Initializing upload...');
|
||||
const initRes = await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: formData.title || file.name })
|
||||
body: JSON.stringify({ title: formData.title || file.name }),
|
||||
});
|
||||
|
||||
if (!initRes.ok) {
|
||||
@@ -274,7 +312,9 @@ export default function NewVideoPageClient({
|
||||
throw new Error(data.error || 'Failed to initialize upload');
|
||||
}
|
||||
|
||||
const { data: { videoId, libraryId, signature, expirationTime, uploadToken } } = await initRes.json();
|
||||
const {
|
||||
data: { videoId, libraryId, signature, expirationTime, uploadToken },
|
||||
} = await initRes.json();
|
||||
setPendingBunnyVideoId(videoId);
|
||||
setPendingBunnyUploadToken(uploadToken);
|
||||
pendingBunnyVideoIdRef.current = videoId;
|
||||
@@ -414,7 +454,10 @@ export default function NewVideoPageClient({
|
||||
console.error('Failed to add video:', error);
|
||||
setSubmitError(error instanceof Error ? error.message : 'An unexpected error occurred');
|
||||
if (pendingBunnyVideoIdRef.current && pendingBunnyUploadTokenRef.current) {
|
||||
await cleanupPendingBunnyVideo(pendingBunnyVideoIdRef.current, pendingBunnyUploadTokenRef.current);
|
||||
await cleanupPendingBunnyVideo(
|
||||
pendingBunnyVideoIdRef.current,
|
||||
pendingBunnyUploadTokenRef.current
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
activeTusUploadRef.current = null;
|
||||
@@ -455,17 +498,26 @@ export default function NewVideoPageClient({
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs value={uploadMode} onValueChange={(v) => !isLoading && setUploadMode(v as 'url' | 'file')} className="mb-6">
|
||||
<TabsList className={`grid w-full ${bunnyUploadsEnabled ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||
<TabsTrigger value="url" disabled={isLoading}>Paste URL</TabsTrigger>
|
||||
<Tabs
|
||||
value={uploadMode}
|
||||
onValueChange={(v) => !isLoading && setUploadMode(v as 'url' | 'file')}
|
||||
className="mb-6"
|
||||
>
|
||||
<TabsList
|
||||
className={`grid w-full ${bunnyUploadsEnabled ? 'grid-cols-2' : 'grid-cols-1'}`}
|
||||
>
|
||||
<TabsTrigger value="url" disabled={isLoading}>
|
||||
Paste URL
|
||||
</TabsTrigger>
|
||||
{bunnyUploadsEnabled ? (
|
||||
<TabsTrigger value="file" disabled={isLoading}>Direct Upload</TabsTrigger>
|
||||
<TabsTrigger value="file" disabled={isLoading}>
|
||||
Direct Upload
|
||||
</TabsTrigger>
|
||||
) : null}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
|
||||
{uploadMode === 'url' ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="url">Video URL</Label>
|
||||
@@ -492,7 +544,9 @@ export default function NewVideoPageClient({
|
||||
{videoSource && (
|
||||
<p className="text-sm text-green-600 flex items-center gap-1">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
{videoSource.providerId.charAt(0).toUpperCase() + videoSource.providerId.slice(1)} video detected
|
||||
{videoSource.providerId.charAt(0).toUpperCase() +
|
||||
videoSource.providerId.slice(1)}{' '}
|
||||
video detected
|
||||
{isFetchingMeta && ' — fetching metadata...'}
|
||||
</p>
|
||||
)}
|
||||
@@ -519,7 +573,9 @@ export default function NewVideoPageClient({
|
||||
{selectedFile ? (
|
||||
<>
|
||||
<FileVideo className="w-10 h-10 mb-3 text-primary" />
|
||||
<p className="mb-2 text-sm text-foreground font-medium">{selectedFile.name}</p>
|
||||
<p className="mb-2 text-sm text-foreground font-medium">
|
||||
{selectedFile.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(selectedFile.size / (1024 * 1024)).toFixed(2)} MB
|
||||
</p>
|
||||
@@ -534,7 +590,14 @@ export default function NewVideoPageClient({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<input id="file" type="file" accept="video/*" className="hidden" onChange={handleFileChange} disabled={isLoading} />
|
||||
<input
|
||||
id="file"
|
||||
type="file"
|
||||
accept="video/*"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -561,7 +624,11 @@ export default function NewVideoPageClient({
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
placeholder={isFetchingMeta ? 'Fetching title...' : 'Video title (will auto-fill from video if empty)'}
|
||||
placeholder={
|
||||
isFetchingMeta
|
||||
? 'Fetching title...'
|
||||
: 'Video title (will auto-fill from video if empty)'
|
||||
}
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, title: e.target.value }))}
|
||||
disabled={isLoading}
|
||||
@@ -596,7 +663,10 @@ export default function NewVideoPageClient({
|
||||
<p className="text-sm text-muted-foreground">{uploadStatus}</p>
|
||||
{uploadProgress > 0 && uploadProgress < 100 && (
|
||||
<div className="w-full bg-secondary rounded-full h-2">
|
||||
<div className="bg-primary h-2 rounded-full transition-all" style={{ width: `${uploadProgress}%` }}></div>
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
{isUploadingFile && (
|
||||
@@ -608,11 +678,23 @@ export default function NewVideoPageClient({
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button type="submit" disabled={isLoading || (uploadMode === 'url' && !videoSource) || (uploadMode === 'file' && !selectedFile)}>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
isLoading ||
|
||||
(uploadMode === 'url' && !videoSource) ||
|
||||
(uploadMode === 'file' && !selectedFile)
|
||||
}
|
||||
>
|
||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
Add Video
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => router.back()} disabled={isLoading}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.back()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,12 @@ interface Workspace {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const visibilityOptions: { value: Visibility; label: string; description: string; icon: React.ReactNode }[] = [
|
||||
const visibilityOptions: {
|
||||
value: Visibility;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
}[] = [
|
||||
{
|
||||
value: 'PRIVATE',
|
||||
label: 'Private',
|
||||
@@ -71,7 +76,7 @@ export default function NewProjectPage() {
|
||||
setWorkspaces(workspacesData);
|
||||
// Auto-select if only one workspace and none preselected
|
||||
if (!preselectedWorkspace && workspacesData.length === 1) {
|
||||
setFormData(prev => ({ ...prev, workspaceId: workspacesData[0].id }));
|
||||
setFormData((prev) => ({ ...prev, workspaceId: workspacesData[0].id }));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -160,7 +165,7 @@ export default function NewProjectPage() {
|
||||
) : (
|
||||
<Select
|
||||
value={formData.workspaceId}
|
||||
onValueChange={(v) => setFormData(prev => ({ ...prev, workspaceId: v }))}
|
||||
onValueChange={(v) => setFormData((prev) => ({ ...prev, workspaceId: v }))}
|
||||
>
|
||||
<SelectTrigger className="h-11">
|
||||
<SelectValue placeholder="Select a workspace" />
|
||||
@@ -187,7 +192,7 @@ export default function NewProjectPage() {
|
||||
id="name"
|
||||
placeholder="e.g. Product Demo Q1"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
|
||||
required
|
||||
disabled={isLoading}
|
||||
className="h-11"
|
||||
@@ -203,7 +208,9 @@ export default function NewProjectPage() {
|
||||
id="description"
|
||||
placeholder="Brief description of what this project is about..."
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, description: e.target.value }))
|
||||
}
|
||||
rows={3}
|
||||
disabled={isLoading}
|
||||
className="resize-none"
|
||||
@@ -217,29 +224,34 @@ export default function NewProjectPage() {
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setFormData(prev => ({ ...prev, visibility: option.value }))}
|
||||
onClick={() => setFormData((prev) => ({ ...prev, visibility: option.value }))}
|
||||
disabled={isLoading}
|
||||
className={`w-full flex items-center gap-4 p-4 rounded-xl border-2 text-left transition-all ${formData.visibility === option.value
|
||||
className={`w-full flex items-center gap-4 p-4 rounded-xl border-2 text-left transition-all ${
|
||||
formData.visibility === option.value
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
|
||||
: 'border-border hover:border-border/80 hover:bg-accent/50'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
<div className={`shrink-0 w-10 h-10 rounded-lg flex items-center justify-center ${formData.visibility === option.value
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}>
|
||||
<div
|
||||
className={`shrink-0 w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||
formData.visibility === option.value
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{option.icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium">{option.label}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{option.description}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{option.description}</div>
|
||||
</div>
|
||||
<div className={`shrink-0 w-5 h-5 rounded-full border-2 flex items-center justify-center ${formData.visibility === option.value
|
||||
? 'border-primary bg-primary'
|
||||
: 'border-muted-foreground/30'
|
||||
}`}>
|
||||
<div
|
||||
className={`shrink-0 w-5 h-5 rounded-full border-2 flex items-center justify-center ${
|
||||
formData.visibility === option.value
|
||||
? 'border-primary bg-primary'
|
||||
: 'border-muted-foreground/30'
|
||||
}`}
|
||||
>
|
||||
{formData.visibility === option.value && (
|
||||
<div className="w-2 h-2 rounded-full bg-primary-foreground" />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user