Files
vfxreview/app/(dashboard)/board/FeedbackPasteDialog.tsx
T
twotalesanimation 842a8fe036
Deploy / deploy (push) Successful in 3m34s
feat(board): paste client feedback from WhatsApp
"Paste feedback" on each board card takes a client's message as it arrived
and splits it into rounds ("FEEDBACK 113 DUE Oct 7 12:00 PM") and per-shot
notes, tolerating WhatsApp copy prefixes, bold markers, wrapped lines,
several codes per note and codes without the show prefix.

A review step shows each note's shot, the version the client was sent,
the player frame and a "move task to Changes" tick (on by default).
Saving creates a FeedbackRound, a player comment per note tagged
source=WHATSAPP (shown as "Client feedback") and a board line under the
round's due time; ticking the line off resolves the comment.

Quoted frames are read as comp frames: shot frameStart or the house 1001,
plus the slate (recorded, else 1 for pipeline previews). Version.duration
is never stored on live, so the length-based slate inference can't be used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:31:34 +02:00

343 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { format } from "date-fns";
import { AlertTriangle, ArrowLeft, Link2, MessageSquareText } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { useToast } from "@/components/ui/use-toast";
import { cn } from "@/lib/utils";
import { parseDue } from "@/lib/feedback-parse";
import { commitFeedback, previewFeedback, type FeedbackPreview, type FeedbackPreviewNote } from "@/actions/feedback";
type NoteState = {
include: boolean;
text: string;
versionId: string | null;
/** Typed frame; null = use the selected version's converted frame */
frame: string | null;
setChanges: boolean;
};
type RoundState = { title: string; due: string; notes: Record<string, NoteState> };
const LOCAL_DT = "yyyy-MM-dd'T'HH:mm";
function initialState(preview: FeedbackPreview): RoundState[] {
return preview.rounds.map((r) => {
const due = parseDue(r.dueText);
return {
title: r.title || `Feedback ${format(new Date(), "d MMM")}`,
due: due ? format(due, LOCAL_DT) : "",
notes: Object.fromEntries(
r.notes.map((n) => [
n.key,
{ include: true, text: n.text, versionId: n.defaultVersionId, frame: null, setChanges: true },
]),
),
};
});
}
export function FeedbackPasteDialog({
projectId,
projectName,
open,
onOpenChange,
}: {
projectId: string;
projectName: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const router = useRouter();
const { toast } = useToast();
const [input, setInput] = useState("");
const [preview, setPreview] = useState<FeedbackPreview | null>(null);
const [rounds, setRounds] = useState<RoundState[]>([]);
const [isPending, startTransition] = useTransition();
const reset = () => {
setInput("");
setPreview(null);
setRounds([]);
};
const read = (text: string) =>
startTransition(async () => {
try {
const p = await previewFeedback(projectId, text);
if (p.rounds.length === 0) {
toast({ title: "No shot notes found", description: "Each note needs to start with a shot code.", variant: "destructive" });
return;
}
setPreview(p);
setRounds(initialState(p));
} catch (e) {
toast({ title: "Couldn't read feedback", description: (e as Error).message, variant: "destructive" });
}
});
// Accepting a suggested code rewrites the paste and reads it again, so
// everything downstream (versions, frames) comes from the real shot.
const applySuggestion = (code: string, suggestion: string) => {
const next = input.split(code).join(suggestion);
setInput(next);
read(next);
};
const updateNote = (ri: number, key: string, patch: Partial<NoteState>) =>
setRounds((rs) => rs.map((r, i) => (i === ri ? { ...r, notes: { ...r.notes, [key]: { ...r.notes[key], ...patch } } } : r)));
const frameFor = (n: FeedbackPreviewNote, s: NoteState): number | null => {
if (s.frame !== null) return s.frame.trim() === "" ? null : Number(s.frame);
return n.versions.find((v) => v.id === s.versionId)?.playerFrame ?? null;
};
const included = preview
? preview.rounds.flatMap((r, ri) => r.notes.filter((n) => rounds[ri]?.notes[n.key]?.include && rounds[ri].notes[n.key].text.trim()))
: [];
const save = () => {
if (!preview) return;
const payload = preview.rounds
.map((r, ri) => {
const rs = rounds[ri];
return {
title: rs.title,
dueAt: rs.due ? new Date(rs.due).toISOString() : null,
notes: r.notes
.filter((n) => rs.notes[n.key].include && rs.notes[n.key].text.trim())
.map((n) => {
const s = rs.notes[n.key];
const frame = frameFor(n, s);
return {
code: n.code,
shotId: n.shot?.id ?? null,
text: s.text.trim(),
versionId: n.shot ? s.versionId : null,
frame: frame != null && Number.isFinite(frame) && frame >= 0 ? Math.floor(frame) : null,
setChanges: !!s.versionId && s.setChanges,
};
}),
};
})
.filter((r) => r.notes.length > 0);
if (payload.length === 0) return;
startTransition(async () => {
try {
const res = await commitFeedback({ projectId, rounds: payload });
toast({
title: `${res.notes} note${res.notes === 1 ? "" : "s"} logged`,
description: `${res.comments} in the player · ${res.tasksChanged} task${res.tasksChanged === 1 ? "" : "s"} moved to Changes`,
});
reset();
onOpenChange(false);
router.refresh();
} catch (e) {
toast({ title: "Couldn't save feedback", description: (e as Error).message, variant: "destructive" });
}
});
};
const field = "rounded-md border border-zinc-700 bg-zinc-950 px-2 text-xs text-white placeholder:text-zinc-600 focus:border-amber-500/50 focus:outline-none";
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) reset(); onOpenChange(o); }}>
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<MessageSquareText className="h-4 w-4 text-amber-400" />
Paste client feedback · {projectName}
</DialogTitle>
<DialogDescription>
{preview
? "Check where each note will go. Notes are saved in the player as client feedback and added to the board."
: "Paste the message as it arrived. Each note should start with a shot code; a line like “FEEDBACK 113 DUE Oct 7 12:00 PM” starts a round."}
</DialogDescription>
</DialogHeader>
{!preview ? (
<div className="space-y-3">
<textarea
autoFocus
value={input}
onChange={(e) => setInput(e.target.value)}
rows={12}
placeholder={"FEEDBACK 113 DUE Oct 7, 2026 12:00 PM\nUNG_113_008_040 The screen is partially peeking under the mask\nUNG_113_018_050 At frame 1018, the mask is still slipping"}
className={cn(field, "w-full resize-y py-2 font-mono text-[13px] leading-relaxed")}
/>
<div className="flex justify-end">
<Button onClick={() => read(input)} disabled={isPending || !input.trim()}>
{isPending ? "Reading…" : "Read feedback"}
</Button>
</div>
</div>
) : (
<div className="space-y-5">
{preview.ignored.length > 0 && (
<p className="text-xs text-zinc-500">
Skipped (no shot code): {preview.ignored.map((l) => `“${l}”`).join(", ")}
</p>
)}
{preview.rounds.map((r, ri) => {
const rs = rounds[ri];
if (!rs) return null;
return (
<div key={ri} className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<input
value={rs.title}
onChange={(e) => setRounds((all) => all.map((x, i) => (i === ri ? { ...x, title: e.target.value } : x)))}
className={cn(field, "h-8 w-56 text-sm font-medium")}
/>
<span className="text-xs text-zinc-500">due</span>
<input
type="datetime-local"
value={rs.due}
onChange={(e) => setRounds((all) => all.map((x, i) => (i === ri ? { ...x, due: e.target.value } : x)))}
className={cn(field, "h-8 [color-scheme:dark]")}
/>
{r.dueText && !rs.due && (
<span className="text-xs text-amber-400">Couldn’t read “{r.dueText}”, set it by hand</span>
)}
</div>
<div className="overflow-x-auto rounded-lg border border-zinc-800">
<table className="w-full text-xs">
<thead className="bg-zinc-950 text-left uppercase tracking-wider text-zinc-500">
<tr>
<th className="w-8 px-2 py-2" />
<th className="px-2 py-2">Shot</th>
<th className="px-2 py-2">Note</th>
<th className="px-2 py-2">Version</th>
<th className="px-2 py-2">Frame</th>
<th className="px-2 py-2" title="Move the task to Changes">Changes</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800">
{r.notes.map((n) => {
const s = rs.notes[n.key];
const v = n.versions.find((x) => x.id === s.versionId) ?? null;
const frame = frameFor(n, s);
return (
<tr key={n.key} className={cn("align-top", !s.include && "opacity-40")}>
<td className="px-2 py-2">
<input
type="checkbox"
checked={s.include}
onChange={(e) => updateNote(ri, n.key, { include: e.target.checked })}
className="mt-1 accent-amber-500"
/>
</td>
<td className="whitespace-nowrap px-2 py-2 font-mono">
{n.shot ? (
<span className="text-amber-400">{n.shot.shotCode}</span>
) : (
<div className="space-y-1">
<span className="flex items-center gap-1 text-red-400">
<AlertTriangle className="h-3 w-3" /> {n.code}
</span>
{n.suggestion ? (
<button
type="button"
onClick={() => applySuggestion(n.code, n.suggestion!)}
className="flex items-center gap-1 font-sans text-zinc-400 hover:text-amber-400"
>
<Link2 className="h-3 w-3" /> Use {n.suggestion}
</button>
) : (
<span className="block font-sans text-zinc-500">Not in project; flag only</span>
)}
</div>
)}
</td>
<td className="min-w-[16rem] px-2 py-2">
<textarea
value={s.text}
onChange={(e) => updateNote(ri, n.key, { text: e.target.value })}
rows={Math.min(5, Math.max(1, Math.ceil(s.text.length / 60)))}
className={cn(field, "w-full resize-y py-1 leading-snug")}
/>
</td>
<td className="px-2 py-2">
{n.versions.length > 0 ? (
<select
value={s.versionId ?? ""}
onChange={(e) => updateNote(ri, n.key, { versionId: e.target.value || null, frame: null })}
className={cn(field, "h-7")}
>
{n.versions.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
{o.sent ? ` · sent${o.sharedAt ? " " + format(new Date(o.sharedAt), "d MMM") : ""}` : " · not sent"}
</option>
))}
<option value="">Board only</option>
</select>
) : (
<span className="text-zinc-500">{n.shot ? "No versions; board only" : "—"}</span>
)}
{v && !v.sent && <span className="mt-1 block text-amber-400/80">Not sent to client</span>}
</td>
<td className="px-2 py-2">
{s.versionId ? (
<>
<input
inputMode="numeric"
value={s.frame ?? (frame != null ? String(frame) : "")}
onChange={(e) => updateNote(ri, n.key, { frame: e.target.value.replace(/\D/g, "") })}
placeholder="0"
className={cn(field, "h-7 w-16 font-mono")}
/>
{n.quotedFrame != null && s.frame === null && v && (
<span className="mt-1 block whitespace-nowrap text-zinc-500">
{v.fromComp ? `comp ${n.quotedFrame}` : `as quoted`}
</span>
)}
</>
) : (
<span className="text-zinc-600">—</span>
)}
</td>
<td className="px-2 py-2 text-center">
{v?.taskId ? (
<input
type="checkbox"
checked={s.setChanges}
disabled={v.taskStatus === "CHANGES"}
title={v.taskStatus === "CHANGES" ? "Already in Changes" : "Move the task to Changes"}
onChange={(e) => updateNote(ri, n.key, { setChanges: e.target.checked })}
className="mt-1 accent-amber-500"
/>
) : (
<span className="text-zinc-600">—</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
})}
<div className="flex flex-wrap items-center justify-between gap-2">
<Button variant="ghost" onClick={() => setPreview(null)} disabled={isPending}>
<ArrowLeft /> Edit paste
</Button>
<Button onClick={save} disabled={isPending || included.length === 0}>
{isPending ? "Saving…" : `Save ${included.length} note${included.length === 1 ? "" : "s"}`}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}