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 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 { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
import { ProjectFilter } from './project-filter';
|
||||||
|
|
||||||
function formatRelativeTime(date: Date): string {
|
function formatRelativeTime(date: Date): string {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -38,15 +39,26 @@ export default async function DashboardPage() {
|
|||||||
redirect('/login');
|
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({
|
const projects = await db.project.findMany({
|
||||||
where: {
|
where: {
|
||||||
OR: [
|
OR: [
|
||||||
{ ownerId: session.user.id },
|
{ ownerId: session.user.id },
|
||||||
{ members: { some: { userId: session.user.id } } },
|
{ members: { some: { userId: session.user.id } } },
|
||||||
|
{
|
||||||
|
workspace: {
|
||||||
|
OR: [
|
||||||
|
{ ownerId: session.user.id },
|
||||||
|
{ members: { some: { userId: session.user.id } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
|
workspace: {
|
||||||
|
select: { id: true, name: true },
|
||||||
|
},
|
||||||
_count: {
|
_count: {
|
||||||
select: {
|
select: {
|
||||||
videos: true,
|
videos: true,
|
||||||
@@ -57,79 +69,30 @@ export default async function DashboardPage() {
|
|||||||
orderBy: { updatedAt: 'desc' },
|
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 (
|
return (
|
||||||
<div className="px-6 lg:px-8 py-8 w-full">
|
<div className="px-6 lg:px-8 py-8 w-full">
|
||||||
{/* Header */}
|
<ProjectFilter projects={serializedProjects} workspaces={workspaces} />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</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 { Header } from '@/components/layout';
|
||||||
// import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
|
|
||||||
export default async function DashboardLayout({
|
export default async function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
// TODO: Uncomment when database is set up
|
const session = await auth();
|
||||||
// const session = await auth();
|
|
||||||
const mockUser = {
|
|
||||||
name: 'Demo User',
|
|
||||||
email: '[email protected]',
|
|
||||||
image: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-screen flex-col">
|
<div className="relative flex min-h-screen flex-col">
|
||||||
<Header user={mockUser} />
|
<Header user={session?.user ?? null} />
|
||||||
<main className="flex-1">{children}</main>
|
<main className="flex-1">{children}</main>
|
||||||
</div>
|
</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,
|
Globe,
|
||||||
Lock,
|
Lock,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
|
Users,
|
||||||
|
Building2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
@@ -61,6 +63,7 @@ export default async function ProjectPage({ params }: ProjectPageProps) {
|
|||||||
const project = await db.project.findUnique({
|
const project = await db.project.findUnique({
|
||||||
where: { id: projectId },
|
where: { id: projectId },
|
||||||
include: {
|
include: {
|
||||||
|
workspace: { select: { id: true, name: true } },
|
||||||
owner: { select: { id: true, name: true } },
|
owner: { select: { id: true, name: true } },
|
||||||
members: {
|
members: {
|
||||||
where: { userId: session?.user?.id || '' },
|
where: { userId: session?.user?.id || '' },
|
||||||
@@ -91,7 +94,29 @@ export default async function ProjectPage({ params }: ProjectPageProps) {
|
|||||||
const isMember = project.members.length > 0;
|
const isMember = project.members.length > 0;
|
||||||
const isPublicOrLink = project.visibility !== 'PRIVATE';
|
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');
|
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 (
|
return (
|
||||||
<div className="px-6 lg:px-8 py-8 w-full">
|
<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()}
|
{project.visibility.toLowerCase()}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{project.description && (
|
<div className="flex items-center gap-2">
|
||||||
<p className="text-muted-foreground">{project.description}</p>
|
{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>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -147,12 +182,20 @@ export default async function ProjectPage({ params }: ProjectPageProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
{(isOwner || project.members[0]?.role === 'ADMIN') && (
|
{(isOwner || project.members[0]?.role === 'ADMIN') && (
|
||||||
<Button variant="outline" size="sm" asChild>
|
<>
|
||||||
<Link href={`/projects/${projectId}/settings`}>
|
<Button variant="outline" size="sm" asChild>
|
||||||
<Settings className="h-4 w-4 mr-2" />
|
<Link href={`/projects/${projectId}/members`}>
|
||||||
Settings
|
<Users className="h-4 w-4 mr-2" />
|
||||||
</Link>
|
Members
|
||||||
</Button>
|
</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 && (
|
{canEdit && (
|
||||||
<Button size="sm" asChild>
|
<Button size="sm" asChild>
|
||||||
|
|||||||
@@ -1,22 +1,34 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
|
||||||
type Visibility = 'PRIVATE' | 'INVITE' | 'PUBLIC';
|
type Visibility = 'PRIVATE' | 'INVITE' | 'PUBLIC';
|
||||||
|
|
||||||
|
interface Workspace {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
const visibilityOptions: { value: Visibility; label: string; description: string; icon: React.ReactNode }[] = [
|
const visibilityOptions: { value: Visibility; label: string; description: string; icon: React.ReactNode }[] = [
|
||||||
{
|
{
|
||||||
value: 'PRIVATE',
|
value: 'PRIVATE',
|
||||||
label: '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" />,
|
icon: <Lock className="h-5 w-5" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -35,16 +47,47 @@ const visibilityOptions: { value: Visibility; label: string; description: string
|
|||||||
|
|
||||||
export default function NewProjectPage() {
|
export default function NewProjectPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const preselectedWorkspace = searchParams.get('workspace');
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||||
|
const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(true);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
visibility: 'PRIVATE' as Visibility,
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!formData.workspaceId) {
|
||||||
|
setError('Please select a workspace');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
@@ -95,6 +138,46 @@ export default function NewProjectPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<form onSubmit={handleSubmit} className="space-y-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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name" className="text-sm font-medium">
|
<Label htmlFor="name" className="text-sm font-medium">
|
||||||
Project Name
|
Project Name
|
||||||
@@ -174,7 +257,7 @@ export default function NewProjectPage() {
|
|||||||
<div className="flex gap-3 pt-4">
|
<div className="flex gap-3 pt-4">
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading || !formData.name.trim()}
|
disabled={isLoading || !formData.name.trim() || !formData.workspaceId}
|
||||||
className="flex-1 h-11"
|
className="flex-1 h-11"
|
||||||
>
|
>
|
||||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||||
|
|||||||
@@ -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 isOwner = project.ownerId === userId;
|
||||||
const membership = project.members[0];
|
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 {
|
return {
|
||||||
project,
|
project,
|
||||||
role,
|
role,
|
||||||
canEdit: isOwner || role === ProjectMemberRole.ADMIN || role === ProjectMemberRole.EDITOR,
|
canEdit: isOwner || role === 'OWNER' || role === ProjectMemberRole.ADMIN,
|
||||||
canDelete: isOwner,
|
canDelete: isOwner || role === 'OWNER',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +82,25 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
const isOwner = session?.user?.id === project.ownerId;
|
const isOwner = session?.user?.id === project.ownerId;
|
||||||
const isMember = project.members.some(m => m.userId === session?.user?.id);
|
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 });
|
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 isOwner = video.project.ownerId === session.user.id;
|
||||||
const membership = video.project.members[0];
|
const membership = video.project.members[0];
|
||||||
const canEdit = isOwner ||
|
const canEdit = isOwner ||
|
||||||
membership?.role === ProjectMemberRole.ADMIN ||
|
membership?.role === ProjectMemberRole.ADMIN;
|
||||||
membership?.role === ProjectMemberRole.EDITOR;
|
|
||||||
|
|
||||||
if (!canEdit) {
|
if (!canEdit) {
|
||||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
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 isOwner = video.project.ownerId === session.user.id;
|
||||||
const membership = video.project.members[0];
|
const membership = video.project.members[0];
|
||||||
const canEdit = isOwner ||
|
const canEdit = isOwner ||
|
||||||
membership?.role === ProjectMemberRole.ADMIN ||
|
membership?.role === ProjectMemberRole.ADMIN;
|
||||||
membership?.role === ProjectMemberRole.EDITOR;
|
|
||||||
|
|
||||||
if (!canEdit) {
|
if (!canEdit) {
|
||||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
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 });
|
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({
|
const project = await db.project.findUnique({
|
||||||
where: { id: projectId },
|
where: { id: projectId },
|
||||||
include: { members: { where: { userId: session.user.id } } },
|
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 isOwner = project.ownerId === session.user.id;
|
||||||
const membership = project.members[0];
|
const membership = project.members[0];
|
||||||
const canEdit = isOwner ||
|
const canEdit = isOwner ||
|
||||||
membership?.role === ProjectMemberRole.ADMIN ||
|
membership?.role === ProjectMemberRole.ADMIN;
|
||||||
membership?.role === ProjectMemberRole.EDITOR;
|
|
||||||
|
|
||||||
if (!canEdit) {
|
if (!canEdit) {
|
||||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
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 { searchParams } = new URL(request.url);
|
||||||
const page = parseInt(searchParams.get('page') || '1');
|
const page = parseInt(searchParams.get('page') || '1');
|
||||||
const limit = parseInt(searchParams.get('limit') || '10');
|
const limit = parseInt(searchParams.get('limit') || '10');
|
||||||
|
const workspaceId = searchParams.get('workspaceId');
|
||||||
const skip = (page - 1) * limit;
|
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
|
// Get projects where user is owner OR a member
|
||||||
const [projects, total] = await Promise.all([
|
const [projects, total] = await Promise.all([
|
||||||
db.project.findMany({
|
db.project.findMany({
|
||||||
where: {
|
where: baseFilter,
|
||||||
OR: [
|
|
||||||
{ ownerId: session.user.id },
|
|
||||||
{ members: { some: { userId: session.user.id } } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
include: {
|
include: {
|
||||||
owner: { select: { id: true, name: true, image: true } },
|
owner: { select: { id: true, name: true, image: true } },
|
||||||
_count: { select: { videos: true, members: true } },
|
_count: { select: { videos: true, members: true } },
|
||||||
@@ -35,12 +50,7 @@ export async function GET(request: NextRequest) {
|
|||||||
take: limit,
|
take: limit,
|
||||||
}),
|
}),
|
||||||
db.project.count({
|
db.project.count({
|
||||||
where: {
|
where: baseFilter,
|
||||||
OR: [
|
|
||||||
{ ownerId: session.user.id },
|
|
||||||
{ members: { some: { userId: session.user.id } } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -72,7 +82,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
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) {
|
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
||||||
return NextResponse.json(
|
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
|
// Generate URL-friendly slug
|
||||||
const baseSlug = name
|
const baseSlug = name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -99,6 +116,26 @@ export async function POST(request: NextRequest) {
|
|||||||
attempts++;
|
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({
|
const project = await db.project.create({
|
||||||
data: {
|
data: {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
@@ -106,6 +143,7 @@ export async function POST(request: NextRequest) {
|
|||||||
slug,
|
slug,
|
||||||
visibility: visibility || ProjectVisibility.PRIVATE,
|
visibility: visibility || ProjectVisibility.PRIVATE,
|
||||||
ownerId: session.user.id,
|
ownerId: session.user.id,
|
||||||
|
workspaceId,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
owner: { select: { id: true, name: true, image: true } },
|
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 {
|
import {
|
||||||
Video,
|
Video,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
|
Building2,
|
||||||
Plus,
|
Plus,
|
||||||
Settings,
|
Settings,
|
||||||
LogOut,
|
LogOut,
|
||||||
@@ -32,6 +33,7 @@ interface NavItem {
|
|||||||
|
|
||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{ href: '/dashboard', label: 'Projects', icon: FolderOpen },
|
{ href: '/dashboard', label: 'Projects', icon: FolderOpen },
|
||||||
|
{ href: '/workspaces', label: 'Workspaces', icon: Building2 },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
@@ -151,7 +153,7 @@ export function Header({ user }: HeaderProps) {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href="/api/auth/signout">
|
<Link href="/signout">
|
||||||
<LogOut className="h-4 w-4 mr-2" />
|
<LogOut className="h-4 w-4 mr-2" />
|
||||||
Sign out
|
Sign out
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
|||||||
},
|
},
|
||||||
pages: {
|
pages: {
|
||||||
signIn: '/login',
|
signIn: '/login',
|
||||||
|
signOut: '/signout',
|
||||||
},
|
},
|
||||||
callbacks: {
|
callbacks: {
|
||||||
async session({ session, token }) {
|
async session({ session, token }) {
|
||||||
|
|||||||
+64
-9
@@ -23,11 +23,13 @@ model User {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
projects Project[]
|
ownedWorkspaces Workspace[]
|
||||||
comments Comment[]
|
workspaceMemberships WorkspaceMember[]
|
||||||
memberships ProjectMember[]
|
projects Project[]
|
||||||
|
comments Comment[]
|
||||||
|
projectMemberships ProjectMember[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@@ -76,6 +78,55 @@ model VerificationToken {
|
|||||||
// APPLICATION MODELS
|
// 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 {
|
model Project {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
@@ -89,6 +140,10 @@ model Project {
|
|||||||
ownerId String
|
ownerId String
|
||||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
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
|
// Timestamps
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -100,6 +155,7 @@ model Project {
|
|||||||
|
|
||||||
@@index([ownerId])
|
@@index([ownerId])
|
||||||
@@index([slug])
|
@@index([slug])
|
||||||
|
@@index([workspaceId])
|
||||||
@@map("projects")
|
@@map("projects")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +167,7 @@ enum ProjectVisibility {
|
|||||||
|
|
||||||
model ProjectMember {
|
model ProjectMember {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
role ProjectMemberRole @default(VIEWER)
|
role ProjectMemberRole @default(COMMENTATOR)
|
||||||
|
|
||||||
projectId String
|
projectId String
|
||||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
@@ -127,9 +183,8 @@ model ProjectMember {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum ProjectMemberRole {
|
enum ProjectMemberRole {
|
||||||
VIEWER // Can view and comment
|
ADMIN // Can manage members, settings, delete videos
|
||||||
EDITOR // Can add/edit videos
|
COMMENTATOR // Can view and comment only
|
||||||
ADMIN // Can manage members and settings
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model Video {
|
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 { PrismaPg } from '@prisma/adapter-pg';
|
||||||
import pg from 'pg';
|
import pg from 'pg';
|
||||||
|
|
||||||
@@ -21,6 +22,8 @@ async function main() {
|
|||||||
await prisma.shareLink.deleteMany();
|
await prisma.shareLink.deleteMany();
|
||||||
await prisma.projectMember.deleteMany();
|
await prisma.projectMember.deleteMany();
|
||||||
await prisma.project.deleteMany();
|
await prisma.project.deleteMany();
|
||||||
|
await prisma.workspaceMember.deleteMany();
|
||||||
|
await prisma.workspace.deleteMany();
|
||||||
await prisma.session.deleteMany();
|
await prisma.session.deleteMany();
|
||||||
await prisma.account.deleteMany();
|
await prisma.account.deleteMany();
|
||||||
await prisma.verificationToken.deleteMany();
|
await prisma.verificationToken.deleteMany();
|
||||||
@@ -57,7 +60,39 @@ async function main() {
|
|||||||
|
|
||||||
console.log('✓ Created 3 demo users');
|
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({
|
const techProject = await prisma.project.create({
|
||||||
data: {
|
data: {
|
||||||
name: 'Tech Review Series',
|
name: 'Tech Review Series',
|
||||||
@@ -65,10 +100,11 @@ async function main() {
|
|||||||
slug: 'tech-review-series',
|
slug: 'tech-review-series',
|
||||||
visibility: ProjectVisibility.PRIVATE,
|
visibility: ProjectVisibility.PRIVATE,
|
||||||
ownerId: demoUser.id,
|
ownerId: demoUser.id,
|
||||||
|
workspaceId: mainWorkspace.id,
|
||||||
members: {
|
members: {
|
||||||
create: [
|
create: [
|
||||||
{ userId: collaborator.id, role: ProjectMemberRole.EDITOR },
|
{ userId: collaborator.id, role: ProjectMemberRole.ADMIN },
|
||||||
{ userId: reviewer.id, role: ProjectMemberRole.VIEWER },
|
{ userId: reviewer.id, role: ProjectMemberRole.COMMENTATOR },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -81,6 +117,7 @@ async function main() {
|
|||||||
slug: 'programming-tutorials',
|
slug: 'programming-tutorials',
|
||||||
visibility: ProjectVisibility.INVITE,
|
visibility: ProjectVisibility.INVITE,
|
||||||
ownerId: demoUser.id,
|
ownerId: demoUser.id,
|
||||||
|
workspaceId: mainWorkspace.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -91,6 +128,7 @@ async function main() {
|
|||||||
slug: 'xyz-corp-promo',
|
slug: 'xyz-corp-promo',
|
||||||
visibility: ProjectVisibility.PRIVATE,
|
visibility: ProjectVisibility.PRIVATE,
|
||||||
ownerId: collaborator.id,
|
ownerId: collaborator.id,
|
||||||
|
workspaceId: clientWorkspace.id,
|
||||||
members: {
|
members: {
|
||||||
create: [{ userId: demoUser.id, role: ProjectMemberRole.ADMIN }],
|
create: [{ userId: demoUser.id, role: ProjectMemberRole.ADMIN }],
|
||||||
},
|
},
|
||||||
@@ -273,8 +311,8 @@ async function main() {
|
|||||||
console.log('\n✅ Seeding complete!\n');
|
console.log('\n✅ Seeding complete!\n');
|
||||||
console.log('Demo accounts:');
|
console.log('Demo accounts:');
|
||||||
console.log(' - [email protected] (Owner)');
|
console.log(' - [email protected] (Owner)');
|
||||||
console.log(' - [email protected] (Editor)');
|
console.log(' - [email protected] (Admin)');
|
||||||
console.log(' - [email protected] (Viewer)');
|
console.log(' - [email protected] (Commentator)');
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
|
|||||||
Reference in New Issue
Block a user