mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
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:
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user