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
+4 -4
View File
@@ -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';
function ProjectCardSkeleton() {
return (
@@ -24,7 +24,7 @@ function ProjectCardSkeleton() {
</div>
</CardContent>
</Card>
)
);
}
export default function DashboardLoading() {
@@ -44,5 +44,5 @@ export default function DashboardLoading() {
))}
</div>
</div>
)
);
}
+134 -134
View File
@@ -2,162 +2,162 @@ import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';
import { db } from '@/lib/db';
import { Prisma } from '@prisma/client';
import { hasCollaboratorBillingBackedAccess, requireBillingAccessOrRedirect } from '@/lib/route-access';
import {
hasCollaboratorBillingBackedAccess,
requireBillingAccessOrRedirect,
} from '@/lib/route-access';
import { DashboardClient } from './dashboard-client';
import { buildBillingAccessWhereInput } from '@/lib/billing';
import { isBunnyUploadsEnabled } from '@/lib/feature-flags';
export default async function DashboardPage({
searchParams,
searchParams,
}: {
searchParams: Promise<{ ws?: string; sort?: string; page?: string }>
searchParams: Promise<{ ws?: string; sort?: string; page?: string }>;
}) {
const session = await auth();
if (!session?.user?.id) {
redirect('/login');
}
const session = await auth();
if (!session?.user?.id) {
redirect('/login');
}
const hasCollaboratorAccess = await hasCollaboratorBillingBackedAccess(session.user.id);
if (!hasCollaboratorAccess) {
await requireBillingAccessOrRedirect({ userId: session.user.id });
}
const hasCollaboratorAccess = await hasCollaboratorBillingBackedAccess(session.user.id);
if (!hasCollaboratorAccess) {
await requireBillingAccessOrRedirect({ userId: session.user.id });
}
const userOnboarding = await db.user.findUnique({
where: { id: session.user.id },
select: { onboardingCompletedAt: true },
});
if (!userOnboarding?.onboardingCompletedAt) {
redirect('/onboarding');
}
const userOnboarding = await db.user.findUnique({
where: { id: session.user.id },
select: { onboardingCompletedAt: true },
});
if (!userOnboarding?.onboardingCompletedAt) {
redirect('/onboarding');
}
const resolvedSearchParams = await searchParams;
const { ws, sort, page: pageParam } = resolvedSearchParams || {};
const resolvedSearchParams = await searchParams;
const { ws, sort, page: pageParam } = resolvedSearchParams || {};
const page = Number(pageParam) || 1;
const pageSize = 20;
const skip = (page - 1) * pageSize;
const orderByDirection = sort === 'asc' ? 'asc' : 'desc';
const page = Number(pageParam) || 1;
const pageSize = 20;
const skip = (page - 1) * pageSize;
const orderByDirection = sort === 'asc' ? 'asc' : 'desc';
// Base permission where clause
const baseWhere: Prisma.ProjectWhereInput = {
OR: [
{ ownerId: session.user.id },
{ members: { some: { userId: session.user.id } } },
{
workspace: {
owner: buildBillingAccessWhereInput(),
OR: [
{ ownerId: session.user.id },
{ members: { some: { userId: session.user.id } } },
],
},
},
],
// Base permission where clause
const baseWhere: Prisma.ProjectWhereInput = {
OR: [
{ ownerId: session.user.id },
{ members: { some: { userId: session.user.id } } },
{
workspace: {
owner: buildBillingAccessWhereInput(),
owner: buildBillingAccessWhereInput(),
OR: [{ ownerId: session.user.id }, { members: { some: { userId: session.user.id } } }],
},
};
},
],
workspace: {
owner: buildBillingAccessWhereInput(),
},
};
// Build unique workspace list for filter (Needs an unbounded list of accessible workspaces)
const accessibleProjects = await db.project.findMany({
where: baseWhere,
select: {
// Build unique workspace list for filter (Needs an unbounded list of accessible workspaces)
const accessibleProjects = await db.project.findMany({
where: baseWhere,
select: {
workspace: {
select: { id: true, name: true },
},
},
distinct: ['workspaceId'],
});
const [creatableWorkspaces, editableProject] = await Promise.all([
db.workspace.count({
where: {
OR: [
{ ownerId: session.user.id },
{ members: { some: { userId: session.user.id, role: 'ADMIN' } } },
],
},
}),
db.project.findFirst({
where: {
OR: [
{ ownerId: session.user.id },
{ members: { some: { userId: session.user.id, role: 'ADMIN' } } },
{
workspace: {
select: { id: true, name: true }
}
},
distinct: ['workspaceId']
});
const [creatableWorkspaces, editableProject] = await Promise.all([
db.workspace.count({
where: {
OR: [
{ ownerId: session.user.id },
{ members: { some: { userId: session.user.id, role: 'ADMIN' } } },
],
OR: [
{ ownerId: session.user.id },
{ members: { some: { userId: session.user.id, role: 'ADMIN' } } },
],
},
}),
db.project.findFirst({
where: {
OR: [
{ ownerId: session.user.id },
{ members: { some: { userId: session.user.id, role: 'ADMIN' } } },
{
workspace: {
OR: [
{ ownerId: session.user.id },
{ members: { some: { userId: session.user.id, role: 'ADMIN' } } },
],
},
},
],
},
select: { id: true },
}),
]);
const canCreateProjects = creatableWorkspaces > 0;
const canUploadVideos = Boolean(editableProject);
},
],
},
select: { id: true },
}),
]);
const canCreateProjects = creatableWorkspaces > 0;
const canUploadVideos = Boolean(editableProject);
const workspaceMap = new Map<string, string>();
for (const project of accessibleProjects) {
if (project.workspace) {
workspaceMap.set(project.workspace.id, project.workspace.name);
}
const workspaceMap = new Map<string, string>();
for (const project of accessibleProjects) {
if (project.workspace) {
workspaceMap.set(project.workspace.id, project.workspace.name);
}
const workspaces = Array.from(workspaceMap, ([id, name]) => ({ id, name }));
}
const workspaces = Array.from(workspaceMap, ([id, name]) => ({ id, name }));
// Final query constraints
const queryWhere: Prisma.ProjectWhereInput = {
...baseWhere,
...(ws && ws !== 'all' ? { workspaceId: ws } : {})
};
// Final query constraints
const queryWhere: Prisma.ProjectWhereInput = {
...baseWhere,
...(ws && ws !== 'all' ? { workspaceId: ws } : {}),
};
const [projects, totalProjects] = await Promise.all([
db.project.findMany({
skip,
take: pageSize,
where: queryWhere,
include: {
workspace: {
select: { id: true, name: true },
},
_count: {
select: {
videos: true,
members: true,
},
},
},
orderBy: { updatedAt: orderByDirection },
}),
db.project.count({
where: queryWhere
})
]);
const [projects, totalProjects] = await Promise.all([
db.project.findMany({
skip,
take: pageSize,
where: queryWhere,
include: {
workspace: {
select: { id: true, name: true },
},
_count: {
select: {
videos: true,
members: true,
},
},
},
orderBy: { updatedAt: orderByDirection },
}),
db.project.count({
where: queryWhere,
}),
]);
const totalPages = Math.ceil(totalProjects / pageSize);
const totalPages = Math.ceil(totalProjects / pageSize);
const serializedProjects = projects.map((p) => ({
id: p.id,
name: p.name,
description: p.description,
visibility: p.visibility,
updatedAt: p.updatedAt.toISOString(),
workspaceId: p.workspace?.id ?? null,
workspaceName: p.workspace?.name ?? null,
memberCount: p._count.members + 1,
videoCount: p._count.videos,
}));
const serializedProjects = projects.map((p) => ({
id: p.id,
name: p.name,
description: p.description,
visibility: p.visibility,
updatedAt: p.updatedAt.toISOString(),
workspaceId: p.workspace?.id ?? null,
workspaceName: p.workspace?.name ?? null,
memberCount: p._count.members + 1,
videoCount: p._count.videos,
}));
return (
<DashboardClient
serializedProjects={serializedProjects}
workspaces={workspaces}
totalPages={totalPages}
canCreateProjects={canCreateProjects}
canUploadVideos={canUploadVideos}
bunnyUploadsEnabled={isBunnyUploadsEnabled()}
/>
);
return (
<DashboardClient
serializedProjects={serializedProjects}
workspaces={workspaces}
totalPages={totalPages}
canCreateProjects={canCreateProjects}
canUploadVideos={canUploadVideos}
bunnyUploadsEnabled={isBunnyUploadsEnabled()}
/>
);
}
+19 -3
View File
@@ -3,7 +3,18 @@
import { useCallback } from 'react';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { Plus, FolderOpen, Clock, Users, Globe, Lock, UserPlus, Building2, ArrowUp, ArrowDown } from 'lucide-react';
import {
Plus,
FolderOpen,
Clock,
Users,
Globe,
Lock,
UserPlus,
Building2,
ArrowUp,
ArrowDown,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
@@ -62,13 +73,18 @@ function VisibilityIcon({ visibility }: { visibility: string }) {
type SortOrder = 'desc' | 'asc';
export function ProjectFilter({ projects, workspaces, totalPages, canCreateProjects }: ProjectFilterProps) {
export function ProjectFilter({
projects,
workspaces,
totalPages,
canCreateProjects,
}: ProjectFilterProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const selectedWorkspace = searchParams.get('ws') || 'all';
const sortOrder = searchParams.get('sort') as SortOrder || 'desc';
const sortOrder = (searchParams.get('sort') as SortOrder) || 'desc';
const page = Number(searchParams.get('page')) || 1;
const createQueryString = useCallback(
+7 -11
View File
@@ -1,8 +1,8 @@
"use client";
'use client';
import { useEffect } from "react";
import { Button } from "@/components/ui/button";
import { AlertTriangle } from "lucide-react";
import { useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { AlertTriangle } from 'lucide-react';
export default function DashboardError({
error,
@@ -12,7 +12,7 @@ export default function DashboardError({
reset: () => void;
}) {
useEffect(() => {
console.error("Dashboard error:", error);
console.error('Dashboard error:', error);
}, [error]);
return (
@@ -23,17 +23,13 @@ export default function DashboardError({
<p className="text-muted-foreground max-w-md">
Something went wrong loading the dashboard. Your projects and videos are safe.
</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">
Try again
</Button>
<Button onClick={() => window.location.href = "/dashboard"} variant="outline">
<Button onClick={() => (window.location.href = '/dashboard')} variant="outline">
Go to dashboard
</Button>
</div>
@@ -10,7 +10,13 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
type FeedbackCategory = 'BUG' | 'FEATURE' | 'OTHER';
type TabValue = 'feedback' | 'review';
@@ -70,7 +76,10 @@ export default function FeedbackPage() {
for (const file of allowedFiles) {
const isImage = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'].includes(file.type);
if (!isImage) {
setStatus({ type: 'error', message: 'Unsupported screenshot format. Use JPG, PNG, WEBP, or GIF.' });
setStatus({
type: 'error',
message: 'Unsupported screenshot format. Use JPG, PNG, WEBP, or GIF.',
});
continue;
}
if (file.size > 10 * 1024 * 1024) {
@@ -92,7 +101,9 @@ export default function FeedbackPage() {
const targetUrl = feedbackScreenshotPreviewUrls[index];
if (targetUrl) URL.revokeObjectURL(targetUrl);
setFeedbackScreenshotFiles((prev) => prev.filter((_, currentIndex) => currentIndex !== index));
setFeedbackScreenshotPreviewUrls((prev) => prev.filter((_, currentIndex) => currentIndex !== index));
setFeedbackScreenshotPreviewUrls((prev) =>
prev.filter((_, currentIndex) => currentIndex !== index)
);
};
const clearFeedbackScreenshots = () => {
@@ -169,7 +180,10 @@ export default function FeedbackPage() {
setReviewMessage('');
setReviewRating('5');
setAllowShowcase(false);
setStatus({ type: 'success', message: 'Review submitted. Thank you for sharing your experience.' });
setStatus({
type: 'success',
message: 'Review submitted. Thank you for sharing your experience.',
});
} catch {
setStatus({ type: 'error', message: 'Failed to submit review' });
} finally {
@@ -180,7 +194,10 @@ export default function FeedbackPage() {
return (
<div className="min-h-[calc(100vh-4rem)] px-4 py-10">
<div className="mx-auto w-full max-w-3xl space-y-6">
<Link href="/dashboard" className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground">
<Link
href="/dashboard"
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="mr-1 h-4 w-4" />
Back to Dashboard
</Link>
@@ -189,7 +206,8 @@ export default function FeedbackPage() {
<CardHeader>
<CardTitle className="text-2xl">Feedback & Review</CardTitle>
<CardDescription>
Send product feedback, report bugs, or share a review we can feature on the landing page.
Send product feedback, report bugs, or share a review we can feature on the landing
page.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@@ -205,7 +223,11 @@ export default function FeedbackPage() {
</div>
)}
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as TabValue)} className="w-full">
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as TabValue)}
className="w-full"
>
<TabsList className="w-full">
<TabsTrigger value="feedback" className="gap-1.5">
<Bug className="h-3.5 w-3.5" />
@@ -308,7 +330,11 @@ export default function FeedbackPage() {
</div>
<Button type="submit" disabled={isSubmittingFeedback}>
{isSubmittingFeedback ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <ImageIcon className="mr-2 h-4 w-4" />}
{isSubmittingFeedback ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<ImageIcon className="mr-2 h-4 w-4" />
)}
Submit Feedback
</Button>
</form>
@@ -332,7 +358,11 @@ export default function FeedbackPage() {
<div className="space-y-2">
<Label>Rating</Label>
<Select value={reviewRating} onValueChange={setReviewRating} disabled={isSubmittingReview}>
<Select
value={reviewRating}
onValueChange={setReviewRating}
disabled={isSubmittingReview}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
@@ -369,11 +399,17 @@ export default function FeedbackPage() {
onChange={(event) => setAllowShowcase(event.target.checked)}
disabled={isSubmittingReview}
/>
<span>I allow OpenFrame to potentially showcase this review on the landing page.</span>
<span>
I allow OpenFrame to potentially showcase this review on the landing page.
</span>
</label>
<Button type="submit" disabled={isSubmittingReview}>
{isSubmittingReview ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <MessageSquareQuote className="mr-2 h-4 w-4" />}
{isSubmittingReview ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<MessageSquareQuote className="mr-2 h-4 w-4" />
)}
Submit Review
</Button>
</form>
+1 -5
View File
@@ -2,11 +2,7 @@ import { Header } from '@/components/layout';
import { auth } from '@/lib/auth';
import { hasAppNavigationAccess } from '@/lib/route-access';
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const session = await auth();
const showAppNavigation = session?.user?.id
? await hasAppNavigationAccess(session.user.id)
+3 -3
View File
@@ -1,6 +1,6 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { FileQuestion } from "lucide-react";
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { FileQuestion } from 'lucide-react';
export default function DashboardNotFound() {
return (
@@ -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" />
)}
+4 -4
View File
@@ -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';
function SettingsCardSkeleton({ rows }: { rows: number }) {
return (
@@ -20,7 +20,7 @@ function SettingsCardSkeleton({ rows }: { rows: number }) {
))}
</CardContent>
</Card>
)
);
}
export default function SettingsLoading() {
@@ -68,5 +68,5 @@ export default function SettingsLoading() {
</div>
</div>
</div>
)
);
}
+357 -362
View File
@@ -1,7 +1,17 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Bell, Send, Mail, CheckCircle2, AlertCircle, Loader2, Globe, CreditCard, HardDrive } from 'lucide-react';
import {
Bell,
Send,
Mail,
CheckCircle2,
AlertCircle,
Loader2,
Globe,
CreditCard,
HardDrive,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -93,16 +103,12 @@ function ToggleButton({
onClick={onToggle}
className={cn(
'flex items-center justify-between w-full p-3 rounded-lg border transition-colors text-left',
enabled
? 'border-primary/50 bg-primary/5'
: 'border-border hover:bg-accent/50'
enabled ? 'border-primary/50 bg-primary/5' : 'border-border hover:bg-accent/50'
)}
>
<div className="flex-1 min-w-0 pr-4">
<span className="text-sm font-medium">{label}</span>
{description && (
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
)}
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
</div>
<div
className={cn(
@@ -336,9 +342,7 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
<CreditCard className="h-5 w-5" />
Billing
</CardTitle>
<CardDescription>
Manage your paid plan and workspace creation access
</CardDescription>
<CardDescription>Manage your paid plan and workspace creation access</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{billingLoading || !billing ? (
@@ -349,22 +353,25 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
</div>
) : !billing.isEnabled ? (
<div className="rounded-md border border-muted bg-muted/40 p-4 text-sm text-muted-foreground">
Stripe billing is disabled by this host. Workspace creation is unrestricted in this environment.
Stripe billing is disabled by this host. Workspace creation is unrestricted in this
environment.
</div>
) : !billing.isConfigured ? (
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-700 dark:text-amber-400">
Stripe is not configured yet. Add your Stripe environment variables before using billing.
Stripe is not configured yet. Add your Stripe environment variables before using
billing.
</div>
) : (
<>
{!billing.subscription.hasActiveSubscription
&& !billing.subscription.hasActiveTrial
&& billing.subscription.isTrialEligible
&& billing.checkoutAvailable ? (
{!billing.subscription.hasActiveSubscription &&
!billing.subscription.hasActiveTrial &&
billing.subscription.isTrialEligible &&
billing.checkoutAvailable ? (
<div className="rounded-md border border-primary/30 bg-primary/5 p-4 space-y-2">
<p className="text-sm font-semibold">Start your 7-day free trial</p>
<p className="text-sm text-muted-foreground">
Get full access to all features no charge until the trial ends. Cancel anytime.
Get full access to all features no charge until the trial ends. Cancel
anytime.
</p>
</div>
) : null}
@@ -382,7 +389,7 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
: billing.subscription.hasActiveTrial
? 'Trial access is active.'
: billing.subscription.isTrialEligible
? 'You haven\'t started your free trial yet.'
? "You haven't started your free trial yet."
: 'Billing access has ended.'}
</p>
</div>
@@ -393,35 +400,37 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
</Badge>
</div>
{billing.subscription.hasActiveTrial
&& billing.subscription.trialEndsAt
&& hasScheduledCancellation ? (
<p
className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm font-medium text-destructive"
>
Access ends on {' '}
{new Date(billing.subscription.trialEndsAt).toLocaleDateString()}.
{billing.subscription.hasActiveTrial &&
billing.subscription.trialEndsAt &&
hasScheduledCancellation ? (
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm font-medium text-destructive">
Access ends on {new Date(billing.subscription.trialEndsAt).toLocaleDateString()}.
</p>
) : null}
{billing.subscription.currentPeriodEnd ? (
<p className="text-sm text-muted-foreground">
{hasScheduledCancellation ? 'Your subscription ends on ' : 'Current billing period ends on '}
{hasScheduledCancellation
? 'Your subscription ends on '
: 'Current billing period ends on '}
{new Date(billing.subscription.currentPeriodEnd).toLocaleDateString()}.
</p>
) : null}
{hasScheduledCancellation && billing.subscription.cancelAt ? (
<p className="text-sm text-muted-foreground">
Cancellation was scheduled on {new Date(billing.subscription.cancelAt).toLocaleDateString()}.
Cancellation was scheduled on{' '}
{new Date(billing.subscription.cancelAt).toLocaleDateString()}.
</p>
) : null}
{!billing.subscription.hasBillingAccess
&& billing.subscription.billingAccessEndedAt
&& billing.subscription.storageCleanupEligibleAt ? (
{!billing.subscription.hasBillingAccess &&
billing.subscription.billingAccessEndedAt &&
billing.subscription.storageCleanupEligibleAt ? (
<p className="text-sm text-amber-700 dark:text-amber-400">
Stored media cleanup is scheduled after {new Date(billing.subscription.storageCleanupEligibleAt).toLocaleDateString()} unless billing is restored first.
Stored media cleanup is scheduled after{' '}
{new Date(billing.subscription.storageCleanupEligibleAt).toLocaleDateString()}{' '}
unless billing is restored first.
</p>
) : null}
@@ -473,346 +482,332 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
</Card>
{billing?.subscription.hasBillingAccess && (
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<HardDrive className="h-5 w-5" />
Storage
</CardTitle>
<CardDescription>
Combined usage across video files and media attachments (200 GB limit)
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{storageLoading || !storageInfo ? (
<div className="space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-2 w-full rounded-full" />
</div>
) : (
<>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{formatBytes(storageInfo.usedBytes)} used of {formatBytes(storageInfo.limitBytes)}
</span>
<span
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<HardDrive className="h-5 w-5" />
Storage
</CardTitle>
<CardDescription>
Combined usage across video files and media attachments (200 GB limit)
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{storageLoading || !storageInfo ? (
<div className="space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-2 w-full rounded-full" />
</div>
) : (
<>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{formatBytes(storageInfo.usedBytes)} used of{' '}
{formatBytes(storageInfo.limitBytes)}
</span>
<span
className={
storageInfo.percentage >= 90
? 'text-destructive font-medium'
: storageInfo.percentage >= 75
? 'text-amber-600 dark:text-amber-400 font-medium'
: 'text-muted-foreground'
}
>
{storageInfo.percentage < 0.1
? '<0.1%'
: `${storageInfo.percentage.toFixed(1)}%`}
</span>
</div>
<Progress
value={storageInfo.percentage}
className={
storageInfo.percentage >= 90
? 'text-destructive font-medium'
? '[&>div]:bg-destructive'
: storageInfo.percentage >= 75
? 'text-amber-600 dark:text-amber-400 font-medium'
: 'text-muted-foreground'
? '[&>div]:bg-amber-500'
: ''
}
>
{storageInfo.percentage < 0.1 ? '<0.1%' : `${storageInfo.percentage.toFixed(1)}%`}
</span>
</div>
<Progress
value={storageInfo.percentage}
className={
storageInfo.percentage >= 90
? '[&>div]:bg-destructive'
: storageInfo.percentage >= 75
? '[&>div]:bg-amber-500'
: ''
}
/>
{storageInfo.percentage >= 90 && (
<p className="text-xs text-destructive">
Storage is almost full. Delete unused files or contact support.
</p>
)}
</>
)}
</CardContent>
</Card>
/>
{storageInfo.percentage >= 90 && (
<p className="text-xs text-destructive">
Storage is almost full. Delete unused files or contact support.
</p>
)}
</>
)}
</CardContent>
</Card>
)}
{!billingOnly && (
<>
{/* Event Subscriptions */}
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bell className="h-5 w-5" />
Notification Events
</CardTitle>
<CardDescription>
Choose which events trigger notifications
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<ToggleButton
enabled={settings.onNewVideo}
onToggle={() =>
setSettings((s) => ({ ...s, onNewVideo: !s.onNewVideo }))
}
label="New Video Added"
description="When a new video is added to one of your projects"
/>
<ToggleButton
enabled={settings.onNewVersion}
onToggle={() =>
setSettings((s) => ({ ...s, onNewVersion: !s.onNewVersion }))
}
label="New Version Added"
description="When a new version is added to an existing video"
/>
<ToggleButton
enabled={settings.onNewComment}
onToggle={() =>
setSettings((s) => ({ ...s, onNewComment: !s.onNewComment }))
}
label="New Comment"
description="When someone leaves a comment on your videos"
/>
<ToggleButton
enabled={settings.onNewReply}
onToggle={() =>
setSettings((s) => ({ ...s, onNewReply: !s.onNewReply }))
}
label="New Reply"
description="When someone replies to a comment thread"
/>
<ToggleButton
enabled={settings.onApprovalEvents}
onToggle={() =>
setSettings((s) => ({ ...s, onApprovalEvents: !s.onApprovalEvents }))
}
label="Approval Workflow"
description="When approval requests are created, responded to, or finalized"
/>
</CardContent>
</Card>
{/* Event Subscriptions */}
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bell className="h-5 w-5" />
Notification Events
</CardTitle>
<CardDescription>Choose which events trigger notifications</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<ToggleButton
enabled={settings.onNewVideo}
onToggle={() => setSettings((s) => ({ ...s, onNewVideo: !s.onNewVideo }))}
label="New Video Added"
description="When a new video is added to one of your projects"
/>
<ToggleButton
enabled={settings.onNewVersion}
onToggle={() => setSettings((s) => ({ ...s, onNewVersion: !s.onNewVersion }))}
label="New Version Added"
description="When a new version is added to an existing video"
/>
<ToggleButton
enabled={settings.onNewComment}
onToggle={() => setSettings((s) => ({ ...s, onNewComment: !s.onNewComment }))}
label="New Comment"
description="When someone leaves a comment on your videos"
/>
<ToggleButton
enabled={settings.onNewReply}
onToggle={() => setSettings((s) => ({ ...s, onNewReply: !s.onNewReply }))}
label="New Reply"
description="When someone replies to a comment thread"
/>
<ToggleButton
enabled={settings.onApprovalEvents}
onToggle={() =>
setSettings((s) => ({ ...s, onApprovalEvents: !s.onApprovalEvents }))
}
label="Approval Workflow"
description="When approval requests are created, responded to, or finalized"
/>
</CardContent>
</Card>
{/* Telegram */}
<Card className="mb-6">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Send className="h-5 w-5" />
Telegram
</CardTitle>
<Badge variant={settings.telegramEnabled ? 'default' : 'secondary'}>
{settings.telegramEnabled ? 'Enabled' : 'Disabled'}
</Badge>
{/* Telegram */}
<Card className="mb-6">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Send className="h-5 w-5" />
Telegram
</CardTitle>
<Badge variant={settings.telegramEnabled ? 'default' : 'secondary'}>
{settings.telegramEnabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
<CardDescription>Get instant notifications via Telegram</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-md border bg-muted/40 p-3 space-y-2 text-sm text-muted-foreground">
<p className="font-medium text-foreground">Setup instructions</p>
<ol className="space-y-1.5 list-decimal list-inside">
<li>
Message{' '}
<a
href="https://t.me/UserInfeBot"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2"
>
@UserInfeBot
</a>{' '}
on Telegram and send{' '}
<code className="bg-muted px-1 rounded text-xs">/start</code> to get your Chat
ID
</li>
<li>
Start{' '}
<a
href="https://t.me/openframe_bot"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2"
>
@openframe_bot
</a>{' '}
and send <code className="bg-muted px-1 rounded text-xs">/start</code> so it can
message you
</li>
<li>Paste your Chat ID below and enable notifications</li>
</ol>
</div>
<div>
<Label htmlFor="telegram-chat-id">Your Chat ID</Label>
<Input
id="telegram-chat-id"
placeholder="123456789"
value={telegramChatId}
onChange={(e) => setTelegramChatId(e.target.value)}
className="mt-1 font-mono text-sm"
/>
</div>
<ToggleButton
enabled={settings.telegramEnabled}
onToggle={() => setSettings((s) => ({ ...s, telegramEnabled: !s.telegramEnabled }))}
label="Enable Telegram notifications"
/>
<Button
variant="outline"
size="sm"
onClick={() => handleTest('telegram')}
disabled={!telegramChatId || testing === 'telegram'}
>
{testing === 'telegram' ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<Send className="h-4 w-4 mr-2" />
)}
Send Test Message
</Button>
</CardContent>
</Card>
{/* Email */}
<Card className="mb-6">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Mail className="h-5 w-5" />
Email
</CardTitle>
<Badge variant={settings.emailEnabled ? 'default' : 'secondary'}>
{settings.emailEnabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
<CardDescription>
Receive notification emails to your account email address
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<ToggleButton
enabled={settings.emailEnabled}
onToggle={() => setSettings((s) => ({ ...s, emailEnabled: !s.emailEnabled }))}
label="Enable email notifications"
/>
<Button
variant="outline"
size="sm"
onClick={() => handleTest('email')}
disabled={!settings.emailEnabled || testing === 'email'}
>
{testing === 'email' ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<Mail className="h-4 w-4 mr-2" />
)}
Send Test Email
</Button>
</CardContent>
</Card>
{/* Timezone */}
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5" />
Timezone
</CardTitle>
<CardDescription>Timestamps in notifications will use this timezone</CardDescription>
</CardHeader>
<CardContent>
<Select
value={settings.timezone}
onValueChange={(value) => setSettings((s) => ({ ...s, timezone: value }))}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select timezone" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Americas</SelectLabel>
<SelectItem value="America/New_York">Eastern Time (New York)</SelectItem>
<SelectItem value="America/Chicago">Central Time (Chicago)</SelectItem>
<SelectItem value="America/Denver">Mountain Time (Denver)</SelectItem>
<SelectItem value="America/Los_Angeles">Pacific Time (Los Angeles)</SelectItem>
<SelectItem value="America/Anchorage">Alaska (Anchorage)</SelectItem>
<SelectItem value="Pacific/Honolulu">Hawaii (Honolulu)</SelectItem>
<SelectItem value="America/Toronto">Toronto</SelectItem>
<SelectItem value="America/Vancouver">Vancouver</SelectItem>
<SelectItem value="America/Mexico_City">Mexico City</SelectItem>
<SelectItem value="America/Sao_Paulo">São Paulo</SelectItem>
<SelectItem value="America/Argentina/Buenos_Aires">Buenos Aires</SelectItem>
<SelectItem value="America/Bogota">Bogotá</SelectItem>
</SelectGroup>
<SelectGroup>
<SelectLabel>Europe</SelectLabel>
<SelectItem value="Europe/London">London (GMT/BST)</SelectItem>
<SelectItem value="Europe/Paris">Paris (CET)</SelectItem>
<SelectItem value="Europe/Berlin">Berlin (CET)</SelectItem>
<SelectItem value="Europe/Amsterdam">Amsterdam (CET)</SelectItem>
<SelectItem value="Europe/Madrid">Madrid (CET)</SelectItem>
<SelectItem value="Europe/Rome">Rome (CET)</SelectItem>
<SelectItem value="Europe/Zurich">Zurich (CET)</SelectItem>
<SelectItem value="Europe/Stockholm">Stockholm (CET)</SelectItem>
<SelectItem value="Europe/Helsinki">Helsinki (EET)</SelectItem>
<SelectItem value="Europe/Athens">Athens (EET)</SelectItem>
<SelectItem value="Europe/Istanbul">Istanbul (TRT)</SelectItem>
<SelectItem value="Europe/Moscow">Moscow (MSK)</SelectItem>
<SelectItem value="Europe/Kiev">Kyiv (EET)</SelectItem>
<SelectItem value="Europe/Warsaw">Warsaw (CET)</SelectItem>
</SelectGroup>
<SelectGroup>
<SelectLabel>Asia & Pacific</SelectLabel>
<SelectItem value="Asia/Dubai">Dubai (GST)</SelectItem>
<SelectItem value="Asia/Kolkata">India (IST)</SelectItem>
<SelectItem value="Asia/Bangkok">Bangkok (ICT)</SelectItem>
<SelectItem value="Asia/Singapore">Singapore (SGT)</SelectItem>
<SelectItem value="Asia/Hong_Kong">Hong Kong (HKT)</SelectItem>
<SelectItem value="Asia/Shanghai">Shanghai (CST)</SelectItem>
<SelectItem value="Asia/Tokyo">Tokyo (JST)</SelectItem>
<SelectItem value="Asia/Seoul">Seoul (KST)</SelectItem>
<SelectItem value="Asia/Taipei">Taipei (CST)</SelectItem>
<SelectItem value="Asia/Jakarta">Jakarta (WIB)</SelectItem>
<SelectItem value="Australia/Sydney">Sydney (AEST)</SelectItem>
<SelectItem value="Australia/Melbourne">Melbourne (AEST)</SelectItem>
<SelectItem value="Australia/Perth">Perth (AWST)</SelectItem>
<SelectItem value="Pacific/Auckland">Auckland (NZST)</SelectItem>
</SelectGroup>
<SelectGroup>
<SelectLabel>Africa & Middle East</SelectLabel>
<SelectItem value="Africa/Cairo">Cairo (EET)</SelectItem>
<SelectItem value="Africa/Lagos">Lagos (WAT)</SelectItem>
<SelectItem value="Africa/Johannesburg">Johannesburg (SAST)</SelectItem>
<SelectItem value="Africa/Nairobi">Nairobi (EAT)</SelectItem>
<SelectItem value="Asia/Riyadh">Riyadh (AST)</SelectItem>
<SelectItem value="Asia/Tehran">Tehran (IRST)</SelectItem>
</SelectGroup>
<SelectGroup>
<SelectLabel>Other</SelectLabel>
<SelectItem value="UTC">UTC</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</CardContent>
</Card>
<Separator className="my-6" />
{/* Save button */}
<div className="flex justify-end">
<Button onClick={handleSave} disabled={saving}>
{saving ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Saving...
</>
) : (
'Save Settings'
)}
</Button>
</div>
<CardDescription>
Get instant notifications via Telegram
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-md border bg-muted/40 p-3 space-y-2 text-sm text-muted-foreground">
<p className="font-medium text-foreground">Setup instructions</p>
<ol className="space-y-1.5 list-decimal list-inside">
<li>
Message{' '}
<a
href="https://t.me/UserInfeBot"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2"
>
@UserInfeBot
</a>
{' '}on Telegram and send <code className="bg-muted px-1 rounded text-xs">/start</code> to get your Chat ID
</li>
<li>
Start{' '}
<a
href="https://t.me/openframe_bot"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2"
>
@openframe_bot
</a>
{' '}and send <code className="bg-muted px-1 rounded text-xs">/start</code> so it can message you
</li>
<li>Paste your Chat ID below and enable notifications</li>
</ol>
</div>
<div>
<Label htmlFor="telegram-chat-id">Your Chat ID</Label>
<Input
id="telegram-chat-id"
placeholder="123456789"
value={telegramChatId}
onChange={(e) => setTelegramChatId(e.target.value)}
className="mt-1 font-mono text-sm"
/>
</div>
<ToggleButton
enabled={settings.telegramEnabled}
onToggle={() =>
setSettings((s) => ({ ...s, telegramEnabled: !s.telegramEnabled }))
}
label="Enable Telegram notifications"
/>
<Button
variant="outline"
size="sm"
onClick={() => handleTest('telegram')}
disabled={!telegramChatId || testing === 'telegram'}
>
{testing === 'telegram' ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<Send className="h-4 w-4 mr-2" />
)}
Send Test Message
</Button>
</CardContent>
</Card>
{/* Email */}
<Card className="mb-6">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Mail className="h-5 w-5" />
Email
</CardTitle>
<Badge variant={settings.emailEnabled ? 'default' : 'secondary'}>
{settings.emailEnabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
<CardDescription>
Receive notification emails to your account email address
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<ToggleButton
enabled={settings.emailEnabled}
onToggle={() =>
setSettings((s) => ({ ...s, emailEnabled: !s.emailEnabled }))
}
label="Enable email notifications"
/>
<Button
variant="outline"
size="sm"
onClick={() => handleTest('email')}
disabled={!settings.emailEnabled || testing === 'email'}
>
{testing === 'email' ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<Mail className="h-4 w-4 mr-2" />
)}
Send Test Email
</Button>
</CardContent>
</Card>
{/* Timezone */}
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5" />
Timezone
</CardTitle>
<CardDescription>
Timestamps in notifications will use this timezone
</CardDescription>
</CardHeader>
<CardContent>
<Select
value={settings.timezone}
onValueChange={(value) =>
setSettings((s) => ({ ...s, timezone: value }))
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select timezone" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Americas</SelectLabel>
<SelectItem value="America/New_York">Eastern Time (New York)</SelectItem>
<SelectItem value="America/Chicago">Central Time (Chicago)</SelectItem>
<SelectItem value="America/Denver">Mountain Time (Denver)</SelectItem>
<SelectItem value="America/Los_Angeles">Pacific Time (Los Angeles)</SelectItem>
<SelectItem value="America/Anchorage">Alaska (Anchorage)</SelectItem>
<SelectItem value="Pacific/Honolulu">Hawaii (Honolulu)</SelectItem>
<SelectItem value="America/Toronto">Toronto</SelectItem>
<SelectItem value="America/Vancouver">Vancouver</SelectItem>
<SelectItem value="America/Mexico_City">Mexico City</SelectItem>
<SelectItem value="America/Sao_Paulo">São Paulo</SelectItem>
<SelectItem value="America/Argentina/Buenos_Aires">Buenos Aires</SelectItem>
<SelectItem value="America/Bogota">Bogotá</SelectItem>
</SelectGroup>
<SelectGroup>
<SelectLabel>Europe</SelectLabel>
<SelectItem value="Europe/London">London (GMT/BST)</SelectItem>
<SelectItem value="Europe/Paris">Paris (CET)</SelectItem>
<SelectItem value="Europe/Berlin">Berlin (CET)</SelectItem>
<SelectItem value="Europe/Amsterdam">Amsterdam (CET)</SelectItem>
<SelectItem value="Europe/Madrid">Madrid (CET)</SelectItem>
<SelectItem value="Europe/Rome">Rome (CET)</SelectItem>
<SelectItem value="Europe/Zurich">Zurich (CET)</SelectItem>
<SelectItem value="Europe/Stockholm">Stockholm (CET)</SelectItem>
<SelectItem value="Europe/Helsinki">Helsinki (EET)</SelectItem>
<SelectItem value="Europe/Athens">Athens (EET)</SelectItem>
<SelectItem value="Europe/Istanbul">Istanbul (TRT)</SelectItem>
<SelectItem value="Europe/Moscow">Moscow (MSK)</SelectItem>
<SelectItem value="Europe/Kiev">Kyiv (EET)</SelectItem>
<SelectItem value="Europe/Warsaw">Warsaw (CET)</SelectItem>
</SelectGroup>
<SelectGroup>
<SelectLabel>Asia & Pacific</SelectLabel>
<SelectItem value="Asia/Dubai">Dubai (GST)</SelectItem>
<SelectItem value="Asia/Kolkata">India (IST)</SelectItem>
<SelectItem value="Asia/Bangkok">Bangkok (ICT)</SelectItem>
<SelectItem value="Asia/Singapore">Singapore (SGT)</SelectItem>
<SelectItem value="Asia/Hong_Kong">Hong Kong (HKT)</SelectItem>
<SelectItem value="Asia/Shanghai">Shanghai (CST)</SelectItem>
<SelectItem value="Asia/Tokyo">Tokyo (JST)</SelectItem>
<SelectItem value="Asia/Seoul">Seoul (KST)</SelectItem>
<SelectItem value="Asia/Taipei">Taipei (CST)</SelectItem>
<SelectItem value="Asia/Jakarta">Jakarta (WIB)</SelectItem>
<SelectItem value="Australia/Sydney">Sydney (AEST)</SelectItem>
<SelectItem value="Australia/Melbourne">Melbourne (AEST)</SelectItem>
<SelectItem value="Australia/Perth">Perth (AWST)</SelectItem>
<SelectItem value="Pacific/Auckland">Auckland (NZST)</SelectItem>
</SelectGroup>
<SelectGroup>
<SelectLabel>Africa & Middle East</SelectLabel>
<SelectItem value="Africa/Cairo">Cairo (EET)</SelectItem>
<SelectItem value="Africa/Lagos">Lagos (WAT)</SelectItem>
<SelectItem value="Africa/Johannesburg">Johannesburg (SAST)</SelectItem>
<SelectItem value="Africa/Nairobi">Nairobi (EAT)</SelectItem>
<SelectItem value="Asia/Riyadh">Riyadh (AST)</SelectItem>
<SelectItem value="Asia/Tehran">Tehran (IRST)</SelectItem>
</SelectGroup>
<SelectGroup>
<SelectLabel>Other</SelectLabel>
<SelectItem value="UTC">UTC</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</CardContent>
</Card>
<Separator className="my-6" />
{/* Save button */}
<div className="flex justify-end">
<Button onClick={handleSave} disabled={saving}>
{saving ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Saving...
</>
) : (
'Save Settings'
)}
</Button>
</div>
</>
)}
</div>
@@ -60,9 +60,8 @@ export default async function WorkspacePage({ params, searchParams }: WorkspaceP
const pageParam = resolvedSearchParams?.page;
const parsedPage = pageParam ? Number(pageParam) : 1;
const page = Number.isSafeInteger(parsedPage) && parsedPage > 0 && parsedPage <= MAX_PAGE
? parsedPage
: 1;
const page =
Number.isSafeInteger(parsedPage) && parsedPage > 0 && parsedPage <= MAX_PAGE ? parsedPage : 1;
const pageSize = 20;
const skip = (page - 1) * pageSize;
@@ -94,7 +93,10 @@ export default async function WorkspacePage({ params, searchParams }: WorkspaceP
const membership = workspace.members[0];
const isMember = !!membership;
const isAdmin = isOwner || membership?.role === 'ADMIN';
const access = await checkWorkspaceAccess({ id: workspace.id, ownerId: workspace.ownerId }, session.user.id);
const access = await checkWorkspaceAccess(
{ id: workspace.id, ownerId: workspace.ownerId },
session.user.id
);
if (!access.hasAccess || (!isOwner && !isMember)) {
redirect('/dashboard');
@@ -213,7 +215,12 @@ export default async function WorkspacePage({ params, searchParams }: WorkspaceP
<span className="text-sm font-medium">
Page {page} of {totalPages}
</span>
<Button variant="outline" size="sm" disabled={page >= totalPages} asChild={page < totalPages}>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
asChild={page < totalPages}
>
{page < totalPages ? (
<Link href={`/workspaces/${workspaceId}?page=${page + 1}`}>Next</Link>
) : (
@@ -12,7 +12,12 @@ import { Textarea } from '@/components/ui/textarea';
type Visibility = 'PRIVATE' | 'INVITE' | 'PUBLIC';
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',
@@ -111,8 +116,7 @@ export default function NewWorkspaceProjectPageClient({ workspaceId }: { workspa
<div className="space-y-2">
<Label htmlFor="description" className="text-sm font-medium">
Description{' '}
<span className="text-muted-foreground font-normal">(optional)</span>
Description <span className="text-muted-foreground font-normal">(optional)</span>
</Label>
<Textarea
id="description"
@@ -148,9 +148,7 @@ export default function WorkspaceSettingsPageClient({
<div className="mb-8">
<h1 className="text-3xl font-bold tracking-tight">Workspace Settings</h1>
<p className="text-muted-foreground mt-1">
Manage workspace configuration
</p>
<p className="text-muted-foreground mt-1">Manage workspace configuration</p>
</div>
<Card className="mb-8">
@@ -215,9 +213,7 @@ export default function WorkspaceSettingsPageClient({
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive">Danger Zone</CardTitle>
<CardDescription>
Irreversible actions. Proceed with caution.
</CardDescription>
<CardDescription>Irreversible actions. Proceed with caution.</CardDescription>
</CardHeader>
<CardContent>
<AlertDialog>
@@ -234,11 +230,13 @@ export default function WorkspaceSettingsPageClient({
<div className="space-y-4">
<p>
This will permanently delete this workspace and everything inside it
(projects, videos, comments, images, and voice notes). This action cannot be undone.
(projects, videos, comments, images, and voice notes). This action cannot
be undone.
</p>
<div className="space-y-2">
<Label htmlFor="delete-workspace-confirm">
Type <strong className="text-foreground">{workspace.name}</strong> to confirm
Type <strong className="text-foreground">{workspace.name}</strong> to
confirm
</Label>
<Input
id="delete-workspace-confirm"
+4 -4
View File
@@ -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';
function WorkspaceCardSkeleton() {
return (
@@ -20,7 +20,7 @@ function WorkspaceCardSkeleton() {
</div>
</CardContent>
</Card>
)
);
}
export default function WorkspacesLoading() {
@@ -40,5 +40,5 @@ export default function WorkspacesLoading() {
))}
</div>
</div>
)
);
}
@@ -95,9 +95,7 @@ export default function NewWorkspacePage({
id="name"
placeholder="e.g., My Studio"
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
disabled={isLoading}
/>
@@ -112,9 +110,7 @@ export default function NewWorkspacePage({
id="description"
placeholder="What is this workspace for?"
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
disabled={isLoading}
/>
@@ -140,7 +136,8 @@ export default function NewWorkspacePage({
) : (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
You can still create and manage projects inside workspaces where you are already a member.
You can still create and manage projects inside workspaces where you are already a
member.
</p>
<Button asChild className="w-full">
<Link href="/settings">Open Billing Settings</Link>
+7 -4
View File
@@ -2,13 +2,16 @@ import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';
import { db } from '@/lib/db';
import { buildBillingAccessWhereInput, getBillingOverview } from '@/lib/billing';
import { hasCollaboratorBillingBackedAccess, requireBillingAccessOrRedirect } from '@/lib/route-access';
import {
hasCollaboratorBillingBackedAccess,
requireBillingAccessOrRedirect,
} from '@/lib/route-access';
import { WorkspacesClient } from './workspaces-client';
export default async function WorkspacesPage({
searchParams,
}: {
searchParams: Promise<{ page?: string }>
searchParams: Promise<{ page?: string }>;
}) {
const session = await auth();
if (!session?.user?.id) {
@@ -52,7 +55,7 @@ export default async function WorkspacesPage({
{ ownerId: session.user.id, owner: buildBillingAccessWhereInput() },
{ members: { some: { userId: session.user.id } }, owner: buildBillingAccessWhereInput() },
],
}
},
}),
getBillingOverview(session.user.id),
]);
@@ -64,7 +67,7 @@ export default async function WorkspacesPage({
name: w.name,
description: w.description,
updatedAt: w.updatedAt.toISOString(),
_count: w._count
_count: w._count,
}));
return (
@@ -55,9 +55,7 @@ export function WorkspacesClient({
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8">
<div>
<h1 className="text-3xl font-bold tracking-tight">Workspaces</h1>
<p className="text-muted-foreground mt-1">
Manage your workspaces and their projects
</p>
<p className="text-muted-foreground mt-1">Manage your workspaces and their projects</p>
{!workspaceCreation.canCreateWorkspace && workspaceCreation.reason ? (
<p className="text-sm text-amber-700 dark:text-amber-400 mt-2">
{workspaceCreation.reason}
@@ -73,9 +71,7 @@ export function WorkspacesClient({
</Button>
) : (
<Button asChild className="w-full sm:w-auto">
<Link href="/settings">
Upgrade to Create Workspace
</Link>
<Link href="/settings">Upgrade to Create Workspace</Link>
</Button>
)}
</div>