mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
feat: add workspace management features including member invitations and role updates
- Implemented API endpoints for managing workspace members (GET, POST, PATCH, DELETE). - Added workspace creation and retrieval functionalities. - Enhanced project model to associate with workspaces. - Updated project member roles and access control logic. - Created sign-out page and updated authentication flow. - Modified header to include navigation to workspaces. - Updated Prisma schema to include workspace and member models. - Seed script updated to create demo workspaces and associated members.
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
import Link from 'next/link';
|
||||
import { Plus, FolderOpen, Clock, Users, Globe, Lock, UserPlus } from 'lucide-react';
|
||||
import { Plus, FolderOpen, Clock, Users, Globe, Lock, UserPlus, Building2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { db } from '@/lib/db';
|
||||
import { ProjectFilter } from './project-filter';
|
||||
|
||||
function formatRelativeTime(date: Date): string {
|
||||
const now = new Date();
|
||||
@@ -38,15 +39,26 @@ export default async function DashboardPage() {
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
// Fetch projects where user is owner or member
|
||||
// Fetch projects where user is owner, member, or workspace member
|
||||
const projects = await db.project.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ ownerId: session.user.id },
|
||||
{ members: { some: { userId: session.user.id } } },
|
||||
{
|
||||
workspace: {
|
||||
OR: [
|
||||
{ ownerId: session.user.id },
|
||||
{ members: { some: { userId: session.user.id } } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
workspace: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
videos: true,
|
||||
@@ -57,79 +69,30 @@ export default async function DashboardPage() {
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
|
||||
// Build unique workspace list for filter
|
||||
const workspaceMap = new Map<string, string>();
|
||||
for (const project of projects) {
|
||||
if (project.workspace) {
|
||||
workspaceMap.set(project.workspace.id, project.workspace.name);
|
||||
}
|
||||
}
|
||||
const workspaces = Array.from(workspaceMap, ([id, name]) => ({ id, name }));
|
||||
|
||||
const serializedProjects = projects.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
visibility: p.visibility,
|
||||
updatedAt: p.updatedAt.toISOString(),
|
||||
workspaceId: p.workspace?.id ?? null,
|
||||
workspaceName: p.workspace?.name ?? null,
|
||||
memberCount: p._count.members + 1,
|
||||
videoCount: p._count.videos,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="px-6 lg:px-8 py-8 w-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Projects</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Manage your video projects and collect feedback
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/projects/new">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Project
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Projects Grid */}
|
||||
{projects.length > 0 ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((project) => (
|
||||
<Link key={project.id} href={`/projects/${project.id}`}>
|
||||
<Card className="h-full transition-colors hover:bg-accent/50 cursor-pointer">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FolderOpen className="h-5 w-5 text-primary" />
|
||||
{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">
|
||||
{project.description || 'No description'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{formatRelativeTime(project.updatedAt)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
{project._count.members + 1}
|
||||
</span>
|
||||
<span>{project._count.videos} videos</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||
<FolderOpen className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">No projects yet</h3>
|
||||
<p className="text-muted-foreground text-center mb-4">
|
||||
Create your first project to start collecting video feedback
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link href="/projects/new">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Project
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<ProjectFilter projects={serializedProjects} workspaces={workspaces} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Plus, FolderOpen, Clock, Users, Globe, Lock, UserPlus, Building2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
interface SerializedProject {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
visibility: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string | null;
|
||||
workspaceName: string | null;
|
||||
memberCount: number;
|
||||
videoCount: number;
|
||||
}
|
||||
|
||||
interface ProjectFilterProps {
|
||||
projects: SerializedProject[];
|
||||
workspaces: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
function VisibilityIcon({ visibility }: { visibility: string }) {
|
||||
switch (visibility) {
|
||||
case 'PUBLIC':
|
||||
return <Globe className="h-3.5 w-3.5" />;
|
||||
case 'INVITE':
|
||||
return <UserPlus className="h-3.5 w-3.5" />;
|
||||
default:
|
||||
return <Lock className="h-3.5 w-3.5" />;
|
||||
}
|
||||
}
|
||||
|
||||
export function ProjectFilter({ projects, workspaces }: ProjectFilterProps) {
|
||||
const [selectedWorkspace, setSelectedWorkspace] = useState<string>('all');
|
||||
|
||||
const filtered =
|
||||
selectedWorkspace === 'all'
|
||||
? projects
|
||||
: projects.filter((p) => p.workspaceId === selectedWorkspace);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Projects</h1>
|
||||
{workspaces.length > 0 && (
|
||||
<Select value={selectedWorkspace} onValueChange={setSelectedWorkspace}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="All Workspaces" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Workspaces</SelectItem>
|
||||
{workspaces.map((ws) => (
|
||||
<SelectItem key={ws.id} value={ws.id}>
|
||||
{ws.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/projects/new">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Project
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Projects Grid */}
|
||||
{filtered.length > 0 ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filtered.map((project) => (
|
||||
<Link key={project.id} href={`/projects/${project.id}`}>
|
||||
<Card className="h-full transition-colors hover:bg-accent/50 cursor-pointer">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FolderOpen className="h-5 w-5 text-primary" />
|
||||
{project.name}
|
||||
</CardTitle>
|
||||
<Badge variant="outline" className="flex items-center gap-1">
|
||||
<VisibilityIcon visibility={project.visibility} />
|
||||
{project.visibility.toLowerCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
{project.workspaceName && (
|
||||
<div className="mt-1">
|
||||
<Badge variant="secondary" className="text-xs flex items-center gap-1 w-fit">
|
||||
<Building2 className="h-3 w-3" />
|
||||
{project.workspaceName}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
<CardDescription className="line-clamp-2">
|
||||
{project.description || 'No description'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{formatRelativeTime(project.updatedAt)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
{project.memberCount}
|
||||
</span>
|
||||
<span>{project.videoCount} videos</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||
<FolderOpen className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">
|
||||
{selectedWorkspace === 'all' ? 'No projects yet' : 'No projects in this workspace'}
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-center mb-4">
|
||||
{selectedWorkspace === 'all'
|
||||
? 'Create your first project to start collecting video feedback'
|
||||
: 'Create a project in this workspace to get started'}
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link href="/projects/new">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Project
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,16 @@
|
||||
import { Header } from '@/components/layout';
|
||||
// import { auth } from '@/lib/auth';
|
||||
import { auth } from '@/lib/auth';
|
||||
|
||||
export default async function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// TODO: Uncomment when database is set up
|
||||
// const session = await auth();
|
||||
const mockUser = {
|
||||
name: 'Demo User',
|
||||
email: '[email protected]',
|
||||
image: null,
|
||||
};
|
||||
const session = await auth();
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen flex-col">
|
||||
<Header user={mockUser} />
|
||||
<Header user={session?.user ?? null} />
|
||||
<main className="flex-1">{children}</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
Loader2,
|
||||
Crown,
|
||||
Shield,
|
||||
MessageSquare,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
} 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 { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
interface Member {
|
||||
id: string;
|
||||
role: 'ADMIN' | 'COMMENTATOR';
|
||||
userId: string;
|
||||
user: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
image: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface Owner {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
image: string | null;
|
||||
}
|
||||
|
||||
export default function ProjectMembersPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const projectId = params.projectId as string;
|
||||
|
||||
const [members, setMembers] = useState<Member[]>([]);
|
||||
const [owner, setOwner] = useState<Owner | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [inviteEmail, setInviteEmail] = useState('');
|
||||
const [inviteRole, setInviteRole] = useState<'ADMIN' | 'COMMENTATOR'>('COMMENTATOR');
|
||||
const [isInviting, setIsInviting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const fetchMembers = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}/members`);
|
||||
if (!res.ok) {
|
||||
if (res.status === 403) router.push('/dashboard');
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setMembers(data.members);
|
||||
setOwner(data.owner);
|
||||
} catch {
|
||||
setError('Failed to load members');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [projectId, router]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMembers();
|
||||
}, [fetchMembers]);
|
||||
|
||||
const handleInvite = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsInviting(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}/members`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: inviteEmail, role: inviteRole }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Failed to invite member');
|
||||
return;
|
||||
}
|
||||
|
||||
setSuccess(`Invited ${data.user.name || data.user.email} as ${inviteRole.toLowerCase()}`);
|
||||
setInviteEmail('');
|
||||
fetchMembers();
|
||||
} catch {
|
||||
setError('Something went wrong');
|
||||
} finally {
|
||||
setIsInviting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoleChange = async (memberId: string, newRole: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}/members/${memberId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role: newRole }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
setError(data.error || 'Failed to update role');
|
||||
return;
|
||||
}
|
||||
|
||||
fetchMembers();
|
||||
} catch {
|
||||
setError('Failed to update role');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (memberId: string) => {
|
||||
if (!confirm('Are you sure you want to remove this member?')) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}/members/${memberId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
setError(data.error || 'Failed to remove member');
|
||||
return;
|
||||
}
|
||||
|
||||
fetchMembers();
|
||||
} catch {
|
||||
setError('Failed to remove member');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[50vh]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-6 lg:px-8 py-8 w-full max-w-4xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<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="mb-8">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Project Members</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Manage who has access to this project. Admins can manage settings and delete content.
|
||||
Commentators can only view and leave comments.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Invite Form */}
|
||||
<Card className="mb-8">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<UserPlus className="h-5 w-5" />
|
||||
Invite Member
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Invite someone by email. They must have an account to be added.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleInvite} className="flex gap-3 items-end">
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="email" className="mb-2 block">Email Address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="[email protected]"
|
||||
value={inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
required
|
||||
disabled={isInviting}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<Label className="mb-2 block">Role</Label>
|
||||
<Select value={inviteRole} onValueChange={(v) => setInviteRole(v as 'ADMIN' | 'COMMENTATOR')}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ADMIN">Admin</SelectItem>
|
||||
<SelectItem value="COMMENTATOR">Commentator</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button type="submit" disabled={isInviting}>
|
||||
{isInviting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Invite
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{error && (
|
||||
<div className="mt-3 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="mt-3 rounded-md bg-green-500/10 p-3 text-sm text-green-700 dark:text-green-400">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Members List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Current Members</CardTitle>
|
||||
<CardDescription>
|
||||
<strong>Admin</strong> — can manage project settings, members, and delete content.{' '}
|
||||
<strong>Commentator</strong> — can view and comment only.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* Owner */}
|
||||
{owner && (
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-accent/30">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarImage src={owner.image ?? undefined} />
|
||||
<AvatarFallback>{owner.name?.charAt(0).toUpperCase() ?? 'U'}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{owner.name || 'Unnamed'}</p>
|
||||
<p className="text-xs text-muted-foreground">{owner.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="default" className="flex items-center gap-1">
|
||||
<Crown className="h-3 w-3" />
|
||||
Owner
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Members */}
|
||||
{members.map((member) => (
|
||||
<div key={member.id} className="flex items-center justify-between p-3 rounded-lg border">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarImage src={member.user.image ?? undefined} />
|
||||
<AvatarFallback>{member.user.name?.charAt(0).toUpperCase() ?? 'U'}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{member.user.name || 'Unnamed'}</p>
|
||||
<p className="text-xs text-muted-foreground">{member.user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={member.role}
|
||||
onValueChange={(v) => handleRoleChange(member.id, v)}
|
||||
>
|
||||
<SelectTrigger className="w-36 h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ADMIN">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Shield className="h-3.5 w-3.5" />
|
||||
Admin
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="COMMENTATOR">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
Commentator
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={() => handleRemove(member.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{members.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No members yet. Invite someone above.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
Globe,
|
||||
Lock,
|
||||
UserPlus,
|
||||
Users,
|
||||
Building2,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
@@ -61,6 +63,7 @@ export default async function ProjectPage({ params }: ProjectPageProps) {
|
||||
const project = await db.project.findUnique({
|
||||
where: { id: projectId },
|
||||
include: {
|
||||
workspace: { select: { id: true, name: true } },
|
||||
owner: { select: { id: true, name: true } },
|
||||
members: {
|
||||
where: { userId: session?.user?.id || '' },
|
||||
@@ -91,7 +94,29 @@ export default async function ProjectPage({ params }: ProjectPageProps) {
|
||||
const isMember = project.members.length > 0;
|
||||
const isPublicOrLink = project.visibility !== 'PRIVATE';
|
||||
|
||||
if (!isOwner && !isMember && !isPublicOrLink) {
|
||||
// Check workspace membership
|
||||
let isWorkspaceMember = false;
|
||||
let workspaceRole: string | null = null;
|
||||
if (session?.user?.id) {
|
||||
const wsMember = await db.workspaceMember.findUnique({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId: project.workspaceId,
|
||||
userId: session.user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
const ws = await db.workspace.findUnique({
|
||||
where: { id: project.workspaceId },
|
||||
select: { ownerId: true },
|
||||
});
|
||||
if (ws?.ownerId === session.user.id || wsMember) {
|
||||
isWorkspaceMember = true;
|
||||
workspaceRole = ws?.ownerId === session.user.id ? 'OWNER' : wsMember?.role || null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOwner && !isMember && !isPublicOrLink && !isWorkspaceMember) {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
|
||||
@@ -109,7 +134,7 @@ export default async function ProjectPage({ params }: ProjectPageProps) {
|
||||
};
|
||||
});
|
||||
|
||||
const canEdit = isOwner || project.members[0]?.role === 'ADMIN' || project.members[0]?.role === 'EDITOR';
|
||||
const canEdit = isOwner || project.members[0]?.role === 'ADMIN' || workspaceRole === 'OWNER' || workspaceRole === 'ADMIN';
|
||||
|
||||
return (
|
||||
<div className="px-6 lg:px-8 py-8 w-full">
|
||||
@@ -134,9 +159,19 @@ export default async function ProjectPage({ params }: ProjectPageProps) {
|
||||
{project.visibility.toLowerCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
{project.description && (
|
||||
<p className="text-muted-foreground">{project.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{project.workspace && (
|
||||
<Link href={`/workspaces/${project.workspace.id}`}>
|
||||
<Badge variant="secondary" className="flex items-center gap-1 hover:bg-accent transition-colors">
|
||||
<Building2 className="h-3 w-3" />
|
||||
{project.workspace.name}
|
||||
</Badge>
|
||||
</Link>
|
||||
)}
|
||||
{project.description && (
|
||||
<span className="text-muted-foreground">{project.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -147,12 +182,20 @@ export default async function ProjectPage({ params }: ProjectPageProps) {
|
||||
</Link>
|
||||
</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>
|
||||
<>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/projects/${projectId}/members`}>
|
||||
<Users className="h-4 w-4 mr-2" />
|
||||
Members
|
||||
</Link>
|
||||
</Button>
|
||||
<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>
|
||||
|
||||
@@ -1,22 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, Loader2, Globe, Lock, UserPlus, FolderPlus } from 'lucide-react';
|
||||
import { ArrowLeft, Loader2, Globe, Lock, UserPlus, FolderPlus, Building2 } 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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
type Visibility = 'PRIVATE' | 'INVITE' | 'PUBLIC';
|
||||
|
||||
interface Workspace {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const visibilityOptions: { value: Visibility; label: string; description: string; icon: React.ReactNode }[] = [
|
||||
{
|
||||
value: 'PRIVATE',
|
||||
label: 'Private',
|
||||
description: 'Only you can access this project',
|
||||
description: 'Only workspace members and project members can access',
|
||||
icon: <Lock className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
@@ -35,16 +47,47 @@ const visibilityOptions: { value: Visibility; label: string; description: string
|
||||
|
||||
export default function NewProjectPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const preselectedWorkspace = searchParams.get('workspace');
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||
const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(true);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
visibility: 'PRIVATE' as Visibility,
|
||||
workspaceId: preselectedWorkspace || '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchWorkspaces() {
|
||||
try {
|
||||
const res = await fetch('/api/workspaces');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setWorkspaces(data.workspaces);
|
||||
// Auto-select if only one workspace and none preselected
|
||||
if (!preselectedWorkspace && data.workspaces.length === 1) {
|
||||
setFormData(prev => ({ ...prev, workspaceId: data.workspaces[0].id }));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setIsLoadingWorkspaces(false);
|
||||
}
|
||||
}
|
||||
fetchWorkspaces();
|
||||
}, [preselectedWorkspace]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formData.workspaceId) {
|
||||
setError('Please select a workspace');
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
@@ -95,6 +138,46 @@ export default function NewProjectPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Workspace selector */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Workspace</Label>
|
||||
{isLoadingWorkspaces ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading workspaces...
|
||||
</div>
|
||||
) : workspaces.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed p-4 text-center">
|
||||
<Building2 className="h-8 w-8 mx-auto text-muted-foreground mb-2" />
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
You need a workspace first. Every project belongs to a workspace.
|
||||
</p>
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link href="/workspaces/new">Create Workspace</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={formData.workspaceId}
|
||||
onValueChange={(v) => setFormData(prev => ({ ...prev, workspaceId: v }))}
|
||||
>
|
||||
<SelectTrigger className="h-11">
|
||||
<SelectValue placeholder="Select a workspace" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{workspaces.map((ws) => (
|
||||
<SelectItem key={ws.id} value={ws.id}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4" />
|
||||
{ws.name}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name" className="text-sm font-medium">
|
||||
Project Name
|
||||
@@ -174,7 +257,7 @@ export default function NewProjectPage() {
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading || !formData.name.trim()}
|
||||
disabled={isLoading || !formData.name.trim() || !formData.workspaceId}
|
||||
className="flex-1 h-11"
|
||||
>
|
||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
Loader2,
|
||||
Crown,
|
||||
Shield,
|
||||
MessageSquare,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
} 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 { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
interface Member {
|
||||
id: string;
|
||||
role: 'ADMIN' | 'COMMENTATOR';
|
||||
userId: string;
|
||||
user: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
image: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface Owner {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
image: string | null;
|
||||
}
|
||||
|
||||
export default function WorkspaceMembersPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const workspaceId = params.workspaceId as string;
|
||||
|
||||
const [members, setMembers] = useState<Member[]>([]);
|
||||
const [owner, setOwner] = useState<Owner | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [inviteEmail, setInviteEmail] = useState('');
|
||||
const [inviteRole, setInviteRole] = useState<'ADMIN' | 'COMMENTATOR'>('COMMENTATOR');
|
||||
const [isInviting, setIsInviting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const fetchMembers = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/workspaces/${workspaceId}/members`);
|
||||
if (!res.ok) {
|
||||
if (res.status === 403) router.push('/workspaces');
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setMembers(data.members);
|
||||
setOwner(data.owner);
|
||||
} catch {
|
||||
setError('Failed to load members');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [workspaceId, router]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMembers();
|
||||
}, [fetchMembers]);
|
||||
|
||||
const handleInvite = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsInviting(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/workspaces/${workspaceId}/members`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: inviteEmail, role: inviteRole }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Failed to invite member');
|
||||
return;
|
||||
}
|
||||
|
||||
setSuccess(`Invited ${data.user.name || data.user.email} as ${inviteRole.toLowerCase()}`);
|
||||
setInviteEmail('');
|
||||
fetchMembers();
|
||||
} catch {
|
||||
setError('Something went wrong');
|
||||
} finally {
|
||||
setIsInviting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoleChange = async (memberId: string, newRole: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/workspaces/${workspaceId}/members/${memberId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role: newRole }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
setError(data.error || 'Failed to update role');
|
||||
return;
|
||||
}
|
||||
|
||||
fetchMembers();
|
||||
} catch {
|
||||
setError('Failed to update role');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (memberId: string) => {
|
||||
if (!confirm('Are you sure you want to remove this member?')) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/workspaces/${workspaceId}/members/${memberId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
setError(data.error || 'Failed to remove member');
|
||||
return;
|
||||
}
|
||||
|
||||
fetchMembers();
|
||||
} catch {
|
||||
setError('Failed to remove member');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[50vh]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-6 lg:px-8 py-8 w-full max-w-4xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
href={`/workspaces/${workspaceId}`}
|
||||
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 Workspace
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Members</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Manage who has access to this workspace and all its projects
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Invite Form */}
|
||||
<Card className="mb-8">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<UserPlus className="h-5 w-5" />
|
||||
Invite Member
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Invite someone by email. They must have an account to be added.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleInvite} className="flex gap-3 items-end">
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="email" className="mb-2 block">Email Address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="[email protected]"
|
||||
value={inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
required
|
||||
disabled={isInviting}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<Label className="mb-2 block">Role</Label>
|
||||
<Select value={inviteRole} onValueChange={(v) => setInviteRole(v as 'ADMIN' | 'COMMENTATOR')}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ADMIN">Admin</SelectItem>
|
||||
<SelectItem value="COMMENTATOR">Commentator</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button type="submit" disabled={isInviting}>
|
||||
{isInviting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Invite
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{error && (
|
||||
<div className="mt-3 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="mt-3 rounded-md bg-green-500/10 p-3 text-sm text-green-700 dark:text-green-400">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Members List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Current Members</CardTitle>
|
||||
<CardDescription>
|
||||
Admins can manage projects and members. Commentators can view and comment only.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* Owner */}
|
||||
{owner && (
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-accent/30">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarImage src={owner.image ?? undefined} />
|
||||
<AvatarFallback>{owner.name?.charAt(0).toUpperCase() ?? 'U'}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{owner.name || 'Unnamed'}</p>
|
||||
<p className="text-xs text-muted-foreground">{owner.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="default" className="flex items-center gap-1">
|
||||
<Crown className="h-3 w-3" />
|
||||
Owner
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Members */}
|
||||
{members.map((member) => (
|
||||
<div key={member.id} className="flex items-center justify-between p-3 rounded-lg border">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarImage src={member.user.image ?? undefined} />
|
||||
<AvatarFallback>{member.user.name?.charAt(0).toUpperCase() ?? 'U'}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{member.user.name || 'Unnamed'}</p>
|
||||
<p className="text-xs text-muted-foreground">{member.user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={member.role}
|
||||
onValueChange={(v) => handleRoleChange(member.id, v)}
|
||||
>
|
||||
<SelectTrigger className="w-36 h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ADMIN">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Shield className="h-3.5 w-3.5" />
|
||||
Admin
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="COMMENTATOR">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
Commentator
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={() => handleRemove(member.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{members.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No members yet. Invite someone above.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import Link from 'next/link';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
Settings,
|
||||
FolderOpen,
|
||||
Clock,
|
||||
Users,
|
||||
Globe,
|
||||
Lock,
|
||||
UserPlus,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
function VisibilityIcon({ visibility }: { visibility: string }) {
|
||||
switch (visibility) {
|
||||
case 'PUBLIC':
|
||||
return <Globe className="h-3.5 w-3.5" />;
|
||||
case 'INVITE':
|
||||
return <UserPlus className="h-3.5 w-3.5" />;
|
||||
default:
|
||||
return <Lock className="h-3.5 w-3.5" />;
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelativeTime(date: Date): string {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
interface WorkspacePageProps {
|
||||
params: Promise<{ workspaceId: string }>;
|
||||
}
|
||||
|
||||
export default async function WorkspacePage({ params }: WorkspacePageProps) {
|
||||
const session = await auth();
|
||||
const { workspaceId } = await params;
|
||||
|
||||
if (!session?.user?.id) {
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
const workspace = await db.workspace.findUnique({
|
||||
where: { id: workspaceId },
|
||||
include: {
|
||||
owner: { select: { id: true, name: true } },
|
||||
members: {
|
||||
where: { userId: session.user.id },
|
||||
select: { role: true },
|
||||
},
|
||||
projects: {
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
include: {
|
||||
_count: { select: { videos: true, members: true } },
|
||||
},
|
||||
},
|
||||
_count: { select: { projects: true, members: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!workspace) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const isOwner = session.user.id === workspace.ownerId;
|
||||
const membership = workspace.members[0];
|
||||
const isMember = !!membership;
|
||||
const isAdmin = isOwner || membership?.role === 'ADMIN';
|
||||
|
||||
if (!isOwner && !isMember) {
|
||||
redirect('/workspaces');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-6 lg:px-8 py-8 w-full">
|
||||
{/* Back & Header */}
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
href="/workspaces"
|
||||
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
All Workspaces
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{workspace.name}</h1>
|
||||
{workspace.description && (
|
||||
<p className="text-muted-foreground mt-1">{workspace.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 mt-2 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
{workspace._count.projects} projects
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
{workspace._count.members + 1} members
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/workspaces/${workspaceId}/members`}>
|
||||
<Users className="h-4 w-4 mr-2" />
|
||||
Members
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/workspaces/${workspaceId}/settings`}>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Settings
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm">
|
||||
<Link href={`/workspaces/${workspaceId}/projects/new`}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Project
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Projects Grid */}
|
||||
{workspace.projects.length > 0 ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{workspace.projects.map((project) => (
|
||||
<Link key={project.id} href={`/projects/${project.id}`}>
|
||||
<Card className="h-full transition-colors hover:bg-accent/50 cursor-pointer">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FolderOpen className="h-5 w-5 text-primary" />
|
||||
{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">
|
||||
{project.description || 'No description'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{formatRelativeTime(project.updatedAt)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
{project._count.members + 1}
|
||||
</span>
|
||||
<span>{project._count.videos} videos</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||
<FolderOpen className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">No projects yet</h3>
|
||||
<p className="text-muted-foreground text-center mb-4">
|
||||
Create a project in this workspace to get started
|
||||
</p>
|
||||
{isAdmin && (
|
||||
<Button asChild>
|
||||
<Link href={`/workspaces/${workspaceId}/projects/new`}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Project
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
'use client';
|
||||
|
||||
import { useState, use } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, Loader2, Globe, Lock, UserPlus, FolderPlus } 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';
|
||||
|
||||
type Visibility = 'PRIVATE' | 'INVITE' | 'PUBLIC';
|
||||
|
||||
const visibilityOptions: { value: Visibility; label: string; description: string; icon: React.ReactNode }[] = [
|
||||
{
|
||||
value: 'PRIVATE',
|
||||
label: 'Private',
|
||||
description: 'Only workspace members and project members can access',
|
||||
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 NewWorkspaceProjectPage({ params }: { params: Promise<{ workspaceId: string }> }) {
|
||||
const { workspaceId } = use(params);
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
visibility: 'PRIVATE' as Visibility,
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...formData, workspaceId }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.error || 'Failed to create project');
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(`/projects/${data.id}`);
|
||||
} catch {
|
||||
setError('Something went wrong. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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={`/workspaces/${workspaceId}`}
|
||||
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 Workspace
|
||||
</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 Project in Workspace</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
This project will be accessible to all workspace members
|
||||
</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 Launch Video"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description" className="text-sm font-medium">
|
||||
Description{' '}
|
||||
<span className="text-muted-foreground font-normal">(optional)</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="What is this project about?"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">Visibility</Label>
|
||||
<div className="grid gap-2">
|
||||
{visibilityOptions.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className={`flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition-colors ${
|
||||
formData.visibility === option.value
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'hover:bg-accent/50'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="visibility"
|
||||
value={option.value}
|
||||
checked={formData.visibility === option.value}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, visibility: e.target.value as Visibility })
|
||||
}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className="mt-0.5 text-muted-foreground">{option.icon}</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{option.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{option.description}</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
'Create Project'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, Loader2, Building2, Trash2 } 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 { Separator } from '@/components/ui/separator';
|
||||
|
||||
interface WorkspaceData {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
slug: string;
|
||||
ownerId: string;
|
||||
}
|
||||
|
||||
export default function WorkspaceSettingsPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const workspaceId = params.workspaceId as string;
|
||||
|
||||
const [workspace, setWorkspace] = useState<WorkspaceData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [formData, setFormData] = useState({ name: '', description: '' });
|
||||
|
||||
const fetchWorkspace = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/workspaces/${workspaceId}`);
|
||||
if (!res.ok) {
|
||||
router.push('/workspaces');
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setWorkspace(data);
|
||||
setFormData({
|
||||
name: data.name,
|
||||
description: data.description || '',
|
||||
});
|
||||
} catch {
|
||||
setError('Failed to load workspace');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [workspaceId, router]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchWorkspace();
|
||||
}, [fetchWorkspace]);
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSaving(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/workspaces/${workspaceId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
setError(data.error || 'Failed to update workspace');
|
||||
return;
|
||||
}
|
||||
|
||||
setSuccess('Workspace updated successfully');
|
||||
} catch {
|
||||
setError('Something went wrong');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Are you sure? This will NOT delete the projects inside, but they will be unlinked from this workspace.')) return;
|
||||
if (!confirm('This action cannot be undone. Type the workspace name to confirm.')) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const res = await fetch(`/api/workspaces/${workspaceId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
setError(data.error || 'Failed to delete workspace');
|
||||
return;
|
||||
}
|
||||
|
||||
router.push('/workspaces');
|
||||
} catch {
|
||||
setError('Failed to delete workspace');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[50vh]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!workspace) return null;
|
||||
|
||||
return (
|
||||
<div className="px-6 lg:px-8 py-8 w-full max-w-2xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
href={`/workspaces/${workspaceId}`}
|
||||
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 Workspace
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Workspace Settings</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Manage workspace configuration
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="mb-8">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building2 className="h-5 w-5" />
|
||||
General
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSave} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Workspace Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="rounded-md bg-green-500/10 p-3 text-sm text-green-700 dark:text-green-400">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save Changes'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Separator className="my-8" />
|
||||
|
||||
{/* Danger Zone */}
|
||||
<Card className="border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">Danger Zone</CardTitle>
|
||||
<CardDescription>
|
||||
Irreversible actions. Proceed with caution.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Workspace
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, Loader2, Building2 } 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';
|
||||
|
||||
export default function NewWorkspacePage() {
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/workspaces', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.error || 'Failed to create workspace');
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(`/workspaces/${data.id}`);
|
||||
} catch {
|
||||
setError('Something went wrong. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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="/workspaces"
|
||||
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 Workspaces
|
||||
</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">
|
||||
<Building2 className="h-7 w-7 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Create New Workspace</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
Set up a workspace to organize projects and invite your team
|
||||
</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">
|
||||
Workspace Name
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="e.g., My Studio"
|
||||
value={formData.name}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, name: e.target.value })
|
||||
}
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description" className="text-sm font-medium">
|
||||
Description{' '}
|
||||
<span className="text-muted-foreground font-normal">(optional)</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="What is this workspace for?"
|
||||
value={formData.description}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, description: e.target.value })
|
||||
}
|
||||
rows={3}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
'Create Workspace'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import Link from 'next/link';
|
||||
import { Plus, Building2, Clock, FolderOpen, Users } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
function formatRelativeTime(date: Date): string {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
export default async function WorkspacesPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
const workspaces = await db.workspace.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ ownerId: session.user.id },
|
||||
{ members: { some: { userId: session.user.id } } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
owner: { select: { id: true, name: true } },
|
||||
_count: {
|
||||
select: {
|
||||
projects: true,
|
||||
members: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="px-6 lg:px-8 py-8 w-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Workspaces</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Manage your workspaces and their projects
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/workspaces/new">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Workspace
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Workspaces Grid */}
|
||||
{workspaces.length > 0 ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{workspaces.map((workspace) => (
|
||||
<Link key={workspace.id} href={`/workspaces/${workspace.id}`}>
|
||||
<Card className="h-full transition-colors hover:bg-accent/50 cursor-pointer">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building2 className="h-5 w-5 text-primary" />
|
||||
{workspace.name}
|
||||
</CardTitle>
|
||||
<CardDescription className="line-clamp-2">
|
||||
{workspace.description || 'No description'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{formatRelativeTime(workspace.updatedAt)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
{workspace._count.projects} projects
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
{workspace._count.members + 1}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||
<Building2 className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">No workspaces yet</h3>
|
||||
<p className="text-muted-foreground text-center mb-4">
|
||||
Create a workspace to organize your projects and invite team members
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link href="/workspaces/new">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Workspace
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { ProjectMemberRole } from '@prisma/client';
|
||||
|
||||
type RouteParams = { params: Promise<{ projectId: string; memberId: string }> };
|
||||
|
||||
// PATCH /api/projects/[projectId]/members/[memberId] - Update member role
|
||||
export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const session = await auth();
|
||||
const { projectId, memberId } = await params;
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const project = await db.project.findUnique({
|
||||
where: { id: projectId },
|
||||
include: { members: { where: { userId: session.user.id } } },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const isOwner = project.ownerId === session.user.id;
|
||||
const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN;
|
||||
|
||||
if (!isOwner && !isAdmin) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { role } = body;
|
||||
|
||||
const validRoles = ['ADMIN', 'COMMENTATOR'];
|
||||
if (!validRoles.includes(role)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid role. Must be ADMIN or COMMENTATOR.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const member = await db.projectMember.update({
|
||||
where: { id: memberId },
|
||||
data: { role: role as ProjectMemberRole },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, image: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(member);
|
||||
} catch (error) {
|
||||
console.error('Error updating member role:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update member role' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/projects/[projectId]/members/[memberId] - Remove member
|
||||
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const session = await auth();
|
||||
const { projectId, memberId } = await params;
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const project = await db.project.findUnique({
|
||||
where: { id: projectId },
|
||||
include: { members: { where: { userId: session.user.id } } },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const isOwner = project.ownerId === session.user.id;
|
||||
const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN;
|
||||
|
||||
const memberToRemove = await db.projectMember.findUnique({
|
||||
where: { id: memberId },
|
||||
});
|
||||
|
||||
if (!memberToRemove) {
|
||||
return NextResponse.json({ error: 'Member not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const isSelf = memberToRemove.userId === session.user.id;
|
||||
|
||||
if (!isOwner && !isAdmin && !isSelf) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
await db.projectMember.delete({ where: { id: memberId } });
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Member removed' });
|
||||
} catch (error) {
|
||||
console.error('Error removing member:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to remove member' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { ProjectMemberRole } from '@prisma/client';
|
||||
|
||||
type RouteParams = { params: Promise<{ projectId: string }> };
|
||||
|
||||
// GET /api/projects/[projectId]/members - List members
|
||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const session = await auth();
|
||||
const { projectId } = await params;
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const project = await db.project.findUnique({
|
||||
where: { id: projectId },
|
||||
include: {
|
||||
members: { where: { userId: session.user.id } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const isOwner = project.ownerId === session.user.id;
|
||||
const isMember = project.members.length > 0;
|
||||
|
||||
if (!isOwner && !isMember) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const members = await db.projectMember.findMany({
|
||||
where: { projectId },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, image: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
const owner = await db.user.findUnique({
|
||||
where: { id: project.ownerId },
|
||||
select: { id: true, name: true, image: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({ members, owner });
|
||||
} catch (error) {
|
||||
console.error('Error fetching project members:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch members' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/projects/[projectId]/members - Invite a member
|
||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const session = await auth();
|
||||
const { projectId } = await params;
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check if user is owner or admin
|
||||
const project = await db.project.findUnique({
|
||||
where: { id: projectId },
|
||||
include: { members: { where: { userId: session.user.id } } },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const isOwner = project.ownerId === session.user.id;
|
||||
const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN;
|
||||
|
||||
if (!isOwner && !isAdmin) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only project owners and admins can invite members' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { email, role } = body;
|
||||
|
||||
if (!email || typeof email !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate role
|
||||
const validRoles = ['ADMIN', 'COMMENTATOR'];
|
||||
const memberRole = validRoles.includes(role) ? role : 'COMMENTATOR';
|
||||
|
||||
// Find user by email
|
||||
const userToInvite = await db.user.findUnique({
|
||||
where: { email: email.toLowerCase().trim() },
|
||||
});
|
||||
|
||||
if (!userToInvite) {
|
||||
return NextResponse.json(
|
||||
{ message: 'If the user exists, an invitation has been sent.' },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
if (userToInvite.id === project.ownerId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot invite the project owner as a member' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if already a member
|
||||
const existingMember = await db.projectMember.findUnique({
|
||||
where: { projectId_userId: { projectId, userId: userToInvite.id } },
|
||||
});
|
||||
|
||||
if (existingMember) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User is already a member of this project' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const member = await db.projectMember.create({
|
||||
data: {
|
||||
projectId,
|
||||
userId: userToInvite.id,
|
||||
role: memberRole as ProjectMemberRole,
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, name: true, image: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(member, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Error inviting project member:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to invite member' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,13 +18,29 @@ async function checkProjectAccess(projectId: string, userId: string) {
|
||||
|
||||
const isOwner = project.ownerId === userId;
|
||||
const membership = project.members[0];
|
||||
const role = isOwner ? 'OWNER' : membership?.role || null;
|
||||
let role: string | null = isOwner ? 'OWNER' : membership?.role || null;
|
||||
|
||||
// Check workspace-level access if not already authorized
|
||||
if (!isOwner && !membership) {
|
||||
const wsMember = await db.workspaceMember.findUnique({
|
||||
where: { workspaceId_userId: { workspaceId: project.workspaceId, userId } },
|
||||
});
|
||||
const wsOwner = await db.workspace.findUnique({
|
||||
where: { id: project.workspaceId },
|
||||
select: { ownerId: true },
|
||||
});
|
||||
if (wsOwner?.ownerId === userId) {
|
||||
role = 'OWNER';
|
||||
} else if (wsMember) {
|
||||
role = wsMember.role; // ADMIN or COMMENTATOR from workspace
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
project,
|
||||
role,
|
||||
canEdit: isOwner || role === ProjectMemberRole.ADMIN || role === ProjectMemberRole.EDITOR,
|
||||
canDelete: isOwner,
|
||||
canEdit: isOwner || role === 'OWNER' || role === ProjectMemberRole.ADMIN,
|
||||
canDelete: isOwner || role === 'OWNER',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -66,7 +82,25 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
const isOwner = session?.user?.id === project.ownerId;
|
||||
const isMember = project.members.some(m => m.userId === session?.user?.id);
|
||||
|
||||
if (!isPublic && !isOwner && !isMember) {
|
||||
// Check workspace membership
|
||||
let isWorkspaceMember = false;
|
||||
if (!isPublic && !isOwner && !isMember && session?.user?.id) {
|
||||
const wsMember = await db.workspaceMember.findUnique({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId: project.workspaceId,
|
||||
userId: session.user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
const wsOwner = await db.workspace.findUnique({
|
||||
where: { id: project.workspaceId },
|
||||
select: { ownerId: true },
|
||||
});
|
||||
isWorkspaceMember = !!wsMember || wsOwner?.ownerId === session.user.id;
|
||||
}
|
||||
|
||||
if (!isPublic && !isOwner && !isMember && !isWorkspaceMember) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
|
||||
@@ -86,8 +86,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
const isOwner = video.project.ownerId === session.user.id;
|
||||
const membership = video.project.members[0];
|
||||
const canEdit = isOwner ||
|
||||
membership?.role === ProjectMemberRole.ADMIN ||
|
||||
membership?.role === ProjectMemberRole.EDITOR;
|
||||
membership?.role === ProjectMemberRole.ADMIN;
|
||||
|
||||
if (!canEdit) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||
|
||||
@@ -76,8 +76,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
const isOwner = video.project.ownerId === session.user.id;
|
||||
const membership = video.project.members[0];
|
||||
const canEdit = isOwner ||
|
||||
membership?.role === ProjectMemberRole.ADMIN ||
|
||||
membership?.role === ProjectMemberRole.EDITOR;
|
||||
membership?.role === ProjectMemberRole.ADMIN;
|
||||
|
||||
if (!canEdit) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||
|
||||
@@ -64,7 +64,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check project access (must be owner, admin, or editor)
|
||||
// Check project access (must be owner or admin)
|
||||
const project = await db.project.findUnique({
|
||||
where: { id: projectId },
|
||||
include: { members: { where: { userId: session.user.id } } },
|
||||
@@ -77,8 +77,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
const isOwner = project.ownerId === session.user.id;
|
||||
const membership = project.members[0];
|
||||
const canEdit = isOwner ||
|
||||
membership?.role === ProjectMemberRole.ADMIN ||
|
||||
membership?.role === ProjectMemberRole.EDITOR;
|
||||
membership?.role === ProjectMemberRole.ADMIN;
|
||||
|
||||
if (!canEdit) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||
|
||||
+51
-13
@@ -15,17 +15,32 @@ export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const limit = parseInt(searchParams.get('limit') || '10');
|
||||
const workspaceId = searchParams.get('workspaceId');
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// Build base filter: user is owner OR a member
|
||||
const baseFilter: Record<string, unknown> = {
|
||||
OR: [
|
||||
{ ownerId: session.user.id },
|
||||
{ members: { some: { userId: session.user.id } } },
|
||||
// Also include projects in workspaces where the user is a workspace member
|
||||
...(workspaceId ? [] : [{
|
||||
workspace: {
|
||||
members: { some: { userId: session.user.id } },
|
||||
},
|
||||
}]),
|
||||
],
|
||||
};
|
||||
|
||||
// Filter by workspace if provided
|
||||
if (workspaceId) {
|
||||
baseFilter.workspaceId = workspaceId;
|
||||
}
|
||||
|
||||
// Get projects where user is owner OR a member
|
||||
const [projects, total] = await Promise.all([
|
||||
db.project.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ ownerId: session.user.id },
|
||||
{ members: { some: { userId: session.user.id } } },
|
||||
],
|
||||
},
|
||||
where: baseFilter,
|
||||
include: {
|
||||
owner: { select: { id: true, name: true, image: true } },
|
||||
_count: { select: { videos: true, members: true } },
|
||||
@@ -35,12 +50,7 @@ export async function GET(request: NextRequest) {
|
||||
take: limit,
|
||||
}),
|
||||
db.project.count({
|
||||
where: {
|
||||
OR: [
|
||||
{ ownerId: session.user.id },
|
||||
{ members: { some: { userId: session.user.id } } },
|
||||
],
|
||||
},
|
||||
where: baseFilter,
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -72,7 +82,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name, description, visibility } = body;
|
||||
const { name, description, visibility, workspaceId } = body;
|
||||
|
||||
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
||||
return NextResponse.json(
|
||||
@@ -81,6 +91,13 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!workspaceId || typeof workspaceId !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: 'A workspace is required. Every project must belong to a workspace.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Generate URL-friendly slug
|
||||
const baseSlug = name
|
||||
.toLowerCase()
|
||||
@@ -99,6 +116,26 @@ export async function POST(request: NextRequest) {
|
||||
attempts++;
|
||||
}
|
||||
|
||||
// Verify user has access to the workspace
|
||||
const workspace = await db.workspace.findUnique({
|
||||
where: { id: workspaceId },
|
||||
include: { members: { where: { userId: session.user.id } } },
|
||||
});
|
||||
|
||||
if (!workspace) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const isWsOwner = workspace.ownerId === session.user.id;
|
||||
const isWsAdmin = workspace.members[0]?.role === 'ADMIN';
|
||||
|
||||
if (!isWsOwner && !isWsAdmin) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only workspace owners and admins can create projects' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const project = await db.project.create({
|
||||
data: {
|
||||
name: name.trim(),
|
||||
@@ -106,6 +143,7 @@ export async function POST(request: NextRequest) {
|
||||
slug,
|
||||
visibility: visibility || ProjectVisibility.PRIVATE,
|
||||
ownerId: session.user.id,
|
||||
workspaceId,
|
||||
},
|
||||
include: {
|
||||
owner: { select: { id: true, name: true, image: true } },
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { WorkspaceMemberRole } from '@prisma/client';
|
||||
|
||||
type RouteParams = { params: Promise<{ workspaceId: string; memberId: string }> };
|
||||
|
||||
// PATCH /api/workspaces/[workspaceId]/members/[memberId] - Update member role
|
||||
export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const session = await auth();
|
||||
const { workspaceId, memberId } = await params;
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check if user is owner or admin
|
||||
const workspace = await db.workspace.findUnique({
|
||||
where: { id: workspaceId },
|
||||
include: { members: { where: { userId: session.user.id } } },
|
||||
});
|
||||
|
||||
if (!workspace) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const isOwner = workspace.ownerId === session.user.id;
|
||||
const isAdmin = workspace.members[0]?.role === WorkspaceMemberRole.ADMIN;
|
||||
|
||||
if (!isOwner && !isAdmin) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { role } = body;
|
||||
|
||||
const validRoles = ['ADMIN', 'COMMENTATOR'];
|
||||
if (!validRoles.includes(role)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid role. Must be ADMIN or COMMENTATOR.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const member = await db.workspaceMember.update({
|
||||
where: { id: memberId },
|
||||
data: { role: role as WorkspaceMemberRole },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, image: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(member);
|
||||
} catch (error) {
|
||||
console.error('Error updating member role:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update member role' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/workspaces/[workspaceId]/members/[memberId] - Remove member
|
||||
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const session = await auth();
|
||||
const { workspaceId, memberId } = await params;
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const workspace = await db.workspace.findUnique({
|
||||
where: { id: workspaceId },
|
||||
include: { members: { where: { userId: session.user.id } } },
|
||||
});
|
||||
|
||||
if (!workspace) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const isOwner = workspace.ownerId === session.user.id;
|
||||
const isAdmin = workspace.members[0]?.role === WorkspaceMemberRole.ADMIN;
|
||||
|
||||
// Users can remove themselves, admins/owners can remove anyone
|
||||
const memberToRemove = await db.workspaceMember.findUnique({
|
||||
where: { id: memberId },
|
||||
});
|
||||
|
||||
if (!memberToRemove) {
|
||||
return NextResponse.json({ error: 'Member not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const isSelf = memberToRemove.userId === session.user.id;
|
||||
|
||||
if (!isOwner && !isAdmin && !isSelf) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
await db.workspaceMember.delete({ where: { id: memberId } });
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Member removed' });
|
||||
} catch (error) {
|
||||
console.error('Error removing member:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to remove member' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { WorkspaceMemberRole } from '@prisma/client';
|
||||
|
||||
type RouteParams = { params: Promise<{ workspaceId: string }> };
|
||||
|
||||
// GET /api/workspaces/[workspaceId]/members - List members
|
||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const session = await auth();
|
||||
const { workspaceId } = await params;
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const workspace = await db.workspace.findUnique({
|
||||
where: { id: workspaceId },
|
||||
include: {
|
||||
members: { where: { userId: session.user.id } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!workspace) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const isOwner = workspace.ownerId === session.user.id;
|
||||
const isMember = workspace.members.length > 0;
|
||||
|
||||
if (!isOwner && !isMember) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const members = await db.workspaceMember.findMany({
|
||||
where: { workspaceId },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, image: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
// Include the owner as well
|
||||
const owner = await db.user.findUnique({
|
||||
where: { id: workspace.ownerId },
|
||||
select: { id: true, name: true, image: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({ members, owner });
|
||||
} catch (error) {
|
||||
console.error('Error fetching workspace members:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch members' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/workspaces/[workspaceId]/members - Invite a member
|
||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const session = await auth();
|
||||
const { workspaceId } = await params;
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check if user is owner or admin
|
||||
const workspace = await db.workspace.findUnique({
|
||||
where: { id: workspaceId },
|
||||
include: { members: { where: { userId: session.user.id } } },
|
||||
});
|
||||
|
||||
if (!workspace) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const isOwner = workspace.ownerId === session.user.id;
|
||||
const isAdmin = workspace.members[0]?.role === WorkspaceMemberRole.ADMIN;
|
||||
|
||||
if (!isOwner && !isAdmin) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only workspace owners and admins can invite members' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { email, role } = body;
|
||||
|
||||
if (!email || typeof email !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate role
|
||||
const validRoles = ['ADMIN', 'COMMENTATOR'];
|
||||
const memberRole = validRoles.includes(role) ? role : 'COMMENTATOR';
|
||||
|
||||
// Find user by email
|
||||
const userToInvite = await db.user.findUnique({
|
||||
where: { email: email.toLowerCase().trim() },
|
||||
});
|
||||
|
||||
if (!userToInvite) {
|
||||
return NextResponse.json(
|
||||
{ message: 'If the user exists, an invitation has been sent.' },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
if (userToInvite.id === workspace.ownerId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot invite the workspace owner as a member' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if already a member
|
||||
const existingMember = await db.workspaceMember.findUnique({
|
||||
where: { workspaceId_userId: { workspaceId, userId: userToInvite.id } },
|
||||
});
|
||||
|
||||
if (existingMember) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User is already a member of this workspace' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const member = await db.workspaceMember.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
userId: userToInvite.id,
|
||||
role: memberRole as WorkspaceMemberRole,
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, name: true, image: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(member, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Error inviting workspace member:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to invite member' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { auth } from '@/lib/auth';
|
||||
|
||||
type RouteParams = { params: Promise<{ workspaceId: string }> };
|
||||
|
||||
// Helper to check workspace access
|
||||
async function checkWorkspaceAccess(workspaceId: string, userId: string) {
|
||||
const workspace = await db.workspace.findUnique({
|
||||
where: { id: workspaceId },
|
||||
include: {
|
||||
members: { where: { userId } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!workspace) return { workspace: null, role: null, isOwner: false, isAdmin: false };
|
||||
|
||||
const isOwner = workspace.ownerId === userId;
|
||||
const membership = workspace.members[0];
|
||||
const role = isOwner ? 'OWNER' : membership?.role || null;
|
||||
|
||||
return {
|
||||
workspace,
|
||||
role,
|
||||
isOwner,
|
||||
isAdmin: isOwner || role === 'ADMIN',
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/workspaces/[workspaceId] - Get a single workspace
|
||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const session = await auth();
|
||||
const { workspaceId } = await params;
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const workspace = await db.workspace.findUnique({
|
||||
where: { id: workspaceId },
|
||||
include: {
|
||||
owner: { select: { id: true, name: true, image: true } },
|
||||
members: {
|
||||
include: {
|
||||
user: { select: { id: true, name: true, image: true } },
|
||||
},
|
||||
},
|
||||
projects: {
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
include: {
|
||||
_count: { select: { videos: true, members: true } },
|
||||
},
|
||||
},
|
||||
_count: { select: { projects: true, members: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!workspace) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check access
|
||||
const isOwner = session?.user?.id === workspace.ownerId;
|
||||
const isMember = workspace.members.some(m => m.userId === session?.user?.id);
|
||||
|
||||
if (!isOwner && !isMember) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
return NextResponse.json(workspace);
|
||||
} catch (error) {
|
||||
console.error('Error fetching workspace:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch workspace' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/workspaces/[workspaceId] - Update a workspace
|
||||
export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const session = await auth();
|
||||
const { workspaceId } = await params;
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { isAdmin } = await checkWorkspaceAccess(workspaceId, session.user.id);
|
||||
if (!isAdmin) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name, description } = body;
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (name !== undefined) updateData.name = name.trim();
|
||||
if (description !== undefined) updateData.description = description?.trim() || null;
|
||||
|
||||
const workspace = await db.workspace.update({
|
||||
where: { id: workspaceId },
|
||||
data: updateData,
|
||||
include: {
|
||||
owner: { select: { id: true, name: true, image: true } },
|
||||
_count: { select: { projects: true, members: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(workspace);
|
||||
} catch (error) {
|
||||
console.error('Error updating workspace:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update workspace' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/workspaces/[workspaceId] - Delete a workspace
|
||||
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const session = await auth();
|
||||
const { workspaceId } = await params;
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { isOwner, workspace } = await checkWorkspaceAccess(workspaceId, session.user.id);
|
||||
|
||||
if (!workspace) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!isOwner) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only the workspace owner can delete it' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
await db.workspace.delete({ where: { id: workspaceId } });
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Workspace deleted' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting workspace:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete workspace' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { auth } from '@/lib/auth';
|
||||
|
||||
// GET /api/workspaces - List all workspaces for the authenticated user
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get workspaces where user is owner OR a member
|
||||
const workspaces = await db.workspace.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ ownerId: session.user.id },
|
||||
{ members: { some: { userId: session.user.id } } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
owner: { select: { id: true, name: true, image: true } },
|
||||
_count: { select: { projects: true, members: true } },
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
|
||||
return NextResponse.json({ workspaces });
|
||||
} catch (error) {
|
||||
console.error('Error fetching workspaces:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch workspaces' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/workspaces - Create a new workspace
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name, description } = body;
|
||||
|
||||
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Workspace name is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Generate slug
|
||||
const baseSlug = name
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-');
|
||||
|
||||
let slug = baseSlug;
|
||||
let attempts = 0;
|
||||
while (attempts < 10) {
|
||||
const existing = await db.workspace.findUnique({ where: { slug } });
|
||||
if (!existing) break;
|
||||
slug = `${baseSlug}-${Math.random().toString(36).substring(2, 6)}`;
|
||||
attempts++;
|
||||
}
|
||||
|
||||
const workspace = await db.workspace.create({
|
||||
data: {
|
||||
name: name.trim(),
|
||||
description: description?.trim() || null,
|
||||
slug,
|
||||
ownerId: session.user.id,
|
||||
},
|
||||
include: {
|
||||
owner: { select: { id: true, name: true, image: true } },
|
||||
_count: { select: { projects: true, members: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(workspace, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Error creating workspace:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create workspace' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { signOut } from 'next-auth/react';
|
||||
import { LogOut } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
export default function SignOutPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSignOut = async () => {
|
||||
setLoading(true);
|
||||
await signOut({ callbackUrl: '/login' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background px-4">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
<LogOut className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Sign out</CardTitle>
|
||||
<CardDescription>
|
||||
Are you sure you want to sign out?
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<Button onClick={handleSignOut} disabled={loading} className="w-full">
|
||||
{loading ? 'Signing out...' : 'Sign out'}
|
||||
</Button>
|
||||
<Button variant="outline" asChild className="w-full">
|
||||
<a href="/dashboard">Cancel</a>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { usePathname } from 'next/navigation';
|
||||
import {
|
||||
Video,
|
||||
FolderOpen,
|
||||
Building2,
|
||||
Plus,
|
||||
Settings,
|
||||
LogOut,
|
||||
@@ -32,6 +33,7 @@ interface NavItem {
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ href: '/dashboard', label: 'Projects', icon: FolderOpen },
|
||||
{ href: '/workspaces', label: 'Workspaces', icon: Building2 },
|
||||
];
|
||||
|
||||
interface HeaderProps {
|
||||
@@ -151,7 +153,7 @@ export function Header({ user }: HeaderProps) {
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/api/auth/signout">
|
||||
<Link href="/signout">
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
Sign out
|
||||
</Link>
|
||||
|
||||
@@ -55,6 +55,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
},
|
||||
pages: {
|
||||
signIn: '/login',
|
||||
signOut: '/signout',
|
||||
},
|
||||
callbacks: {
|
||||
async session({ session, token }) {
|
||||
|
||||
+64
-9
@@ -23,11 +23,13 @@ model User {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
projects Project[]
|
||||
comments Comment[]
|
||||
memberships ProjectMember[]
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
ownedWorkspaces Workspace[]
|
||||
workspaceMemberships WorkspaceMember[]
|
||||
projects Project[]
|
||||
comments Comment[]
|
||||
projectMemberships ProjectMember[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -76,6 +78,55 @@ model VerificationToken {
|
||||
// APPLICATION MODELS
|
||||
// ============================================
|
||||
|
||||
// ---- Workspace ----
|
||||
|
||||
model Workspace {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
slug String @unique
|
||||
description String? @db.Text
|
||||
|
||||
// Ownership
|
||||
ownerId String
|
||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
|
||||
// Timestamps
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
members WorkspaceMember[]
|
||||
projects Project[]
|
||||
|
||||
@@index([ownerId])
|
||||
@@index([slug])
|
||||
@@map("workspaces")
|
||||
}
|
||||
|
||||
model WorkspaceMember {
|
||||
id String @id @default(cuid())
|
||||
role WorkspaceMemberRole @default(COMMENTATOR)
|
||||
|
||||
workspaceId String
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([workspaceId, userId])
|
||||
@@index([userId])
|
||||
@@map("workspace_members")
|
||||
}
|
||||
|
||||
enum WorkspaceMemberRole {
|
||||
ADMIN // Full access: manage members, delete projects, etc.
|
||||
COMMENTATOR // Can view all projects and comment only
|
||||
}
|
||||
|
||||
// ---- Project ----
|
||||
|
||||
model Project {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
@@ -89,6 +140,10 @@ model Project {
|
||||
ownerId String
|
||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
|
||||
// Workspace (required - every project belongs to a workspace)
|
||||
workspaceId String
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
|
||||
// Timestamps
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -100,6 +155,7 @@ model Project {
|
||||
|
||||
@@index([ownerId])
|
||||
@@index([slug])
|
||||
@@index([workspaceId])
|
||||
@@map("projects")
|
||||
}
|
||||
|
||||
@@ -111,7 +167,7 @@ enum ProjectVisibility {
|
||||
|
||||
model ProjectMember {
|
||||
id String @id @default(cuid())
|
||||
role ProjectMemberRole @default(VIEWER)
|
||||
role ProjectMemberRole @default(COMMENTATOR)
|
||||
|
||||
projectId String
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
@@ -127,9 +183,8 @@ model ProjectMember {
|
||||
}
|
||||
|
||||
enum ProjectMemberRole {
|
||||
VIEWER // Can view and comment
|
||||
EDITOR // Can add/edit videos
|
||||
ADMIN // Can manage members and settings
|
||||
ADMIN // Can manage members, settings, delete videos
|
||||
COMMENTATOR // Can view and comment only
|
||||
}
|
||||
|
||||
model Video {
|
||||
|
||||
+44
-6
@@ -1,4 +1,5 @@
|
||||
import { PrismaClient, ProjectVisibility, ProjectMemberRole, SharePermission } from '@prisma/client';
|
||||
import 'dotenv/config';
|
||||
import { PrismaClient, ProjectVisibility, ProjectMemberRole, SharePermission, WorkspaceMemberRole } from '@prisma/client';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import pg from 'pg';
|
||||
|
||||
@@ -21,6 +22,8 @@ async function main() {
|
||||
await prisma.shareLink.deleteMany();
|
||||
await prisma.projectMember.deleteMany();
|
||||
await prisma.project.deleteMany();
|
||||
await prisma.workspaceMember.deleteMany();
|
||||
await prisma.workspace.deleteMany();
|
||||
await prisma.session.deleteMany();
|
||||
await prisma.account.deleteMany();
|
||||
await prisma.verificationToken.deleteMany();
|
||||
@@ -57,7 +60,39 @@ async function main() {
|
||||
|
||||
console.log('✓ Created 3 demo users');
|
||||
|
||||
// Create projects
|
||||
// Create workspaces
|
||||
const mainWorkspace = await prisma.workspace.create({
|
||||
data: {
|
||||
name: 'OpenFrame Studio',
|
||||
slug: 'openframe-studio',
|
||||
description: 'Main workspace for all video production projects',
|
||||
ownerId: demoUser.id,
|
||||
members: {
|
||||
create: [
|
||||
{ userId: collaborator.id, role: WorkspaceMemberRole.ADMIN },
|
||||
{ userId: reviewer.id, role: WorkspaceMemberRole.COMMENTATOR },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const clientWorkspace = await prisma.workspace.create({
|
||||
data: {
|
||||
name: 'XYZ Corp',
|
||||
slug: 'xyz-corp',
|
||||
description: 'Client workspace for XYZ Corporation projects',
|
||||
ownerId: collaborator.id,
|
||||
members: {
|
||||
create: [
|
||||
{ userId: demoUser.id, role: WorkspaceMemberRole.ADMIN },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log('✓ Created 2 workspaces with members');
|
||||
|
||||
// Create projects (linked to workspace)
|
||||
const techProject = await prisma.project.create({
|
||||
data: {
|
||||
name: 'Tech Review Series',
|
||||
@@ -65,10 +100,11 @@ async function main() {
|
||||
slug: 'tech-review-series',
|
||||
visibility: ProjectVisibility.PRIVATE,
|
||||
ownerId: demoUser.id,
|
||||
workspaceId: mainWorkspace.id,
|
||||
members: {
|
||||
create: [
|
||||
{ userId: collaborator.id, role: ProjectMemberRole.EDITOR },
|
||||
{ userId: reviewer.id, role: ProjectMemberRole.VIEWER },
|
||||
{ userId: collaborator.id, role: ProjectMemberRole.ADMIN },
|
||||
{ userId: reviewer.id, role: ProjectMemberRole.COMMENTATOR },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -81,6 +117,7 @@ async function main() {
|
||||
slug: 'programming-tutorials',
|
||||
visibility: ProjectVisibility.INVITE,
|
||||
ownerId: demoUser.id,
|
||||
workspaceId: mainWorkspace.id,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -91,6 +128,7 @@ async function main() {
|
||||
slug: 'xyz-corp-promo',
|
||||
visibility: ProjectVisibility.PRIVATE,
|
||||
ownerId: collaborator.id,
|
||||
workspaceId: clientWorkspace.id,
|
||||
members: {
|
||||
create: [{ userId: demoUser.id, role: ProjectMemberRole.ADMIN }],
|
||||
},
|
||||
@@ -273,8 +311,8 @@ async function main() {
|
||||
console.log('\n✅ Seeding complete!\n');
|
||||
console.log('Demo accounts:');
|
||||
console.log(' - [email protected] (Owner)');
|
||||
console.log(' - [email protected] (Editor)');
|
||||
console.log(' - [email protected] (Viewer)');
|
||||
console.log(' - [email protected] (Admin)');
|
||||
console.log(' - [email protected] (Commentator)');
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user