dragonflight/services/web-ui/public/player.html
ZGaetano 280fc9dff2 fix: XSS in renderTags and stale api.js version in player.html
Tag values were inserted into innerHTML unsanitized — a tag containing
HTML would execute as markup. Switch to DOM-only construction for the
tag badges. Also bump api.js cache-buster to v=6.
2026-05-19 00:30:54 -04:00

496 lines
18 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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 Player</title>
<link rel="stylesheet" href="/css/common.css">
<style>
.player-container {
display: grid;
grid-template-columns: 1fr 300px;
gap: var(--spacing-lg);
height: calc(100vh - 110px);
padding: var(--spacing-lg);
overflow: hidden;
}
.player-main {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
}
.video-container {
flex: 1;
background-color: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.video-player {
width: 100%;
height: 100%;
background-color: var(--color-bg-primary);
}
.metadata-panel {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.metadata-section {
padding: var(--spacing-md);
background-color: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
}
.metadata-section-title {
font-size: 0.85rem;
font-weight: 700;
color: var(--color-text-secondary);
text-transform: uppercase;
margin-bottom: var(--spacing-md);
letter-spacing: 0.3px;
}
.metadata-row {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
margin-bottom: var(--spacing-sm);
padding-bottom: var(--spacing-sm);
border-bottom: 1px solid var(--color-border);
}
.metadata-row:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.metadata-label {
font-size: 0.75rem;
color: var(--color-text-tertiary);
text-transform: uppercase;
letter-spacing: 0.2px;
}
.metadata-value {
font-size: 0.9rem;
color: var(--color-text-primary);
font-weight: 500;
}
.metadata-editable {
display: flex;
gap: var(--spacing-sm);
flex-direction: column;
}
.metadata-textarea {
width: 100%;
padding: var(--spacing-sm);
background-color: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-primary);
font-family: 'Courier New', monospace;
font-size: 0.85rem;
resize: vertical;
min-height: 80px;
}
.metadata-textarea:focus {
outline: none;
border-color: var(--color-accent-primary);
box-shadow: 0 0 0 3px rgba(233, 69, 96, 0.1);
}
.sidebar {
overflow-y: auto;
}
.tags-list {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-md);
}
.tag-badge {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: 4px 10px;
background-color: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: 20px;
font-size: 0.8rem;
color: var(--color-text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
}
.tag-badge:hover {
border-color: var(--color-accent-primary);
color: var(--color-accent-primary);
}
.tag-remove {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-weight: bold;
}
.edit-controls {
display: flex;
gap: var(--spacing-sm);
margin-top: var(--spacing-md);
}
.back-button {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
background-color: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
}
.back-button:hover {
border-color: var(--color-accent-primary);
color: var(--color-accent-primary);
}
@media (max-width: 768px) {
.player-container {
grid-template-columns: 1fr;
height: auto;
}
.metadata-panel {
display: grid;
grid-template-columns: 1fr 1fr;
}
.video-container {
height: 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" data-page="assets">Assets</div>
<div class="nav-item" data-page="capture">Capture</div>
<div class="nav-item" data-page="projects">Projects</div>
</nav>
</header>
<!-- Main Content -->
<div class="main-content">
<div class="content-area">
<div class="content-main">
<button class="back-button" onclick="goBack()">
<span></span> Back to Assets
</button>
<div class="player-container">
<!-- Main Player -->
<div class="player-main">
<div class="video-container">
<video class="video-player" id="videoPlayer" controls></video>
</div>
</div>
<!-- Sidebar -->
<div class="sidebar">
<!-- File Info -->
<div class="metadata-section">
<div class="metadata-section-title">File Information</div>
<div class="metadata-row">
<div class="metadata-label">Filename</div>
<div class="metadata-value" id="metaFilename"></div>
</div>
<div class="metadata-row">
<div class="metadata-label">Format</div>
<div class="metadata-value" id="metaFormat"></div>
</div>
<div class="metadata-row">
<div class="metadata-label">Codec</div>
<div class="metadata-value" id="metaCodec"></div>
</div>
<div class="metadata-row">
<div class="metadata-label">Resolution</div>
<div class="metadata-value" id="metaResolution"></div>
</div>
<div class="metadata-row">
<div class="metadata-label">Framerate</div>
<div class="metadata-value" id="metaFps"></div>
</div>
<div class="metadata-row">
<div class="metadata-label">Duration</div>
<div class="metadata-value" id="metaDuration"></div>
</div>
<div class="metadata-row">
<div class="metadata-label">File Size</div>
<div class="metadata-value" id="metaFileSize"></div>
</div>
<div class="metadata-row">
<div class="metadata-label">Status</div>
<div class="metadata-value" id="metaStatus"></div>
</div>
<div class="metadata-row">
<div class="metadata-label">Captured</div>
<div class="metadata-value" id="metaCreated"></div>
</div>
</div>
<!-- Tags -->
<div class="metadata-section">
<div class="metadata-section-title">Tags</div>
<div class="tags-list" id="tagsList"></div>
<input
type="text"
id="newTagInput"
class="form-input"
placeholder="Add tag..."
style="font-size: 0.85rem;"
>
<button class="btn btn-secondary btn-sm" style="width: 100%; margin-top: var(--spacing-sm);" onclick="addTag()">Add Tag</button>
</div>
<!-- Notes -->
<div class="metadata-section">
<div class="metadata-section-title">Notes</div>
<textarea id="notesInput" class="metadata-textarea" placeholder="Add notes about this asset..."></textarea>
<div class="edit-controls">
<button class="btn btn-primary btn-sm" style="flex: 1;" onclick="saveMetadata()">Save</button>
<button class="btn btn-secondary btn-sm" style="flex: 1;" onclick="resetMetadata()">Reset</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Status Bar -->
<footer class="status-bar">
<div class="status-item">
<span class="status-indicator" id="statusIndicator"></span>
<span id="statusText">Connected</span>
</div>
</footer>
</div>
<script src="/js/api.js?v=6"></script>
<script src="/js/topbar-strip.js?v=1"></script>
<script>
// ============================================================
// STATE MANAGEMENT
// ============================================================
let playerState = {
assetId: null,
asset: null,
tags: [],
notes: '',
originalTags: [],
originalNotes: '',
};
// ============================================================
// INITIALIZATION
// ============================================================
document.addEventListener('DOMContentLoaded', () => {
setupEventListeners();
const params = new URLSearchParams(window.location.search);
playerState.assetId = params.get('id');
if (playerState.assetId) {
loadAsset();
} else {
document.getElementById('statusText').textContent = 'No asset selected';
}
});
function setupEventListeners() {
document.getElementById('newTagInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
addTag();
e.preventDefault();
}
});
document.querySelectorAll('[data-page]').forEach(el => {
el.addEventListener('click', (e) => navigateTo(e.target.dataset.page));
});
}
async function loadAsset() {
try {
const result = await getAsset(playerState.assetId);
if (result.success) {
playerState.asset = result.data;
playerState.tags = result.data.tags || [];
playerState.notes = result.data.notes || '';
playerState.originalTags = [...playerState.tags];
playerState.originalNotes = playerState.notes;
renderAsset();
// Load video stream
const streamResult = await getAssetStreamUrl(playerState.assetId);
if (streamResult.success) {
document.getElementById('videoPlayer').src = streamResult.data.url;
}
} else {
document.getElementById('statusText').textContent = 'Failed to load asset';
}
} catch (error) {
console.error('Error loading asset:', error);
document.getElementById('statusText').textContent = 'Error loading asset';
}
}
// ============================================================
// RENDERING
// ============================================================
function renderAsset() {
const asset = playerState.asset;
document.getElementById('metaFilename').textContent = asset.filename;
document.getElementById('metaFormat').textContent = asset.format || '—';
document.getElementById('metaCodec').textContent = asset.codec || '—';
document.getElementById('metaResolution').textContent = asset.resolution || '—';
document.getElementById('metaFps').textContent = asset.fps ? `${asset.fps} fps` : '—';
document.getElementById('metaDuration').textContent = formatDuration(asset.duration);
document.getElementById('metaFileSize').textContent = formatFileSize(asset.file_size || 0);
document.getElementById('metaStatus').innerHTML =
`<span class="badge ${getStatusBadgeClass(asset.status)}">${getStatusLabel(asset.status)}</span>`;
document.getElementById('metaCreated').textContent = new Date(asset.created_at).toLocaleDateString();
renderTags();
document.getElementById('notesInput').value = playerState.notes;
document.title = `${asset.filename} - Wild Dragon Player`;
}
function renderTags() {
const container = document.getElementById('tagsList');
container.innerHTML = '';
playerState.tags.forEach((tag, index) => {
const badge = document.createElement('div');
badge.className = 'tag-badge';
const tagSpan = document.createElement('span');
tagSpan.textContent = tag;
const removeSpan = document.createElement('span');
removeSpan.className = 'tag-remove';
removeSpan.textContent = '×';
removeSpan.onclick = () => removeTag(index);
badge.appendChild(tagSpan);
badge.appendChild(removeSpan);
container.appendChild(badge);
});
}
// ============================================================
// METADATA EDITING
// ============================================================
function addTag() {
const input = document.getElementById('newTagInput');
const tag = input.value.trim().toLowerCase();
if (tag && !playerState.tags.includes(tag)) {
playerState.tags.push(tag);
renderTags();
input.value = '';
}
}
function removeTag(index) {
playerState.tags.splice(index, 1);
renderTags();
}
async function saveMetadata() {
if (!playerState.assetId) return;
playerState.notes = document.getElementById('notesInput').value;
try {
const result = await updateAsset(playerState.assetId, {
tags: playerState.tags,
notes: playerState.notes,
});
if (result.success) {
playerState.originalTags = [...playerState.tags];
playerState.originalNotes = playerState.notes;
document.getElementById('statusText').textContent = 'Changes saved';
setTimeout(() => {
document.getElementById('statusText').textContent = 'Connected';
}, 2000);
} else {
document.getElementById('statusText').textContent = 'Failed to save changes';
}
} catch (error) {
console.error('Error saving metadata:', error);
document.getElementById('statusText').textContent = 'Error saving changes';
}
}
function resetMetadata() {
playerState.tags = [...playerState.originalTags];
playerState.notes = playerState.originalNotes;
renderTags();
document.getElementById('notesInput').value = playerState.notes;
}
// ============================================================
// NAVIGATION
// ============================================================
function navigateTo(page) {
if (page === 'assets') {
window.location.href = '/index.html';
} else if (page === 'capture') {
window.location.href = '/capture.html';
}
}
function goBack() {
window.location.href = '/index.html';
}
</script>
</body>
</html>