diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx index 1356e54..7946270 100644 --- a/app/(dashboard)/dashboard/page.tsx +++ b/app/(dashboard)/dashboard/page.tsx @@ -1,11 +1,12 @@ import Link from 'next/link'; -import { Plus, FolderOpen, Clock, Users, Globe, Lock, UserPlus } from 'lucide-react'; +import { Plus, FolderOpen, Clock, Users, Globe, Lock, UserPlus, Building2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { auth } from '@/lib/auth'; import { redirect } from 'next/navigation'; import { db } from '@/lib/db'; +import { ProjectFilter } from './project-filter'; function formatRelativeTime(date: Date): string { const now = new Date(); @@ -38,15 +39,26 @@ export default async function DashboardPage() { redirect('/login'); } - // Fetch projects where user is owner or member + // Fetch projects where user is owner, member, or workspace member const projects = await db.project.findMany({ where: { OR: [ { ownerId: session.user.id }, { members: { some: { userId: session.user.id } } }, + { + workspace: { + OR: [ + { ownerId: session.user.id }, + { members: { some: { userId: session.user.id } } }, + ], + }, + }, ], }, include: { + workspace: { + select: { id: true, name: true }, + }, _count: { select: { videos: true, @@ -57,79 +69,30 @@ export default async function DashboardPage() { orderBy: { updatedAt: 'desc' }, }); + // Build unique workspace list for filter + const workspaceMap = new Map(); + 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 (
- {/* Header */} -
-
-

Projects

-

- Manage your video projects and collect feedback -

-
- -
- - {/* Projects Grid */} - {projects.length > 0 ? ( -
- {projects.map((project) => ( - - - -
- - - {project.name} - - - - {project.visibility.toLowerCase()} - -
- - {project.description || 'No description'} - -
- -
- - - {formatRelativeTime(project.updatedAt)} - - - - {project._count.members + 1} - - {project._count.videos} videos -
-
-
- - ))} -
- ) : ( - - - -

No projects yet

-

- Create your first project to start collecting video feedback -

- -
-
- )} +
); } diff --git a/app/(dashboard)/dashboard/project-filter.tsx b/app/(dashboard)/dashboard/project-filter.tsx new file mode 100644 index 0000000..e7c6ea9 --- /dev/null +++ b/app/(dashboard)/dashboard/project-filter.tsx @@ -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 ; + case 'INVITE': + return ; + default: + return ; + } +} + +export function ProjectFilter({ projects, workspaces }: ProjectFilterProps) { + const [selectedWorkspace, setSelectedWorkspace] = useState('all'); + + const filtered = + selectedWorkspace === 'all' + ? projects + : projects.filter((p) => p.workspaceId === selectedWorkspace); + + return ( + <> + {/* Header */} +
+
+

Projects

+ {workspaces.length > 0 && ( + + )} +
+ +
+ + {/* Projects Grid */} + {filtered.length > 0 ? ( +
+ {filtered.map((project) => ( + + + +
+ + + {project.name} + + + + {project.visibility.toLowerCase()} + +
+ {project.workspaceName && ( +
+ + + {project.workspaceName} + +
+ )} + + {project.description || 'No description'} + +
+ +
+ + + {formatRelativeTime(project.updatedAt)} + + + + {project.memberCount} + + {project.videoCount} videos +
+
+
+ + ))} +
+ ) : ( + + + +

+ {selectedWorkspace === 'all' ? 'No projects yet' : 'No projects in this workspace'} +

+

+ {selectedWorkspace === 'all' + ? 'Create your first project to start collecting video feedback' + : 'Create a project in this workspace to get started'} +

+ +
+
+ )} + + ); +} diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index 8db95fa..dfc19e9 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -1,22 +1,16 @@ import { Header } from '@/components/layout'; -// import { auth } from '@/lib/auth'; +import { auth } from '@/lib/auth'; export default async function DashboardLayout({ children, }: { children: React.ReactNode; }) { - // TODO: Uncomment when database is set up - // const session = await auth(); - const mockUser = { - name: 'Demo User', - email: 'demo@openframe.dev', - image: null, - }; + const session = await auth(); return (
-
+
{children}
); diff --git a/app/(dashboard)/projects/[projectId]/members/page.tsx b/app/(dashboard)/projects/[projectId]/members/page.tsx new file mode 100644 index 0000000..0164da8 --- /dev/null +++ b/app/(dashboard)/projects/[projectId]/members/page.tsx @@ -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([]); + const [owner, setOwner] = useState(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 ( +
+ +
+ ); + } + + return ( +
+
+ + + Back to Project + +
+ +
+

Project Members

+

+ Manage who has access to this project. Admins can manage settings and delete content. + Commentators can only view and leave comments. +

+
+ + {/* Invite Form */} + + + + + Invite Member + + + Invite someone by email. They must have an account to be added. + + + +
+
+ + setInviteEmail(e.target.value)} + required + disabled={isInviting} + /> +
+
+ + +
+ +
+ + {error && ( +
+ {error} +
+ )} + {success && ( +
+ {success} +
+ )} +
+
+ + {/* Members List */} + + + Current Members + + Admin — can manage project settings, members, and delete content.{' '} + Commentator — can view and comment only. + + + + {/* Owner */} + {owner && ( +
+
+ + + {owner.name?.charAt(0).toUpperCase() ?? 'U'} + +
+

{owner.name || 'Unnamed'}

+

{owner.email}

+
+
+ + + Owner + +
+ )} + + {/* Members */} + {members.map((member) => ( +
+
+ + + {member.user.name?.charAt(0).toUpperCase() ?? 'U'} + +
+

{member.user.name || 'Unnamed'}

+

{member.user.email}

+
+
+
+ + +
+
+ ))} + + {members.length === 0 && ( +

+ No members yet. Invite someone above. +

+ )} +
+
+
+ ); +} diff --git a/app/(dashboard)/projects/[projectId]/page.tsx b/app/(dashboard)/projects/[projectId]/page.tsx index ba6e4f5..be42a0b 100644 --- a/app/(dashboard)/projects/[projectId]/page.tsx +++ b/app/(dashboard)/projects/[projectId]/page.tsx @@ -9,6 +9,8 @@ import { Globe, Lock, UserPlus, + Users, + Building2, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; @@ -61,6 +63,7 @@ export default async function ProjectPage({ params }: ProjectPageProps) { const project = await db.project.findUnique({ where: { id: projectId }, include: { + workspace: { select: { id: true, name: true } }, owner: { select: { id: true, name: true } }, members: { where: { userId: session?.user?.id || '' }, @@ -91,7 +94,29 @@ export default async function ProjectPage({ params }: ProjectPageProps) { const isMember = project.members.length > 0; const isPublicOrLink = project.visibility !== 'PRIVATE'; - if (!isOwner && !isMember && !isPublicOrLink) { + // Check workspace membership + let isWorkspaceMember = false; + let workspaceRole: string | null = null; + if (session?.user?.id) { + const wsMember = await db.workspaceMember.findUnique({ + where: { + workspaceId_userId: { + workspaceId: project.workspaceId, + userId: session.user.id, + }, + }, + }); + const ws = await db.workspace.findUnique({ + where: { id: project.workspaceId }, + select: { ownerId: true }, + }); + if (ws?.ownerId === session.user.id || wsMember) { + isWorkspaceMember = true; + workspaceRole = ws?.ownerId === session.user.id ? 'OWNER' : wsMember?.role || null; + } + } + + if (!isOwner && !isMember && !isPublicOrLink && !isWorkspaceMember) { redirect('/dashboard'); } @@ -109,7 +134,7 @@ export default async function ProjectPage({ params }: ProjectPageProps) { }; }); - const canEdit = isOwner || project.members[0]?.role === 'ADMIN' || project.members[0]?.role === 'EDITOR'; + const canEdit = isOwner || project.members[0]?.role === 'ADMIN' || workspaceRole === 'OWNER' || workspaceRole === 'ADMIN'; return (
@@ -134,9 +159,19 @@ export default async function ProjectPage({ params }: ProjectPageProps) { {project.visibility.toLowerCase()}
- {project.description && ( -

{project.description}

- )} +
+ {project.workspace && ( + + + + {project.workspace.name} + + + )} + {project.description && ( + {project.description} + )} +
@@ -147,12 +182,20 @@ export default async function ProjectPage({ params }: ProjectPageProps) { {(isOwner || project.members[0]?.role === 'ADMIN') && ( - + <> + + + )} {canEdit && ( +
+ ) : ( + + )} + +