Skip to content

Commit 181ec61

Browse files
authored
Merge pull request #840 from NanaKhadija1980j/feature/825-project-organisation
feat: add project-level clip organisation (#825)
2 parents 664c496 + f30b091 commit 181ec61

12 files changed

Lines changed: 663 additions & 31 deletions

File tree

app/(dashboard)/dashboard/page.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ export default function DashboardPage() {
191191
: recentProjects.map((project) => (
192192
<ProjectCard
193193
key={project.id}
194+
id={project.id}
194195
title={project.title}
195196
clipsCount={project.clipsGenerated}
196197
status={project.status}
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
"use client";
2+
3+
import React, { useState, useEffect, useCallback } from "react";
4+
import { useParams, useRouter } from "next/navigation";
5+
import Image from "next/image";
6+
import Link from "next/link";
7+
import { ArrowLeft, Pencil, Trash2, Play, Sparkles } from "lucide-react";
8+
import { useToast } from "@/hooks/useToast";
9+
import type { Clip } from "@/components/projects/ClipGrid";
10+
11+
interface ProjectDetail {
12+
id: string;
13+
name: string;
14+
thumbnailUrl: string;
15+
videoUrl: string;
16+
clipCount: number;
17+
}
18+
19+
export default function ProjectDetailPage() {
20+
const params = useParams();
21+
const router = useRouter();
22+
const projectId = params.id as string;
23+
const { showToast, ToastEl } = useToast();
24+
25+
const [project, setProject] = useState<ProjectDetail | null>(null);
26+
const [clips, setClips] = useState<Clip[]>([]);
27+
const [loading, setLoading] = useState(true);
28+
const [renaming, setRenaming] = useState(false);
29+
const [newName, setNewName] = useState("");
30+
31+
const fetchProject = useCallback(async () => {
32+
setLoading(true);
33+
try {
34+
const [projRes, clipsRes] = await Promise.all([
35+
fetch(`/api/projects/${projectId}`),
36+
fetch(`/api/projects/${projectId}/clips`),
37+
]);
38+
39+
if (!projRes.ok) throw new Error("Project not found");
40+
const projJson = await projRes.json();
41+
setProject(projJson.data);
42+
setNewName(projJson.data.name);
43+
44+
if (clipsRes.ok) {
45+
const clipsJson = await clipsRes.json();
46+
setClips(clipsJson.data?.clips ?? []);
47+
}
48+
} catch {
49+
showToast("Failed to load project", "error");
50+
} finally {
51+
setLoading(false);
52+
}
53+
}, [projectId, showToast]);
54+
55+
useEffect(() => {
56+
fetchProject();
57+
}, [fetchProject]);
58+
59+
const handleRename = async () => {
60+
if (!newName.trim()) return;
61+
try {
62+
const res = await fetch(`/api/projects/${projectId}`, {
63+
method: "PATCH",
64+
headers: { "Content-Type": "application/json" },
65+
body: JSON.stringify({ name: newName.trim() }),
66+
});
67+
if (!res.ok) throw new Error("Rename failed");
68+
setRenaming(false);
69+
showToast("Project renamed", "success");
70+
fetchProject();
71+
} catch {
72+
showToast("Failed to rename project", "error");
73+
}
74+
};
75+
76+
const handleDelete = async () => {
77+
if (!confirm("Delete this project and all its clips?")) return;
78+
try {
79+
const res = await fetch(`/api/projects/${projectId}`, { method: "DELETE" });
80+
if (!res.ok) throw new Error("Delete failed");
81+
showToast("Project deleted", "success");
82+
router.push("/projects");
83+
} catch {
84+
showToast("Failed to delete project", "error");
85+
}
86+
};
87+
88+
if (loading) {
89+
return (
90+
<div className="flex items-center justify-center min-h-[400px]">
91+
<div className="w-8 h-8 border-4 border-brand border-t-transparent rounded-full animate-spin" />
92+
</div>
93+
);
94+
}
95+
96+
if (!project) {
97+
return (
98+
<div className="text-center py-20">
99+
<p className="text-muted-foreground">Project not found</p>
100+
<Link href="/projects" className="text-brand hover:underline mt-4 inline-block">
101+
Back to Projects
102+
</Link>
103+
</div>
104+
);
105+
}
106+
107+
return (
108+
<div className="space-y-8 max-w-[1400px] mx-auto w-full">
109+
{ToastEl}
110+
111+
<div className="flex items-center gap-4">
112+
<Link
113+
href="/projects"
114+
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-white/70 hover:text-white transition-colors"
115+
>
116+
<ArrowLeft className="w-5 h-5" />
117+
</Link>
118+
<div className="flex-1">
119+
{renaming ? (
120+
<div className="flex items-center gap-2">
121+
<input
122+
value={newName}
123+
onChange={(e) => setNewName(e.target.value)}
124+
className="bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-white text-xl font-bold"
125+
autoFocus
126+
/>
127+
<button onClick={handleRename} className="px-4 py-2 bg-brand text-black rounded-xl text-sm font-bold">
128+
Save
129+
</button>
130+
<button onClick={() => setRenaming(false)} className="px-4 py-2 bg-white/5 text-white rounded-xl text-sm">
131+
Cancel
132+
</button>
133+
</div>
134+
) : (
135+
<div className="flex items-center gap-3">
136+
<h1 className="text-2xl font-extrabold text-white">{project.name}</h1>
137+
<button
138+
onClick={() => setRenaming(true)}
139+
className="p-2 rounded-lg hover:bg-white/10 text-white/50 hover:text-white"
140+
aria-label="Rename project"
141+
>
142+
<Pencil className="w-4 h-4" />
143+
</button>
144+
<button
145+
onClick={handleDelete}
146+
className="p-2 rounded-lg hover:bg-red-500/10 text-white/50 hover:text-red-400"
147+
aria-label="Delete project"
148+
>
149+
<Trash2 className="w-4 h-4" />
150+
</button>
151+
</div>
152+
)}
153+
<p className="text-muted-foreground text-sm mt-1">{clips.length} clips</p>
154+
</div>
155+
</div>
156+
157+
<div className="relative aspect-video max-w-2xl rounded-2xl overflow-hidden bg-black">
158+
<Image src={project.thumbnailUrl} alt={project.name} fill className="object-cover opacity-60" />
159+
<div className="absolute inset-0 flex items-center justify-center">
160+
<a
161+
href={project.videoUrl}
162+
target="_blank"
163+
rel="noopener noreferrer"
164+
className="w-16 h-16 rounded-full bg-brand/90 flex items-center justify-center hover:scale-105 transition-transform"
165+
>
166+
<Play className="w-8 h-8 text-black ml-1" />
167+
</a>
168+
</div>
169+
</div>
170+
171+
<div>
172+
<h2 className="text-lg font-bold text-white mb-4">Project Clips</h2>
173+
{clips.length === 0 ? (
174+
<div className="text-center py-12 bg-white/5 rounded-2xl">
175+
<Sparkles className="w-8 h-8 text-muted-foreground mx-auto mb-3" />
176+
<p className="text-muted-foreground">No clips in this project yet</p>
177+
</div>
178+
) : (
179+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
180+
{clips.map((clip) => (
181+
<div key={clip.id} className="rounded-2xl overflow-hidden border border-white/10 bg-white/5">
182+
<div className="aspect-[9/16] relative">
183+
<Image src={clip.thumbnail} alt={clip.title} fill className="object-cover" />
184+
<div className="absolute top-3 left-3 bg-brand text-black px-2 py-0.5 rounded text-xs font-bold">
185+
{clip.score}
186+
</div>
187+
</div>
188+
<div className="p-3">
189+
<h4 className="text-white font-bold text-sm truncate">{clip.title}</h4>
190+
<p className="text-xs text-muted-foreground">{clip.duration} · {clip.style}</p>
191+
</div>
192+
</div>
193+
))}
194+
</div>
195+
)}
196+
</div>
197+
</div>
198+
);
199+
}

app/api/clips/clipsStore.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export interface ScoreBreakdown {
1010
export interface Clip {
1111
id: string;
1212
userId: string;
13+
projectId?: string;
1314
title: string;
1415
thumbnail: string;
1516
score: number;
@@ -45,9 +46,10 @@ class ClipsStore {
4546
];
4647

4748
// Create base pool that users will pull from
48-
this.clips = mockClips.map(clip => ({
49+
this.clips = mockClips.map((clip, idx) => ({
4950
...clip,
5051
userId: "default", // will be replaced when requested
52+
projectId: `default-proj-${(idx % 3) + 1}`,
5153
createdAt: new Date().toISOString()
5254
}));
5355
}
@@ -66,7 +68,8 @@ class ClipsStore {
6668
const newClips = this.clips.filter(c => c.userId === "default").map((c, idx) => ({
6769
...c,
6870
id: `${userId}-clip-${idx}`,
69-
userId
71+
userId,
72+
projectId: `${userId}-proj-${(idx % 3) + 1}`,
7073
}));
7174
this.clips.push(...newClips);
7275
return newClips;
@@ -189,6 +192,18 @@ class ClipsStore {
189192
);
190193
return clipIds.filter(id => !owned.has(id));
191194
}
195+
196+
getClipsForProject(userId: string, projectId: string): Clip[] {
197+
return this.getClipsForUser(userId).filter((c) => c.projectId === projectId);
198+
}
199+
200+
/** Cascade soft-delete all clips belonging to a project. */
201+
softDeleteClipsByProject(userId: string, projectId: string): number {
202+
const clipIds = this.clips
203+
.filter((c) => c.userId === userId && c.projectId === projectId && !c.deletedAt)
204+
.map((c) => c.id);
205+
return this.softDeleteClips(userId, clipIds);
206+
}
192207
}
193208

194209
export const clipsStore = new ClipsStore();

app/api/dashboard/route.ts

Lines changed: 26 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { auth } from "@/app/lib/auth";
33
import { requireAuth } from "@/app/api/jobs/shared/authGuard";
44
import { applyRateLimit } from "@/app/lib/serverRateLimit";
55
import { earningsStore } from "@/app/api/earnings/earningsStore";
6-
import { jobStore } from "@/app/api/jobs/shared/jobStore";
6+
import { projectsStore } from "@/app/api/projects/projectsStore";
7+
import { clipsStore } from "@/app/api/clips/clipsStore";
78
import type { ApiResponse } from "../types";
89
import type {
910
DashboardStats,
@@ -91,26 +92,28 @@ export async function GET(request: NextRequest) {
9192
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
9293

9394
// 2. Calculate Clips stats & Recent Projects
94-
const userJobs = await jobStore.getUserJobs(userId);
95-
const totalClipsNum = userJobs.reduce(
96-
(acc, j) => acc + (j.momentsFound || (j.status === "complete" ? 1 : 0)),
97-
0
95+
const userProjects = projectsStore.getProjectsForUser(userId);
96+
clipsStore.getClipsForUser(userId);
97+
const totalClipsNum = userProjects.reduce(
98+
(acc, p) => acc + clipsStore.getClipsForProject(userId, p.id).length,
99+
0,
98100
);
99101

100-
const currentPeriodJobs = userJobs.filter(
101-
(j) => j.createdAt >= now.getTime() - thirtyDaysMs
102-
);
103-
const priorPeriodJobs = userJobs.filter(
104-
(j) => j.createdAt >= now.getTime() - 2 * thirtyDaysMs && j.createdAt < now.getTime() - thirtyDaysMs
102+
const currentPeriodProjects = userProjects.filter(
103+
(p) => new Date(p.createdAt).getTime() >= now.getTime() - thirtyDaysMs,
105104
);
105+
const priorPeriodProjects = userProjects.filter((p) => {
106+
const t = new Date(p.createdAt).getTime();
107+
return t >= now.getTime() - 2 * thirtyDaysMs && t < now.getTime() - thirtyDaysMs;
108+
});
106109

107-
const currentClipsSum = currentPeriodJobs.reduce(
108-
(acc, j) => acc + (j.momentsFound || 1),
109-
0
110+
const currentClipsSum = currentPeriodProjects.reduce(
111+
(acc, p) => acc + clipsStore.getClipsForProject(userId, p.id).length,
112+
0,
110113
);
111-
const priorClipsSum = priorPeriodJobs.reduce(
112-
(acc, j) => acc + (j.momentsFound || 1),
113-
0
114+
const priorClipsSum = priorPeriodProjects.reduce(
115+
(acc, p) => acc + clipsStore.getClipsForProject(userId, p.id).length,
116+
0,
114117
);
115118

116119
let clipsTrend = 0;
@@ -135,19 +138,14 @@ export async function GET(request: NextRequest) {
135138
trendLabel: clipsTrendLabel,
136139
};
137140

138-
const sortedJobs = [...userJobs].sort((a, b) => b.createdAt - a.createdAt);
139-
const recentProjects: Project[] = sortedJobs.slice(0, 6).map((job) => {
140-
const filename = (job as { filename?: string }).filename;
141-
const title = filename
142-
? filename.replace(/\.[^/.]+$/, "")
143-
: `Project ${job.id.slice(0, 6)}`;
144-
141+
const recentProjects: Project[] = userProjects.slice(0, 6).map((project) => {
142+
const clipCount = clipsStore.getClipsForProject(userId, project.id).length;
145143
return {
146-
id: job.id,
147-
title,
148-
clipsGenerated: job.momentsFound || 0,
149-
status: job.status === "complete" ? "completed" : "processing",
150-
image: "/projects/thumb1.png",
144+
id: project.id,
145+
title: project.name,
146+
clipsGenerated: clipCount,
147+
status: clipCount > 0 ? "completed" : "processing",
148+
image: project.thumbnailUrl,
151149
accent: "",
152150
};
153151
});
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { requireAuth } from "@/app/api/jobs/shared/authGuard";
3+
import { projectsStore } from "@/app/api/projects/projectsStore";
4+
import { clipsStore } from "@/app/api/clips/clipsStore";
5+
import type { ApiResponse } from "@/app/api/types";
6+
7+
/**
8+
* GET /api/projects/:id/clips — list clips for a specific project.
9+
*/
10+
export async function GET(
11+
request: NextRequest,
12+
context: { params: Promise<{ id: string }> },
13+
) {
14+
const authResult = await requireAuth();
15+
if (authResult instanceof NextResponse) return authResult;
16+
const { userId } = authResult;
17+
18+
const { id: projectId } = await context.params;
19+
const project = projectsStore.getProjectById(userId, projectId);
20+
21+
if (!project) {
22+
return NextResponse.json({ error: "Project not found" }, { status: 404 });
23+
}
24+
25+
const { searchParams } = new URL(request.url);
26+
const page = Math.max(1, parseInt(searchParams.get("page") ?? "1", 10));
27+
const pageSize = Math.min(50, Math.max(1, parseInt(searchParams.get("pageSize") ?? "20", 10)));
28+
29+
const allClips = clipsStore.getClipsForProject(userId, projectId);
30+
const total = allClips.length;
31+
const start = (page - 1) * pageSize;
32+
const clips = allClips.slice(start, start + pageSize);
33+
34+
const body: ApiResponse<{
35+
project: { id: string; name: string; thumbnailUrl: string };
36+
clips: typeof clips;
37+
total: number;
38+
page: number;
39+
pageSize: number;
40+
}> = {
41+
data: {
42+
project: {
43+
id: project.id,
44+
name: project.name,
45+
thumbnailUrl: project.thumbnailUrl,
46+
},
47+
clips,
48+
total,
49+
page,
50+
pageSize,
51+
},
52+
error: null,
53+
};
54+
55+
return NextResponse.json(body);
56+
}

0 commit comments

Comments
 (0)