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;