feat: enhance media management with CRUD operations and runtime tracking in admin UI
CI / test (push) Successful in 1m39s
CI / docker (push) Successful in 3m9s
CI / release (push) Skipped

This commit is contained in:
zwitschi committed 2026-08-04 19:28:23 +02:00
1 parent 47740e3f4a
commit 7cfddfc7ef
6 files changed
+535 -103

No files matched your search

+128 -9
View File
@@ -38,7 +38,7 @@
<template x-if="currentTab === 'dashboard'">
<div>
<h1 class="text-3xl font-bold mb-8">Dashboard</h1>
<div class="grid grid-cols-3 gap-6">
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6">
<div class="bg-slate-800 p-6 rounded-3xl">
<div class="text-emerald-400 text-sm">Concurrent Viewers</div>
<div class="text-5xl font-bold mt-2" x-text="stats.viewers"></div>
@@ -51,6 +51,13 @@
<div class="text-amber-400 text-sm">Moderation Actions</div>
<div class="text-5xl font-bold mt-2" x-text="stats.moderationActions"></div>
</div>
<div class="bg-slate-800 p-6 rounded-3xl">
<div class="text-violet-400 text-sm">Media Runtime</div>
<div class="text-3xl font-bold mt-2" x-text="stats.mediaTotalRuntime"></div>
<div class="text-xs text-slate-400 mt-2">
<span x-text="stats.mediaCount"></span> files
</div>
</div>
</div>
<div class="mt-6 text-sm text-slate-400" x-text="statusMessage"></div>
</div>
@@ -82,21 +89,34 @@
<thead class="text-slate-400 border-b border-slate-700">
<tr>
<th class="text-left py-3 pr-4 font-medium">File</th>
<th class="text-left py-3 pr-4 font-medium">Title</th>
<th class="text-left py-3 pr-4 font-medium">Runtime</th>
<th class="text-left py-3 pr-4 font-medium">Status</th>
<th class="text-left py-3 pr-4 font-medium">Size</th>
<th class="text-left py-3 font-medium">Modified</th>
<th class="text-left py-3 pr-4 font-medium">Modified</th>
<th class="text-left py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
<template x-if="media.files.length === 0">
<tr>
<td colspan="3" class="py-6 text-slate-500">No media files available.</td>
<td colspan="7" class="py-6 text-slate-500">No media files available.</td>
</tr>
</template>
<template x-for="file in media.files" :key="file.name">
<template x-for="file in media.files" :key="file.id || file.name">
<tr class="border-b border-slate-800">
<td class="py-3 pr-4" x-text="file.name"></td>
<td class="py-3 pr-4" x-text="file.title || '-' "></td>
<td class="py-3 pr-4 font-mono text-xs" x-text="file.runtime || '00:00:00'"></td>
<td class="py-3 pr-4" x-text="file.status || 'unknown'"></td>
<td class="py-3 pr-4" x-text="file.size"></td>
<td class="py-3 font-mono text-xs" x-text="file.modifiedAt"></td>
<td class="py-3 pr-4 font-mono text-xs" x-text="file.modifiedAt"></td>
<td class="py-3">
<div class="flex gap-2">
<button @click="editMedia(file)" class="bg-slate-700 hover:bg-slate-600 px-3 py-1 rounded-lg text-xs">Edit</button>
<button @click="deleteMedia(file)" class="bg-rose-700 hover:bg-rose-600 px-3 py-1 rounded-lg text-xs">Delete</button>
</div>
</td>
</tr>
</template>
</tbody>
@@ -116,25 +136,33 @@
<button @click="refreshSchedule()" class="bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-xl text-sm">Reload schedule</button>
</div>
<div class="bg-slate-800 rounded-3xl p-6">
<div class="mb-4 text-sm text-slate-400">
<span x-text="schedule.matchedEntries"></span>/<span x-text="schedule.totalEntries"></span> matched
• total runtime <span class="font-mono" x-text="schedule.totalRuntime"></span>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-slate-400 border-b border-slate-700">
<tr>
<th class="text-left py-3 pr-4 font-medium">#</th>
<th class="text-left py-3 pr-4 font-medium">Label</th>
<th class="text-left py-3 pr-4 font-medium">Runtime</th>
<th class="text-left py-3 pr-4 font-medium">Status</th>
<th class="text-left py-3 font-medium">Path</th>
</tr>
</thead>
<tbody>
<template x-if="schedule.entries.length === 0">
<tr>
<td colspan="3" class="py-6 text-slate-500">No schedule entries found.</td>
<td colspan="5" class="py-6 text-slate-500">No schedule entries found.</td>
</tr>
</template>
<template x-for="entry in schedule.entries" :key="entry.index + ':' + entry.path">
<tr class="border-b border-slate-800">
<td class="py-3 pr-4" x-text="entry.index"></td>
<td class="py-3 pr-4" x-text="entry.label"></td>
<td class="py-3 pr-4 font-mono text-xs" x-text="entry.runtime || '00:00:00'"></td>
<td class="py-3 pr-4" x-text="entry.status || 'missing'"></td>
<td class="py-3 font-mono text-xs" x-text="entry.path"></td>
</tr>
</template>
@@ -448,7 +476,9 @@
stats: {
viewers: 0,
storedMessages: 0,
moderationActions: 0
moderationActions: 0,
mediaCount: 0,
mediaTotalRuntime: '00:00:00'
},
system: {
redisEnabled: false,
@@ -467,6 +497,8 @@
messagesLast24Hours: 0,
distinctRooms: 0,
moderationActions: 0,
mediaCount: 0,
mediaTotalRuntime: '00:00:00',
topRooms: [],
generatedAt: 'loading...',
status: 'Loading analytics...',
@@ -474,6 +506,9 @@
},
schedule: {
entries: [],
totalEntries: 0,
matchedEntries: 0,
totalRuntime: '00:00:00',
status: 'Loading schedule...',
error: false
},
@@ -520,6 +555,8 @@
this.stats.viewers = overview.viewers;
this.stats.storedMessages = overview.storedMessages;
this.stats.moderationActions = overview.moderationActions;
this.stats.mediaCount = overview.mediaCount || 0;
this.stats.mediaTotalRuntime = overview.mediaTotalRuntime || '00:00:00';
this.system.redisEnabled = overview.redisEnabled;
this.system.redisClients = overview.redisClients;
this.system.redisMemoryUsed = overview.redisMemoryUsed;
@@ -549,6 +586,8 @@
this.analytics.messagesLast24Hours = payload.messagesLast24Hours;
this.analytics.distinctRooms = payload.distinctRooms;
this.analytics.moderationActions = payload.moderationActions;
this.analytics.mediaCount = payload.mediaCount || 0;
this.analytics.mediaTotalRuntime = payload.mediaTotalRuntime || '00:00:00';
this.analytics.topRooms = payload.topRooms || [];
this.analytics.generatedAt = payload.generatedAt;
this.analytics.status = 'Analytics loaded.';
@@ -589,7 +628,10 @@
const payload = await response.json();
this.schedule.entries = payload.entries || [];
this.schedule.status = 'Schedule loaded.';
this.schedule.totalEntries = payload.totalEntries || 0;
this.schedule.matchedEntries = payload.matchedEntries || 0;
this.schedule.totalRuntime = payload.totalRuntime || '00:00:00';
this.schedule.status = `Schedule loaded. Total runtime ${this.schedule.totalRuntime}.`;
} catch (error) {
console.error('Failed to load schedule', error);
this.schedule.status = 'Failed to load schedule.';
@@ -641,6 +683,83 @@
this.media.error = true;
}
},
async editMedia(file) {
if (!file.id) {
this.media.status = 'Cannot edit media item without id.';
this.media.error = true;
return;
}
const title = window.prompt('Title', file.title || file.name || '');
if (title === null) {
return;
}
const status = window.prompt('Status', file.status || 'ready');
if (status === null) {
return;
}
const tags = window.prompt('Tags (comma-separated)', file.tags || '');
if (tags === null) {
return;
}
this.media.error = false;
this.media.status = `Saving metadata for ${file.name}...`;
try {
const response = await fetch(`/api/admin/media/${file.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ title, status, tags })
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
this.media.status = 'Media metadata updated.';
await this.refreshMediaLibrary();
await this.refreshOverview();
await this.refreshSchedule();
} catch (error) {
console.error('Failed to update media metadata', error);
this.media.status = 'Failed to update media metadata.';
this.media.error = true;
}
},
async deleteMedia(file) {
if (!file.id) {
this.media.status = 'Cannot delete media item without id.';
this.media.error = true;
return;
}
if (!window.confirm(`Delete ${file.name}? This removes file and metadata.`)) {
return;
}
this.media.error = false;
this.media.status = `Deleting ${file.name}...`;
try {
const response = await fetch(`/api/admin/media/${file.id}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
this.media.status = `${file.name} deleted.`;
await this.refreshMediaLibrary();
await this.refreshOverview();
await this.refreshSchedule();
} catch (error) {
console.error('Failed to delete media', error);
this.media.status = 'Failed to delete media file.';
this.media.error = true;
}
},
async savePlaylist() {
this.playlist.saving = true;
this.playlist.status = 'Saving playlist...';
@@ -799,7 +918,7 @@
}
function runVacuum() {
alert('VACUUM executed on SQLite database (per ADR-006). Audit log entry created.');
alert('Database maintenance action placeholder. Use storage backend-specific operations from server-side APIs.');
}
</script>
</body>