From 2a20b449a8e1f381eefb5a3fc4a5a07997ce8f57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0pek?= Date: Fri, 20 Feb 2026 15:50:50 +0300 Subject: [PATCH] refactor: Migrate dashboard project listing to client components, adding URL-driven filtering, sorting, and pagination. --- .../dashboard/dashboard-client.tsx | 33 +++ app/(dashboard)/dashboard/page.tsx | 192 ++++++++++-------- app/(dashboard)/dashboard/project-filter.tsx | 95 +++++++-- app/(dashboard)/projects/[projectId]/page.tsx | 52 +++-- .../[projectId]/project-content-client.tsx | 75 ++++++- app/(dashboard)/workspaces/page.tsx | 155 +++++--------- .../workspaces/workspaces-client.tsx | 150 ++++++++++++++ 7 files changed, 511 insertions(+), 241 deletions(-) create mode 100644 app/(dashboard)/dashboard/dashboard-client.tsx create mode 100644 app/(dashboard)/workspaces/workspaces-client.tsx diff --git a/app/(dashboard)/dashboard/dashboard-client.tsx b/app/(dashboard)/dashboard/dashboard-client.tsx new file mode 100644 index 0000000..b53af83 --- /dev/null +++ b/app/(dashboard)/dashboard/dashboard-client.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { ProjectFilter } from './project-filter'; + +interface SerializedProject { + id: string; + name: string; + description: string | null; + visibility: string; + updatedAt: string; + workspaceId: string | null; + workspaceName: string | null; + memberCount: number; + videoCount: number; +} + +interface DashboardClientProps { + serializedProjects: SerializedProject[]; + workspaces: { id: string; name: string }[]; + totalPages: number; +} + +export function DashboardClient({ serializedProjects, workspaces, totalPages }: DashboardClientProps) { + return ( +
+ +
+ ); +} diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx index 8aa65e6..45239e3 100644 --- a/app/(dashboard)/dashboard/page.tsx +++ b/app/(dashboard)/dashboard/page.tsx @@ -1,98 +1,110 @@ -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 { auth } from '@/lib/auth'; import { redirect } from 'next/navigation'; import { db } from '@/lib/db'; -import { ProjectFilter } from './project-filter'; +import { Prisma } from '@prisma/client'; +import { DashboardClient } from './dashboard-client'; -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(); -} - -function VisibilityIcon({ visibility }: { visibility: string }) { - switch (visibility) { - case 'PUBLIC': - return ; - case 'INVITE': - return ; - default: - return ; - } -} - -export default async function DashboardPage() { - const session = await auth(); - if (!session?.user?.id) { - redirect('/login'); - } - - // 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, - members: true, - }, - }, - }, - 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); +export default async function DashboardPage({ + searchParams, +}: { + searchParams: Promise<{ ws?: string; sort?: string; page?: string }> +}) { + const session = await auth(); + if (!session?.user?.id) { + redirect('/login'); } - } - const workspaces = Array.from(workspaceMap, ([id, name]) => ({ id, name })); - const serializedProjects = projects.map((p: typeof projects[0]) => ({ - 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, - })); + const resolvedSearchParams = await searchParams; + const { ws, sort, page: pageParam } = resolvedSearchParams || {}; - return ( -
- -
- ); + const page = Number(pageParam) || 1; + const pageSize = 20; + const skip = (page - 1) * pageSize; + const orderByDirection = sort === 'asc' ? 'asc' : 'desc'; + + // Base permission where clause + const baseWhere: Prisma.ProjectWhereInput = { + OR: [ + { ownerId: session.user.id }, + { members: { some: { userId: session.user.id } } }, + { + workspace: { + OR: [ + { ownerId: session.user.id }, + { members: { some: { userId: session.user.id } } }, + ], + }, + }, + ], + }; + + // Build unique workspace list for filter (Needs an unbounded list of accessible workspaces) + const accessibleProjects = await db.project.findMany({ + where: baseWhere, + select: { + workspace: { + select: { id: true, name: true } + } + }, + distinct: ['workspaceId'] + }); + + const workspaceMap = new Map(); + for (const project of accessibleProjects) { + if (project.workspace) { + workspaceMap.set(project.workspace.id, project.workspace.name); + } + } + const workspaces = Array.from(workspaceMap, ([id, name]) => ({ id, name })); + + // Final query constraints + const queryWhere: Prisma.ProjectWhereInput = { + ...baseWhere, + ...(ws && ws !== 'all' ? { workspaceId: ws } : {}) + }; + + const [projects, totalProjects] = await Promise.all([ + db.project.findMany({ + skip, + take: pageSize, + where: queryWhere, + include: { + workspace: { + select: { id: true, name: true }, + }, + _count: { + select: { + videos: true, + members: true, + }, + }, + }, + orderBy: { updatedAt: orderByDirection }, + }), + db.project.count({ + where: queryWhere + }) + ]); + + const totalPages = Math.ceil(totalProjects / pageSize); + + 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 ( + + ); } diff --git a/app/(dashboard)/dashboard/project-filter.tsx b/app/(dashboard)/dashboard/project-filter.tsx index e377139..f594575 100644 --- a/app/(dashboard)/dashboard/project-filter.tsx +++ b/app/(dashboard)/dashboard/project-filter.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useState, useMemo } from 'react'; +import { useCallback } from 'react'; +import { useRouter, usePathname, useSearchParams } from 'next/navigation'; import Link from 'next/link'; import { Plus, FolderOpen, Clock, Users, Globe, Lock, UserPlus, Building2, ArrowUp, ArrowDown } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -29,6 +30,7 @@ interface SerializedProject { interface ProjectFilterProps { projects: SerializedProject[]; workspaces: { id: string; name: string }[]; + totalPages: number; } function formatRelativeTime(dateStr: string): string { @@ -59,25 +61,33 @@ function VisibilityIcon({ visibility }: { visibility: string }) { type SortOrder = 'desc' | 'asc'; -export function ProjectFilter({ projects, workspaces }: ProjectFilterProps) { - const [selectedWorkspace, setSelectedWorkspace] = useState('all'); - const [sortOrder, setSortOrder] = useState('desc'); +export function ProjectFilter({ projects, workspaces, totalPages }: ProjectFilterProps) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); - const filtered = useMemo(() => { - let result = - selectedWorkspace === 'all' - ? projects - : projects.filter((p) => p.workspaceId === selectedWorkspace); + const selectedWorkspace = searchParams.get('ws') || 'all'; + const sortOrder = searchParams.get('sort') as SortOrder || 'desc'; + const page = Number(searchParams.get('page')) || 1; - // Sort by date - result = [...result].sort((a, b) => { - const dateA = new Date(a.updatedAt).getTime(); - const dateB = new Date(b.updatedAt).getTime(); - return sortOrder === 'desc' ? dateB - dateA : dateA - dateB; - }); + const createQueryString = useCallback( + (name: string, value: string) => { + const params = new URLSearchParams(searchParams.toString()); + if (value === 'all' && name === 'ws') { + params.delete(name); + } else { + params.set(name, value); + } - return result; - }, [projects, selectedWorkspace, sortOrder]); + // Reset page when filter or sort changes + if (name !== 'page') { + params.set('page', '1'); + } + + return params.toString(); + }, + [searchParams] + ); return ( <> @@ -86,7 +96,12 @@ export function ProjectFilter({ projects, workspaces }: ProjectFilterProps) {

