User feedback after v2.1.9: panel still chrome-heavy. The Asset Info panel duplicates what the card already shows; 8 buttons across 3 full-width rows still claim too much vertical real estate. Three surgical changes: 1. Drop the Asset Info details panel entirely. Card meta (name + duration + codec) already carries everything we showed in the key:value table. Library._showDetails / hideDetails become no-ops so the existing call sites in main.js + library.js don't need conditional branches. 2. Shrink .action-row .btn to 20px tall, 10.5px font, 6px horiz padding, 3px radius. Two rows of compact buttons fit where one bulky row used to. 3. Collapse Advanced section behind a toggle (▸ / ▾). Default collapsed so the main 6 buttons stay the primary action surface; click the row to expand and reveal Export & Conform / Fetch & Relink All. Per DESIGN.md "density over whitespace." Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
270 lines
11 KiB
JavaScript
270 lines
11 KiB
JavaScript
// Asset library — v2.1.0
|
|
// Handles: asset grid render, card selection, details panel, tabs, growing poll.
|
|
|
|
(function () {
|
|
const Library = {};
|
|
|
|
Library.state = {
|
|
assets: [],
|
|
growing: [],
|
|
selectedId: null,
|
|
currentTab: 'library', // 'library' | 'growing'
|
|
projects: [],
|
|
selectedProject: 'all',
|
|
searchQuery: '',
|
|
_growingTimer: null,
|
|
_livePolls: {}, // assetId → intervalId (status poll for live → ready)
|
|
_importedAssets: JSON.parse(localStorage.getItem('df.uxp.imported') || '{}'),
|
|
};
|
|
|
|
// ── Project filter ──────────────────────────────────────────────
|
|
Library.loadProjects = async function () {
|
|
try {
|
|
const projects = await API.listProjects();
|
|
Library.state.projects = projects;
|
|
const sel = document.getElementById('project-filter');
|
|
sel.innerHTML = '<option value="all">All Projects</option>';
|
|
projects.forEach(p => {
|
|
const o = document.createElement('option');
|
|
o.value = p.id; o.textContent = p.name;
|
|
sel.appendChild(o);
|
|
});
|
|
// Also populate export project selects
|
|
['export-proj-select'].forEach(id => {
|
|
const el = document.getElementById(id);
|
|
if (!el) return;
|
|
el.innerHTML = '<option value="">— Select project —</option>';
|
|
projects.forEach(p => {
|
|
const o = document.createElement('option');
|
|
o.value = p.id; o.textContent = p.name;
|
|
el.appendChild(o);
|
|
});
|
|
});
|
|
} catch (e) {
|
|
console.warn('loadProjects:', e.message);
|
|
}
|
|
};
|
|
|
|
// ── Tab switching ───────────────────────────────────────────────
|
|
Library.switchTab = function (tab) {
|
|
Library.state.currentTab = tab;
|
|
document.getElementById('tab-library').classList.toggle('active', tab === 'library');
|
|
document.getElementById('tab-growing').classList.toggle('active', tab === 'growing');
|
|
document.getElementById('library-container').classList.toggle('hidden', tab !== 'library');
|
|
document.getElementById('growing-container').classList.toggle('hidden', tab !== 'growing');
|
|
Library.hideDetails();
|
|
if (tab === 'library') Library.refresh(Library.state.searchQuery);
|
|
else Library._pollGrowing();
|
|
};
|
|
|
|
// ── Growing poll ────────────────────────────────────────────────
|
|
Library.startGrowingPoll = function () {
|
|
Library.stopGrowingPoll();
|
|
Library._pollGrowing();
|
|
Library.state._growingTimer = setInterval(Library._pollGrowing, 5000);
|
|
};
|
|
|
|
Library.stopGrowingPoll = function () {
|
|
if (Library.state._growingTimer) {
|
|
clearInterval(Library.state._growingTimer);
|
|
Library.state._growingTimer = null;
|
|
}
|
|
};
|
|
|
|
Library._pollGrowing = async function () {
|
|
if (!API.state.connected) return;
|
|
try {
|
|
const data = await API.listAssets(Library.state.searchQuery, Library.state.selectedProject);
|
|
const all = (data && (data.assets || data.rows)) || [];
|
|
Library.state.growing = all.filter(a =>
|
|
a.status === 'live' || a.status === 'ingesting' || a.status === 'processing' ||
|
|
(a.status === 'ready' && Library.state._importedAssets['live:' + a.id])
|
|
);
|
|
|
|
const active = Library.state.growing.filter(a =>
|
|
a.status === 'live' || a.status === 'ingesting' || a.status === 'processing'
|
|
).length;
|
|
const badge = document.getElementById('growing-count');
|
|
badge.textContent = active;
|
|
badge.style.display = active > 0 ? 'inline-flex' : 'none';
|
|
|
|
if (Library.state.currentTab === 'growing') {
|
|
Library._renderGrid('growing-grid', Library.state.growing);
|
|
}
|
|
} catch (_) {}
|
|
};
|
|
|
|
// ── Asset refresh ───────────────────────────────────────────────
|
|
Library.refresh = async function (query) {
|
|
Library.state.searchQuery = query || '';
|
|
const grid = document.getElementById('asset-grid');
|
|
grid.innerHTML = '<div class="empty muted">Loading…</div>';
|
|
try {
|
|
const data = await API.listAssets(Library.state.searchQuery, Library.state.selectedProject);
|
|
Library.state.assets = (data && (data.assets || data.rows)) || [];
|
|
Library._renderGrid('asset-grid', Library.state.assets);
|
|
UI.toast('Loaded ' + Library.state.assets.length + ' assets', 'ok');
|
|
} catch (e) {
|
|
grid.innerHTML = '<div class="empty muted">Error: ' + e.message + '</div>';
|
|
UI.toast('Asset load failed: ' + e.message, 'error');
|
|
}
|
|
};
|
|
|
|
// ── Grid render ─────────────────────────────────────────────────
|
|
Library._renderGrid = function (gridId, assets) {
|
|
const grid = document.getElementById(gridId);
|
|
grid.innerHTML = '';
|
|
if (!assets.length) {
|
|
const e = document.createElement('div');
|
|
e.className = 'empty muted';
|
|
e.textContent = gridId === 'growing-grid' ? 'No growing files' : 'No assets';
|
|
grid.appendChild(e);
|
|
return;
|
|
}
|
|
assets.forEach(a => grid.appendChild(Library._makeCard(a)));
|
|
Library._syncActions();
|
|
};
|
|
|
|
// Fetch thumbnail with Bearer auth, assign as blob URL.
|
|
// img.src direct assignment never sends Authorization headers.
|
|
Library._loadThumb = function (assetId, img) {
|
|
API.request('/api/v1/assets/' + assetId + '/thumbnail?redirect=1')
|
|
.then(function (r) { return r.ok ? r.blob() : Promise.reject(new Error('HTTP ' + r.status)); })
|
|
.then(function (blob) { img.src = URL.createObjectURL(blob); })
|
|
.catch(function () {
|
|
const p = document.createElement('div');
|
|
p.className = 'asset-thumb-placeholder';
|
|
p.textContent = 'no preview';
|
|
if (img.parentNode) img.replaceWith(p);
|
|
});
|
|
};
|
|
|
|
Library._makeCard = function (asset) {
|
|
const card = document.createElement('div');
|
|
card.className = 'asset-card';
|
|
if (asset.id === Library.state.selectedId) card.classList.add('selected');
|
|
card.dataset.assetId = asset.id;
|
|
|
|
// Thumbnail — loaded async with auth header
|
|
const img = document.createElement('img');
|
|
img.className = 'asset-thumb';
|
|
img.alt = asset.display_name || asset.filename || asset.id;
|
|
card.appendChild(img);
|
|
Library._loadThumb(asset.id, img);
|
|
|
|
// Status badge
|
|
const status = (asset.status || 'ready').toLowerCase();
|
|
const badge = document.createElement('div');
|
|
badge.className = 'asset-status status-' + status;
|
|
badge.textContent = status.toUpperCase();
|
|
card.appendChild(badge);
|
|
|
|
// Info row
|
|
const info = document.createElement('div');
|
|
info.className = 'asset-info';
|
|
const name = document.createElement('div');
|
|
name.className = 'asset-name';
|
|
name.title = asset.display_name || asset.filename || asset.id;
|
|
name.textContent = asset.display_name || asset.filename || asset.id;
|
|
info.appendChild(name);
|
|
|
|
const meta = document.createElement('div');
|
|
meta.className = 'asset-meta';
|
|
const dur = asset.duration_ms ? UI.formatDuration(asset.duration_ms / 1000) : (status === 'live' ? 'LIVE' : '—');
|
|
const codec = asset.codec || asset.media_type || '';
|
|
meta.textContent = dur + (codec ? ' · ' + codec.toUpperCase() : '');
|
|
info.appendChild(meta);
|
|
card.appendChild(info);
|
|
|
|
card.addEventListener('click', () => Library.select(asset.id));
|
|
return card;
|
|
};
|
|
|
|
// ── Selection ───────────────────────────────────────────────────
|
|
Library.select = function (id) {
|
|
Library.state.selectedId = id;
|
|
// Re-render selection highlight without full refresh
|
|
document.querySelectorAll('.asset-card').forEach(c => {
|
|
c.classList.toggle('selected', c.dataset.assetId === id);
|
|
});
|
|
const asset = Library.selectedAsset();
|
|
if (asset) Library._showDetails(asset);
|
|
Library._syncActions();
|
|
};
|
|
|
|
Library.selectedAsset = function () {
|
|
const all = Library.state.currentTab === 'growing'
|
|
? Library.state.growing : Library.state.assets;
|
|
return all.find(a => a.id === Library.state.selectedId) || null;
|
|
};
|
|
|
|
Library.hideDetails = function () {
|
|
// v2.2.0: details panel dropped — clearing selection is enough.
|
|
Library.state.selectedId = null;
|
|
Library._syncActions();
|
|
};
|
|
|
|
Library._showDetails = function (_asset) {
|
|
// v2.2.0: card already shows display_name / duration / codec, and the
|
|
// accent-bordered selected card is the affordance. No-op kept so call
|
|
// sites in main.js / library.js don't need branching.
|
|
};
|
|
|
|
Library._syncActions = function () {
|
|
const sel = Library.selectedAsset();
|
|
const live = sel && sel.status === 'live';
|
|
const ready = sel && sel.status === 'ready';
|
|
const hasLiveImport = sel && !!Library.state._importedAssets['live:' + sel.id];
|
|
|
|
_btn('import-proxy-btn').disabled = !sel || live;
|
|
_btn('import-hires-btn').disabled = !sel || live || !sel.original_s3_key;
|
|
_btn('mount-live-btn').disabled = !sel || !live;
|
|
_btn('relink-btn').disabled = !(ready && hasLiveImport);
|
|
_btn('import-all-btn').disabled = Library.state.currentTab !== 'library';
|
|
_btn('export-timeline-btn').disabled = false; // available once connected
|
|
_btn('export-conform-btn').disabled = false;
|
|
_btn('fetch-relink-btn').disabled = false;
|
|
};
|
|
|
|
function _btn(id) { return document.getElementById(id) || { disabled: false }; }
|
|
|
|
// ── Import mapping persistence ──────────────────────────────────
|
|
Library.recordImport = function (key, entry) {
|
|
Library.state._importedAssets[key] = entry;
|
|
try { localStorage.setItem('df.uxp.imported', JSON.stringify(Library.state._importedAssets)); } catch (_) {}
|
|
};
|
|
|
|
Library.getImport = function (key) {
|
|
return Library.state._importedAssets[key] || null;
|
|
};
|
|
|
|
Library.resolveClipsToAssets = function (clips) {
|
|
return clips.map(clip => {
|
|
const byPath = Library.getImport(clip.filePath);
|
|
const byName = Library.getImport('name:' + clip.fileName);
|
|
const entry = byPath || byName;
|
|
return Object.assign({}, clip, { asset_id: entry ? entry.assetId : null });
|
|
});
|
|
};
|
|
|
|
// ── Live status poll (single asset: live → ready) ───────────────
|
|
Library.startLiveStatusPoll = function (assetId) {
|
|
if (Library.state._livePolls[assetId]) return;
|
|
Library.state._livePolls[assetId] = setInterval(async () => {
|
|
try {
|
|
const a = await API.getAsset(assetId);
|
|
if (a.status === 'ready') {
|
|
clearInterval(Library.state._livePolls[assetId]);
|
|
delete Library.state._livePolls[assetId];
|
|
UI.toast('"' + (a.display_name || assetId) + '" finalized — Relink Hi-Res available', 'ok');
|
|
if (Library.state.selectedId === assetId) {
|
|
Library._showDetails(a);
|
|
Library._syncActions();
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
}, 5000);
|
|
};
|
|
|
|
window.Library = Library;
|
|
})();
|