diff --git a/services/mam-api/src/db/migrations/010-asset-comments.sql b/services/mam-api/src/db/migrations/010-asset-comments.sql new file mode 100644 index 0000000..7a6127a --- /dev/null +++ b/services/mam-api/src/db/migrations/010-asset-comments.sql @@ -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); diff --git a/services/mam-api/src/index.js b/services/mam-api/src/index.js index d915042..bcff8d5 100644 --- a/services/mam-api/src/index.js +++ b/services/mam-api/src/index.js @@ -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); diff --git a/services/mam-api/src/routes/comments.js b/services/mam-api/src/routes/comments.js new file mode 100644 index 0000000..4b65089 --- /dev/null +++ b/services/mam-api/src/routes/comments.js @@ -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; diff --git a/services/web-ui/public/screens-asset.jsx b/services/web-ui/public/screens-asset.jsx index ea9f7e9..1c7ed32 100644 --- a/services/web-ui/public/screens-asset.jsx +++ b/services/web-ui/public/screens-asset.jsx @@ -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 }) {