mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat: Implement new project visibility types, add dedicated project settings and share pages, and enhance the dashboard with dynamic project data and visibility badges.
This commit is contained in:
@@ -1,44 +1,61 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Plus, FolderOpen, Clock, Users } from 'lucide-react';
|
import { Plus, FolderOpen, Clock, Users, Globe, Lock, UserPlus } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
// import { auth } from '@/lib/auth';
|
import { Badge } from '@/components/ui/badge';
|
||||||
// import { redirect } from 'next/navigation';
|
import { auth } from '@/lib/auth';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
// Placeholder data - will be replaced with real data from database
|
function formatRelativeTime(date: Date): string {
|
||||||
const mockProjects = [
|
const now = new Date();
|
||||||
{
|
const diffMs = now.getTime() - date.getTime();
|
||||||
id: '1',
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
name: 'Product Demo v2',
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
description: 'New product walkthrough video for Q1 launch',
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
videoCount: 3,
|
|
||||||
lastUpdated: '2 hours ago',
|
if (diffMins < 1) return 'just now';
|
||||||
memberCount: 4,
|
if (diffMins < 60) return `${diffMins}m ago`;
|
||||||
},
|
if (diffHours < 24) return `${diffHours}h ago`;
|
||||||
{
|
if (diffDays < 7) return `${diffDays}d ago`;
|
||||||
id: '2',
|
return date.toLocaleDateString();
|
||||||
name: 'Marketing Campaign',
|
}
|
||||||
description: 'Social media ads for summer campaign',
|
|
||||||
videoCount: 8,
|
function VisibilityIcon({ visibility }: { visibility: string }) {
|
||||||
lastUpdated: '1 day ago',
|
switch (visibility) {
|
||||||
memberCount: 2,
|
case 'PUBLIC':
|
||||||
},
|
return <Globe className="h-3.5 w-3.5" />;
|
||||||
{
|
case 'INVITE':
|
||||||
id: '3',
|
return <UserPlus className="h-3.5 w-3.5" />;
|
||||||
name: 'Tutorial Series',
|
default:
|
||||||
description: 'Getting started tutorials for new users',
|
return <Lock className="h-3.5 w-3.5" />;
|
||||||
videoCount: 12,
|
}
|
||||||
lastUpdated: '3 days ago',
|
}
|
||||||
memberCount: 1,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
// TODO: Uncomment when database is set up
|
const session = await auth();
|
||||||
// const session = await auth();
|
if (!session?.user?.id) {
|
||||||
// if (!session) {
|
redirect('/login');
|
||||||
// redirect('/login');
|
}
|
||||||
// }
|
|
||||||
|
// Fetch projects where user is owner or member
|
||||||
|
const projects = await db.project.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ ownerId: session.user.id },
|
||||||
|
{ members: { some: { userId: session.user.id } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
videos: true,
|
||||||
|
members: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-6 lg:px-8 py-8 w-full">
|
<div className="px-6 lg:px-8 py-8 w-full">
|
||||||
@@ -59,31 +76,37 @@ export default async function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Projects Grid */}
|
{/* Projects Grid */}
|
||||||
{mockProjects.length > 0 ? (
|
{projects.length > 0 ? (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{mockProjects.map((project) => (
|
{projects.map((project) => (
|
||||||
<Link key={project.id} href={`/projects/${project.id}`}>
|
<Link key={project.id} href={`/projects/${project.id}`}>
|
||||||
<Card className="h-full transition-colors hover:bg-accent/50 cursor-pointer">
|
<Card className="h-full transition-colors hover:bg-accent/50 cursor-pointer">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<div className="flex items-start justify-between">
|
||||||
<FolderOpen className="h-5 w-5 text-primary" />
|
<CardTitle className="flex items-center gap-2">
|
||||||
{project.name}
|
<FolderOpen className="h-5 w-5 text-primary" />
|
||||||
</CardTitle>
|
{project.name}
|
||||||
|
</CardTitle>
|
||||||
|
<Badge variant="outline" className="flex items-center gap-1">
|
||||||
|
<VisibilityIcon visibility={project.visibility} />
|
||||||
|
{project.visibility.toLowerCase()}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
<CardDescription className="line-clamp-2">
|
<CardDescription className="line-clamp-2">
|
||||||
{project.description}
|
{project.description || 'No description'}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Clock className="h-3.5 w-3.5" />
|
<Clock className="h-3.5 w-3.5" />
|
||||||
{project.lastUpdated}
|
{formatRelativeTime(project.updatedAt)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Users className="h-3.5 w-3.5" />
|
<Users className="h-3.5 w-3.5" />
|
||||||
{project.memberCount}
|
{project._count.members + 1}
|
||||||
</span>
|
</span>
|
||||||
<span>{project.videoCount} videos</span>
|
<span>{project._count.videos} videos</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,74 +1,122 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound, redirect } from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Plus,
|
Plus,
|
||||||
Settings,
|
Settings,
|
||||||
Share2,
|
Share2,
|
||||||
Play,
|
Play,
|
||||||
|
Globe,
|
||||||
|
Lock,
|
||||||
|
UserPlus,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { VideoCard } from '@/components/video-card';
|
import { VideoCard } from '@/components/video-card';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
// Mock data - will be replaced with real data
|
function VisibilityIcon({ visibility }: { visibility: string }) {
|
||||||
const mockProject = {
|
switch (visibility) {
|
||||||
id: '1',
|
case 'PUBLIC':
|
||||||
name: 'Product Demo v2',
|
return <Globe className="h-3.5 w-3.5" />;
|
||||||
description: 'New product walkthrough video for Q1 launch',
|
case 'INVITE':
|
||||||
visibility: 'PRIVATE',
|
return <UserPlus className="h-3.5 w-3.5" />;
|
||||||
videos: [
|
default:
|
||||||
{
|
return <Lock className="h-3.5 w-3.5" />;
|
||||||
id: 'v1',
|
}
|
||||||
title: 'Main Product Walkthrough',
|
}
|
||||||
thumbnailUrl: 'https://img.youtube.com/vi/dQw4w9WgXcQ/mqdefault.jpg',
|
|
||||||
currentVersion: 3,
|
function formatDuration(seconds: number | null): string {
|
||||||
commentCount: 12,
|
if (!seconds) return '0:00';
|
||||||
duration: '5:42',
|
const mins = Math.floor(seconds / 60);
|
||||||
lastUpdated: '2 hours ago',
|
const secs = Math.floor(seconds % 60);
|
||||||
},
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
{
|
}
|
||||||
id: 'v2',
|
|
||||||
title: 'Feature Highlight - Dashboard',
|
function formatRelativeTime(date: Date): string {
|
||||||
thumbnailUrl: 'https://img.youtube.com/vi/dQw4w9WgXcQ/mqdefault.jpg',
|
const now = new Date();
|
||||||
currentVersion: 1,
|
const diffMs = now.getTime() - date.getTime();
|
||||||
commentCount: 5,
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
duration: '2:18',
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
lastUpdated: '1 day ago',
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
},
|
|
||||||
{
|
if (diffMins < 1) return 'just now';
|
||||||
id: 'v3',
|
if (diffMins < 60) return `${diffMins}m ago`;
|
||||||
title: 'Onboarding Flow',
|
if (diffHours < 24) return `${diffHours}h ago`;
|
||||||
thumbnailUrl: 'https://img.youtube.com/vi/dQw4w9WgXcQ/mqdefault.jpg',
|
if (diffDays < 7) return `${diffDays}d ago`;
|
||||||
currentVersion: 2,
|
return date.toLocaleDateString();
|
||||||
commentCount: 8,
|
}
|
||||||
duration: '3:55',
|
|
||||||
lastUpdated: '3 days ago',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ProjectPageProps {
|
interface ProjectPageProps {
|
||||||
params: Promise<{ projectId: string }>;
|
params: Promise<{ projectId: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ProjectPage({ params }: ProjectPageProps) {
|
export default async function ProjectPage({ params }: ProjectPageProps) {
|
||||||
|
const session = await auth();
|
||||||
const { projectId } = await params;
|
const { projectId } = await params;
|
||||||
|
|
||||||
// TODO: Fetch real project data
|
// Fetch project with videos
|
||||||
const project = mockProject;
|
const project = await db.project.findUnique({
|
||||||
|
where: { id: projectId },
|
||||||
|
include: {
|
||||||
|
owner: { select: { id: true, name: true } },
|
||||||
|
members: {
|
||||||
|
where: { userId: session?.user?.id || '' },
|
||||||
|
select: { role: true },
|
||||||
|
},
|
||||||
|
videos: {
|
||||||
|
orderBy: { position: 'asc' },
|
||||||
|
include: {
|
||||||
|
versions: {
|
||||||
|
where: { isActive: true },
|
||||||
|
take: 1,
|
||||||
|
include: {
|
||||||
|
_count: { select: { comments: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: { select: { versions: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check access
|
||||||
|
const isOwner = session?.user?.id === project.ownerId;
|
||||||
|
const isMember = project.members.length > 0;
|
||||||
|
const isPublicOrLink = project.visibility !== 'PRIVATE';
|
||||||
|
|
||||||
|
if (!isOwner && !isMember && !isPublicOrLink) {
|
||||||
|
redirect('/dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform videos for VideoCard component
|
||||||
|
const videos = project.videos.map((video) => {
|
||||||
|
const activeVersion = video.versions[0];
|
||||||
|
return {
|
||||||
|
id: video.id,
|
||||||
|
title: video.title,
|
||||||
|
thumbnailUrl: activeVersion?.thumbnailUrl || 'https://via.placeholder.com/320x180?text=No+Thumbnail',
|
||||||
|
currentVersion: video._count.versions,
|
||||||
|
commentCount: activeVersion?._count.comments || 0,
|
||||||
|
duration: formatDuration(activeVersion?.duration),
|
||||||
|
lastUpdated: formatRelativeTime(video.updatedAt),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const canEdit = isOwner || project.members[0]?.role === 'ADMIN' || project.members[0]?.role === 'EDITOR';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-6 lg:px-8 py-8 w-full">
|
<div className="px-6 lg:px-8 py-8 w-full">
|
||||||
{/* Back link */}
|
{/* Back link */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard"
|
href="/dashboard"
|
||||||
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
|
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||||
@@ -81,7 +129,8 @@ export default async function ProjectPage({ params }: ProjectPageProps) {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">{project.name}</h1>
|
<h1 className="text-3xl font-bold tracking-tight">{project.name}</h1>
|
||||||
<Badge variant="outline" className="capitalize">
|
<Badge variant="outline" className="flex items-center gap-1">
|
||||||
|
<VisibilityIcon visibility={project.visibility} />
|
||||||
{project.visibility.toLowerCase()}
|
{project.visibility.toLowerCase()}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -89,29 +138,37 @@ export default async function ProjectPage({ params }: ProjectPageProps) {
|
|||||||
<p className="text-muted-foreground">{project.description}</p>
|
<p className="text-muted-foreground">{project.description}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm" asChild>
|
||||||
<Share2 className="h-4 w-4 mr-2" />
|
<Link href={`/projects/${projectId}/share`}>
|
||||||
Share
|
<Share2 className="h-4 w-4 mr-2" />
|
||||||
</Button>
|
Share
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<Settings className="h-4 w-4 mr-2" />
|
|
||||||
Settings
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" asChild>
|
|
||||||
<Link href={`/projects/${projectId}/videos/new`}>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
Add Video
|
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
{(isOwner || project.members[0]?.role === 'ADMIN') && (
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<Link href={`/projects/${projectId}/settings`}>
|
||||||
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
|
Settings
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canEdit && (
|
||||||
|
<Button size="sm" asChild>
|
||||||
|
<Link href={`/projects/${projectId}/videos/new`}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Add Video
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Videos Grid */}
|
{/* Videos Grid */}
|
||||||
{project.videos.length > 0 ? (
|
{videos.length > 0 ? (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{project.videos.map((video) => (
|
{videos.map((video) => (
|
||||||
<VideoCard key={video.id} video={video} projectId={projectId} />
|
<VideoCard key={video.id} video={video} projectId={projectId} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -123,12 +180,14 @@ export default async function ProjectPage({ params }: ProjectPageProps) {
|
|||||||
<p className="text-muted-foreground text-center mb-4">
|
<p className="text-muted-foreground text-center mb-4">
|
||||||
Add your first video to start collecting feedback
|
Add your first video to start collecting feedback
|
||||||
</p>
|
</p>
|
||||||
<Button asChild>
|
{canEdit && (
|
||||||
<Link href={`/projects/${projectId}/videos/new`}>
|
<Button asChild>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Link href={`/projects/${projectId}/videos/new`}>
|
||||||
Add Video
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
</Link>
|
Add Video
|
||||||
</Button>
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,339 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ArrowLeft, Loader2, Globe, Lock, UserPlus, Trash2, AlertTriangle, Settings, Save } 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 {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
|
||||||
|
type Visibility = 'PRIVATE' | 'INVITE' | 'PUBLIC';
|
||||||
|
|
||||||
|
const visibilityOptions: { value: Visibility; label: string; description: string; icon: React.ReactNode }[] = [
|
||||||
|
{
|
||||||
|
value: 'PRIVATE',
|
||||||
|
label: 'Private',
|
||||||
|
description: 'Only you can access this project',
|
||||||
|
icon: <Lock className="h-5 w-5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'INVITE',
|
||||||
|
label: 'Invite Only',
|
||||||
|
description: 'Share with specific people via email',
|
||||||
|
icon: <UserPlus className="h-5 w-5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'PUBLIC',
|
||||||
|
label: 'Public',
|
||||||
|
description: 'Anyone with the link can view',
|
||||||
|
icon: <Globe className="h-5 w-5" />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ProjectSettingsPageProps {
|
||||||
|
params: Promise<{ projectId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProjectSettingsPage({ params }: ProjectSettingsPageProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [projectId, setProjectId] = useState<string>('');
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [success, setSuccess] = useState('');
|
||||||
|
const [deleteConfirmation, setDeleteConfirmation] = useState('');
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
visibility: 'PRIVATE' as Visibility,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
params.then(({ projectId: id }) => {
|
||||||
|
setProjectId(id);
|
||||||
|
fetch(`/api/projects/${id}`)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.error) {
|
||||||
|
setError(data.error);
|
||||||
|
} else {
|
||||||
|
setFormData({
|
||||||
|
name: data.name || '',
|
||||||
|
description: data.description || '',
|
||||||
|
visibility: data.visibility || 'PRIVATE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => setError('Failed to load project'))
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
});
|
||||||
|
}, [params]);
|
||||||
|
|
||||||
|
const handleSave = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsSaving(true);
|
||||||
|
setError('');
|
||||||
|
setSuccess('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(formData),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
setError(data.error || 'Failed to update project');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSuccess('Project settings saved successfully');
|
||||||
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
|
} catch {
|
||||||
|
setError('Something went wrong. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (deleteConfirmation !== formData.name) {
|
||||||
|
setError('Project name does not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDeleting(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setError(data.error || 'Failed to delete project');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push('/dashboard');
|
||||||
|
} catch {
|
||||||
|
setError('Something went wrong. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
{/* General Settings */}
|
||||||
|
<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">
|
||||||
|
<Settings className="h-7 w-7 text-primary" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl">Project Settings</CardTitle>
|
||||||
|
<CardDescription className="text-base">
|
||||||
|
Update your project details and access settings
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<form onSubmit={handleSave} className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name" className="text-sm font-medium">
|
||||||
|
Project Name
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||||
|
required
|
||||||
|
disabled={isSaving}
|
||||||
|
className="h-11"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description" className="text-sm font-medium">
|
||||||
|
Description
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||||
|
rows={3}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">Who can access?</Label>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{visibilityOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormData(prev => ({ ...prev, visibility: option.value }))}
|
||||||
|
disabled={isSaving}
|
||||||
|
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'
|
||||||
|
}`}>
|
||||||
|
{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>
|
||||||
|
<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" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 rounded-lg bg-destructive/10 border border-destructive/20 text-destructive text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="p-4 rounded-lg bg-green-500/10 border border-green-500/20 text-green-500 text-sm flex items-center gap-2">
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" disabled={isSaving || !formData.name.trim()} className="h-11">
|
||||||
|
{isSaving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Danger Zone */}
|
||||||
|
<Card className="border-destructive/30 shadow-lg">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-lg text-destructive flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-5 w-5" />
|
||||||
|
Danger Zone
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Irreversible actions that will permanently affect your project
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between p-4 rounded-xl border border-destructive/20 bg-destructive/5">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium">Delete this project</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This action cannot be undone
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="destructive" size="sm">
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete "{formData.name}"?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription asChild>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p>
|
||||||
|
This will permanently delete this project and all of its
|
||||||
|
videos, versions, and comments. This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="delete-confirm">
|
||||||
|
Type <strong className="text-foreground">{formData.name}</strong> to confirm
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="delete-confirm"
|
||||||
|
value={deleteConfirmation}
|
||||||
|
onChange={(e) => setDeleteConfirmation(e.target.value)}
|
||||||
|
placeholder="Project name"
|
||||||
|
className="h-11"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={() => setDeleteConfirmation('')}>
|
||||||
|
Cancel
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleteConfirmation !== formData.name || isDeleting}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{isDeleting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||||
|
Delete Project
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ArrowLeft, Copy, Check, Loader2, UserPlus, Trash2, 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';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
|
|
||||||
|
interface ProjectMember {
|
||||||
|
id: string;
|
||||||
|
role: string;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
email: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectSharePageProps {
|
||||||
|
params: Promise<{ projectId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProjectSharePage({ params }: ProjectSharePageProps) {
|
||||||
|
const [projectId, setProjectId] = useState<string>('');
|
||||||
|
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(() => {
|
||||||
|
params.then(({ projectId: id }) => {
|
||||||
|
setProjectId(id);
|
||||||
|
fetch(`/api/projects/${id}`)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.error) {
|
||||||
|
setError(data.error);
|
||||||
|
} else {
|
||||||
|
setProjectName(data.name || '');
|
||||||
|
setProjectVisibility(data.visibility || 'PRIVATE');
|
||||||
|
setMembers(data.members || []);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => setError('Failed to load project'))
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
});
|
||||||
|
}, [params]);
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
return `/projects/${projectId}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
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-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibilityInfo = getVisibilityLabel();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-[calc(100vh-4rem)] flex items-start justify-center py-12 px-4">
|
||||||
|
<div className="w-full max-w-xl">
|
||||||
|
<div className="mb-8">
|
||||||
|
<Link
|
||||||
|
href={`/projects/${projectId}`}
|
||||||
|
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||||
|
Back to Project
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header Card */}
|
||||||
|
<Card className="border-border/50 shadow-lg">
|
||||||
|
<CardHeader className="text-center pb-2">
|
||||||
|
<div className="mx-auto w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center mb-4">
|
||||||
|
<Share2 className="h-7 w-7 text-primary" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl">Share Project</CardTitle>
|
||||||
|
<CardDescription className="text-base">
|
||||||
|
Share "{projectName}" with your team or clients
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
{/* Visibility Status */}
|
||||||
|
<div className={`flex items-center gap-3 p-4 rounded-xl ${getVisibilityColor()}`}>
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-current/10 flex items-center justify-center">
|
||||||
|
<VisibilityIcon />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium">{visibilityInfo.title}</div>
|
||||||
|
<div className="text-sm opacity-80">{visibilityInfo.description}</div>
|
||||||
|
</div>
|
||||||
|
<Link href={`/projects/${projectId}/settings`}>
|
||||||
|
<Button variant="ghost" size="sm" className="text-current hover:bg-current/10">
|
||||||
|
Change
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Invite People - Only show for INVITE visibility */}
|
||||||
|
{projectVisibility === 'INVITE' && (
|
||||||
|
<Card className="border-border/50 shadow-lg">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Mail className="h-5 w-5 text-primary" />
|
||||||
|
Invite People
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Send email invitations to specific people
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<form onSubmit={handleInvite} className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={inviteEmail}
|
||||||
|
onChange={(e) => setInviteEmail(e.target.value)}
|
||||||
|
placeholder="[email protected]"
|
||||||
|
className="h-11 flex-1"
|
||||||
|
disabled={isInviting}
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={isInviting || !inviteEmail.trim()} className="h-11">
|
||||||
|
{isInviting ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UserPlus className="h-4 w-4 mr-2" />
|
||||||
|
Invite
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{inviteSuccess && (
|
||||||
|
<div className="p-3 rounded-lg bg-green-500/10 border border-green-500/20 text-green-500 text-sm">
|
||||||
|
{inviteSuccess}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Current Members */}
|
||||||
|
{members.length > 0 && (
|
||||||
|
<div className="space-y-2 pt-2">
|
||||||
|
<Label className="text-sm text-muted-foreground">Project Members</Label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{members.map((member) => (
|
||||||
|
<div
|
||||||
|
key={member.id}
|
||||||
|
className="flex items-center justify-between p-3 rounded-xl border bg-card"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar className="h-9 w-9">
|
||||||
|
<AvatarFallback className="text-xs">
|
||||||
|
{member.user.name?.charAt(0) || member.user.email?.charAt(0) || '?'}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-sm">
|
||||||
|
{member.user.name || 'Unknown'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{member.user.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="secondary" className="text-xs capitalize">
|
||||||
|
{member.role.toLowerCase()}
|
||||||
|
</Badge>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-destructive">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{members.length === 0 && (
|
||||||
|
<div className="text-center py-6 text-muted-foreground">
|
||||||
|
<UserPlus className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||||
|
<p className="text-sm">No members yet</p>
|
||||||
|
<p className="text-xs opacity-70">Invite people to collaborate on this project</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Public Link - Only show for PUBLIC visibility */}
|
||||||
|
{projectVisibility === 'PUBLIC' && (
|
||||||
|
<Card className="border-border/50 shadow-lg">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Globe className="h-5 w-5 text-primary" />
|
||||||
|
Public Link
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Share this link with anyone
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={getDirectLink()}
|
||||||
|
readOnly
|
||||||
|
className="font-mono text-sm h-11 bg-muted/50"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant={copied ? 'default' : 'outline'}
|
||||||
|
size="icon"
|
||||||
|
className="h-11 w-11 shrink-0"
|
||||||
|
onClick={() => copyToClipboard(getDirectLink())}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Private notice */}
|
||||||
|
{projectVisibility === 'PRIVATE' && (
|
||||||
|
<Card className="border-border/50 shadow-lg">
|
||||||
|
<CardContent className="py-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-muted/50 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Lock className="h-8 w-8 text-muted-foreground/50" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-medium mb-1">This project is private</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Only you can access this project. Change visibility to share with others.
|
||||||
|
</p>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href={`/projects/${projectId}/settings`}>
|
||||||
|
Change Visibility
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 rounded-lg bg-destructive/10 border border-destructive/20 text-destructive text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,102 +3,196 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ArrowLeft, Loader2 } from 'lucide-react';
|
import { ArrowLeft, Loader2, Globe, Lock, UserPlus, FolderPlus } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
|
||||||
|
type Visibility = 'PRIVATE' | 'INVITE' | 'PUBLIC';
|
||||||
|
|
||||||
|
const visibilityOptions: { value: Visibility; label: string; description: string; icon: React.ReactNode }[] = [
|
||||||
|
{
|
||||||
|
value: 'PRIVATE',
|
||||||
|
label: 'Private',
|
||||||
|
description: 'Only you can access this project',
|
||||||
|
icon: <Lock className="h-5 w-5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'INVITE',
|
||||||
|
label: 'Invite Only',
|
||||||
|
description: 'Share with specific people via email',
|
||||||
|
icon: <UserPlus className="h-5 w-5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'PUBLIC',
|
||||||
|
label: 'Public',
|
||||||
|
description: 'Anyone with the link can view',
|
||||||
|
icon: <Globe className="h-5 w-5" />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default function NewProjectPage() {
|
export default function NewProjectPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
|
visibility: 'PRIVATE' as Visibility,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO: Implement actual project creation
|
const response = await fetch('/api/projects', {
|
||||||
// const response = await fetch('/api/projects', {
|
method: 'POST',
|
||||||
// method: 'POST',
|
headers: { 'Content-Type': 'application/json' },
|
||||||
// headers: { 'Content-Type': 'application/json' },
|
body: JSON.stringify(formData),
|
||||||
// body: JSON.stringify(formData),
|
});
|
||||||
// });
|
|
||||||
|
const data = await response.json();
|
||||||
// Simulate API call for now
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
if (!response.ok) {
|
||||||
|
setError(data.error || 'Failed to create project');
|
||||||
// Redirect to dashboard on success
|
return;
|
||||||
router.push('/dashboard');
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to create project:', error);
|
router.push(`/projects/${data.id}`);
|
||||||
|
} catch {
|
||||||
|
setError('Something went wrong. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container max-w-2xl py-8">
|
<div className="min-h-[calc(100vh-4rem)] flex items-start justify-center py-12 px-4">
|
||||||
<div className="mb-6">
|
<div className="w-full max-w-xl">
|
||||||
<Link
|
<div className="mb-8">
|
||||||
href="/dashboard"
|
<Link
|
||||||
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
|
href="/dashboard"
|
||||||
>
|
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 Projects
|
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||||
</Link>
|
Back to Projects
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<FolderPlus className="h-7 w-7 text-primary" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl">Create New Project</CardTitle>
|
||||||
|
<CardDescription className="text-base">
|
||||||
|
Set up a new project to organize your videos and collect feedback
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name" className="text-sm font-medium">
|
||||||
|
Project Name
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
placeholder="e.g. Product Demo Q1"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
className="h-11"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description" className="text-sm font-medium">
|
||||||
|
Description
|
||||||
|
<span className="text-muted-foreground font-normal ml-1">(optional)</span>
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
placeholder="Brief description of what this project is about..."
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||||
|
rows={3}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">Who can access?</Label>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{visibilityOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
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
|
||||||
|
? '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'
|
||||||
|
}`}>
|
||||||
|
{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>
|
||||||
|
<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" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 rounded-lg bg-destructive/10 border border-destructive/20 text-destructive text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || !formData.name.trim()}
|
||||||
|
className="flex-1 h-11"
|
||||||
|
>
|
||||||
|
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||||
|
Create Project
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="h-11 px-6"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Create New Project</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Set up a new project to organize your videos and collect feedback
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="name">Project Name</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
placeholder="My Awesome Project"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
|
||||||
required
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="description">Description (optional)</Label>
|
|
||||||
<Textarea
|
|
||||||
id="description"
|
|
||||||
placeholder="Brief description of what this project is about..."
|
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
|
||||||
rows={3}
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Button type="submit" disabled={isLoading || !formData.name.trim()}>
|
|
||||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
|
||||||
Create Project
|
|
||||||
</Button>
|
|
||||||
<Button type="button" variant="outline" onClick={() => router.back()}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { CircleIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function RadioGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
data-slot="radio-group"
|
||||||
|
className={cn("grid gap-2 w-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RadioGroupItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
data-slot="radio-group-item"
|
||||||
|
className={cn(
|
||||||
|
"border-input text-primary dark:bg-input/30 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 data-checked:bg-primary data-checked:border-primary flex size-4 rounded-full focus-visible:ring-1 aria-invalid:ring-1 group/radio-group-item peer relative aspect-square shrink-0 border outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator
|
||||||
|
data-slot="radio-group-indicator"
|
||||||
|
className="group-aria-invalid/radio-group-item:text-destructive flex size-4 items-center justify-center text-white"
|
||||||
|
>
|
||||||
|
<CircleIcon className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 fill-current" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem }
|
||||||
@@ -104,9 +104,9 @@ model Project {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum ProjectVisibility {
|
enum ProjectVisibility {
|
||||||
PRIVATE // Only owner and members can access
|
PRIVATE // Only owner can access
|
||||||
LINK_ONLY // Anyone with link can view
|
INVITE // Owner + specifically invited members
|
||||||
PUBLIC // Listed publicly
|
PUBLIC // Anyone with the link can access
|
||||||
}
|
}
|
||||||
|
|
||||||
model ProjectMember {
|
model ProjectMember {
|
||||||
|
|||||||
+1
-1
@@ -79,7 +79,7 @@ async function main() {
|
|||||||
name: 'Programming Tutorials',
|
name: 'Programming Tutorials',
|
||||||
description: 'In-depth programming tutorials covering modern web development.',
|
description: 'In-depth programming tutorials covering modern web development.',
|
||||||
slug: 'programming-tutorials',
|
slug: 'programming-tutorials',
|
||||||
visibility: ProjectVisibility.LINK_ONLY,
|
visibility: ProjectVisibility.INVITE,
|
||||||
ownerId: demoUser.id,
|
ownerId: demoUser.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user