refactor: eslint and prettier conflict will be resolved and formatted

This commit is contained in:
Enes Köksal
2026-04-23 17:05:43 +03:00
parent 385b61f29b
commit 3cfea40fbd
219 changed files with 16638 additions and 13663 deletions
@@ -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&apos;re looking for doesn&apos;t exist or you don&apos;t have access to it.
The project you&apos;re looking for doesn&apos;t exist or you don&apos;t have access to
it.
</p>
</div>
<div className="flex gap-2">
+11 -7
View File
@@ -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 &quot;{projectName}&quot; 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 &quot;{projectName}&quot; 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>
);
}
@@ -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 (
@@ -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" />
)}