Projects

{workspaces.length > 0 && ( - { + router.push(`${pathname}?${createQueryString('ws', val)}`); + }} + > @@ -105,7 +120,10 @@ export function ProjectFilter({ projects, workspaces }: ProjectFilterProps) {
{/* Projects Grid */} - {filtered.length > 0 ? ( + {projects.length > 0 ? (
- {filtered.map((project) => ( + {projects.map((project) => ( @@ -196,6 +214,41 @@ export function ProjectFilter({ projects, workspaces }: ProjectFilterProps) { )} + + {/* Pagination */} + {totalPages > 1 && ( +
+ + + Page {page} of {totalPages} + + +
+ )} ); } diff --git a/app/(dashboard)/projects/[projectId]/page.tsx b/app/(dashboard)/projects/[projectId]/page.tsx index c087536..395a17c 100644 --- a/app/(dashboard)/projects/[projectId]/page.tsx +++ b/app/(dashboard)/projects/[projectId]/page.tsx @@ -50,11 +50,17 @@ function formatRelativeTime(date: Date): string { interface ProjectPageProps { params: Promise<{ projectId: string }>; + searchParams: Promise<{ page?: string }>; } -export default async function ProjectPage({ params }: ProjectPageProps) { +export default async function ProjectPage({ params, searchParams }: ProjectPageProps) { const session = await auth(); const { projectId } = await params; + const resolvedSearchParams = await searchParams; + + const page = Number(resolvedSearchParams?.page) || 1; + const pageSize = 20; + const skip = (page - 1) * pageSize; // Fetch project with videos const project = await db.project.findUnique({ @@ -66,19 +72,6 @@ export default async function ProjectPage({ params }: ProjectPageProps) { where: { userId: session?.user?.id || '' }, select: { role: true }, }, - videos: { - orderBy: { position: 'asc' }, - include: { - versions: { - where: { isActive: true }, - take: 1, - include: { - _count: { select: { comments: true } }, - }, - }, - _count: { select: { versions: true } }, - }, - }, }, }); @@ -117,8 +110,33 @@ export default async function ProjectPage({ params }: ProjectPageProps) { redirect('/dashboard'); } + // Fetch videos separately utilizing bounds + const [paginatedVideos, totalVideos] = await Promise.all([ + db.video.findMany({ + where: { projectId: project.id }, + skip, + take: pageSize, + orderBy: { position: 'asc' }, + include: { + versions: { + where: { isActive: true }, + take: 1, + include: { + _count: { select: { comments: true } }, + }, + }, + _count: { select: { versions: true } }, + }, + }), + db.video.count({ + where: { projectId: project.id } + }) + ]); + + const totalPages = Math.ceil(totalVideos / pageSize); + // Transform videos for VideoCard component - const videos = project.videos.map((video: typeof project.videos[0]) => { + const videos = paginatedVideos.map((video) => { const activeVersion = video.versions[0]; return { id: video.id, @@ -165,6 +183,8 @@ export default async function ProjectPage({ params }: ProjectPageProps) { canEdit={false} isOwner={false} workspaceRole={null} + totalPages={totalPages} + currentPage={page} />
@@ -190,6 +210,8 @@ export default async function ProjectPage({ params }: ProjectPageProps) { canEdit={canEdit} isOwner={isOwner} workspaceRole={workspaceRole} + totalPages={totalPages} + currentPage={page} /> ); diff --git a/app/(dashboard)/projects/[projectId]/project-content-client.tsx b/app/(dashboard)/projects/[projectId]/project-content-client.tsx index bb4c15d..4f337b8 100644 --- a/app/(dashboard)/projects/[projectId]/project-content-client.tsx +++ b/app/(dashboard)/projects/[projectId]/project-content-client.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useState, useMemo } from 'react'; +import { useCallback } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; import Link from 'next/link'; import { Plus, @@ -46,6 +47,8 @@ interface ProjectContentClientProps { canEdit: boolean; isOwner: boolean; workspaceRole: string | null; + totalPages: number; + currentPage: number; } export function ProjectContentClient({ @@ -55,16 +58,32 @@ export function ProjectContentClient({ canEdit, isOwner, workspaceRole, + totalPages, + currentPage }: ProjectContentClientProps) { - const [sortOrder, setSortOrder] = useState('desc'); + const router = useRouter(); + const searchParams = useSearchParams(); + const sortOrder = searchParams.get('sort') || 'desc'; - const sortedVideos = useMemo(() => { - return [...videos].sort((a, b) => { - const dateA = new Date(a.updatedAt).getTime(); - const dateB = new Date(b.updatedAt).getTime(); - return sortOrder === 'desc' ? dateB - dateA : dateA - dateB; - }); - }, [videos, sortOrder]); + const createQueryString = useCallback( + (name: string, value: string) => { + const params = new URLSearchParams(searchParams.toString()); + params.set(name, value); + + if (name !== 'page') { + params.set('page', '1'); + } + + return params.toString(); + }, + [searchParams] + ); + + const sortedVideos = [...videos].sort((a, b) => { + const dateA = new Date(a.updatedAt).getTime(); + const dateB = new Date(b.updatedAt).getTime(); + return sortOrder === 'desc' ? dateB - dateA : dateA - dateB; + }); return ( <> @@ -100,7 +119,10 @@ export function ProjectContentClient({ + + Page {currentPage} of {totalPages} + + + + )} ); } diff --git a/app/(dashboard)/workspaces/page.tsx b/app/(dashboard)/workspaces/page.tsx index 5fbabc4..a0a6c7c 100644 --- a/app/(dashboard)/workspaces/page.tsx +++ b/app/(dashboard)/workspaces/page.tsx @@ -1,120 +1,65 @@ -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'; +import { WorkspacesClient } from './workspaces-client'; -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() { +export default async function WorkspacesPage({ + searchParams, +}: { + searchParams: Promise<{ page?: string }> +}) { 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, + const resolvedSearchParams = await searchParams; + const page = Number(resolvedSearchParams?.page) || 1; + const pageSize = 20; + const skip = (page - 1) * pageSize; + + const [workspaces, totalWorkspaces] = await Promise.all([ + db.workspace.findMany({ + skip, + take: pageSize, + 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' }, - }); + orderBy: { updatedAt: 'desc' }, + }), + db.workspace.count({ + where: { + OR: [ + { ownerId: session.user.id }, + { members: { some: { userId: session.user.id } } }, + ], + } + }) + ]); + + const totalPages = Math.ceil(totalWorkspaces / pageSize); + + const serializedWorkspaces = workspaces.map((w) => ({ + id: w.id, + name: w.name, + description: w.description, + updatedAt: w.updatedAt.toISOString(), + _count: w._count + })); return ( -
- {/* Header */} -
-
-

Workspaces

-

- Manage your workspaces and their projects -

-
- -
- - {/* Workspaces Grid */} - {workspaces.length > 0 ? ( -
- {workspaces.map((workspace: typeof workspaces[number]) => ( - - - - - - {workspace.name} - - - {workspace.description || 'No description'} - - - -
- - - {formatRelativeTime(workspace.updatedAt)} - - - - {workspace._count.projects} projects - - - - {workspace._count.members + 1} - -
-
-
- - ))} -
- ) : ( - - - -

No workspaces yet

-

- Create a workspace to organize your projects and invite team members -

- -
-
- )} -
+ ); } diff --git a/app/(dashboard)/workspaces/workspaces-client.tsx b/app/(dashboard)/workspaces/workspaces-client.tsx new file mode 100644 index 0000000..6b3ad0e --- /dev/null +++ b/app/(dashboard)/workspaces/workspaces-client.tsx @@ -0,0 +1,150 @@ +'use client'; + +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 { useRouter } from 'next/navigation'; + +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 SerializedWorkspace { + id: string; + name: string; + description: string | null; + updatedAt: string; + _count: { + projects: number; + members: number; + }; +} + +interface WorkspacesClientProps { + workspaces: SerializedWorkspace[]; + totalPages: number; + currentPage: number; +} + +export function WorkspacesClient({ workspaces, totalPages, currentPage }: WorkspacesClientProps) { + const router = useRouter(); + + return ( +
+ {/* Header */} +
+
+

Workspaces

+

+ Manage your workspaces and their projects +

+
+ +
+ + {/* Workspaces Grid */} + {workspaces.length > 0 ? ( +
+ {workspaces.map((workspace) => ( + + + + + + {workspace.name} + + + {workspace.description || 'No description'} + + + +
+ + + {formatRelativeTime(new Date(workspace.updatedAt))} + + + + {workspace._count.projects} projects + + + + {workspace._count.members + 1} + +
+
+
+ + ))} +
+ ) : ( + + + +

No workspaces yet

+

+ Create a workspace to organize your projects and invite team members +

+ +
+
+ )} + + {/* Pagination */} + {totalPages > 1 && ( +
+ + + Page {currentPage} of {totalPages} + + +
+ )} +
+ ); +}