From 521eb38dcd12b1b6f80cbde2823a9845685b596a Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Fri, 29 May 2026 09:35:35 +0200 Subject: [PATCH] added edit tasks --- .../tasks/[taskId]/TaskDetailClient.tsx | 72 +++++- app/api/ext/shots/route.ts | 216 ++++++++++++++++++ middleware.ts | 3 + 3 files changed, 281 insertions(+), 10 deletions(-) create mode 100644 app/api/ext/shots/route.ts diff --git a/app/(dashboard)/tasks/[taskId]/TaskDetailClient.tsx b/app/(dashboard)/tasks/[taskId]/TaskDetailClient.tsx index 250d543..0902fd5 100644 --- a/app/(dashboard)/tasks/[taskId]/TaskDetailClient.tsx +++ b/app/(dashboard)/tasks/[taskId]/TaskDetailClient.tsx @@ -123,6 +123,12 @@ export function TaskDetailClient({ const { toast } = useToast(); const [showUpload, setShowUpload] = useState(false); const [updatingStatus, setUpdatingStatus] = useState(false); + const [dueDateValue, setDueDateValue] = useState( + task.dueDate ? format(new Date(task.dueDate), "yyyy-MM-dd") : "" + ); + const [estimatedHoursValue, setEstimatedHoursValue] = useState( + task.estimatedHours != null ? String(task.estimatedHours) : "" + ); const statusCfg = TASK_STATUS_CONFIG[task.status]; const StatusIcon = statusCfg.icon; @@ -147,6 +153,27 @@ export function TaskDetailClient({ } }; + const handleDueDateChange = async (value: string) => { + setDueDateValue(value); + try { + await updateTask(task.id, { dueDate: value || null }); + router.refresh(); + } catch { + toast({ title: "Failed to update due date", variant: "destructive" }); + } + }; + + const handleEstimatedHoursBlur = async () => { + const parsed = estimatedHoursValue === "" ? null : parseFloat(estimatedHoursValue); + if (parsed !== null && (isNaN(parsed) || parsed <= 0)) return; + try { + await updateTask(task.id, { estimatedHours: parsed }); + router.refresh(); + } catch { + toast({ title: "Failed to update estimated hours", variant: "destructive" }); + } + }; + const handleAssigneeChange = async (artistId: string) => { try { await updateTask(task.id, { assignedArtistId: artistId === "__none__" ? null : artistId }); @@ -410,26 +437,51 @@ export function TaskDetailClient({

- {task.dueDate && ( -
-

Due Date

+
+

Due Date

+ {canManage ? ( + handleDueDateChange(e.target.value)} + className="w-full text-sm bg-transparent border border-border rounded-md px-2 py-1 text-zinc-300 focus:outline-none focus:ring-1 focus:ring-amber-500/50 [color-scheme:dark]" + /> + ) : task.dueDate ? (

{format(new Date(task.dueDate), "MMM d, yyyy")} {isOverdue && (Overdue)}

-
- )} + ) : ( +

+ )} +
- {task.estimatedHours && ( -
-

Estimated

+
+

Estimated Hours

+ {canManage ? ( +
+ setEstimatedHoursValue(e.target.value)} + onBlur={handleEstimatedHoursBlur} + placeholder="—" + className="w-full text-sm bg-transparent border border-border rounded-md px-2 py-1 text-zinc-300 focus:outline-none focus:ring-1 focus:ring-amber-500/50 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none" + /> + h +
+ ) : task.estimatedHours ? (

{task.estimatedHours}h

-
- )} + ) : ( +

+ )} +
diff --git a/app/api/ext/shots/route.ts b/app/api/ext/shots/route.ts new file mode 100644 index 0000000..4de6572 --- /dev/null +++ b/app/api/ext/shots/route.ts @@ -0,0 +1,216 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import { uploadFile } from "@/lib/storage"; +import { ShotPriority } from "@prisma/client"; +import { z } from "zod"; + +// ── Auth ───────────────────────────────────────────────────────────────────── + +function isAuthorized(req: NextRequest): boolean { + const apiKey = process.env.API_SECRET_KEY; + if (!apiKey) return false; // key not configured → deny all + + const authHeader = req.headers.get("authorization") ?? ""; + if (authHeader.startsWith("Bearer ")) { + return authHeader.slice(7) === apiKey; + } + // Also accept X-API-Key header + const headerKey = req.headers.get("x-api-key") ?? ""; + return headerKey === apiKey; +} + +// ── Validation ──────────────────────────────────────────────────────────────── + +const createShotSchema = z.object({ + projectId: z.string().cuid(), + scene: z + .string() + .min(1) + .max(50) + .regex(/^[A-Z0-9_]+$/i, "Alphanumeric and underscore only"), + episode: z.string().max(50).optional(), + description: z.string().optional(), + artistId: z.string().cuid().optional(), + priority: z.nativeEnum(ShotPriority).default("NORMAL"), + fps: z.coerce.number().default(24), + frameStart: z.coerce.number().int().optional(), + frameEnd: z.coerce.number().int().optional(), + dueDate: z.string().optional(), + thumbnailUrl: z.string().url().optional(), + shotGroupName: z.string().max(100).optional(), +}); + +// ── POST /api/ext/shots ─────────────────────────────────────────────────────── + +export async function POST(req: NextRequest) { + if (!isAuthorized(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + let fields: Record = {}; + let thumbnailFile: File | null = null; + + const contentType = req.headers.get("content-type") ?? ""; + + if (contentType.includes("multipart/form-data")) { + const formData = await req.formData(); + for (const [key, value] of formData.entries()) { + if (key === "thumbnail" && value instanceof File) { + thumbnailFile = value; + } else if (typeof value === "string") { + fields[key] = value; + } + } + } else { + // application/json + fields = await req.json(); + } + + // Parse and validate fields + const parsed = createShotSchema.parse({ + projectId: fields.projectId, + scene: fields.scene, + episode: fields.episode || undefined, + description: fields.description || undefined, + artistId: fields.artistId || undefined, + priority: fields.priority || undefined, + fps: fields.fps || undefined, + frameStart: fields.frameStart || undefined, + frameEnd: fields.frameEnd || undefined, + dueDate: fields.dueDate || undefined, + thumbnailUrl: fields.thumbnailUrl || undefined, + shotGroupName: fields.shotGroupName || undefined, + }); + + const scene = parsed.scene.toUpperCase(); + const episode = parsed.episode?.toUpperCase() ?? null; + + // Fetch project for showId and projectType + const project = await db.project.findUnique({ + where: { id: parsed.projectId }, + select: { showId: true, projectType: true }, + }); + if (!project) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } + if (!project.showId) { + return NextResponse.json( + { error: "Project has no Show ID set. Please edit the project to add one." }, + { status: 422 } + ); + } + + // Episodic projects require episode + if (project.projectType === "EPISODIC" && !episode) { + return NextResponse.json( + { error: "Episode is required for episodic projects." }, + { status: 422 } + ); + } + + // Auto-increment shot number within scene (+ episode for episodic) + const scopeWhere = { + projectId: parsed.projectId, + scene, + ...(project.projectType === "EPISODIC" ? { episode } : {}), + }; + + const maxShot = await db.shot.findFirst({ + where: scopeWhere, + orderBy: { shotNumber: "desc" }, + select: { shotNumber: true }, + }); + + const shotNumber = (maxShot?.shotNumber ?? 0) + 10; + const paddedNumber = shotNumber.toString().padStart(4, "0"); + + const shotCode = + project.projectType === "EPISODIC" && episode + ? `${project.showId}_${episode}_${scene}_${paddedNumber}` + : `${project.showId}_${scene}_${paddedNumber}`; + + // Upload thumbnail if provided as a file + let thumbnailUrl = parsed.thumbnailUrl; + if (thumbnailFile) { + if (!thumbnailFile.type.startsWith("image/")) { + return NextResponse.json( + { error: "Thumbnail must be an image file" }, + { status: 400 } + ); + } + const maxSize = 50 * 1024 * 1024; // 50 MB + if (thumbnailFile.size > maxSize) { + return NextResponse.json( + { error: "Thumbnail too large (max 50 MB)" }, + { status: 413 } + ); + } + const buffer = Buffer.from(await thumbnailFile.arrayBuffer()); + const result = await uploadFile(buffer, thumbnailFile.name, thumbnailFile.type, "image"); + thumbnailUrl = result.url; + } + + // Resolve shot group + let shotGroupId: string | undefined; + if (parsed.shotGroupName?.trim()) { + const group = await db.shotGroup.upsert({ + where: { + projectId_name: { + projectId: parsed.projectId, + name: parsed.shotGroupName.trim(), + }, + }, + create: { projectId: parsed.projectId, name: parsed.shotGroupName.trim() }, + update: {}, + }); + shotGroupId = group.id; + } + + const shot = await db.shot.create({ + data: { + shotCode, + scene, + episode, + shotNumber, + description: parsed.description, + projectId: parsed.projectId, + artistId: parsed.artistId || undefined, + priority: parsed.priority, + fps: parsed.fps, + frameStart: parsed.frameStart, + frameEnd: parsed.frameEnd, + dueDate: parsed.dueDate ? new Date(parsed.dueDate) : undefined, + thumbnailUrl, + shotGroupId, + }, + select: { + id: true, + shotCode: true, + scene: true, + episode: true, + shotNumber: true, + description: true, + status: true, + priority: true, + fps: true, + frameStart: true, + frameEnd: true, + dueDate: true, + thumbnailUrl: true, + projectId: true, + artistId: true, + shotGroupId: true, + createdAt: true, + }, + }); + + return NextResponse.json({ shot }, { status: 201 }); + } catch (err) { + if (err instanceof z.ZodError) { + return NextResponse.json({ error: "Validation error", details: err.errors }, { status: 422 }); + } + console.error("[POST /api/ext/shots]", err); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/middleware.ts b/middleware.ts index c6c968e..1770d4b 100644 --- a/middleware.ts +++ b/middleware.ts @@ -14,6 +14,9 @@ export default auth((req) => { // Allow token-gated client API routes (comments, approvals via review token) if (pathname.startsWith("/api/client/")) return; + // Allow external/scripting API routes (authenticated via API key header) + if (pathname.startsWith("/api/ext/")) return; + // Allow local file serving (needed for video playback in client portal) if (pathname.startsWith("/api/files/")) return;