Compare commits
14 commits
feat/hls-v
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 108390e823 | |||
| 7704988978 | |||
| a00e90ecc8 | |||
| c21260c9b0 | |||
| d16d19c26d | |||
| 63f05cd652 | |||
|
|
dbef15ae0a | ||
|
|
99bd6a8c9c | ||
|
|
4e6142f455 | ||
|
|
02d502baaf | ||
|
|
00a7af7c54 | ||
| cb9ef9c14e | |||
| f48a0b73ee | |||
| 463cc3694d |
14 changed files with 201 additions and 94 deletions
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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+/);
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 ────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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; });
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue