842a8fe036
Deploy / deploy (push) Successful in 3m34s
"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>
343 lines
16 KiB
TypeScript
343 lines
16 KiB
TypeScript
"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>
|
||
);
|
||
}
|