feat(comments): persistent frame-anchored comments on asset detail

- migration 010: asset_comments table (id, asset_id, user_id, body,
  frame_ms, resolved, timestamps) with index on asset_id+created_at
- new routes mounted at /api/v1/assets/:assetId/comments — GET/POST/
  PATCH/DELETE with author join (display_name + initials), nullable
  user_id so comments still attach when AUTH_ENABLED is off
- Asset detail loads comments from the API on mount instead of the
  empty ZAMPP_DATA.COMMENTS seed; addComment POSTs and merges the
  returned row; resolved-toggle and delete are wired
- CommentsList: new trash-icon delete action per comment, helpful
  empty-state copy ('Add one below to mark a frame'), tooltips on
  the timestamp and resolved buttons

Now editor comments survive page reload, are visible to other users
via the same API, and pin reliably to frame_ms (integer) instead of
a parsed HH:MM:SS:FF string.
This commit is contained in:
claude 2026-05-23 04:21:11 +00:00
parent 7da171cf1f
commit 90a9e4361a
5 changed files with 233 additions and 30 deletions

View file

@ -0,0 +1,22 @@
-- Asset comments — frame-anchored notes on the Asset Detail page.
--
-- Comments are scoped to an asset and optionally to a timecode within that
-- asset. `frame_ms` is the playhead position when the comment was posted.
-- `resolved` lets editors hide rolled-up notes once addressed.
--
-- User ID is optional (nullable) so comments still attach when AUTH_ENABLED
-- is off and there's no real session user.
CREATE TABLE IF NOT EXISTS asset_comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
asset_id UUID NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
body TEXT NOT NULL,
frame_ms INTEGER,
resolved BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_asset_comments_asset
ON asset_comments (asset_id, created_at);

View file

@ -29,6 +29,7 @@ import clusterRouter from './routes/cluster.js';
import sdkRouter from './routes/sdk.js';
import schedulesRouter from './routes/schedules.js';
import metricsRouter from './routes/metrics.js';
import commentsRouter from './routes/comments.js';
import { startSchedulerLoop } from './scheduler.js';
const app = express();
@ -81,6 +82,7 @@ app.use('/api/v1/cluster', clusterRouter);
app.use('/api/v1/sdk', sdkRouter);
app.use('/api/v1/schedules', schedulesRouter);
app.use('/api/v1/metrics', metricsRouter);
app.use('/api/v1/assets/:assetId/comments', commentsRouter);
// ── Error handler ─────────────────────────────────────────────────────────────
app.use(errorHandler);

View file

@ -0,0 +1,115 @@
// Asset-scoped comments for the Asset Detail page.
//
// Mounted at /api/v1/assets/:assetId/comments via app.use('/api/v1/assets/:assetId/comments', router).
// Express's :assetId param flows through from the parent mount.
import express from 'express';
import pool from '../db/pool.js';
import { requireAuth } from '../middleware/auth.js';
const router = express.Router({ mergeParams: true });
router.use(requireAuth);
function rowToJson(r) {
return {
id: r.id,
asset_id: r.asset_id,
user_id: r.user_id,
body: r.body,
frame_ms: r.frame_ms,
resolved: r.resolved,
created_at: r.created_at,
updated_at: r.updated_at,
author_name: r.author_name || null,
author_initials: r.author_initials || null,
};
}
// GET /api/v1/assets/:assetId/comments
router.get('/', async (req, res, next) => {
try {
const { assetId } = req.params;
const result = await pool.query(
`SELECT c.*,
u.display_name AS author_name,
UPPER(SUBSTRING(COALESCE(u.display_name, u.username, '?'), 1, 2)) AS author_initials
FROM asset_comments c
LEFT JOIN users u ON u.id = c.user_id
WHERE c.asset_id = $1
ORDER BY c.created_at ASC`,
[assetId]
);
res.json({ comments: result.rows.map(rowToJson) });
} catch (err) { next(err); }
});
// POST /api/v1/assets/:assetId/comments
router.post('/', async (req, res, next) => {
try {
const { assetId } = req.params;
const { body, frame_ms } = req.body || {};
if (!body || !String(body).trim()) {
return res.status(400).json({ error: 'body is required' });
}
// Best-effort author lookup — pull from the session if AUTH_ENABLED is on.
const userId = req.session?.userId || null;
const ins = await pool.query(
`INSERT INTO asset_comments (asset_id, user_id, body, frame_ms)
VALUES ($1, $2, $3, $4)
RETURNING *`,
[assetId, userId, String(body).trim(), frame_ms != null ? Math.max(0, Math.round(Number(frame_ms))) : null]
);
// Re-fetch with the author join so the response has the same shape as list.
const result = await pool.query(
`SELECT c.*,
u.display_name AS author_name,
UPPER(SUBSTRING(COALESCE(u.display_name, u.username, '?'), 1, 2)) AS author_initials
FROM asset_comments c
LEFT JOIN users u ON u.id = c.user_id
WHERE c.id = $1`,
[ins.rows[0].id]
);
res.status(201).json(rowToJson(result.rows[0]));
} catch (err) { next(err); }
});
// PATCH /api/v1/assets/:assetId/comments/:id
router.patch('/:id', async (req, res, next) => {
try {
const { id, assetId } = req.params;
const { body, resolved } = req.body || {};
const fields = [];
const values = [];
let i = 1;
if (body !== undefined) { fields.push(`body = $${i++}`); values.push(String(body).trim()); }
if (resolved !== undefined) { fields.push(`resolved = $${i++}`); values.push(!!resolved); }
if (fields.length === 0) return res.status(400).json({ error: 'Nothing to update' });
fields.push('updated_at = NOW()');
values.push(id, assetId);
const result = await pool.query(
`UPDATE asset_comments SET ${fields.join(', ')}
WHERE id = $${i++} AND asset_id = $${i}
RETURNING *`,
values
);
if (result.rows.length === 0) return res.status(404).json({ error: 'Comment not found' });
res.json(rowToJson(result.rows[0]));
} catch (err) { next(err); }
});
// DELETE /api/v1/assets/:assetId/comments/:id
router.delete('/:id', async (req, res, next) => {
try {
const { id, assetId } = req.params;
const result = await pool.query(
`DELETE FROM asset_comments WHERE id = $1 AND asset_id = $2 RETURNING id`,
[id, assetId]
);
if (result.rows.length === 0) return res.status(404).json({ error: 'Comment not found' });
res.json({ id });
} catch (err) { next(err); }
});
export default router;

