@@ -28,12 +28,15 @@ import {
|
||||
ExternalLink,
|
||||
XCircle,
|
||||
FileVideo,
|
||||
Pencil,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { ShotWithDetails } from "@/types";
|
||||
import { ShotSettingsTab } from "@/components/shots/ShotSettingsTab";
|
||||
import { FootageViewer } from "@/components/shots/FootageViewer";
|
||||
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<
|
||||
string,
|
||||
@@ -87,6 +90,9 @@ export default function ShotDetailPage() {
|
||||
const [isActioning, setIsActioning] = useState(false);
|
||||
const [highResDialogOpen, setHighResDialogOpen] = useState(false);
|
||||
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 () => {
|
||||
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 () => {
|
||||
if (!shot) return;
|
||||
setIsDuplicating(true);
|
||||
@@ -237,6 +263,55 @@ export default function ShotDetailPage() {
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
<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 && (
|
||||
<span className="text-sm text-muted-foreground">Seq: {shot.sequence}</span>
|
||||
)}
|
||||
|
||||
@@ -38,7 +38,7 @@ export async function POST(
|
||||
if (shotId && action) {
|
||||
const shot = await db.shot.findUnique({
|
||||
where: { id: shotId },
|
||||
select: { projectId: true, sharedWithClient: true },
|
||||
select: { projectId: true, sharedWithClient: true, shotVersion: true },
|
||||
});
|
||||
if (!shot || shot.projectId !== session.projectId) {
|
||||
return NextResponse.json({ error: "Shot not found" }, { status: 404 });
|
||||
@@ -53,10 +53,16 @@ export async function POST(
|
||||
data: { shotApprovalStatus: "CLIENT_APPROVED", status: "COMPLETE" },
|
||||
});
|
||||
} 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 tx.shot.update({
|
||||
where: { id: shotId },
|
||||
data: { shotApprovalStatus: "PENDING", sharedWithClient: false },
|
||||
data: { shotApprovalStatus: "PENDING", sharedWithClient: false, shotVersion: nextVersion },
|
||||
});
|
||||
// Mark all non-DONE tasks as CHANGES
|
||||
await tx.task.updateMany({
|
||||
|
||||
@@ -57,6 +57,7 @@ export async function GET(
|
||||
exrOutput: true,
|
||||
seqTimecodeStart: true,
|
||||
seqTimecodeEnd: true,
|
||||
shotVersion: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
project: {
|
||||
@@ -102,3 +103,51 @@ export async function GET(
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ export async function GET(req: NextRequest) {
|
||||
exrOutput: true,
|
||||
seqTimecodeStart: true,
|
||||
seqTimecodeEnd: true,
|
||||
shotVersion: true,
|
||||
thumbnailUrl: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
|
||||
Reference in New Issue
Block a user