dragonflight/services/web-ui/public/index.html

795 lines
29 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wild Dragon - Asset Browser</title>
<link rel="stylesheet" href="/css/common.css">
<style>
.assets-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-lg);
gap: var(--spacing-lg);
}
.assets-controls {
display: flex;
gap: var(--spacing-md);
}
.asset-detail-panel {
display: none;
position: fixed;
top: 0;
right: 0;
width: 400px;
height: 100vh;
background-color: var(--color-bg-secondary);
border-left: 1px solid var(--color-border);
z-index: 500;
overflow-y: auto;
animation: slideInRight var(--transition-normal);
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.3);
}
.asset-detail-panel.active {
display: flex;
flex-direction: column;
}
@keyframes slideInRight {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
.detail-header {
padding: var(--spacing-lg);
border-bottom: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.detail-close {
background: none;
border: none;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 1.5rem;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
}
.detail-close:hover { color: var(--color-text-primary); }
.detail-body {
padding: var(--spacing-lg);
flex: 1;
overflow-y: auto;
}
.detail-section { margin-bottom: var(--spacing-lg); }
.detail-section-title {
font-size: 0.85rem;
font-weight: 700;
color: var(--color-text-tertiary);
text-transform: uppercase;
margin-bottom: var(--spacing-md);
}
.detail-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: var(--spacing-sm) 0;
border-bottom: 1px solid var(--color-border);
}
.detail-label {
font-size: 0.9rem;
color: var(--color-text-tertiary);
font-weight: 600;
}
.detail-value {
font-size: 0.9rem;
color: var(--color-text-primary);
word-break: break-word;
text-align: right;
flex: 1;
margin-left: var(--spacing-md);
}
.video-player {
width: 100%;
height: auto;
background-color: var(--color-bg-primary);
border-radius: var(--radius-md);
margin-bottom: var(--spacing-lg);
}
.detail-actions {
display: flex;
gap: var(--spacing-sm);
padding-top: var(--spacing-md);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 400px;
text-align: center;
}
.empty-state-icon {
font-size: 3rem;
margin-bottom: var(--spacing-md);
opacity: 0.3;
}
.empty-state-text {
color: var(--color-text-tertiary);
margin-bottom: var(--spacing-md);
}
.filter-group {
display: flex;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-lg);
flex-wrap: wrap;
}
.filter-tag {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: 4px 10px;
background-color: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: 20px;
font-size: 0.85rem;
color: var(--color-text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
}
.filter-tag:hover {
border-color: var(--color-accent-primary);
color: var(--color-accent-primary);
}
.filter-tag.active {
background-color: var(--color-accent-primary);
border-color: var(--color-accent-primary);
color: white;
}
.content-main {
display: flex;
flex-direction: column;
transition: margin-right var(--transition-normal);
}
.content-main.detail-open { margin-right: 400px; }
/* Thumbnail inside asset card */
.card-thumbnail {
position: relative;
aspect-ratio: 16/9;
background-color: var(--color-bg-tertiary);
overflow: hidden;
border-radius: var(--radius-md) var(--radius-md) 0 0;
}
.card-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: opacity 0.3s ease;
}
.card-thumbnail img.loading { opacity: 0.3; }
.card-thumbnail img.loaded { opacity: 1; }
.card-thumbnail .thumb-placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
opacity: 0.2;
}
/* Status pulse for recording/processing */
.card-status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 4px;
}
.card-status-dot.processing { background: #f59e0b; animation: pulse 1.5s infinite; }
.card-status-dot.ingesting { background: #3b82f6; animation: pulse 1.5s infinite; }
.card-status-dot.ready { background: #22c55e; }
.card-status-dot.error { background: #ef4444; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
</style>
</head>
<body>
<div class="app-container">
<!-- Header -->
<header class="header">
<div class="header-logo">
<div class="header-logo-icon">D</div>
<span>WILD DRAGON</span>
</div>
<nav class="header-nav">
<div class="nav-item active" data-page="assets">Assets</div>
<div class="nav-item" data-page="capture">Capture</div>
<div class="nav-item" data-page="upload">Upload</div>
<div class="nav-item" data-page="recorders">Recorders</div>
</nav>
</header>
<!-- Main Content -->
<div class="main-content">
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-section">
<div class="sidebar-section-title">Projects</div>
<div id="projectsList"></div>
</div>
<div class="sidebar-section">
<div class="sidebar-section-title">Bins</div>
<div id="binsList" class="sidebar-tree"></div>
</div>
</aside>
<!-- Content Area -->
<div class="content-area">
<div class="content-main" id="contentMain">
<!-- Assets Header -->
<div class="assets-header">
<h1>Assets</h1>
<div class="assets-controls">
<button class="btn btn-secondary" id="refreshBtn">↻ Refresh</button>
<button class="btn btn-primary" onclick="navigateTo('upload')">↑ Upload</button>
<button class="btn btn-primary" onclick="navigateTo('capture')">⏺ New Capture</button>
</div>
</div>
<!-- Search Bar -->
<div class="search-bar">
<span class="search-icon">🔍</span>
<input type="text" id="searchInput" placeholder="Search assets by name, tag, or codec...">
</div>
<!-- Filter Tags -->
<div class="filter-group" id="filterGroup"></div>
<!-- Assets Grid -->
<div class="grid grid-2" id="assetsGrid"></div>
<!-- Empty State -->
<div class="empty-state" id="emptyState" style="display: none;">
<div class="empty-state-icon">📁</div>
<div class="empty-state-text">No assets found</div>
<p style="color: var(--color-text-tertiary); font-size: 0.9rem;">
Create a new capture or upload a file to get started
</p>
</div>
</div>
</div>
</div>
<!-- Asset Detail Panel -->
<div class="asset-detail-panel" id="detailPanel">
<div class="detail-header">
<h2>Asset Details</h2>
<button class="detail-close" onclick="closeDetailPanel()"></button>
</div>
<div class="detail-body">
<video class="video-player" id="videoPlayer" controls preload="metadata"></video>
<div class="detail-section">
<div class="detail-section-title">File Information</div>
<div class="detail-row">
<div class="detail-label">Filename</div>
<div class="detail-value" id="detailFilename"></div>
</div>
<div class="detail-row">
<div class="detail-label">Codec</div>
<div class="detail-value" id="detailCodec"></div>
</div>
<div class="detail-row">
<div class="detail-label">Resolution</div>
<div class="detail-value" id="detailResolution"></div>
</div>
<div class="detail-row">
<div class="detail-label">Framerate</div>
<div class="detail-value" id="detailFps"></div>
</div>
<div class="detail-row">
<div class="detail-label">Duration</div>
<div class="detail-value" id="detailDuration"></div>
</div>
<div class="detail-row">
<div class="detail-label">File Size</div>
<div class="detail-value" id="detailFileSize"></div>
</div>
<div class="detail-row">
<div class="detail-label">Status</div>
<div class="detail-value" id="detailStatus"></div>
</div>
<div class="detail-row">
<div class="detail-label">Captured</div>
<div class="detail-value" id="detailCreated"></div>
</div>
</div>
<div class="detail-section">
<div class="detail-section-title">Metadata</div>
<div class="form-group">
<label class="form-label">Tags</label>
<input type="text" id="detailTags" class="form-input" placeholder="comma-separated tags">
</div>
<div class="form-group">
<label class="form-label">Notes</label>
<textarea id="detailNotes" class="form-textarea" placeholder="Add notes about this asset..."></textarea>
</div>
<div class="detail-actions">
<button class="btn btn-primary btn-sm" onclick="saveAssetMetadata()">Save Changes</button>
<button class="btn btn-secondary btn-sm" onclick="closeDetailPanel()">Close</button>
</div>
</div>
</div>
</div>
<!-- Status Bar -->
<footer class="status-bar">
<div class="status-item">
<span class="status-indicator" id="statusIndicator"></span>
<span id="statusText">Initializing...</span>
</div>
<div class="status-item" id="assetCountItem" style="display: none;">
<span id="assetCount">0</span> assets
</div>
</footer>
</div>
<script src="/js/api.js"></script>
<script>
// ============================================================
// STATE
// ============================================================
const state = {
assets: [],
projects: [],
bins: [],
selectedAsset: null,
selectedProject: null,
selectedBin: null,
searchQuery: '',
filterStatus: null,
// Cache of assetId -> thumbnail URL to avoid re-fetching
thumbCache: {},
};
// IntersectionObserver for lazy thumbnail loading
const thumbObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
const assetId = img.dataset.assetId;
if (assetId && !img.dataset.loaded) {
loadThumbnail(img, assetId);
}
thumbObserver.unobserve(img);
}
});
}, { rootMargin: '100px' });
// ============================================================
// INIT
// ============================================================
document.addEventListener('DOMContentLoaded', async () => {
setupEventListeners();
await loadInitialData();
});
function setupEventListeners() {
document.querySelectorAll('[data-page]').forEach(el => {
el.addEventListener('click', (e) => navigateTo(e.target.dataset.page));
});
const searchInput = document.getElementById('searchInput');
searchInput.addEventListener('input', debounce((e) => {
state.searchQuery = e.target.value.toLowerCase();
renderAssets();
}, 300));
document.getElementById('refreshBtn').addEventListener('click', loadInitialData);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeDetailPanel();
});
}
async function loadInitialData() {
try {
updateStatusBar('Loading...');
const [projectsRes, assetsRes] = await Promise.all([
getProjects(),
getAssets({ limit: 100 }),
]);
if (projectsRes.success) {
state.projects = projectsRes.data || [];
renderProjects();
}
if (assetsRes.success) {
// API returns { assets: [...], total: n }
state.assets = assetsRes.data?.assets ?? assetsRes.data ?? [];
renderAssets();
}
updateStatusBar();
} catch (err) {
console.error('Failed to load data:', err);
updateStatusBar('Error loading data', true);
}
}
// ============================================================
// RENDERING
// ============================================================
function renderProjects() {
const container = document.getElementById('projectsList');
container.innerHTML = '';
if (state.projects.length === 0) {
container.innerHTML = '<div style="padding:8px;color:var(--color-text-tertiary);font-size:0.85rem;">No projects</div>';
return;
}
state.projects.forEach(project => {
const item = document.createElement('div');
item.className = `sidebar-item ${state.selectedProject === project.id ? 'active' : ''}`;
item.textContent = project.name;
item.addEventListener('click', () => selectProject(project.id));
container.appendChild(item);
});
}
function renderAssets() {
const grid = document.getElementById('assetsGrid');
const emptyState = document.getElementById('emptyState');
let filtered = [...state.assets];
if (state.searchQuery) {
filtered = filtered.filter(asset => {
const haystack = [
asset.filename,
asset.display_name,
...(asset.tags || []),
asset.notes || '',
].join(' ').toLowerCase();
return haystack.includes(state.searchQuery);
});
}
if (state.filterStatus) {
filtered = filtered.filter(a => a.status === state.filterStatus);
}
if (state.selectedProject) {
filtered = filtered.filter(a => a.project_id === state.selectedProject);
}
if (state.selectedBin) {
filtered = filtered.filter(a => a.bin_id === state.selectedBin);
}
if (filtered.length === 0) {
grid.innerHTML = '';
emptyState.style.display = 'flex';
document.getElementById('assetCountItem').style.display = 'none';
return;
}
emptyState.style.display = 'none';
grid.innerHTML = '';
filtered.forEach(asset => {
const card = createAssetCard(asset);
grid.appendChild(card);
});
renderFilterGroup();
updateStatusBar();
}
function createAssetCard(asset) {
const card = document.createElement('div');
card.className = 'card';
card.dataset.assetId = asset.id;
const durationStr = formatDuration(
asset.duration_ms ? Math.round(asset.duration_ms / 1000) : 0
);
card.innerHTML = `
<div class="card-thumbnail">
<span class="thumb-placeholder">🎬</span>
<img
data-asset-id="${asset.id}"
alt="${escapeHtml(asset.display_name || asset.filename)}"
class="loading"
style="position:absolute;inset:0;"
>
</div>
<div class="card-content">
<div class="card-title">${escapeHtml(asset.display_name || asset.filename)}</div>
<div class="card-meta">
<div class="card-duration">${durationStr}</div>
<div class="card-status">
<span class="card-status-dot ${asset.status}"></span>
<span class="badge ${getStatusBadgeClass(asset.status)}">
${getStatusLabel(asset.status)}
</span>
</div>
</div>
</div>
`;
// Observe the img element for lazy thumbnail loading
const img = card.querySelector('img[data-asset-id]');
if (asset.thumbnail_s3_key) {
thumbObserver.observe(img);
} else {
// No thumbnail yet — hide the img entirely, show placeholder
img.style.display = 'none';
}
card.addEventListener('click', () => openAssetDetail(asset));
return card;
}
async function loadThumbnail(img, assetId) {
// Use cached URL if available
if (state.thumbCache[assetId]) {
img.src = state.thumbCache[assetId];
img.classList.remove('loading');
img.classList.add('loaded');
img.dataset.loaded = '1';
return;
}
try {
const result = await api(`/assets/${assetId}/thumbnail`);
if (result.success) {
state.thumbCache[assetId] = result.data.url;
img.src = result.data.url;
img.onload = () => {
img.classList.remove('loading');
img.classList.add('loaded');
};
img.dataset.loaded = '1';
}
} catch (err) {
// Silently ignore — placeholder stays
}
}
function renderFilterGroup() {
const container = document.getElementById('filterGroup');
container.innerHTML = '';
const counts = {};
state.assets.forEach(asset => {
counts[asset.status] = (counts[asset.status] || 0) + 1;
});
Object.entries(counts).forEach(([status, count]) => {
const tag = document.createElement('div');
tag.className = `filter-tag ${state.filterStatus === status ? 'active' : ''}`;
tag.textContent = `${getStatusLabel(status)} (${count})`;
tag.addEventListener('click', () => {
state.filterStatus = state.filterStatus === status ? null : status;
renderAssets();
});
container.appendChild(tag);
});
}
// ============================================================
// ASSET DETAIL PANEL
// ============================================================
async function openAssetDetail(asset) {
state.selectedAsset = asset;
const panel = document.getElementById('detailPanel');
const main = document.getElementById('contentMain');
const video = document.getElementById('videoPlayer');
// Clear previous video
video.src = '';
// Populate with what we already have
document.getElementById('detailFilename').textContent = asset.display_name || asset.filename;
document.getElementById('detailCodec').textContent = asset.codec || '—';
document.getElementById('detailResolution').textContent = asset.resolution || '—';
document.getElementById('detailFps').textContent = asset.fps ? `${asset.fps} fps` : '—';
document.getElementById('detailDuration').textContent = formatDuration(
asset.duration_ms ? Math.round(asset.duration_ms / 1000) : 0
);
document.getElementById('detailFileSize').textContent = formatFileSize(asset.file_size || 0);
document.getElementById('detailStatus').innerHTML =
`<span class="badge ${getStatusBadgeClass(asset.status)}">${getStatusLabel(asset.status)}</span>`;
document.getElementById('detailCreated').textContent = new Date(asset.created_at).toLocaleString();
document.getElementById('detailTags').value = (asset.tags || []).join(', ');
document.getElementById('detailNotes').value = asset.notes || '';
panel.classList.add('active');
main.classList.add('detail-open');
// Load proxy stream URL asynchronously
if (asset.proxy_s3_key) {
const streamResult = await getAssetStreamUrl(asset.id);
if (streamResult.success) {
video.src = streamResult.data.url;
}
}
}
function closeDetailPanel() {
const panel = document.getElementById('detailPanel');
const main = document.getElementById('contentMain');
const video = document.getElementById('videoPlayer');
video.pause();
video.src = '';
panel.classList.remove('active');
main.classList.remove('detail-open');
state.selectedAsset = null;
}
async function saveAssetMetadata() {
if (!state.selectedAsset) return;
const tags = document.getElementById('detailTags').value
.split(',').map(t => t.trim()).filter(Boolean);
const notes = document.getElementById('detailNotes').value;
const result = await updateAsset(state.selectedAsset.id, { tags, notes });
if (result.success) {
updateStatusBar('Saved');
const idx = state.assets.findIndex(a => a.id === state.selectedAsset.id);
if (idx >= 0) {
state.assets[idx] = { ...state.assets[idx], tags, notes };
}
setTimeout(() => updateStatusBar(), 2000);
} else {
updateStatusBar('Save failed', true);
}
}
// ============================================================
// PROJECT / BIN SELECTION
// ============================================================
async function selectProject(projectId) {
state.selectedProject = state.selectedProject === projectId ? null : projectId;
state.selectedBin = null;
renderProjects();
// Load bins for this project
const binsContainer = document.getElementById('binsList');
binsContainer.innerHTML = '';
if (state.selectedProject) {
const result = await getBins(state.selectedProject);
if (result.success) {
state.bins = result.data || [];
renderBins(state.bins, binsContainer, null);
}
}
renderAssets();
}
function renderBins(bins, container, parentId) {
const children = bins.filter(b => b.parent_id === parentId);
children.forEach(bin => {
const item = document.createElement('div');
item.className = `sidebar-item ${state.selectedBin === bin.id ? 'active' : ''}`;
item.style.paddingLeft = parentId ? '24px' : '8px';
item.textContent = bin.name;
item.addEventListener('click', (e) => {
e.stopPropagation();
state.selectedBin = state.selectedBin === bin.id ? null : bin.id;
renderAssets();
// Re-render bins to update active state
container.innerHTML = '';
renderBins(state.bins, container, null);
});
container.appendChild(item);
// Recurse for nested bins
renderBins(bins, container, bin.id);
});
}
// ============================================================
// NAVIGATION
// ============================================================
function navigateTo(page) {
const pages = {
capture: '/capture.html',
upload: '/upload.html',
recorders: '/recorders.html',
};
if (pages[page]) window.location.href = pages[page];
}
// ============================================================
// STATUS BAR
// ============================================================
function updateStatusBar(message = '', isError = false) {
const indicator = document.getElementById('statusIndicator');
const text = document.getElementById('statusText');
const countEl = document.getElementById('assetCountItem');
if (message) {
text.textContent = message;
indicator.className = isError ? 'status-indicator disconnected' : 'status-indicator';
} else {
indicator.className = 'status-indicator';
text.textContent = 'Connected';
}
const total = state.assets.length;
if (total > 0) {
countEl.style.display = 'flex';
document.getElementById('assetCount').textContent = total;
} else {
countEl.style.display = 'none';
}
}
// ============================================================
// HELPERS
// ============================================================
function escapeHtml(str) {
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}
</script>
</body>
</html>