/* Review screen: the player on the left, the notes on the right.
A note always carries the exact moment it was written at. Optionally it also
carries a pin or a drawing on the frame — click the picture with the pin tool,
or scribble with the pen. */
function CommentItem({ comment, replies, author, directory, active, onActivate, onResolve, onDelete, me, index }) {
return (
onActivate(comment)}>
{author ? (author.name || author.email) : 'Someone'}
{timecode(comment.timestamp_ms)}{comment.frame != null ? ` · f${comment.frame}` : ''}
{comment.kind === 'client' &&
client }
{(comment.x != null || comment.drawing) &&
#{index} }
{ e.stopPropagation(); onResolve(comment); }}>
{(comment.author_id === me.id || me.role === 'admin') && (
{ e.stopPropagation(); onDelete(comment); }}>
)}
{comment.body}
{replies.map((r) => {
const who = directory.byId[r.author_id];
return (
{who ? (who.name || who.email) : 'Someone'}
{r.body}
);
})}
);
}
function ReviewScreen({ versionId, go, me, directory }) {
const [version, setVersion] = React.useState(null);
const [task, setTask] = React.useState(null);
const [versions, setVersions] = React.useState([]);
const [comments, setComments] = React.useState([]);
const [activeId, setActiveId] = React.useState(null);
const [tool, setTool] = React.useState('none');
const [draft, setDraft] = React.useState(null);
const [text, setText] = React.useState('');
const [replyTo, setReplyTo] = React.useState(null);
const [showResolved, setShowResolved] = React.useState(false);
const [nowMs, setNowMs] = React.useState(0);
const playerRef = React.useRef(null);
const loadComments = React.useCallback((id) => {
api.get(`/api/versions/${id}/comments`).then(setComments).catch((e) => toast(e.message, 'error'));
}, []);
React.useEffect(() => {
setDraft(null);
setTool('none');
api.get(`/api/videos/${versionId}`)
.then((v) => {
setVersion(v);
loadComments(v.id);
return api.get(`/api/tasks/${v.task_id}`);
})
.then((d) => { setTask(d.task); setVersions(d.versions); })
.catch((e) => toast(e.message, 'error'));
}, [versionId, loadComments]);
if (!version) return Loading…
;
if (version.status !== 'ready') {
return (
go('task', version.task_id)}>Back to task}
/>
);
}
const client = task ? directory.byId[task.client_id] : null;
const roots = comments.filter((c) => !c.parent_id)
.filter((c) => showResolved || !c.resolved)
.sort((a, b) => a.timestamp_ms - b.timestamp_ms);
const repliesOf = (id) => comments.filter((c) => c.parent_id === id);
const pinIndex = {};
comments.filter((c) => c.x != null || c.drawing).forEach((c, i) => { pinIndex[c.id] = i + 1; });
const startDraft = () => {
const ms = playerRef.current ? playerRef.current.currentMs() : nowMs;
playerRef.current && playerRef.current.pause();
setDraft((d) => d || { timestamp_ms: ms, frame: frameAt(ms, version.fps || 25) });
};
const onAnnotate = (payload) => {
const ms = playerRef.current ? playerRef.current.currentMs() : nowMs;
setDraft((d) => {
const base = d || { timestamp_ms: ms, frame: frameAt(ms, version.fps || 25) };
if (payload.pin) return { ...base, x: payload.pin.x, y: payload.pin.y };
if (payload.stroke) {
const strokes = (base.drawing && base.drawing.strokes) || [];
return { ...base, drawing: { strokes: [...strokes, payload.stroke] } };
}
return base;
});
};
const submit = () => {
if (!text.trim()) return;
const ms = draft ? draft.timestamp_ms : (playerRef.current ? playerRef.current.currentMs() : nowMs);
const payload = {
body: text,
timestamp_ms: replyTo ? replyTo.timestamp_ms : ms,
frame: draft ? draft.frame : frameAt(ms, version.fps || 25),
x: draft ? draft.x : null,
y: draft ? draft.y : null,
drawing: draft ? draft.drawing : null,
parent_id: replyTo ? replyTo.id : null,
};
api.post(`/api/versions/${version.id}/comments`, payload)
.then(() => {
setText(''); setDraft(null); setReplyTo(null); setTool('none');
loadComments(version.id);
})
.catch((e) => toast(e.message, 'error'));
};
const activate = (comment) => {
setActiveId(comment.id);
if (playerRef.current) playerRef.current.seekMs(comment.timestamp_ms);
};
return (
<>
go('task', version.task_id)}>
Task
{client && {client.name} }
{task ? task.title : ''}
{version.label && {version.label} }
{task && }
{versions.length > 1 && (
go('review', e.target.value)}>
{versions.map((v) => (
v{v.version_number}{v.label ? ` · ${v.label}` : ''}{v.status !== 'ready' ? ' (preparing)' : ''}
))}
)}
setNowMs(ms)}
/>
Notes
{roots.length}
setShowResolved(e.target.checked)} />
done
{roots.length === 0 && (
No notes yet. Pause where something is wrong and write one.
)}
{roots.map((c) => (
api.patch(`/api/comments/${cm.id}`, { resolved: !cm.resolved })
.then(() => loadComments(version.id)).catch((e) => toast(e.message, 'error'))}
onDelete={(cm) => api.del(`/api/comments/${cm.id}`)
.then(() => loadComments(version.id)).catch((e) => toast(e.message, 'error'))}
/>
))}
{replyTo && (
Replying at {timecode(replyTo.timestamp_ms)}
setReplyTo(null)}>
)}
{ setTool(tool === 'pin' ? 'none' : 'pin'); startDraft(); }}
title="Click the frame to drop a pin">
Pin
{ setTool(tool === 'draw' ? 'none' : 'draw'); startDraft(); }}
title="Draw on the frame">
Draw
{draft ? `at ${timecode(draft.timestamp_ms, version.fps)}` : `at ${timecode(nowMs, version.fps)}`}
{draft && (draft.x != null || draft.drawing) && (
setDraft({ timestamp_ms: draft.timestamp_ms, frame: draft.frame })}
title="Clear the annotation">
)}
>
);
}