Initial commit

This commit is contained in:
twotalesanimation
2026-05-19 22:20:29 +02:00
commit 0fbe856dce
173 changed files with 38316 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
+104
View File
@@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { ApprovalStatus } from "@prisma/client";
import { recalcShotStatus } from "@/lib/shot-status";
async function getOrCreateClientUser(email: string, label?: string | null) {
const existing = await db.user.findUnique({ where: { email } });
if (existing) return existing;
return db.user.create({
data: {
email,
name: label ?? email.split("@")[0],
role: "CLIENT",
isActive: true,
},
});
}
async function validateToken(token: string) {
const session = await db.reviewSession.findUnique({ where: { token } });
if (!session || !session.isActive) return null;
if (session.expiresAt && session.expiresAt < new Date()) return null;
return session;
}
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ token: string }> }
) {
const { token } = await params;
const session = await validateToken(token);
if (!session) {
return NextResponse.json({ error: "Invalid or expired review link" }, { status: 403 });
}
const body = await req.json();
const { versionId, status, notes } = body;
const validStatuses: ApprovalStatus[] = ["APPROVED", "REJECTED", "NEEDS_CHANGES"];
if (!versionId || !validStatuses.includes(status)) {
return NextResponse.json({ error: "versionId and valid status required" }, { status: 400 });
}
// Ensure the version belongs to this project via its task
const version = await db.version.findUnique({
where: { id: versionId },
include: {
task: {
include: { shot: true, project: true },
},
},
});
const projectId = version?.task?.projectId;
if (!version || projectId !== session.projectId) {
return NextResponse.json({ error: "Version not found" }, { status: 404 });
}
const email = session.email ?? `client+${token.slice(0, 8)}@review.external`;
const user = await getOrCreateClientUser(email, session.label);
// Record approval
await db.approval.create({
data: { versionId, userId: user.id, status, notes },
});
// Update version approval status
await db.version.update({
where: { id: versionId },
data: { approvalStatus: status },
});
// Update task status based on approval decision
if (version.task) {
if (status === "APPROVED") {
await db.task.update({ where: { id: version.task.id }, data: { status: "DONE" } });
} else {
await db.task.update({ where: { id: version.task.id }, data: { status: "CHANGES" } });
}
// Recalculate derived shot status
if (version.task.shot) {
await recalcShotStatus(version.task.shot.id).catch(() => {});
}
}
// Slack notification
if (version.task?.project?.slackWebhook) {
const { slackNotifyApproval } = await import("@/lib/slack");
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "";
const contextCode = version.task.shot?.shotCode ?? version.task.title;
await slackNotifyApproval(version.task.project.slackWebhook, {
shotCode: contextCode,
versionLabel: `v${String(version.versionNumber).padStart(3, "0")}`,
reviewerName: user.name ?? "Client",
status,
projectName: version.task.project.name,
reviewUrl: `${appUrl}/client/${token}/review/${versionId}`,
});
}
return NextResponse.json({ success: true });
}
+91
View File
@@ -0,0 +1,91 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { slackNotifyNewFeedback } from "@/lib/slack";
/** Find or create a guest user for the client reviewer based on the session email */
async function getOrCreateClientUser(email: string, label?: string | null) {
const existing = await db.user.findUnique({ where: { email } });
if (existing) return existing;
return db.user.create({
data: {
email,
name: label ?? email.split("@")[0],
role: "CLIENT",
isActive: true,
},
});
}
async function validateToken(token: string) {
const session = await db.reviewSession.findUnique({ where: { token } });
if (!session || !session.isActive) return null;
if (session.expiresAt && session.expiresAt < new Date()) return null;
return session;
}
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ token: string }> }
) {
const { token } = await params;
const session = await validateToken(token);
if (!session) {
return NextResponse.json({ error: "Invalid or expired review link" }, { status: 403 });
}
const body = await req.json();
const { versionId, frameNumber, timestamp, text } = body;
if (!versionId || frameNumber == null || timestamp == null || !text?.trim()) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
// Ensure the version belongs to this project
const version = await db.version.findUnique({
where: { id: versionId },
include: {
shot: { select: { projectId: true, shotCode: true, project: { select: { slackWebhook: true } } } },
task: { select: { projectId: true, title: true, project: { select: { slackWebhook: true } }, shot: { select: { shotCode: true } } } },
},
});
const projectId = version?.shot?.projectId ?? version?.task?.projectId;
if (!version || projectId !== session.projectId) {
return NextResponse.json({ error: "Version not found" }, { status: 404 });
}
// Resolve commenter identity
const email = session.email ?? `client+${token.slice(0, 8)}@review.external`;
const user = await getOrCreateClientUser(email, session.label);
const comment = await db.comment.create({
data: {
versionId,
authorId: user.id,
frameNumber,
timestamp,
text: text.trim(),
},
include: {
author: { select: { id: true, name: true, image: true, email: true } },
replies: true,
},
});
// Slack notification
const slackWebhook =
version.shot?.project?.slackWebhook ?? version.task?.project?.slackWebhook ?? null;
const shotCode =
version.shot?.shotCode ?? version.task?.shot?.shotCode ?? version.task?.title ?? "Task";
if (slackWebhook) {
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
await slackNotifyNewFeedback(slackWebhook, {
shotCode,
frameNumber,
authorName: user.name ?? user.email,
commentText: text.trim(),
reviewUrl: `${appUrl}/client/${token}/review/${versionId}`,
});
}
return NextResponse.json({ comment }, { status: 201 });
}
+120
View File
@@ -0,0 +1,120 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
async function validateToken(token: string) {
const session = await db.reviewSession.findUnique({ where: { token } });
if (!session || !session.isActive) return null;
if (session.expiresAt && session.expiresAt < new Date()) return null;
return session;
}
/** GET /api/client/[token]/project — returns project + shots with tasks that have client-visible versions */
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ token: string }> }
) {
const { token } = await params;
const session = await validateToken(token);
if (!session) {
return NextResponse.json({ error: "Invalid or expired review link" }, { status: 403 });
}
const project = await db.project.findUnique({
where: { id: session.projectId },
select: { id: true, name: true, code: true, description: true, status: true },
});
if (!project) {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
// Find shots that have at least one task with a client-visible version
const shots = await db.shot.findMany({
where: {
projectId: session.projectId,
tasks: {
some: {
versions: { some: { isClientVisible: true } },
},
},
},
orderBy: [{ sequence: "asc" }, { shotCode: "asc" }],
select: {
id: true,
shotCode: true,
sequence: true,
description: true,
status: true,
thumbnailUrl: true,
tasks: {
where: {
versions: { some: { isClientVisible: true } },
},
select: {
id: true,
title: true,
type: true,
status: true,
versions: {
where: { isClientVisible: true, isLatest: true },
take: 1,
select: {
id: true,
versionNumber: true,
approvalStatus: true,
fps: true,
duration: true,
thumbnailUrl: true,
notes: true,
createdAt: true,
},
},
},
},
},
});
// Asset tasks with client-visible versions (no shotId)
const assetTasks = await db.task.findMany({
where: {
projectId: session.projectId,
shotId: null,
versions: { some: { isClientVisible: true } },
},
select: {
id: true,
title: true,
type: true,
status: true,
asset: { select: { id: true, assetCode: true, name: true } },
versions: {
where: { isClientVisible: true, isLatest: true },
take: 1,
select: {
id: true,
versionNumber: true,
approvalStatus: true,
fps: true,
duration: true,
thumbnailUrl: true,
notes: true,
createdAt: true,
},
},
},
});
// Increment access count
await db.reviewSession.update({
where: { id: session.id },
data: { accessCount: { increment: 1 } },
});
return NextResponse.json({
project,
shots,
assetTasks,
sessionLabel: session.label,
});
}
@@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
async function validateToken(token: string) {
const session = await db.reviewSession.findUnique({ where: { token } });
if (!session || !session.isActive) return null;
if (session.expiresAt && session.expiresAt < new Date()) return null;
return session;
}
/** GET /api/client/[token]/versions/[versionId] — returns version + comments for client portal */
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ token: string; versionId: string }> }
) {
const { token, versionId } = await params;
const session = await validateToken(token);
if (!session) {
return NextResponse.json({ error: "Invalid or expired review link" }, { status: 403 });
}
const version = await db.version.findUnique({
where: { id: versionId },
include: {
shot: {
include: {
project: { select: { id: true, name: true, code: true } },
versions: {
orderBy: { versionNumber: "desc" },
select: {
id: true,
versionNumber: true,
approvalStatus: true,
isLatest: true,
fps: true,
duration: true,
createdAt: true,
},
},
},
},
task: {
include: {
project: { select: { id: true, name: true, code: true } },
shot: { select: { shotCode: true } },
asset: { select: { assetCode: true, name: true } },
},
},
artist: { select: { id: true, name: true, image: true, email: true } },
approvals: {
orderBy: { createdAt: "desc" },
include: { user: { select: { id: true, name: true } } },
},
},
});
// Resolve project: from shot or from task
const projectId = version?.shot?.projectId ?? version?.task?.projectId;
if (!version || projectId !== session.projectId) {
return NextResponse.json({ error: "Version not found" }, { status: 404 });
}
// For task-only versions (no shot), require explicit client sharing
if (!version.shot && !version.isClientVisible) {
return NextResponse.json({ error: "Version not found" }, { status: 404 });
}
const comments = await db.comment.findMany({
where: { versionId },
orderBy: { frameNumber: "asc" },
include: {
author: { select: { id: true, name: true, image: true, email: true } },
replies: {
orderBy: { createdAt: "asc" },
include: {
author: { select: { id: true, name: true, image: true, email: true } },
},
},
},
});
const serializedVersion = {
...version,
fileSize: version.fileSize?.toString() ?? null,
};
return NextResponse.json({ version: serializedVersion, comments });
}
+57
View File
@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ clientId: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { clientId } = await params;
const client = await db.client.findUnique({
where: { id: clientId },
include: {
projects: {
orderBy: { createdAt: "desc" },
include: {
_count: { select: { shots: true } },
},
},
},
});
if (!client) {
return NextResponse.json({ error: "Client not found" }, { status: 404 });
}
return NextResponse.json({ client });
}
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ clientId: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!["ADMIN", "PRODUCER"].includes(session.user.role as string)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { clientId } = await params;
const body = await req.json();
const { company, contactPerson, email, phone, notes, isActive } = body;
const client = await db.client.update({
where: { id: clientId },
data: { company, contactPerson, email, phone, notes, isActive },
});
return NextResponse.json({ client });
}
+47
View File
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
export async function GET(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const clients = await db.client.findMany({
select: {
id: true,
company: true,
contactPerson: true,
email: true,
isActive: true,
_count: { select: { projects: true } },
},
orderBy: { company: "asc" },
});
return NextResponse.json({ clients });
}
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!["ADMIN", "PRODUCER"].includes(session.user.role as string)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await req.json();
const { company, contactPerson, email, phone, notes } = body;
if (!company || !contactPerson || !email) {
return NextResponse.json({ error: "company, contactPerson and email are required" }, { status: 400 });
}
const client = await db.client.create({
data: { company, contactPerson, email, phone, notes },
});
return NextResponse.json({ client }, { status: 201 });
}
+67
View File
@@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ key: string[] }> }
) {
const { key } = await params;
// key is a catch-all segment array, e.g. ["videos", "uuid-filename.mp4"]
const relativePath = key.join("/");
// Sanitize: prevent path traversal
const uploadDir = path.resolve(process.env.LOCAL_UPLOAD_DIR ?? "./uploads");
const filePath = path.resolve(path.join(uploadDir, relativePath));
if (!filePath.startsWith(uploadDir)) {
return new NextResponse("Forbidden", { status: 403 });
}
if (!fs.existsSync(filePath)) {
return new NextResponse("Not found", { status: 404 });
}
const stat = fs.statSync(filePath);
const ext = path.extname(filePath).toLowerCase();
const mimeMap: Record<string, string> = {
".mp4": "video/mp4",
".mov": "video/quicktime",
".avi": "video/x-msvideo",
".mxf": "application/mxf",
".webm": "video/webm",
};
const contentType = mimeMap[ext] ?? "application/octet-stream";
// Support range requests so the HTML5 video player can seek
const rangeHeader = req.headers.get("range");
if (rangeHeader) {
const [startStr, endStr] = rangeHeader.replace("bytes=", "").split("-");
const start = parseInt(startStr, 10);
const end = endStr ? parseInt(endStr, 10) : stat.size - 1;
const chunkSize = end - start + 1;
const stream = fs.createReadStream(filePath, { start, end });
const nodeStream = stream as unknown as ReadableStream;
return new NextResponse(nodeStream, {
status: 206,
headers: {
"Content-Range": `bytes ${start}-${end}/${stat.size}`,
"Accept-Ranges": "bytes",
"Content-Length": String(chunkSize),
"Content-Type": contentType,
},
});
}
const stream = fs.createReadStream(filePath) as unknown as ReadableStream;
return new NextResponse(stream, {
headers: {
"Content-Length": String(stat.size),
"Content-Type": contentType,
"Accept-Ranges": "bytes",
},
});
}
+36
View File
@@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
export async function GET() {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const notifications = await db.notification.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: "desc" },
take: 50,
});
const unreadCount = await db.notification.count({
where: { userId: session.user.id, isRead: false },
});
return NextResponse.json({ notifications, unreadCount });
}
export async function PATCH() {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
await db.notification.updateMany({
where: { userId: session.user.id, isRead: false },
data: { isRead: true },
});
return NextResponse.json({ success: true });
}
+38
View File
@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
export async function GET(req: Request) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const q = searchParams.get("q") ?? "";
const status = searchParams.get("status");
const projects = await db.project.findMany({
where: {
AND: [
q
? {
OR: [
{ name: { contains: q, mode: "insensitive" } },
{ code: { contains: q, mode: "insensitive" } },
],
}
: {},
status ? { status: status as any } : {},
],
},
orderBy: { createdAt: "desc" },
include: {
client: { select: { id: true, company: true } },
producer: { select: { id: true, name: true, image: true } },
_count: { select: { shots: true } },
},
});
return NextResponse.json({ projects });
}
+78
View File
@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { addDays } from "date-fns";
export async function GET(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const projectId = req.nextUrl.searchParams.get("projectId");
const sessions = await db.reviewSession.findMany({
where: projectId ? { projectId } : undefined,
orderBy: { createdAt: "desc" },
include: {
project: { select: { id: true, name: true, code: true } },
},
});
return NextResponse.json({ sessions });
}
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role as string)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await req.json();
const { projectId, label, email, expiresInDays = 30 } = body;
if (!projectId) {
return NextResponse.json({ error: "projectId is required" }, { status: 400 });
}
const project = await db.project.findUnique({ where: { id: projectId } });
if (!project) {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
const reviewSession = await db.reviewSession.create({
data: {
projectId,
label: label || `Review — ${project.name}`,
email: email || null,
expiresAt: addDays(new Date(), expiresInDays),
},
});
const appUrl =
process.env.NEXT_PUBLIC_APP_URL ||
`${req.headers.get("x-forwarded-proto") ?? "https"}://${req.headers.get("host")}`;
const portalUrl = `${appUrl}/client/${reviewSession.token}`;
return NextResponse.json({ session: reviewSession, portalUrl }, { status: 201 });
}
export async function DELETE(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const id = req.nextUrl.searchParams.get("id");
if (!id) return NextResponse.json({ error: "id required" }, { status: 400 });
await db.reviewSession.update({
where: { id },
data: { isActive: false },
});
return NextResponse.json({ success: true });
}
+84
View File
@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ shotId: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { shotId } = await params;
const shot = await db.shot.findUnique({
where: { id: shotId },
include: {
artist: { select: { id: true, name: true, email: true, image: true } },
versions: {
orderBy: { versionNumber: "desc" },
include: {
artist: { select: { id: true, name: true, email: true, image: true } },
_count: { select: { comments: true } },
approvals: {
orderBy: { createdAt: "desc" },
include: { user: { select: { id: true, name: true, image: true } } },
},
},
},
},
});
if (!shot) {
return NextResponse.json({ error: "Shot not found" }, { status: 404 });
}
const project = await db.project.findUnique({
where: { id: shot.projectId },
select: { name: true },
});
const [tasks, artists] = await Promise.all([
db.task.findMany({
where: { shotId },
orderBy: { sortOrder: "asc" },
include: {
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
_count: { select: { versions: true } },
versions: {
take: 1,
orderBy: { versionNumber: "desc" },
select: { id: true, versionNumber: true, approvalStatus: true, createdAt: true },
},
},
}),
db.user.findMany({
where: { isActive: true },
select: { id: true, name: true, email: true },
orderBy: { name: "asc" },
}),
]);
const canApprove = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(
session.user.role as string
);
// Serialize BigInt fields (fileSize) so JSON.stringify doesn't throw
const shotSerialized = {
...shot,
versions: shot.versions.map((v) => ({
...v,
fileSize: v.fileSize != null ? v.fileSize.toString() : null,
})),
};
return NextResponse.json({
shot: shotSerialized,
projectName: project?.name ?? "",
canApprove,
tasks,
artists,
});
}
+63
View File
@@ -0,0 +1,63 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
export async function GET(
_req: Request,
{ params }: { params: Promise<{ taskId: string }> }
) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { taskId } = await params;
const task = await db.task.findUnique({
where: { id: taskId },
include: {
shot: { select: { id: true, shotCode: true } },
asset: { select: { id: true, assetCode: true, name: true } },
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
createdBy: { select: { id: true, name: true, email: true } },
project: { select: { id: true, name: true, code: true } },
_count: { select: { versions: true } },
},
});
if (!task) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(task);
}
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ taskId: string }> }
) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { taskId } = await params;
const body = await req.json();
const task = await db.task.update({
where: { id: taskId },
data: body,
});
return NextResponse.json(task);
}
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ taskId: string }> }
) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { taskId } = await params;
await db.task.delete({ where: { id: taskId } });
return NextResponse.json({ success: true });
}
+28
View File
@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
export async function GET(request: Request) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { searchParams } = new URL(request.url);
const projectId = searchParams.get("projectId");
const shotId = searchParams.get("shotId");
const assetId = searchParams.get("assetId");
const tasks = await db.task.findMany({
where: {
...(projectId && { projectId }),
...(shotId && { shotId }),
...(assetId && { assetId }),
},
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
include: {
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
_count: { select: { versions: true } },
},
});
return NextResponse.json(tasks);
}
+40
View File
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { uploadFile } from "@/lib/storage";
export const config = { api: { bodyParser: false } };
// Max 2 GB
export const maxDuration = 60;
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const formData = await req.formData();
const file = formData.get("file") as File | null;
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
if (!file.type.match(/video\//)) {
return NextResponse.json({ error: "Only video files are accepted" }, { status: 400 });
}
if (file.size > 2 * 1024 * 1024 * 1024) {
return NextResponse.json({ error: "File too large (max 2 GB)" }, { status: 413 });
}
const buffer = Buffer.from(await file.arrayBuffer());
const result = await uploadFile(buffer, file.name, file.type, "videos");
return NextResponse.json({ url: result.url, key: result.key });
} catch (err) {
console.error("[local-upload]", err);
return NextResponse.json({ error: "Upload failed" }, { status: 500 });
}
}
+51
View File
@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { uploadFile } from "@/lib/storage";
export const config = { api: { bodyParser: false } };
export const maxDuration = 60;
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const formData = await req.formData();
const file = formData.get("file") as File | null;
const type = (formData.get("type") as string) || "videos";
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
// Validate file type based on upload type
if (type === "image" && !file.type.match(/image\//)) {
return NextResponse.json({ error: "Only image files are accepted" }, { status: 400 });
}
if (type === "video" && !file.type.match(/video\//)) {
return NextResponse.json({ error: "Only video files are accepted" }, { status: 400 });
}
// Size limit: 500MB for images, 2GB for videos
const maxSize = type === "image" ? 500 * 1024 * 1024 : 2 * 1024 * 1024 * 1024;
if (file.size > maxSize) {
const maxSizeStr = type === "image" ? "500 MB" : "2 GB";
return NextResponse.json(
{ error: `File too large (max ${maxSizeStr})` },
{ status: 413 }
);
}
const buffer = Buffer.from(await file.arrayBuffer());
const result = await uploadFile(buffer, file.name, file.type, type);
return NextResponse.json({ url: result.url, key: result.key });
} catch (err) {
console.error("[upload]", err);
return NextResponse.json({ error: "Upload failed" }, { status: 500 });
}
}
+32
View File
@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const disabled = () =>
NextResponse.json(
{
error:
"UploadThing is not configured. Add UPLOADTHING_SECRET to .env or use STORAGE_PROVIDER=local.",
},
{ status: 503 }
);
// Lazily resolve the real handlers only when the secret is present.
// This prevents the UploadThing SDK from throwing at module-load time
// when the env var is missing (e.g. STORAGE_PROVIDER=local).
async function getHandlers() {
if (!process.env.UPLOADTHING_SECRET) return null;
const { createRouteHandler } = await import("uploadthing/next");
const { uploadRouter } = await import("@/lib/uploadthing");
return createRouteHandler({ router: uploadRouter });
}
export async function GET(req: NextRequest) {
const h = await getHandlers();
return h ? h.GET(req) : disabled();
}
export async function POST(req: NextRequest) {
const h = await getHandlers();
return h ? h.POST(req) : disabled();
}
@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ versionId: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { versionId } = await params;
const annotations = await db.annotation.findMany({
where: { versionId, isVisible: true },
include: {
author: { select: { id: true, name: true, image: true } },
},
orderBy: { frameNumber: "asc" },
});
return NextResponse.json({ annotations });
}
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ versionId: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { versionId } = await params;
const comments = await db.comment.findMany({
where: { versionId },
orderBy: { frameNumber: "asc" },
include: {
author: { select: { id: true, name: true, image: true, email: true } },
replies: {
orderBy: { createdAt: "asc" },
include: {
author: { select: { id: true, name: true, image: true, email: true } },
},
},
},
});
return NextResponse.json({ comments });
}