feat: add date sorting to projects and videos

Add toggle controls to sort projects and videos by update date in both
the dashboard project list and individual project video grids. Extract
ProjectContent to a dedicated client component to enable client-side
sorting state management.
This commit is contained in:
Yusuf İpek
2026-02-13 10:40:55 +03:00
parent a414953714
commit 2888f7de98
3 changed files with 260 additions and 147 deletions
+38 -135
View File
@@ -2,23 +2,14 @@ import Link from 'next/link';
import { notFound, redirect } from 'next/navigation';
import {
ArrowLeft,
Plus,
Settings,
Share2,
Play,
Globe,
Lock,
UserPlus,
Users,
Building2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { VideoCard } from '@/components/video-card';
import { GuestGate } from '@/components/guest-gate';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { ProjectContentClient } from './project-content-client';
function VisibilityIcon({ visibility }: { visibility: string }) {
switch (visibility) {
@@ -137,59 +128,49 @@ export default async function ProjectPage({ params }: ProjectPageProps) {
commentCount: activeVersion?._count.comments || 0,
duration: formatDuration(activeVersion?.duration),
lastUpdated: formatRelativeTime(video.updatedAt),
updatedAt: video.updatedAt.toISOString(),
};
});
const canEdit = isOwner || project.members[0]?.role === 'ADMIN' || workspaceRole === 'OWNER' || workspaceRole === 'ADMIN';
const isAuthenticated = !!session?.user?.id;
const projectData = {
name: project.name,
description: project.description,
visibility: project.visibility,
workspace: project.workspace,
members: project.members,
};
// Guest name gate for unauthenticated users on public projects
if (!isAuthenticated && isPublic) {
return (
<GuestGate>
<ProjectContent
project={project}
projectId={projectId}
videos={videos}
canEdit={false}
isOwner={false}
isPublic={isPublic}
workspaceRole={null}
/>
<div className="px-6 lg:px-8 py-8 w-full">
{/* Back link */}
<div className="mb-6">
<Link
href="/dashboard"
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 Projects
</Link>
</div>
<ProjectContentClient
project={projectData}
projectId={projectId}
videos={videos}
canEdit={false}
isOwner={false}
workspaceRole={null}
/>
</div>
</GuestGate>
);
}
return (
<ProjectContent
project={project}
projectId={projectId}
videos={videos}
canEdit={canEdit}
isOwner={isOwner}
isPublic={isPublic}
workspaceRole={workspaceRole}
/>
);
}
function ProjectContent({
project,
projectId,
videos,
canEdit,
isOwner,
isPublic,
workspaceRole,
}: {
project: { name: string; description: string | null; visibility: string; workspace: { id: string; name: string } | null; members: { role: string }[] };
projectId: string;
videos: { id: string; title: string; thumbnailUrl: string; currentVersion: number; commentCount: number; duration: string; lastUpdated: string }[];
canEdit: boolean;
isOwner: boolean;
isPublic: boolean;
workspaceRole: string | null;
}) {
return (
<div className="px-6 lg:px-8 py-8 w-full">
{/* Back link */}
@@ -202,92 +183,14 @@ function ProjectContent({
Back to Projects
</Link>
</div>
{/* Project Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8">
<div>
<div className="flex items-center gap-2 mb-1">
<h1 className="text-3xl font-bold tracking-tight">{project.name}</h1>
<Badge variant="outline" className="flex items-center gap-1">
<VisibilityIcon visibility={project.visibility} />
{project.visibility.toLowerCase()}
</Badge>
</div>
<div className="flex items-center gap-2">
{project.workspace && (
<Link href={`/workspaces/${project.workspace.id}`}>
<Badge variant="secondary" className="flex items-center gap-1 hover:bg-accent transition-colors">
<Building2 className="h-3 w-3" />
{project.workspace.name}
</Badge>
</Link>
)}
{project.description && (
<span className="text-muted-foreground">{project.description}</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" asChild>
<Link href={`/projects/${projectId}/share`}>
<Share2 className="h-4 w-4 mr-2" />
Share
</Link>
</Button>
{(isOwner || project.members[0]?.role === 'ADMIN') && (
<>
<Button variant="outline" size="sm" asChild>
<Link href={`/projects/${projectId}/members`}>
<Users className="h-4 w-4 mr-2" />
Members
</Link>
</Button>
<Button variant="outline" size="sm" asChild>
<Link href={`/projects/${projectId}/settings`}>
<Settings className="h-4 w-4 mr-2" />
Settings
</Link>
</Button>
</>
)}
{canEdit && (
<Button size="sm" asChild>
<Link href={`/projects/${projectId}/videos/new`}>
<Plus className="h-4 w-4 mr-2" />
Add Video
</Link>
</Button>
)}
</div>
</div>
{/* Videos Grid */}
{videos.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{videos.map((video) => (
<VideoCard key={video.id} video={video} projectId={projectId} />
))}
</div>
) : (
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-16">
<Play className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No videos yet</h3>
<p className="text-muted-foreground text-center mb-4">
Add your first video to start collecting feedback
</p>
{canEdit && (
<Button asChild>
<Link href={`/projects/${projectId}/videos/new`}>
<Plus className="h-4 w-4 mr-2" />
Add Video
</Link>
</Button>
)}
</CardContent>
</Card>
)}
<ProjectContentClient
project={projectData}
projectId={projectId}
videos={videos}
canEdit={canEdit}
isOwner={isOwner}
workspaceRole={workspaceRole}
/>
</div>
);
}
@@ -0,0 +1,176 @@
'use client';
import { useState, useMemo } from 'react';
import Link from 'next/link';
import {
Plus,
Settings,
Share2,
Play,
Users,
Building2,
ArrowUp,
ArrowDown,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { VideoCard } from '@/components/video-card';
type SortOrder = 'desc' | 'asc';
interface SerializedVideo {
id: string;
title: string;
thumbnailUrl: string;
currentVersion: number;
commentCount: number;
duration: string;
lastUpdated: string;
updatedAt: string;
}
interface ProjectContentClientProps {
project: {
name: string;
description: string | null;
visibility: string;
workspace: { id: string; name: string } | null;
members: { role: string }[];
};
projectId: string;
videos: SerializedVideo[];
canEdit: boolean;
isOwner: boolean;
workspaceRole: string | null;
}
export function ProjectContentClient({
project,
projectId,
videos,
canEdit,
isOwner,
workspaceRole,
}: ProjectContentClientProps) {
const [sortOrder, setSortOrder] = useState<SortOrder>('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]);
return (
<>
{/* Project Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8">
<div>
<div className="flex items-center gap-2 mb-1">
<h1 className="text-3xl font-bold tracking-tight">{project.name}</h1>
<Badge variant="outline" className="flex items-center gap-1">
{project.visibility === 'PUBLIC' && <span className="text-xs">🌐</span>}
{project.visibility === 'INVITE' && <span className="text-xs">📧</span>}
{project.visibility === 'PRIVATE' && <span className="text-xs">🔒</span>}
{project.visibility.toLowerCase()}
</Badge>
</div>
<div className="flex items-center gap-2">
{project.workspace && (
<Link href={`/workspaces/${project.workspace.id}`}>
<Badge variant="secondary" className="flex items-center gap-1 hover:bg-accent transition-colors">
<Building2 className="h-3 w-3" />
{project.workspace.name}
</Badge>
</Link>
)}
{project.description && (
<span className="text-muted-foreground">{project.description}</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
{/* Sort Button - Left of Share */}
<Button
variant="outline"
size="sm"
onClick={() => setSortOrder(sortOrder === 'desc' ? 'asc' : 'desc')}
className="flex items-center gap-2"
>
{sortOrder === 'desc' ? (
<>
<ArrowDown className="h-4 w-4" />
Newest first
</>
) : (
<>
<ArrowUp className="h-4 w-4" />
Oldest first
</>
)}
</Button>
<Button variant="outline" size="sm" asChild>
<Link href={`/projects/${projectId}/share`}>
<Share2 className="h-4 w-4 mr-2" />
Share
</Link>
</Button>
{(isOwner || project.members[0]?.role === 'ADMIN') && (
<>
<Button variant="outline" size="sm" asChild>
<Link href={`/projects/${projectId}/members`}>
<Users className="h-4 w-4 mr-2" />
Members
</Link>
</Button>
<Button variant="outline" size="sm" asChild>
<Link href={`/projects/${projectId}/settings`}>
<Settings className="h-4 w-4 mr-2" />
Settings
</Link>
</Button>
</>
)}
{canEdit && (
<Button size="sm" asChild>
<Link href={`/projects/${projectId}/videos/new`}>
<Plus className="h-4 w-4 mr-2" />
Add Video
</Link>
</Button>
)}
</div>
</div>
{/* Videos Grid */}
{videos.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{sortedVideos.map((video) => (
<VideoCard key={video.id} video={video} projectId={projectId} />
))}
</div>
) : (
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-16">
<Play className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No videos yet</h3>
<p className="text-muted-foreground text-center mb-4">
Add your first video to start collecting feedback
</p>
{canEdit && (
<Button asChild>
<Link href={`/projects/${projectId}/videos/new`}>
<Plus className="h-4 w-4 mr-2" />
Add Video
</Link>
</Button>
)}
</CardContent>
</Card>
)}
</>
);
}