View file

@ -1,7 +1,5 @@
// screens-asset.jsx asset detail (Frame.io-style player, filmstrip, comments)
const { COMMENTS: SEED_COMMENTS } = window.ZAMPP_DATA;
// Simple gradient palette replaces the missing thumbGrad function
const _FRAME_GRADIENTS = [
'linear-gradient(135deg,#1a1f2e 0%,#2a3045 100%)',
@ -22,8 +20,9 @@ function AssetDetail({ asset, onClose }) {
const [currentMs, setCurrentMs] = React.useState(0);
const [tab, setTab] = React.useState("comments");
const [showResolved, setShowResolved] = React.useState(false);
const [comments, setComments] = React.useState(SEED_COMMENTS || []);
const [comments, setComments] = React.useState([]);
const [newComment, setNewComment] = React.useState("");
const [commentsLoading, setCommentsLoading] = React.useState(false);
// Stream / video state
const [streamUrl, setStreamUrl] = React.useState(null);
@ -147,22 +146,72 @@ function AssetDetail({ asset, onClose }) {
.finally(function() { setRetrying(false); });
};
// Map a /assets/:id/comments row into the legacy shape the consumer
// components (PlaybackBar pins, FilmStrip pins, comment list) already expect.
function _normalizeComment(row) {
const frameMs = row.frame_ms != null ? row.frame_ms : 0;
const real = row.created_at
? window.ZAMPP_API.fmtRelative(row.created_at)
: 'just now';
return {
id: row.id,
who: row.author_name || 'You',
avatar: row.author_initials || (row.author_name ? row.author_name.slice(0, 2).toUpperCase() : 'ZG'),
time: msToTimecode(frameMs),
frame_ms: frameMs,
real,
text: row.body,
resolved: !!row.resolved,
frame: Math.floor(frameMs / 1000 * 30),
};
}
// Load persisted comments whenever the open asset changes.
React.useEffect(() => {
if (!assetId) return;
setCommentsLoading(true);
window.ZAMPP_API.fetch('/assets/' + assetId + '/comments')
.then(function(r) { setComments(((r && r.comments) || []).map(_normalizeComment)); })
.catch(function() { setComments([]); })
.finally(function() { setCommentsLoading(false); });
}, [assetId]);
const addComment = function() {
if (!newComment.trim()) return;
const t = msToTimecode(currentMs);
setComments(function(c) {
return [...c, {
id: 'n' + Date.now(),
who: "Zach Gaetano",
avatar: "ZG",
time: t,
real: "just now",
text: newComment,
resolved: false,
frame: Math.floor(currentMs / 1000 * 30),
}];
});
setNewComment("");
const text = newComment.trim();
if (!text) return;
setNewComment('');
window.ZAMPP_API.fetch('/assets/' + assetId + '/comments', {
method: 'POST',
body: JSON.stringify({ body: text, frame_ms: Math.round(currentMs) }),
})
.then(function(row) {
setComments(function(c) { return [...c, _normalizeComment(row)]; });
})
.catch(function(e) {
// Best-effort fallback so the user doesn't lose the typed comment.
window.alert('Could not post comment: ' + (e.message || 'unknown error'));
setNewComment(text);
});
};
const toggleResolved = function(c) {
window.ZAMPP_API.fetch('/assets/' + assetId + '/comments/' + c.id, {
method: 'PATCH',
body: JSON.stringify({ resolved: !c.resolved }),
})
.then(function(row) {
setComments(function(prev) { return prev.map(x => x.id === c.id ? _normalizeComment(row) : x); });
})
.catch(function() {});
};
const deleteComment = function(c) {
if (!confirm('Delete this comment?')) return;
window.ZAMPP_API.fetch('/assets/' + assetId + '/comments/' + c.id, { method: 'DELETE' })
.then(function() {
setComments(function(prev) { return prev.filter(x => x.id !== c.id); });
})
.catch(function(e) { window.alert('Delete failed: ' + e.message); });
};
const visibleComments = comments.filter(function(c) { return showResolved || !c.resolved; });
@ -334,13 +383,22 @@ function AssetDetail({ asset, onClose }) {
<div className="asset-detail-content">
{tab === "comments" && (
<CommentsList
comments={visibleComments}
onSeek={function(c) { seek(parseDuration(c.time)); }}
onResolve={function(id) {
setComments(function(cs) { return cs.map(function(c) { return c.id === id ? Object.assign({}, c, { resolved: !c.resolved }) : c; }); });
}}
/>
commentsLoading ? (
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--text-3)', fontSize: 12.5 }}>Loading comments</div>
) : (
<CommentsList
comments={visibleComments}
onSeek={function(c) { seek(c.frame_ms != null ? c.frame_ms : parseDuration(c.time)); }}
onResolve={function(id) {
const c = comments.find(x => x.id === id);
if (c) toggleResolved(c);
}}
onDelete={function(id) {
const c = comments.find(x => x.id === id);
if (c) deleteComment(c);
}}
/>
)
)}
{tab === "versions" && <VersionsTab />}
{tab === "metadata" && <MetadataTab asset={asset} />}
@ -433,9 +491,9 @@ function FilmStrip({ seed, current, total, onSeek, comments }) {
);
}
function CommentsList({ comments, onSeek, onResolve }) {
function CommentsList({ comments, onSeek, onResolve, onDelete }) {
if (comments.length === 0) {
return <div style={{ padding: 40, textAlign: "center", color: "var(--text-3)" }}>No comments yet.</div>;
return <div style={{ padding: 40, textAlign: "center", color: "var(--text-3)" }}>No comments yet. Add one below to mark a frame.</div>;
}
return (
<div className="comments-list">
@ -446,12 +504,18 @@ function CommentsList({ comments, onSeek, onResolve }) {
<div className="comment-body">
<div className="comment-head">
<span className="comment-who">{c.who}</span>
<button className="comment-time" onClick={function() { onSeek(c); }}><Icon name="clock" size={10} />{c.time}</button>
<button className="comment-time" onClick={function() { onSeek(c); }} title="Jump to this frame"><Icon name="clock" size={10} />{c.time}</button>
<span className="comment-when">{c.real}</span>
<div style={{ flex: 1 }} />
<button className="comment-action" onClick={function() { onResolve(c.id); }}>
<button className="comment-action" onClick={function() { onResolve(c.id); }}
title={c.resolved ? 'Reopen' : 'Mark resolved'}>
<Icon name={c.resolved ? "refresh" : "check"} size={12} />
</button>
{onDelete && (
<button className="comment-action" onClick={function() { onDelete(c.id); }} title="Delete comment">
<Icon name="trash" size={12} />
</button>
)}
</div>
<div className="comment-text">{c.text}</div>
{c.resolved && <div className="comment-resolved"> Resolved</div>}

View file

@ -67,7 +67,7 @@ async function _uploadFile(file, projectId, onProgress) {
/* ===== Upload ===== */
function Upload({ navigate }) {
const { PROJECTS } = window.ZAMPP_DATA;
const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
const [files, setFiles] = React.useState([]);
const [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || '');