versioning added
Deploy / deploy (push) Successful in 3m12s

This commit is contained in:
twotalesanimation
2026-06-25 22:19:22 +02:00
parent 935a293777
commit 75d5149bea
8 changed files with 172 additions and 3 deletions
+32
View File
@@ -970,3 +970,35 @@ export async function clientRequestShotChanges(shotId: string, taskId?: string)
revalidatePath(`/shot-status`); revalidatePath(`/shot-status`);
return { success: true }; return { success: true };
} }
// ── Shot Version ──────────────────────────────────────────────────────────────
const VERSION_RE = /^v\d{3}$/;
/**
* Set or manually override the shot's version string (format: v###).
*/
export async function updateShotVersion(shotId: string, version: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
if (!VERSION_RE.test(version)) {
throw new Error("Version must be in format v### (e.g. v001, v012)");
}
const shot = await db.shot.findUnique({
where: { id: shotId },
select: { projectId: true },
});
if (!shot) throw new Error("Shot not found");
await db.shot.update({
where: { id: shotId },
data: { shotVersion: version },
});
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
return { success: true, shotVersion: version };
}
@@ -28,12 +28,15 @@ import {
ExternalLink, ExternalLink,
XCircle, XCircle,
FileVideo, FileVideo,
Pencil,
Check,
} from "lucide-react"; } from "lucide-react";
import { Input } from "@/components/ui/input";
import type { ShotWithDetails } from "@/types"; import type { ShotWithDetails } from "@/types";
import { ShotSettingsTab } from "@/components/shots/ShotSettingsTab"; import { ShotSettingsTab } from "@/components/shots/ShotSettingsTab";
import { FootageViewer } from "@/components/shots/FootageViewer"; import { FootageViewer } from "@/components/shots/FootageViewer";
import { HighResUploadDialog } from "@/components/shots/HighResUploadDialog"; import { HighResUploadDialog } from "@/components/shots/HighResUploadDialog";
import { duplicateShot, internallyApproveShot, shareWithClient, unshareFromClient, unapproveShot } from "@/actions/shots"; import { duplicateShot, internallyApproveShot, shareWithClient, unshareFromClient, unapproveShot, updateShotVersion } from "@/actions/shots";
const STATUS_CONFIG: Record< const STATUS_CONFIG: Record<
string, string,
@@ -87,6 +90,9 @@ export default function ShotDetailPage() {
const [isActioning, setIsActioning] = useState(false); const [isActioning, setIsActioning] = useState(false);
const [highResDialogOpen, setHighResDialogOpen] = useState(false); const [highResDialogOpen, setHighResDialogOpen] = useState(false);
const [activeTab, setActiveTab] = useState<"tasks" | "reviews" | "footage" | "settings">("tasks"); const [activeTab, setActiveTab] = useState<"tasks" | "reviews" | "footage" | "settings">("tasks");
const [editingVersion, setEditingVersion] = useState(false);
const [versionInput, setVersionInput] = useState("");
const [savingVersion, setSavingVersion] = useState(false);
const fetchShot = async () => { const fetchShot = async () => {
try { try {
@@ -171,6 +177,26 @@ export default function ShotDetailPage() {
} }
}; };
const handleSaveVersion = async () => {
if (!shot) return;
const trimmed = versionInput.trim().toLowerCase();
if (!/^v\d{3}$/.test(trimmed)) {
toast({ title: "Invalid format", description: "Version must be v### (e.g. v001)", variant: "destructive" });
return;
}
setSavingVersion(true);
try {
await updateShotVersion(shot.id, trimmed);
toast({ title: `Version set to ${trimmed}` });
setEditingVersion(false);
fetchShot();
} catch (e) {
toast({ title: "Failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
} finally {
setSavingVersion(false);
}
};
const handleDuplicate = async () => { const handleDuplicate = async () => {
if (!shot) return; if (!shot) return;
setIsDuplicating(true); setIsDuplicating(true);
@@ -237,6 +263,55 @@ export default function ShotDetailPage() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-2xl font-bold font-mono">{shot.shotCode}</h1> <h1 className="text-2xl font-bold font-mono">{shot.shotCode}</h1>
{/* Shot version badge — click to edit for managers */}
{canManage ? (
editingVersion ? (
<div className="flex items-center gap-1.5">
<Input
autoFocus
value={versionInput}
onChange={(e) => setVersionInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSaveVersion();
if (e.key === "Escape") setEditingVersion(false);
}}
className="h-7 w-20 font-mono text-xs px-2"
placeholder="v001"
maxLength={4}
/>
<button
onClick={handleSaveVersion}
disabled={savingVersion}
className="text-emerald-400 hover:text-emerald-300 disabled:opacity-50"
title="Save version"
>
<Check className="h-4 w-4" />
</button>
<button
onClick={() => setEditingVersion(false)}
className="text-zinc-500 hover:text-zinc-300"
title="Cancel"
>
<XCircle className="h-4 w-4" />
</button>
</div>
) : (
<button
onClick={() => { setVersionInput(shot.shotVersion ?? "v001"); setEditingVersion(true); }}
className="group flex items-center gap-1 font-mono text-sm px-2 py-0.5 rounded border border-zinc-700 bg-zinc-800/60 text-zinc-300 hover:border-amber-500/50 hover:text-amber-400 transition-colors"
title="Click to edit version"
>
{shot.shotVersion ?? "v001"}
<Pencil className="h-3 w-3 opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
)
) : (
<span className="font-mono text-sm px-2 py-0.5 rounded border border-zinc-700 bg-zinc-800/60 text-zinc-300">
{shot.shotVersion ?? "v001"}
</span>
)}
{shot.sequence && ( {shot.sequence && (
<span className="text-sm text-muted-foreground">Seq: {shot.sequence}</span> <span className="text-sm text-muted-foreground">Seq: {shot.sequence}</span>
)} )}
+8 -2
View File
@@ -38,7 +38,7 @@ export async function POST(
if (shotId && action) { if (shotId && action) {
const shot = await db.shot.findUnique({ const shot = await db.shot.findUnique({
where: { id: shotId }, where: { id: shotId },
select: { projectId: true, sharedWithClient: true }, select: { projectId: true, sharedWithClient: true, shotVersion: true },
}); });
if (!shot || shot.projectId !== session.projectId) { if (!shot || shot.projectId !== session.projectId) {
return NextResponse.json({ error: "Shot not found" }, { status: 404 }); return NextResponse.json({ error: "Shot not found" }, { status: 404 });
@@ -53,10 +53,16 @@ export async function POST(
data: { shotApprovalStatus: "CLIENT_APPROVED", status: "COMPLETE" }, data: { shotApprovalStatus: "CLIENT_APPROVED", status: "COMPLETE" },
}); });
} else if (action === "NEEDS_CHANGES") { } else if (action === "NEEDS_CHANGES") {
// Increment shotVersion: v001 → v002, v009 → v010, etc.
const currentVer = shot.shotVersion ?? "v001";
const verMatch = currentVer.match(/^v(\d+)$/);
const nextNum = verMatch ? parseInt(verMatch[1], 10) + 1 : 2;
const nextVersion = "v" + String(nextNum).padStart(3, "0");
await db.$transaction(async (tx) => { await db.$transaction(async (tx) => {
await tx.shot.update({ await tx.shot.update({
where: { id: shotId }, where: { id: shotId },
data: { shotApprovalStatus: "PENDING", sharedWithClient: false }, data: { shotApprovalStatus: "PENDING", sharedWithClient: false, shotVersion: nextVersion },
}); });
// Mark all non-DONE tasks as CHANGES // Mark all non-DONE tasks as CHANGES
await tx.task.updateMany({ await tx.task.updateMany({
+49
View File
@@ -57,6 +57,7 @@ export async function GET(
exrOutput: true, exrOutput: true,
seqTimecodeStart: true, seqTimecodeStart: true,
seqTimecodeEnd: true, seqTimecodeEnd: true,
shotVersion: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
project: { project: {
@@ -102,3 +103,51 @@ export async function GET(
return NextResponse.json({ shot }); return NextResponse.json({ shot });
} }
// ── PATCH /api/ext/shots/[shotId] ─────────────────────────────────────────────
//
// Update mutable shot fields from pipeline tools.
// Currently supports: shotVersion (format v###)
//
// Body (JSON): { shotVersion?: string }
// Returns: { success: true, shot: { id, shotVersion } }
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ shotId: string }> }
) {
if (!isAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { shotId } = await params;
const body = await req.json().catch(() => ({}));
const { shotVersion } = body as { shotVersion?: string };
if (shotVersion !== undefined) {
if (!/^v\d{3}$/.test(shotVersion)) {
return NextResponse.json(
{ error: "shotVersion must match format v### (e.g. v001)" },
{ status: 400 }
);
}
} else {
return NextResponse.json({ error: "No updatable fields provided" }, { status: 400 });
}
const shot = await db.shot.findUnique({
where: { id: shotId },
select: { id: true },
});
if (!shot) {
return NextResponse.json({ error: "Shot not found" }, { status: 404 });
}
const updated = await db.shot.update({
where: { id: shotId },
data: { ...(shotVersion !== undefined && { shotVersion }) },
select: { id: true, shotVersion: true },
});
return NextResponse.json({ success: true, shot: updated });
}
+1
View File
@@ -65,6 +65,7 @@ export async function GET(req: NextRequest) {
exrOutput: true, exrOutput: true,
seqTimecodeStart: true, seqTimecodeStart: true,
seqTimecodeEnd: true, seqTimecodeEnd: true,
shotVersion: true,
thumbnailUrl: true, thumbnailUrl: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "shots" ADD COLUMN "shotVersion" TEXT NOT NULL DEFAULT 'v001';
+2
View File
@@ -309,6 +309,8 @@ model Shot {
// High-res deliverable (stored in Hetzner Object Storage) // High-res deliverable (stored in Hetzner Object Storage)
highResKey String? highResKey String?
highResFilename String? highResFilename String?
// Shot-level version tracking (e.g. v001, v002)
shotVersion String @default("v001")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
+2
View File
@@ -179,6 +179,8 @@ export interface ShotWithDetails {
seqTimecodeEnd: string | null; seqTimecodeEnd: string | null;
// High-res deliverable // High-res deliverable
highResFilename: string | null; highResFilename: string | null;
// Shot-level version tracking
shotVersion: string;
shotGroup: { id: string; name: string } | null; shotGroup: { id: string; name: string } | null;
footagePlates: { footagePlates: {
id: string; id: string;