Compare commits

...

14 commits

Author SHA1 Message Date
108390e823 fix(scheduler): add 90s grace before marking stopped-recorder live assets as error 2026-06-03 12:51:41 +00:00
7704988978 fix(recorders): resolve syntax error caused by double declaration of proto variable 2026-06-03 12:17:06 +00:00
a00e90ecc8 fix(merge): resolve conflict in screens-library.jsx 2026-06-03 10:43:59 +00:00
c21260c9b0 fix(ampp): require auth on AMPP endpoint 2026-06-03 10:42:57 +00:00
d16d19c26d fix(node-agent): use timingSafeEqual for token comparison 2026-06-03 10:42:57 +00:00
63f05cd652 fix(audit): critical security hardening and ops reliability fixes 2026-06-03 10:42:57 +00:00
OpenCode
dbef15ae0a fix(library): clicking project in rail now filters assets to that project instead of navigating away 2026-06-03 04:11:32 +00:00
OpenCode
99bd6a8c9c fix(library): auto-expand all bins on load so nested children visible by default 2026-06-03 04:06:48 +00:00
OpenCode
4e6142f455 fix(web-ui): orange pulse logo (bigger, no canvas), fix library missing expandedBins state 2026-06-03 04:02:17 +00:00
OpenCode
02d502baaf fix(web-ui): restore full screens-home.jsx with DragonFlame + Home + Dashboard 2026-06-03 03:58:35 +00:00
OpenCode
00a7af7c54 feat(web-ui): nested bins tree + DragonFlame CSS restored (complete) 2026-06-03 03:48:29 +00:00
cb9ef9c14e fix(web-ui): restore correct styles-fixes.css with DragonFlame logo CSS + upload actual screens-library.jsx nested bins: styles-modal.css 2026-06-02 23:35:12 -04:00
f48a0b73ee feat(web-ui): nested bins tree in library sidebar + bin filter includes descendants: styles-fixes.css 2026-06-02 23:34:14 -04:00
463cc3694d feat(web-ui): nested bins tree, DragonFlame logo, recorder modal 2x2 grid, cleanup .bak
- Library: nested bins with expand/collapse tree in sidebar
  - buildBinTree() + collectDescendantIds() helpers
  - BinTreeNodes recursive component with hover sub-bin create (+) button
  - Selecting a parent bin shows assets from all descendant bins too
- Home: canvas DragonFlame particle animation behind logo (90 flame + 30 spark), logo 140px
- Recorder modal: source-type-grid 3-col → 2x2 so Deltacast card no longer overflows
- CSS: launcher background radial gradient taller; launcher-logo-wrap 160x200px
- Cleanup: remove capture.js.bak: screens-home.jsx
2026-06-02 23:33:58 -04:00
14 changed files with 201 additions and 94 deletions

View file

