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:
parent
7da171cf1f
commit
90a9e4361a
5 changed files with 233 additions and 30 deletions
22
services/mam-api/src/db/migrations/010-asset-comments.sql
Normal file
22
services/mam-api/src/db/migrations/010-asset-comments.sql
Normal 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);
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
115
services/mam-api/src/routes/comments.js
Normal file
115
services/mam-api/src/routes/comments.js
Normal 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;
|
||||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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 || '');
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue