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:
Yusuf İpek
2026-02-07 07:01:04 +03:00
parent 5b436fff2d
commit d90ce4e1f6
8 changed files with 1085 additions and 189 deletions
+64 -41
View File
@@ -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>
<div className="flex items-start justify-between">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<FolderOpen className="h-5 w-5 text-primary" /> <FolderOpen className="h-5 w-5 text-primary" />
{project.name} {project.name}
</CardTitle> </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>
+103 -44
View File
@@ -1,68 +1,116 @@
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 */}
@@ -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>
@@ -91,27 +140,35 @@ export default async function ProjectPage({ params }: ProjectPageProps) {
</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>
<Link href={`/projects/${projectId}/share`}>
<Share2 className="h-4 w-4 mr-2" /> <Share2 className="h-4 w-4 mr-2" />
Share Share
</Link>
</Button> </Button>
<Button variant="outline" size="sm"> {(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 className="h-4 w-4 mr-2" />
Settings Settings
</Link>
</Button> </Button>
)}
{canEdit && (
<Button size="sm" asChild> <Button size="sm" asChild>
<Link href={`/projects/${projectId}/videos/new`}> <Link href={`/projects/${projectId}/videos/new`}>
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
Add Video Add Video
</Link> </Link>
</Button> </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>
{canEdit && (
<Button asChild> <Button asChild>
<Link href={`/projects/${projectId}/videos/new`}> <Link href={`/projects/${projectId}/videos/new`}>
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
Add Video Add Video
</Link> </Link>
</Button> </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 &quot;{formData.name}&quot;?</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 &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>
);
}
+120 -26
View File
@@ -3,48 +3,77 @@
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), });
// });
// Simulate API call for now const data = await response.json();
await new Promise(resolve => setTimeout(resolve, 500));
// Redirect to dashboard on success if (!response.ok) {
router.push('/dashboard'); setError(data.error || 'Failed to create project');
} catch (error) { return;
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">
<div className="mb-8">
<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"
@@ -54,29 +83,38 @@ export default function NewProjectPage() {
</Link> </Link>
</div> </div>
<Card> <Card className="border-border/50 shadow-lg">
<CardHeader> <CardHeader className="text-center pb-2">
<CardTitle>Create New Project</CardTitle> <div className="mx-auto w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<CardDescription> <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 Set up a new project to organize your videos and collect feedback
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="pt-6">
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name">Project Name</Label> <Label htmlFor="name" className="text-sm font-medium">
Project Name
</Label>
<Input <Input
id="name" id="name"
placeholder="My Awesome Project" placeholder="e.g. Product Demo Q1"
value={formData.name} value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))} onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
required required
disabled={isLoading} disabled={isLoading}
className="h-11"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="description">Description (optional)</Label> <Label htmlFor="description" className="text-sm font-medium">
Description
<span className="text-muted-foreground font-normal ml-1">(optional)</span>
</Label>
<Textarea <Textarea
id="description" id="description"
placeholder="Brief description of what this project is about..." placeholder="Brief description of what this project is about..."
@@ -84,15 +122,70 @@ export default function NewProjectPage() {
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))} onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
rows={3} rows={3}
disabled={isLoading} disabled={isLoading}
className="resize-none"
/> />
</div> </div>
<div className="flex gap-3"> <div className="space-y-3">
<Button type="submit" disabled={isLoading || !formData.name.trim()}> <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" />} {isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Create Project Create Project
</Button> </Button>
<Button type="button" variant="outline" onClick={() => router.back()}> <Button
type="button"
variant="outline"
onClick={() => router.back()}
className="h-11 px-6"
>
Cancel Cancel
</Button> </Button>
</div> </div>
@@ -100,5 +193,6 @@ export default function NewProjectPage() {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div>
); );
} }
+45
View File
@@ -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 }
+3 -3
View File
@@ -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
View File
@@ -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,
}, },
}); });