@ -1,4 +1,4 @@
import { randomBytes, createHash } from 'node:crypto'; import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';
const PREFIX = 'dfl_'; const PREFIX = 'dfl_';
@ -10,6 +10,14 @@ export function hashToken(token) {
return createHash('sha256').update(token).digest('hex'); return createHash('sha256').update(token).digest('hex');
} }
export function compareTokens(tokenA, tokenB) {
if (!tokenA || !tokenB) return false;
const a = Buffer.from(tokenA);
const b = Buffer.from(tokenB);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
export function parseBearer(authorizationHeader) { export function parseBearer(authorizationHeader) {
if (!authorizationHeader || typeof authorizationHeader !== 'string') return null; if (!authorizationHeader || typeof authorizationHeader !== 'string') return null;
const m = authorizationHeader.match(/^Bearer\s+(\S+)$/i); const m = authorizationHeader.match(/^Bearer\s+(\S+)$/i);

View file

@ -1,8 +1,9 @@
import express from 'express'; import express from 'express';
import pool from '../db/pool.js'; import pool from '../db/pool.js';
import { requireAuth } from '../middleware/auth.js';
const router = express.Router(); const router = express.Router();
// No session auth — called from AMPP Script Task inside broadcast network // Protected by requireAuth — AMPP Script Task must use an API token (Bearer Auth).
/** /**
* GET /api/v1/ampp/folder-for/:filename * GET /api/v1/ampp/folder-for/:filename
@ -14,7 +15,7 @@ const router = express.Router();
* 200: { folder_id: "abc123" } * 200: { folder_id: "abc123" }
* 404: { error: "..." } (file not uploaded through Dragon-Wind handle gracefully) * 404: { error: "..." } (file not uploaded through Dragon-Wind handle gracefully)
*/ */
router.get('/folder-for/:filename', async (req, res, next) => { router.get('/folder-for/:filename', requireAuth, async (req, res, next) => {
try { try {
const { filename } = req.params; const { filename } = req.params;
const result = await pool.query( const result = await pool.query(

View file

@ -40,7 +40,7 @@ async function requireRecorderEdit(req, res, next) {
const SIDECAR_PORT_BASE = 7438; const SIDECAR_PORT_BASE = 7438;
// Docker API helper function // Docker API helper function
function dockerApi(method, path, body = null) { function dockerApi(method, path, body = null, timeoutMs = 10000) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const options = { const options = {
socketPath: '/var/run/docker.sock', socketPath: '/var/run/docker.sock',
@ -60,9 +60,9 @@ function dockerApi(method, path, body = null) {
}); });
}); });
req.on('error', reject); req.on('error', reject);
// Add 10-second timeout to prevent indefinite hangs if Docker daemon is unresponsive // Use parameterizable timeout to prevent indefinite hangs if Docker daemon is unresponsive
req.setTimeout(10000, () => { req.setTimeout(timeoutMs, () => {
req.destroy(new Error('Docker API timeout after 10s')); req.destroy(new Error(`Docker API timeout after ${timeoutMs/1000}s`));
}); });
if (body) req.write(JSON.stringify(body)); if (body) req.write(JSON.stringify(body));
req.end(); req.end();
@ -796,13 +796,16 @@ router.post('/:id/stop', requireRecorderEdit, async (req, res, next) => {
const containerId = recorder.container_id; const containerId = recorder.container_id;
(async () => { (async () => {
try { try {
const stopRes = await dockerApi('POST', `/containers/${containerId}/stop?t=180`); const stopRes = await dockerApi('POST', `/containers/${containerId}/stop?t=180`, null, 185000);
if (stopRes.status !== 404) { if (stopRes.status !== 404) {
await waitForFinalize(recorder); await waitForFinalize(recorder);
await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {}); await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {});
} }
} catch (e) { } catch (e) {
console.error('[recorders] failed local background stop:', e.message); console.error('[recorders] failed local background stop:', e.message);
// Attempt finalize and cleanup even if stop call timed out
await waitForFinalize(recorder).catch(() => {});
await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {});
} }
})(); })();
} }
@ -990,10 +993,11 @@ router.post('/probe', async (req, res) => {
// Validate URL up-front so we don't even let the capture service see junk. // Validate URL up-front so we don't even let the capture service see junk.
let parsed = null; let parsed = null;
let proto = '';
if (url) { if (url) {
try { parsed = new URL(url); } try { parsed = new URL(url); }
catch { return res.status(400).json({ error: 'Invalid URL' }); } catch { return res.status(400).json({ error: 'Invalid URL' }); }
const proto = (parsed.protocol || '').replace(':', '').toLowerCase(); proto = (parsed.protocol || '').replace(':', '').toLowerCase();
if (!ALLOWED_PROBE_SCHEMES.has(proto)) { if (!ALLOWED_PROBE_SCHEMES.has(proto)) {
return res.status(400).json({ error: `Scheme "${proto}" is not permitted for probe (#104)` }); return res.status(400).json({ error: `Scheme "${proto}" is not permitted for probe (#104)` });
} }
@ -1001,6 +1005,11 @@ router.post('/probe', async (req, res) => {
if (!isAdmin(req) && isPrivateOrLoopback(parsed.hostname)) { if (!isAdmin(req) && isPrivateOrLoopback(parsed.hostname)) {
return res.status(403).json({ error: 'Probe target must be a public host (#104)' }); return res.status(403).json({ error: 'Probe target must be a public host (#104)' });
} }
// Probe target should not be mam-api itself.
if (parsed.hostname === 'mam-api' || parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1') {
return res.status(403).json({ error: 'Internal probe target is not permitted' });
}
} }
// Try the capture service first (5s timeout) // Try the capture service first (5s timeout)
@ -1026,7 +1035,6 @@ router.post('/probe', async (req, res) => {
} }
const host = parsed.hostname; const host = parsed.hostname;
const proto = (parsed.protocol || '').replace(':', '').toLowerCase();
const isUdp = proto === 'srt' || source_type === 'srt'; const isUdp = proto === 'srt' || source_type === 'srt';
const port = parseInt(parsed.port, 10) || (isUdp ? 9000 : 1935); const port = parseInt(parsed.port, 10) || (isUdp ? 9000 : 1935);

View file

@ -57,7 +57,7 @@ async function probeGrowingPath(path) {
// df -P returns POSIX-portable output: "Filesystem 1024-blocks Used Available Capacity Mounted-on" // df -P returns POSIX-portable output: "Filesystem 1024-blocks Used Available Capacity Mounted-on"
try { try {
const { stdout } = await exec(`df -PB1 ${JSON.stringify(path)}`, { timeout: 3000 }); const { stdout } = await exec(`df -PB1 -- ${JSON.stringify(path)}`, { timeout: 3000 });
const lines = stdout.trim().split('\n'); const lines = stdout.trim().split('\n');
if (lines.length >= 2) { if (lines.length >= 2) {
const cols = lines[1].split(/\s+/); const cols = lines[1].split(/\s+/);

View file

@ -34,7 +34,7 @@ router.post('/', async (req, res, next) => {
`INSERT INTO users (username, password_hash, display_name, role) `INSERT INTO users (username, password_hash, display_name, role)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4)
RETURNING id, username, display_name, role, created_at`, RETURNING id, username, display_name, role, created_at`,
[username.trim(), hash, display_name || username.trim(), role || 'admin'] [username.trim(), hash, display_name || username.trim(), role || 'viewer']
); );
res.status(201).json(rows[0]); res.status(201).json(rows[0]);
} catch (err) { } catch (err) {

View file

@ -137,7 +137,10 @@ async function tick() {
// Orphaned live assets: recorder stopped but asset still 'live'. // Orphaned live assets: recorder stopped but asset still 'live'.
// Happens when the capture sidecar crashes before finalize() runs. // Happens when the capture sidecar crashes before finalize() runs.
// Mark error immediately so the library doesn't show "Recording" forever. // Wait 90s grace before marking error — this prevents a mam-api restart
// from racing the capture container's finalize() POST, which can take up
// to 30s for large files on a slow upload or busy node.
const ORPHAN_GRACE_SECONDS = parseInt(process.env.ORPHAN_GRACE_SECONDS || '90', 10);
const orphanResult = await client.query( const orphanResult = await client.query(
`UPDATE assets a `UPDATE assets a
SET status = 'error', updated_at = NOW() SET status = 'error', updated_at = NOW()
@ -145,7 +148,9 @@ async function tick() {
WHERE a.status = 'live' WHERE a.status = 'live'
AND a.display_name = r.current_session_id AND a.display_name = r.current_session_id
AND r.status = 'stopped' AND r.status = 'stopped'
RETURNING a.id, a.display_name` AND a.updated_at < NOW() - ($1 || ' seconds')::INTERVAL
RETURNING a.id, a.display_name`,
[ORPHAN_GRACE_SECONDS]
); );
if (orphanResult.rows.length > 0) { if (orphanResult.rows.length > 0) {
for (const row of orphanResult.rows) { for (const row of orphanResult.rows) {

View file

@ -461,7 +461,15 @@ function checkAgentAuth(req) {
if (!NODE_TOKEN) return true; if (!NODE_TOKEN) return true;
const hdr = req.headers['authorization'] || ''; const hdr = req.headers['authorization'] || '';
const m = /^Bearer\s+(.+)$/i.exec(hdr); const m = /^Bearer\s+(.+)$/i.exec(hdr);
return !!m && m[1] === NODE_TOKEN; if (!m) return false;
const token = m[1];
if (token.length !== NODE_TOKEN.length) return false;
try {
return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(NODE_TOKEN));
} catch (_) {
return false;
}
} }
// ── Driver/SDK install ──────────────────────────────────────────────────── // ── Driver/SDK install ────────────────────────────────────────────────────

View file

@ -73,7 +73,19 @@ server {
types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; } types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; }
add_header Cache-Control "no-store, no-cache, must-revalidate" always; add_header Cache-Control "no-store, no-cache, must-revalidate" always;
add_header Pragma "no-cache" always; add_header Pragma "no-cache" always;
add_header Access-Control-Allow-Origin * always; # Tighten CORS: no wildcard.
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Credentials "true" always;
if ($request_method = 'OPTIONS') {
add_header Access-Control-Allow-Origin $http_origin;
add_header Access-Control-Allow-Credentials "true";
add_header Access-Control-Allow-Methods 'GET, OPTIONS';
add_header Access-Control-Allow-Headers 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
add_header Access-Control-Max-Age 1728000;
add_header Content-Type 'text/plain; charset=utf-8';
add_header Content-Length 0;
return 204;
}
} }
# Playout HLS preview CasparCG sidecar writes to the media volume under # Playout HLS preview CasparCG sidecar writes to the media volume under
@ -83,7 +95,19 @@ server {
types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; } types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; }
add_header Cache-Control "no-store, no-cache, must-revalidate" always; add_header Cache-Control "no-store, no-cache, must-revalidate" always;
add_header Pragma "no-cache" always; add_header Pragma "no-cache" always;
add_header Access-Control-Allow-Origin * always; # Tighten CORS: no wildcard.
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Credentials "true" always;
if ($request_method = 'OPTIONS') {
add_header Access-Control-Allow-Origin $http_origin;
add_header Access-Control-Allow-Credentials "true";
add_header Access-Control-Allow-Methods 'GET, OPTIONS';
add_header Access-Control-Allow-Headers 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
add_header Access-Control-Max-Age 1728000;
add_header Content-Type 'text/plain; charset=utf-8';
add_header Content-Length 0;
return 204;
}
} }
# API proxy - forward to mam-api service # API proxy - forward to mam-api service
@ -133,6 +157,11 @@ server {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
expires -1; expires -1;
add_header Cache-Control "no-cache, no-store, must-revalidate"; add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:;" always;
} }
# Deny access to dotfiles # Deny access to dotfiles

View file

@ -123,7 +123,7 @@ function App() {
switch (effectiveRoute) { switch (effectiveRoute) {
case 'home': content = <Home navigate={navigate} />; break; case 'home': content = <Home navigate={navigate} />; break;
case 'dashboard': content = <Dashboard navigate={navigate} />; break; case 'dashboard': content = <Dashboard navigate={navigate} />; break;
case 'library': content = <Library navigate={navigate} onOpenAsset={setOpenAsset} openProject={openProject} onClearProject={() => setOpenProject(null)} />; break; case 'library': content = <Library navigate={navigate} onOpenAsset={setOpenAsset} openProject={openProject} onClearProject={() => setOpenProject(null)} onOpenProject={openProjectFromAnywhere} />; break;
case 'projects': content = <Projects navigate={navigate} onOpenProject={openProjectFromAnywhere} />; break; case 'projects': content = <Projects navigate={navigate} onOpenProject={openProjectFromAnywhere} />; break;
case 'upload': content = <Upload navigate={navigate} />; break; case 'upload': content = <Upload navigate={navigate} />; break;
case 'recorders': content = <Recorders navigate={navigate} onNew={() => setShowNewRecorder(true)} />; break; case 'recorders': content = <Recorders navigate={navigate} onNew={() => setShowNewRecorder(true)} />; break;

View file

@ -243,7 +243,11 @@ function AssetDetail({ asset, onClose }) {
setDownloading(true); setDownloading(true);
window.ZAMPP_API.fetch('/assets/' + assetId + '/hires') window.ZAMPP_API.fetch('/assets/' + assetId + '/hires')
.then(function(r) { .then(function(r) {
if (!r || !r.url) { window.alert('No hi-res source available for this asset.'); return; } if (!r || !r.url) {
if (window.toast) window.toast.error('No hi-res source available for this asset.');
else window.alert('No hi-res source available for this asset.');
return;
}
const a = document.createElement('a'); const a = document.createElement('a');
a.href = r.url; a.href = r.url;
a.download = r.filename || (asset.name + '.' + (r.ext || 'mov')); a.download = r.filename || (asset.name + '.' + (r.ext || 'mov'));
@ -253,7 +257,10 @@ function AssetDetail({ asset, onClose }) {
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
}) })
.catch(function(e) { window.alert('Download failed: ' + (e.message || 'unknown error')); }) .catch(function(e) {
if (window.toast) window.toast.error('Download failed: ' + (e.message || 'unknown error'));
else window.alert('Download failed: ' + (e.message || 'unknown error'));
})
.finally(function() { setDownloading(false); }); .finally(function() { setDownloading(false); });
}; };
@ -279,7 +286,10 @@ function AssetDetail({ asset, onClose }) {
}))) return; }))) return;
window.ZAMPP_API.fetch('/assets/' + assetId + '?hard=true', { method: 'DELETE' }) window.ZAMPP_API.fetch('/assets/' + assetId + '?hard=true', { method: 'DELETE' })
.then(function() { onClose && onClose(); }) .then(function() { onClose && onClose(); })
.catch(function(e) { window.alert('Delete failed: ' + e.message); }); .catch(function(e) {
if (window.toast) window.toast.error('Delete failed: ' + e.message);
else window.alert('Delete failed: ' + e.message);
});
}; };
const retryProcessing = function() { const retryProcessing = function() {
@ -287,9 +297,13 @@ function AssetDetail({ asset, onClose }) {
setRetrying(true); setRetrying(true);
window.ZAMPP_API.fetch('/assets/' + assetId + '/retry', { method: 'POST' }) window.ZAMPP_API.fetch('/assets/' + assetId + '/retry', { method: 'POST' })
.then(function() { .then(function() {
window.alert('Re-queued for processing. The proxy worker will pick it up shortly; refresh in a minute to see the player.'); if (window.toast) window.toast.success('Re-queued for processing. The proxy worker will pick it up shortly; refresh in a minute to see the player.');
else window.alert('Re-queued for processing. The proxy worker will pick it up shortly; refresh in a minute to see the player.');
})
.catch(function(e) {
if (window.toast) window.toast.error('Retry failed: ' + (e.message || 'unknown error'));
else window.alert('Retry failed: ' + (e.message || 'unknown error'));
}) })
.catch(function(e) { window.alert('Retry failed: ' + (e.message || 'unknown error')); })
.finally(function() { setRetrying(false); }); .finally(function() { setRetrying(false); });
}; };
@ -298,16 +312,26 @@ function AssetDetail({ asset, onClose }) {
setReprocessing(type); setReprocessing(type);
window.ZAMPP_API.fetch('/assets/' + assetId + '/reprocess?type=' + type, { method: 'POST' }) window.ZAMPP_API.fetch('/assets/' + assetId + '/reprocess?type=' + type, { method: 'POST' })
.then(function() { .then(function() {
window.alert((type === 'proxy' ? 'Proxy' : 'Thumbnail') + ' job queued. Refresh in a moment to see the result.'); if (window.toast) window.toast.success((type === 'proxy' ? 'Proxy' : 'Thumbnail') + ' job queued. Refresh in a moment to see the result.');
else window.alert((type === 'proxy' ? 'Proxy' : 'Thumbnail') + ' job queued. Refresh in a moment to see the result.');
})
.catch(function(e) {
if (window.toast) window.toast.error('Reprocess failed: ' + (e.message || 'unknown error'));
else window.alert('Reprocess failed: ' + (e.message || 'unknown error'));
}) })
.catch(function(e) { window.alert('Reprocess failed: ' + (e.message || 'unknown error')); })
.finally(function() { setReprocessing(null); }); .finally(function() { setReprocessing(null); });
}; };
const regenFilmstrip = function() { const regenFilmstrip = function() {
window.ZAMPP_API.fetch('/assets/' + assetId + '/reprocess?type=filmstrip', { method: 'POST' }) window.ZAMPP_API.fetch('/assets/' + assetId + '/reprocess?type=filmstrip', { method: 'POST' })
.then(function() { window.alert('Filmstrip job queued: it will appear automatically when ready.'); }) .then(function() {
.catch(function(e) { window.alert('Failed to queue filmstrip: ' + (e.message || 'unknown error')); }); if (window.toast) window.toast.success('Filmstrip job queued: it will appear automatically when ready.');
else window.alert('Filmstrip job queued: it will appear automatically when ready.');
})
.catch(function(e) {
if (window.toast) window.toast.error('Failed to queue filmstrip: ' + (e.message || 'unknown error'));
else window.alert('Failed to queue filmstrip: ' + (e.message || 'unknown error'));
});
}; };
// Map a /assets/:id/comments row into the legacy shape the consumer // Map a /assets/:id/comments row into the legacy shape the consumer
@ -352,7 +376,8 @@ function AssetDetail({ asset, onClose }) {
setComments(function(c) { return [...c, _normalizeComment(row)]; }); setComments(function(c) { return [...c, _normalizeComment(row)]; });
}) })
.catch(function(e) { .catch(function(e) {
window.alert('Could not post comment: ' + (e.message || 'unknown error')); if (window.toast) window.toast.error('Could not post comment: ' + (e.message || 'unknown error'));
else window.alert('Could not post comment: ' + (e.message || 'unknown error'));
setNewComment(text); setNewComment(text);
}); });
}; };
@ -374,7 +399,10 @@ function AssetDetail({ asset, onClose }) {
.then(function() { .then(function() {
setComments(function(prev) { return prev.filter(x => x.id !== c.id); }); setComments(function(prev) { return prev.filter(x => x.id !== c.id); });
}) })
.catch(function(e) { window.alert('Delete failed: ' + e.message); }); .catch(function(e) {
if (window.toast) window.toast.error('Delete failed: ' + e.message);
else window.alert('Delete failed: ' + e.message);
});
}; };
const visibleComments = comments.filter(function(c) { return showResolved || !c.resolved; }); const visibleComments = comments.filter(function(c) { return showResolved || !c.resolved; });

