649 lines
24 KiB
HTML
649 lines
24 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);
|
|
}
|
|
|
|
.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;
|
|
}
|
|
</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 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></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">Format</div>
|
|
<div class="detail-value" id="detailFormat">—</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 MANAGEMENT
|
|
// ============================================================
|
|
|
|
let state = {
|
|
currentPage: 'assets',
|
|
assets: [],
|
|
projects: [],
|
|
bins: [],
|
|
selectedAsset: null,
|
|
selectedProject: null,
|
|
selectedBin: null,
|
|
searchQuery: '',
|
|
filterStatus: null,
|
|
};
|
|
|
|
// ============================================================
|
|
// INITIALIZATION
|
|
// ============================================================
|
|
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
setupEventListeners();
|
|
await loadInitialData();
|
|
updateStatusBar();
|
|
});
|
|
|
|
function setupEventListeners() {
|
|
// Navigation
|
|
document.querySelectorAll('[data-page]').forEach(el => {
|
|
el.addEventListener('click', (e) => navigateTo(e.target.dataset.page));
|
|
});
|
|
|
|
// Search
|
|
const searchInput = document.getElementById('searchInput');
|
|
searchInput.addEventListener('input', debounce((e) => {
|
|
state.searchQuery = e.target.value.toLowerCase();
|
|
renderAssets();
|
|
}, 300));
|
|
|
|
// Refresh button
|
|
document.getElementById('refreshBtn').addEventListener('click', loadInitialData);
|
|
|
|
// Close detail panel on overlay click
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') closeDetailPanel();
|
|
});
|
|
}
|
|
|
|
async function loadInitialData() {
|
|
try {
|
|
updateStatusBar('Loading data...');
|
|
|
|
// Load projects and assets in parallel
|
|
const [projectsRes, assetsRes] = await Promise.all([
|
|
getProjects(),
|
|
getAssets(),
|
|
]);
|
|
|
|
if (projectsRes.success) {
|
|
state.projects = projectsRes.data;
|
|
renderProjects();
|
|
}
|
|
|
|
if (assetsRes.success) {
|
|
state.assets = assetsRes.data;
|
|
renderAssets();
|
|
}
|
|
|
|
updateStatusBar();
|
|
} catch (error) {
|
|
console.error('Failed to load data:', error);
|
|
updateStatusBar('Error loading data', true);
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// RENDERING
|
|
// ============================================================
|
|
|
|
function renderProjects() {
|
|
const container = document.getElementById('projectsList');
|
|
container.innerHTML = '';
|
|
|
|
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');
|
|
|
|
// Filter assets
|
|
let filtered = state.assets;
|
|
|
|
if (state.searchQuery) {
|
|
filtered = filtered.filter(asset => {
|
|
const searchStr = `${asset.filename} ${asset.tags?.join(' ') || ''}`.toLowerCase();
|
|
return searchStr.includes(state.searchQuery);
|
|
});
|
|
}
|
|
|
|
if (state.filterStatus) {
|
|
filtered = filtered.filter(asset => asset.status === state.filterStatus);
|
|
}
|
|
|
|
if (state.selectedProject) {
|
|
filtered = filtered.filter(asset => asset.project_id === state.selectedProject);
|
|
}
|
|
|
|
// Show/hide empty state
|
|
if (filtered.length === 0) {
|
|
grid.innerHTML = '';
|
|
emptyState.style.display = 'flex';
|
|
return;
|
|
}
|
|
|
|
emptyState.style.display = 'none';
|
|
grid.innerHTML = '';
|
|
|
|
filtered.forEach(asset => {
|
|
const card = createAssetCard(asset);
|
|
grid.appendChild(card);
|
|
});
|
|
|
|
// Update filter group
|
|
renderFilterGroup();
|
|
}
|
|
|
|
function createAssetCard(asset) {
|
|
const card = document.createElement('div');
|
|
card.className = 'card';
|
|
card.innerHTML = `
|
|
<div class="card-thumbnail">
|
|
<img src="${asset.thumbnail_url || '/placeholder.png'}" alt="${asset.filename}">
|
|
</div>
|
|
<div class="card-content">
|
|
<div class="card-title">${asset.filename}</div>
|
|
<div class="card-meta">
|
|
<div class="card-duration">${formatDuration(asset.duration || 0)}</div>
|
|
<div class="card-status">
|
|
<span class="badge ${getStatusBadgeClass(asset.status)}">
|
|
${getStatusLabel(asset.status)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
card.addEventListener('click', () => openAssetDetail(asset));
|
|
return card;
|
|
}
|
|
|
|
function renderFilterGroup() {
|
|
const container = document.getElementById('filterGroup');
|
|
container.innerHTML = '';
|
|
|
|
const statuses = ['ingesting', 'processing', 'ready', 'error', 'archived'];
|
|
const counts = {};
|
|
|
|
state.assets.forEach(asset => {
|
|
counts[asset.status] = (counts[asset.status] || 0) + 1;
|
|
});
|
|
|
|
statuses.forEach(status => {
|
|
if (counts[status] > 0) {
|
|
const tag = document.createElement('div');
|
|
tag.className = `filter-tag ${state.filterStatus === status ? 'active' : ''}`;
|
|
tag.textContent = `${status.charAt(0).toUpperCase() + status.slice(1)} (${counts[status]})`;
|
|
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');
|
|
|
|
// Fetch full asset details
|
|
const result = await getAsset(asset.id);
|
|
if (result.success) {
|
|
const fullAsset = result.data;
|
|
|
|
// Populate detail panel
|
|
document.getElementById('detailFilename').textContent = fullAsset.filename;
|
|
document.getElementById('detailFormat').textContent = fullAsset.format || '—';
|
|
document.getElementById('detailCodec').textContent = fullAsset.codec || '—';
|
|
document.getElementById('detailResolution').textContent = fullAsset.resolution || '—';
|
|
document.getElementById('detailFps').textContent = fullAsset.fps ? `${fullAsset.fps} fps` : '—';
|
|
document.getElementById('detailDuration').textContent = formatDuration(fullAsset.duration);
|
|
document.getElementById('detailFileSize').textContent = formatFileSize(fullAsset.file_size || 0);
|
|
document.getElementById('detailStatus').innerHTML = `<span class="badge ${getStatusBadgeClass(fullAsset.status)}">${getStatusLabel(fullAsset.status)}</span>`;
|
|
document.getElementById('detailCreated').textContent = new Date(fullAsset.created_at).toLocaleDateString();
|
|
document.getElementById('detailTags').value = fullAsset.tags?.join(', ') || '';
|
|
document.getElementById('detailNotes').value = fullAsset.notes || '';
|
|
|
|
// Load video player
|
|
const streamResult = await getAssetStreamUrl(asset.id);
|
|
if (streamResult.success) {
|
|
document.getElementById('videoPlayer').src = streamResult.data.url;
|
|
}
|
|
}
|
|
|
|
panel.classList.add('active');
|
|
main.classList.add('detail-open');
|
|
}
|
|
|
|
function closeDetailPanel() {
|
|
const panel = document.getElementById('detailPanel');
|
|
const main = document.getElementById('contentMain');
|
|
|
|
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(t => t);
|
|
const notes = document.getElementById('detailNotes').value;
|
|
|
|
const result = await updateAsset(state.selectedAsset.id, {
|
|
tags,
|
|
notes,
|
|
});
|
|
|
|
if (result.success) {
|
|
updateStatusBar('Asset saved');
|
|
// Update asset in local state
|
|
const idx = state.assets.findIndex(a => a.id === state.selectedAsset.id);
|
|
if (idx >= 0) {
|
|
state.assets[idx].tags = tags;
|
|
state.assets[idx].notes = notes;
|
|
}
|
|
} else {
|
|
updateStatusBar('Failed to save asset', true);
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// PROJECT SELECTION
|
|
// ============================================================
|
|
|
|
function selectProject(projectId) {
|
|
state.selectedProject = state.selectedProject === projectId ? null : projectId;
|
|
renderProjects();
|
|
renderAssets();
|
|
}
|
|
|
|
// ============================================================
|
|
// NAVIGATION
|
|
// ============================================================
|
|
|
|
function navigateTo(page) {
|
|
if (page === 'capture') {
|
|
window.location.href = '/capture.html';
|
|
} else if (page === 'upload') {
|
|
window.location.href = '/upload.html';
|
|
} else if (page === 'recorders') {
|
|
window.location.href = '/recorders.html';
|
|
} else if (page === 'projects') {
|
|
alert('Projects page coming soon');
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// STATUS BAR
|
|
// ============================================================
|
|
|
|
function updateStatusBar(message = '', isError = false) {
|
|
const indicator = document.getElementById('statusIndicator');
|
|
const text = document.getElementById('statusText');
|
|
const count = 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 assetCount = state.assets.length;
|
|
if (assetCount > 0) {
|
|
count.style.display = 'flex';
|
|
document.getElementById('assetCount').textContent = assetCount;
|
|
} else {
|
|
count.style.display = 'none';
|
|
}
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|