View file

@ -1,6 +1,9 @@
// screens-library.jsx // screens-library.jsx
function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
function buildBinTree(f){const m={};f.forEach(b=>{m[b.id]={...b,children:[]};});const r=[];f.forEach(b=>{if(b.parent_id&&m[b.parent_id])m[b.parent_id].children.push(m[b.id]);else r.push(m[b.id]);});return r;}
function collectDescendantIds(id,f){const s=new Set([id]);let c=true;while(c){c=false;f.forEach(b=>{if(b.parent_id&&s.has(b.parent_id)&&!s.has(b.id)){s.add(b.id);c=true;}});}return s;}
function Library({ navigate, onOpenAsset, openProject, onClearProject, onOpenProject }) {
const PROJECTS = window.ZAMPP_DATA?.PROJECTS || []; const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
const [bins, setBins] = React.useState(window.ZAMPP_DATA?.BINS || []); const [bins, setBins] = React.useState(window.ZAMPP_DATA?.BINS || []);
const BINS = bins; // legacy local name; keep so the rest of the function reads unchanged const BINS = bins; // legacy local name; keep so the rest of the function reads unchanged
@ -14,6 +17,8 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
var normalized = (list || []).map(function(b) { return { ...b, count: b.asset_count != null ? b.asset_count : (b.count || 0), icon: b.type || 'grid' }; }); var normalized = (list || []).map(function(b) { return { ...b, count: b.asset_count != null ? b.asset_count : (b.count || 0), icon: b.type || 'grid' }; });
if (!openProject) window.ZAMPP_DATA.BINS = normalized; if (!openProject) window.ZAMPP_DATA.BINS = normalized;
setBins(normalized); setBins(normalized);
// Auto-expand all bins so nested children are always visible
setExpandedBins(function(prev) { var s = new Set(prev); normalized.forEach(function(b){ s.add(b.id); }); return s; });
}) })
.catch(function() {}); .catch(function() {});
}, [openProject]); }, [openProject]);
@ -25,21 +30,44 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
return function() { window.removeEventListener('df:bins-changed', onBinsChanged); }; return function() { window.removeEventListener('df:bins-changed', onBinsChanged); };
}, [refreshBins]); }, [refreshBins]);
const createBin = () => { const [creatingChildOf, setCreatingChildOf] = React.useState(null);
if (!openProject) { window.alert('Open a project first (Projects → click a project), then create a bin inside it.'); return; } // Start with all bins expanded so nested children are visible immediately
setNewBinName(''); setCreatingBin(true); const [expandedBins, setExpandedBins] = React.useState(() => new Set((window.ZAMPP_DATA?.BINS||[]).map(b=>b.id)));
};
const createBin = () => {
if (!openProject) {
if (window.toast) window.toast.error('Open a project first (Projects → click a project), then create a bin inside it.');
else window.alert('Open a project first (Projects → click a project), then create a bin inside it.');
return;
}
setCreatingChildOf(null); setNewBinName(''); setCreatingBin(true);
};
const createSubBin = (parentId) => {
if (!openProject) return;
setCreatingChildOf(parentId); setNewBinName(''); setCreatingBin(true);
};
const toggleBinExpanded = (binId) => {
setExpandedBins(prev => { const s = new Set(prev); s.has(binId) ? s.delete(binId) : s.add(binId); return s; });
};
const submitBin = (name) => { const submitBin = (name) => {
if (!name || !name.trim()) { setCreatingBin(false); return; } if (!name || !name.trim()) { setCreatingBin(false); setCreatingChildOf(null); return; }
setCreatingBin(false); setCreatingBin(false);
const parentId = creatingChildOf;
setCreatingChildOf(null);
window.ZAMPP_API.fetch('/bins', { window.ZAMPP_API.fetch('/bins', {
method: 'POST', method: 'POST',
body: JSON.stringify({ project_id: openProject.id, name: name.trim() }), body: JSON.stringify({ project_id: openProject.id, name: name.trim(), parent_id: parentId || null }),
}) })
.then(() => window.ZAMPP_API.fetch('/bins?project_id=' + openProject.id)) .then(() => window.ZAMPP_API.fetch('/bins?project_id=' + openProject.id))
.then(list => setBins((list || []).map(b => ({ ...b, count: b.asset_count || 0, icon: b.type || 'grid' })))) .then(list => {
.catch(e => window.alert('Could not create bin: ' + e.message)); const n = (list||[]).map(b=>({...b,count:b.asset_count||0,icon:b.type||'grid'}));
setBins(n);
if (parentId) setExpandedBins(prev => { const s=new Set(prev); s.add(parentId); return s; });
})
.catch(e => {
if (window.toast) window.toast.error('Could not create bin: ' + e.message);
else window.alert('Could not create bin: ' + e.message);
});
}; };
const [view, setView] = React.useState('grid'); const [view, setView] = React.useState('grid');
const [filter, setFilter] = React.useState('all'); // 'all', 'ready', 'processing', 'live', 'error', 'recent' const [filter, setFilter] = React.useState('all'); // 'all', 'ready', 'processing', 'live', 'error', 'recent'
@ -285,12 +313,13 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
assets = assets.filter(function(a) { return a.status === filter; }); assets = assets.filter(function(a) { return a.status === filter; });
} }
if (search) assets = assets.filter(function(a) { return a.name.toLowerCase().includes(search.toLowerCase()); }); if (search) assets = assets.filter(function(a) { return a.name.toLowerCase().includes(search.toLowerCase()); });
if (selectedBinId) assets = assets.filter(function(a) { return a.bin_id === selectedBinId; }); if (selectedBinId) { var sids=collectDescendantIds(selectedBinId,BINS); assets=assets.filter(function(a){return sids.has(a.bin_id);}); }
const activeBin = selectedBinId ? BINS.find(b => b.id === selectedBinId) : null; const activeBin = selectedBinId ? BINS.find(b => b.id === selectedBinId) : null;
const displayTitle = activeBin const displayTitle = activeBin
? (openProject ? openProject.name + ' · ' : '') + activeBin.name ? (openProject ? openProject.name + ' · ' : '') + activeBin.name
: (openProject ? openProject.name : 'All Assets'); : (openProject ? openProject.name : 'All Assets');
const binTree=React.useMemo(function(){return buildBinTree(BINS);},[BINS]);
const errorCount = ALL_ASSETS.filter(function(a) { return a.status === 'error'; }).length; const errorCount = ALL_ASSETS.filter(function(a) { return a.status === 'error'; }).length;
const recentCount = ALL_ASSETS.filter(function(a) { return (Date.now() - new Date(a.created_at)) < 86400000; }).length; const recentCount = ALL_ASSETS.filter(function(a) { return (Date.now() - new Date(a.created_at)) < 86400000; }).length;
@ -309,7 +338,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
{PROJECTS.slice(0, 8).map(function(p) { {PROJECTS.slice(0, 8).map(function(p) {
return ( return (
<div key={p.id} className={`rail-item ${openProject && openProject.id === p.id ? 'active' : ''}`} style={{ cursor: 'pointer' }} <div key={p.id} className={`rail-item ${openProject && openProject.id === p.id ? 'active' : ''}`} style={{ cursor: 'pointer' }}
onClick={function() { navigate('projects'); }} onClick={function() { if (onOpenProject) onOpenProject(p); }}
onContextMenu={function(e) { openProjectCtx(p, e); }}> onContextMenu={function(e) { openProjectCtx(p, e); }}>
<span className="rail-color-dot" style={{ background: p.color }} /> <span className="rail-color-dot" style={{ background: p.color }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span> <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
@ -329,45 +358,30 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
</button> </button>
</div> </div>
<div className="rail-list"> <div className="rail-list">
{creatingBin && (
<div style={{ padding: '4px 6px', display: 'flex', gap: 4, alignItems: 'center' }}>
<input
className="field-input"
autoFocus
value={newBinName}
onChange={function(e) { setNewBinName(e.target.value); }}
onKeyDown={function(e) {
if (e.key === 'Enter') submitBin(newBinName);
if (e.key === 'Escape') { setCreatingBin(false); }
}}
onBlur={function() { submitBin(newBinName); }}
placeholder="Bin name"
style={{ fontSize: 12, height: 26, padding: '0 6px', flex: 1 }}
/>
</div>
)}
{!creatingBin && BINS.length === 0 ? ( {!creatingBin && BINS.length === 0 ? (
<div style={{ fontSize: 11, color: 'var(--text-3)', padding: '6px 8px', fontStyle: 'italic' }}> <div style={{ fontSize: 11, color: 'var(--text-3)', padding: '6px 8px', fontStyle: 'italic' }}>
{openProject ? 'No bins yet: click + to create one.' : 'Open a project to manage bins.'} {openProject ? 'No bins yet: click + to create one.' : 'Open a project to manage bins.'}
</div> </div>
) : BINS.map(function(b) { ) : (
const isActive = selectedBinId === b.id; <BinTreeNodes nodes={binTree} depth={0}
const isDragTarget = draggingAssetId !== null && dragOverBinId === b.id; selectedBinId={selectedBinId} setSelectedBinId={setSelectedBinId}
return ( draggingAssetId={draggingAssetId} dragOverBinId={dragOverBinId}
<div key={b.id} onBinDragOver={onBinDragOver} onBinDrop={onBinDrop} onBinDragLeave={onBinDragLeave}
className={'rail-item' + (isActive ? ' active' : '') + (isDragTarget ? ' droppable' : '')} expandedBins={expandedBins} toggleBinExpanded={toggleBinExpanded}
onClick={function() { setSelectedBinId(isActive ? null : b.id); }} creatingBin={creatingBin} creatingChildOf={creatingChildOf}
onDragOver={function(e) { onBinDragOver(b.id, e); }} newBinName={newBinName} setNewBinName={setNewBinName}
onDrop={function(e) { onBinDrop(b.id, e); }} submitBin={submitBin} setCreatingBin={setCreatingBin} setCreatingChildOf={setCreatingChildOf}
onDragLeave={onBinDragLeave} createSubBin={createSubBin} openProject={openProject} />
style={{ cursor: 'pointer' }} )}
title={isActive ? 'Click to clear bin filter' : 'Filter to this bin'}> {creatingBin && creatingChildOf === null && (
<Icon name={binIcon(b.icon)} size={13} className="rail-icon" /> <div style={{ padding: '4px 6px', display: 'flex', gap: 4, alignItems: 'center' }}>
<span>{b.name}</span> <input className="field-input" autoFocus value={newBinName}
<span className="rail-count">{b.count}</span> onChange={function(e) { setNewBinName(e.target.value); }}
onKeyDown={function(e) { if (e.key==='Enter') submitBin(newBinName); if (e.key==='Escape') { setCreatingBin(false); } }}
onBlur={function() { submitBin(newBinName); }}
placeholder="Bin name" style={{ fontSize: 12, height: 26, padding: '0 6px', flex: 1 }} />
</div> </div>
); )}
})}
</div> </div>
</div> </div>
<div> <div>
@ -596,7 +610,8 @@ function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen, onRen
window.ZAMPP_API.fetch('/assets/' + asset.id + '/promote', { method: 'POST' }) window.ZAMPP_API.fetch('/assets/' + asset.id + '/promote', { method: 'POST' })
.then(function() { .then(function() {
if (onChanged) onChanged(); if (onChanged) onChanged();
window.alert('Promotion job queued. The file is being uploaded to S3 in the background.'); if (window.toast) window.toast.success('Promotion job queued. The file is being uploaded to S3 in the background.');
else window.alert('Promotion job queued. The file is being uploaded to S3 in the background.');
}) })
.catch(function(e) { alert('Promotion failed: ' + e.message); }); .catch(function(e) { alert('Promotion failed: ' + e.message); });
}; };
@ -873,5 +888,6 @@ function DownloadWarningModal({ asset, onClose, onConfirm }) {
); );
} }
function BinTreeNodes(p){var nodes=p.nodes,depth=p.depth,selId=p.selectedBinId,setSel=p.setSelectedBinId;var drag=p.draggingAssetId,over=p.dragOverBinId;var onOver=p.onBinDragOver,onDrop=p.onBinDrop,onLeave=p.onBinDragLeave;var expanded=p.expandedBins,toggle=p.toggleBinExpanded;var creat=p.creatingBin,parentOf=p.creatingChildOf;var newName=p.newBinName,setName=p.setNewBinName;var submit=p.submitBin,setCreat=p.setCreatingBin,setParent=p.setCreatingChildOf;var createSub=p.createSubBin,proj=p.openProject;if(!nodes||!nodes.length)return null;return nodes.map(function(b){var act=selId===b.id,idt=drag!==null&&over===b.id,hasC=b.children&&b.children.length>0,exp=expanded.has(b.id),isCk=creat&&parentOf===b.id,ind=depth*14;return React.createElement(React.Fragment,{key:b.id},React.createElement("div",{className:"rail-item"+(act?" active":"")+(idt?" droppable":""),onClick:function(){setSel(act?null:b.id);},onDragOver:function(e){onOver(b.id,e);},onDrop:function(e){onDrop(b.id,e);},onDragLeave:onLeave,style:{cursor:"pointer",paddingLeft:8+ind}},React.createElement("span",{style:{display:"inline-flex",alignItems:"center",width:16,height:16,flexShrink:0,marginRight:2,color:hasC?"var(--text-3)":"transparent",transition:"transform 120ms",transform:hasC&&exp?"rotate(90deg)":"none"},onClick:function(e){if(!hasC)return;e.stopPropagation();toggle(b.id);}},hasC&&React.createElement("svg",{width:8,height:8,viewBox:"0 0 8 8",fill:"currentColor"},React.createElement("path",{d:"M2 1l4 3-4 3V1z"}))),React.createElement(Icon,{name:binIcon(b.icon),size:13,className:"rail-icon",style:{marginRight:4}}),React.createElement("span",{style:{flex:1,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}},b.name),React.createElement("span",{className:"rail-count"},b.count),proj&&React.createElement("button",{className:"icon-btn bin-add-child-btn","aria-label":"Create sub-bin",onClick:function(e){e.stopPropagation();createSub(b.id);},style:{opacity:0,transition:"opacity 100ms",marginLeft:2,flexShrink:0},onFocus:function(e){e.currentTarget.style.opacity="1";},onBlur:function(e){e.currentTarget.style.opacity="";}},React.createElement(Icon,{name:"plus",size:10}))),isCk&&React.createElement("div",{style:{paddingLeft:8+ind+14,paddingRight:6,paddingTop:4,paddingBottom:4,display:"flex",gap:4,alignItems:"center"}},React.createElement("input",{className:"field-input",autoFocus:true,value:newName,onChange:function(e){setName(e.target.value);},onKeyDown:function(e){if(e.key==="Enter")submit(newName);if(e.key==="Escape"){setCreat(false);setParent(null);}},onBlur:function(){submit(newName);},placeholder:"Sub-bin name",style:{fontSize:12,height:26,padding:"0 6px",flex:1}})),hasC&&exp&&React.createElement(BinTreeNodes,Object.assign({},p,{nodes:b.children,depth:depth+1})));});}
window.Library = Library; window.Library = Library;
window.AssetCard = AssetCard; window.AssetCard = AssetCard;

View file

@ -292,37 +292,38 @@
text-align: center; text-align: center;
margin-top: 8px; margin-top: 8px;
} }
/* Logo wrapper holds the animated pulse halo behind the image. */ /* Logo wrapper — large hero with orange pulse halo. */
.launcher-logo-wrap { .launcher-logo-wrap {
position: relative; position: relative;
display: inline-grid; display: inline-grid;
place-items: center; place-items: center;
width: 52px; width: 120px;
height: 52px; height: 120px;
flex-shrink: 0; flex-shrink: 0;
} }
.launcher-logo-pulse { .launcher-logo-pulse {
position: absolute; position: absolute;
width: 80px; width: 180px;
height: 80px; height: 180px;
border-radius: 50%; border-radius: 50%;
background: radial-gradient(circle, var(--accent-soft) 0%, transparent 70%); background: radial-gradient(circle, rgba(232, 130, 28, 0.35) 0%, rgba(232, 130, 28, 0.08) 55%, transparent 70%);
animation: logoPulse 3s ease-in-out infinite; animation: logoPulse 2.8s ease-in-out infinite;
z-index: 0; z-index: 0;
} }
@keyframes logoPulse { @keyframes logoPulse {
0%, 100% { transform: scale(1); opacity: 0.6; } 0%, 100% { transform: scale(1); opacity: 0.7; }
50% { transform: scale(1.15); opacity: 1; } 50% { transform: scale(1.18); opacity: 1; }
} }
.launcher-logo { .launcher-logo {
position: relative; position: relative;
z-index: 1; z-index: 1;
width: 52px; width: 110px;
height: 52px; height: 110px;
object-fit: contain; object-fit: contain;
filter: filter:
brightness(0) invert(1) brightness(0) invert(1)
drop-shadow(0 0 8px rgba(232, 130, 28, 0.35)); drop-shadow(0 0 14px rgba(232, 130, 28, 0.6))
drop-shadow(0 0 4px rgba(255, 180, 60, 0.4));
animation: launcherLogoIn 400ms cubic-bezier(0.2, 0.7, 0.2, 1) both; animation: launcherLogoIn 400ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
} }
@keyframes launcherLogoIn { @keyframes launcherLogoIn {
@ -330,7 +331,7 @@
to { opacity: 1; transform: scale(1); } to { opacity: 1; transform: scale(1); }
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.launcher-logo-pulse { animation: none; opacity: 0.5; } .launcher-logo-pulse { animation: none; opacity: 0.6; }
.launcher-logo { animation: none; } .launcher-logo { animation: none; }
} }

View file

@ -70,7 +70,7 @@
} }
.source-type-grid { .source-type-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(2, 1fr);
gap: 8px; gap: 8px;
} }
.source-type-card { .source-type-card {

View file

@ -1066,6 +1066,9 @@
.rail-item .rail-icon { color: var(--text-3); } .rail-item .rail-icon { color: var(--text-3); }
.rail-item .rail-count { margin-left: auto; font-family: var(--font-mono); font-size: 10.5px; color: var(--text-3); } .rail-item .rail-count { margin-left: auto; font-family: var(--font-mono); font-size: 10.5px; color: var(--text-3); }
.rail-color-dot { width: 8px; height: 8px; border-radius: 50%; } .rail-color-dot { width: 8px; height: 8px; border-radius: 50%; }
/* Show sub-bin create button only on hover of the parent rail-item */
.rail-item:hover .bin-add-child-btn { opacity: 1 !important; }
.bin-add-child-btn { padding: 0 2px; height: 18px; min-width: 18px; }
.library-main { .library-main {
display: flex; flex-direction: column; display: flex; flex-direction: column;