feat: modernize UI with Astro+Svelte and optimize Docker build
- Migrated frontend to Astro + Svelte 5 for cyberpunk aesthetic - Switched to Bun for faster frontend builds - Implemented multi-stage Docker build for smaller image size - Refactored backend to serve static assets and proxy API requests - Added recovery mode for manual file management
This commit is contained in:
parent
985a05858a
commit
aa94920650
62 changed files with 6589 additions and 18 deletions
119
ui/src/components/Dashboard.svelte
Normal file
119
ui/src/components/Dashboard.svelte
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import StatsCard from "./StatsCard.svelte";
|
||||
import LogViewer from "./LogViewer.svelte";
|
||||
import VideoTable from "./VideoTable.svelte";
|
||||
import DashboardControls from "./DashboardControls.svelte";
|
||||
import RecoveryModal from "./RecoveryModal.svelte";
|
||||
|
||||
let stats = {
|
||||
total_videos: 0,
|
||||
verified_links: 0,
|
||||
missing_count: 0,
|
||||
};
|
||||
|
||||
let videos: any[] = [];
|
||||
let loading = true;
|
||||
let error: string | null = null;
|
||||
let showRecovery = false;
|
||||
|
||||
let interval: ReturnType<typeof setInterval>;
|
||||
|
||||
async function fetchData() {
|
||||
try {
|
||||
const res = await fetch("http://localhost:8002/api/status");
|
||||
if (!res.ok) throw new Error("Failed to fetch status");
|
||||
const data = await res.json();
|
||||
|
||||
stats = {
|
||||
total_videos: data.total_videos,
|
||||
verified_links: data.verified_links,
|
||||
missing_count: data.missing_count,
|
||||
};
|
||||
videos = data.videos || [];
|
||||
loading = false;
|
||||
error = null;
|
||||
} catch (e: any) {
|
||||
console.error("Fetch error", e);
|
||||
error = e.toString();
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleScanTriggered() {
|
||||
// Maybe show a toast or log
|
||||
// Refetch data soon
|
||||
setTimeout(fetchData, 2000);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchData();
|
||||
interval = setInterval(fetchData, 10000); // reduced polling for full list
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
clearInterval(interval);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Stats Row -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<StatsCard
|
||||
title="Total Videos"
|
||||
value={stats.total_videos}
|
||||
color="cyan"
|
||||
icon="bi-collection-play"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Linked & Verified"
|
||||
value={stats.verified_links}
|
||||
color="green"
|
||||
icon="bi-link-45deg"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Missing / Error"
|
||||
value={stats.missing_count}
|
||||
color="red"
|
||||
icon="bi-exclamation-triangle"
|
||||
/>
|
||||
<!-- Placeholder for symmetry or future stat -->
|
||||
<StatsCard
|
||||
title="System Status"
|
||||
value="ONLINE"
|
||||
color="pink"
|
||||
icon="bi-cpu"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div class="lg:col-span-1 space-y-6">
|
||||
<div
|
||||
class="bg-cyber-card border border-gray-800 rounded-xl p-6 shadow-lg relative overflow-hidden group"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-neon-cyan/5 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
|
||||
></div>
|
||||
<h3
|
||||
class="text-lg font-bold mb-4 flex items-center text-white neon-text-cyan"
|
||||
>
|
||||
<i class="bi bi-joystick mr-2"></i> Control Deck
|
||||
</h3>
|
||||
<DashboardControls
|
||||
on:scan={handleScanTriggered}
|
||||
on:openRecovery={() => (showRecovery = true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<LogViewer />
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-2">
|
||||
<VideoTable {videos} {loading} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showRecovery}
|
||||
<RecoveryModal on:close={() => (showRecovery = false)} />
|
||||
{/if}
|
||||
</div>
|
||||
106
ui/src/components/DashboardControls.svelte
Normal file
106
ui/src/components/DashboardControls.svelte
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let scanning = false;
|
||||
let checkingOrphans = false;
|
||||
let orphanResult: string | null = null;
|
||||
|
||||
async function triggerScan() {
|
||||
if (!confirm("Start full library scan?")) return;
|
||||
scanning = true;
|
||||
try {
|
||||
await fetch("http://localhost:8002/api/scan", { method: "POST" });
|
||||
dispatch("scan");
|
||||
// Reset scanning state after a bit since it's async background
|
||||
setTimeout(() => (scanning = false), 2000);
|
||||
} catch (e) {
|
||||
alert("Error: " + e);
|
||||
scanning = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkOrphans() {
|
||||
checkingOrphans = true;
|
||||
orphanResult = "Measuring quantum fluctuations (scanning)...";
|
||||
try {
|
||||
const res = await fetch("http://localhost:8002/api/check-orphans", {
|
||||
method: "POST",
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.count === 0) {
|
||||
orphanResult = "✅ All systems nominal. No orphans.";
|
||||
} else {
|
||||
orphanResult = `⚠️ Found ${data.count} orphaned links!`;
|
||||
}
|
||||
} catch (e) {
|
||||
orphanResult = "❌ Sensor Error: " + e;
|
||||
} finally {
|
||||
checkingOrphans = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<button
|
||||
class="btn-cyber-primary w-full py-4 text-lg font-bold uppercase tracking-wider shadow-lg flex items-center justify-center gap-2 group relative overflow-hidden"
|
||||
on:click={triggerScan}
|
||||
disabled={scanning}
|
||||
>
|
||||
{#if scanning}
|
||||
<span class="animate-spin"><i class="bi bi-arrow-repeat"></i></span>
|
||||
Scanning...
|
||||
{:else}
|
||||
<div
|
||||
class="absolute inset-0 bg-neon-cyan/20 translate-y-full group-hover:translate-y-0 transition-transform duration-300"
|
||||
></div>
|
||||
<i
|
||||
class="bi bi-arrow-repeat group-hover:rotate-180 transition-transform duration-500"
|
||||
></i> Run Full Scan
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
class="btn-cyber-secondary py-3 text-sm font-semibold border border-neon-yellow/30 text-neon-yellow hover:bg-neon-yellow/10 transition-colors rounded-lg flex items-center justify-center gap-2"
|
||||
on:click={checkOrphans}
|
||||
disabled={checkingOrphans}
|
||||
>
|
||||
<i class="bi bi-binoculars"></i> Check Orphans
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn-cyber-secondary py-3 text-sm font-semibold border border-neon-pink/30 text-neon-pink hover:bg-neon-pink/10 transition-colors rounded-lg flex items-center justify-center gap-2"
|
||||
on:click={() => dispatch("openRecovery")}
|
||||
>
|
||||
<i class="bi bi-bandaid"></i> Recovery Mode
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if orphanResult}
|
||||
<div
|
||||
class="mt-2 p-3 rounded bg-black/40 border border-gray-700 text-xs font-mono"
|
||||
>
|
||||
{orphanResult}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.btn-cyber-primary {
|
||||
background: linear-gradient(45deg, #00f3ff, #0066ff);
|
||||
color: black;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.btn-cyber-primary:hover {
|
||||
filter: brightness(1.2);
|
||||
box-shadow: 0 0 20px rgba(0, 243, 255, 0.4);
|
||||
}
|
||||
.btn-cyber-primary:disabled {
|
||||
background: #333;
|
||||
color: #666;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
92
ui/src/components/LogViewer.svelte
Normal file
92
ui/src/components/LogViewer.svelte
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
|
||||
export let endpoint = "http://localhost:8002/api/logs";
|
||||
export let title = "SYSTEM_LOGS";
|
||||
|
||||
let logs: string[] = [];
|
||||
let logEndRef: HTMLDivElement;
|
||||
let interval: ReturnType<typeof setInterval>;
|
||||
let nextIndex = 0;
|
||||
let mounted = false;
|
||||
|
||||
async function fetchLogs() {
|
||||
try {
|
||||
const res = await fetch(`${endpoint}?start=${nextIndex}`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
|
||||
if (data.logs && data.logs.length > 0) {
|
||||
logs = [...logs, ...data.logs];
|
||||
nextIndex = data.next_index;
|
||||
// Limit local buffer
|
||||
if (logs.length > 1000) logs = logs.slice(-1000);
|
||||
}
|
||||
} catch (e) {
|
||||
if (
|
||||
logs.length === 0 ||
|
||||
logs[logs.length - 1] !== "Error connecting to logs..."
|
||||
) {
|
||||
logs = [...logs, "Error connecting to logs..."];
|
||||
// Don't spam error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
fetchLogs();
|
||||
interval = setInterval(fetchLogs, 2000);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
clearInterval(interval);
|
||||
});
|
||||
|
||||
// Auto-scroll
|
||||
$: if (mounted && logs && logEndRef) {
|
||||
logEndRef.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
|
||||
function clearLogs() {
|
||||
logs = [];
|
||||
nextIndex = 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="border border-gray-800 rounded-xl overflow-hidden bg-black shadow-lg flex flex-col"
|
||||
>
|
||||
<div
|
||||
class="bg-gray-900/50 px-4 py-2 border-b border-gray-800 flex justify-between items-center"
|
||||
>
|
||||
<span class="text-xs font-mono text-gray-400 flex items-center">
|
||||
<span class="w-2 h-2 rounded-full bg-neon-green animate-pulse mr-2"
|
||||
></span>
|
||||
{title}
|
||||
</span>
|
||||
<button
|
||||
on:click={clearLogs}
|
||||
class="text-xs text-gray-500 hover:text-white transition-colors"
|
||||
>CLEAR</button
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="p-4 overflow-y-auto h-[300px] font-mono text-xs space-y-1 scrollbar-thin scrollbar-thumb-gray-700 scrollbar-track-transparent"
|
||||
>
|
||||
{#each logs as log, i}
|
||||
<div
|
||||
class="break-words text-neon-green/80 border-l-2 border-transparent hover:border-neon-green hover:bg-neon-green/5 pl-2 transition-colors"
|
||||
>
|
||||
<span class="opacity-50 select-none mr-2">[{i}]</span>
|
||||
{log}
|
||||
</div>
|
||||
{/each}
|
||||
{#if logs.length === 0}
|
||||
<div class="text-gray-600 italic text-center mt-10">
|
||||
Waiting for {title === "SYSTEM_LOGS" ? "system" : "transcode"} logs...
|
||||
</div>
|
||||
{/if}
|
||||
<div bind:this={logEndRef}></div>
|
||||
</div>
|
||||
</div>
|
||||
231
ui/src/components/RecoveryModal.svelte
Normal file
231
ui/src/components/RecoveryModal.svelte
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount, onDestroy } from "svelte";
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let activeTab: "unindexed" | "rescue" | "redundant" | "lost" = "unindexed";
|
||||
let scanning = false;
|
||||
let status = "idle";
|
||||
let results: any = { unindexed: [], rescue: [], redundant: [], lost: [] };
|
||||
let pollInterval: ReturnType<typeof setInterval>;
|
||||
|
||||
async function startScan() {
|
||||
scanning = true;
|
||||
try {
|
||||
await fetch("http://localhost:8002/api/recovery/scan", {
|
||||
method: "POST",
|
||||
});
|
||||
pollResults();
|
||||
} catch (e) {
|
||||
alert("Scan start failed: " + e);
|
||||
scanning = false;
|
||||
}
|
||||
}
|
||||
|
||||
function pollResults() {
|
||||
if (pollInterval) clearInterval(pollInterval);
|
||||
pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
"http://localhost:8002/api/recovery/poll",
|
||||
);
|
||||
const data = await res.json();
|
||||
status = data.status;
|
||||
if (data.status === "done") {
|
||||
results = data.results || results;
|
||||
scanning = false;
|
||||
clearInterval(pollInterval);
|
||||
} else if (data.status === "error") {
|
||||
scanning = false;
|
||||
clearInterval(pollInterval);
|
||||
alert("Scan error: " + data.results);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if (pollInterval) clearInterval(pollInterval);
|
||||
});
|
||||
|
||||
async function recoverFile(path: string, isBatch = false) {
|
||||
// Implementation mirrors existing JS logic
|
||||
if (!isBatch && !confirm("Recover this file?")) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
"http://localhost:8002/api/recovery/start",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ filepath: path }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
const d = await res.json();
|
||||
if (!isBatch) {
|
||||
alert(d.message);
|
||||
startScan();
|
||||
} // Refresh
|
||||
} catch (e) {
|
||||
alert(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFile(path: string) {
|
||||
if (!confirm("Delete file? This cannot be undone.")) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
"http://localhost:8002/api/recovery/delete",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ filepath: path }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
const d = await res.json();
|
||||
if (d.success) {
|
||||
alert("Deleted.");
|
||||
startScan();
|
||||
} else alert("Error: " + d.error);
|
||||
} catch (e) {
|
||||
alert(e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
|
||||
>
|
||||
<div
|
||||
class="bg-cyber-card border border-neon-cyan/30 rounded-xl w-full max-w-5xl h-[80vh] flex flex-col shadow-[0_0_50px_rgba(0,243,255,0.1)]"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="p-5 border-b border-gray-800 flex justify-between items-center"
|
||||
>
|
||||
<h2 class="text-xl font-bold text-white flex items-center gap-2">
|
||||
<i class="bi bi-bandaid text-neon-pink"></i> Advanced Recovery
|
||||
</h2>
|
||||
<button
|
||||
on:click={() => dispatch("close")}
|
||||
class="text-gray-500 hover:text-white"
|
||||
aria-label="Close"><i class="bi bi-x-lg"></i></button
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="p-4 bg-gray-900/50 flex justify-between items-center">
|
||||
<button
|
||||
class="btn-primary px-6 py-2 rounded font-bold text-black bg-neon-cyan hover:bg-white transition-colors"
|
||||
on:click={startScan}
|
||||
disabled={scanning}
|
||||
>
|
||||
{scanning ? "Scanning..." : "Run System Scan"}
|
||||
</button>
|
||||
<div class="text-xs text-mono text-gray-500">Status: {status}</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-gray-800 px-4">
|
||||
{#each ["unindexed", "rescue", "redundant", "lost"] as tab}
|
||||
<button
|
||||
class="px-4 py-3 text-sm font-semibold capitalize border-b-2 transition-colors flex items-center gap-2
|
||||
{activeTab === tab
|
||||
? 'border-neon-cyan text-neon-cyan'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-300'}"
|
||||
on:click={() => (activeTab = tab as any)}
|
||||
>
|
||||
{tab}
|
||||
<span class="bg-gray-800 text-xs px-1.5 rounded-full"
|
||||
>{results[tab]?.length || 0}</span
|
||||
>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-grow overflow-y-auto p-4 bg-black/30">
|
||||
{#if scanning && (!results[activeTab] || results[activeTab].length === 0)}
|
||||
<div
|
||||
class="flex items-center justify-center h-full text-neon-cyan animate-pulse"
|
||||
>
|
||||
Scanning...
|
||||
</div>
|
||||
{:else}
|
||||
<table class="w-full text-left text-xs text-gray-300 font-mono">
|
||||
<thead>
|
||||
<tr class="text-gray-500 border-b border-gray-800">
|
||||
<th class="p-3">Video ID</th>
|
||||
<th class="p-3">Filename / Path</th>
|
||||
<th class="p-3">Size/Info</th>
|
||||
<th class="p-3 text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each results[activeTab] || [] as item}
|
||||
<tr
|
||||
class="border-b border-gray-800/50 hover:bg-white/5"
|
||||
>
|
||||
<td class="p-3 text-neon-pink"
|
||||
>{item.video_id}</td
|
||||
>
|
||||
<td
|
||||
class="p-3 truncate max-w-[300px]"
|
||||
title={item.path}
|
||||
>{item.filename || item.path}</td
|
||||
>
|
||||
<td class="p-3"
|
||||
>{item.size_mb
|
||||
? item.size_mb + " MB"
|
||||
: item.ta_source || "-"}</td
|
||||
>
|
||||
<td class="p-3 text-right">
|
||||
{#if activeTab === "unindexed"}
|
||||
<button
|
||||
class="text-neon-green hover:underline"
|
||||
on:click={() =>
|
||||
recoverFile(item.path)}
|
||||
>Recover</button
|
||||
>
|
||||
{:else if activeTab === "redundant"}
|
||||
<button
|
||||
class="text-red-500 hover:underline"
|
||||
on:click={() =>
|
||||
deleteFile(item.path)}
|
||||
>Delete</button
|
||||
>
|
||||
{:else if activeTab === "lost"}
|
||||
<button
|
||||
class="text-neon-yellow hover:underline mr-2"
|
||||
>Force</button
|
||||
>
|
||||
<button
|
||||
class="text-red-500 hover:underline"
|
||||
on:click={() =>
|
||||
deleteFile(item.path)}
|
||||
>Delete</button
|
||||
>
|
||||
{:else}
|
||||
<button
|
||||
class="text-neon-pink hover:underline"
|
||||
>Rescue</button
|
||||
>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{#if !results[activeTab]?.length}
|
||||
<tr
|
||||
><td
|
||||
colspan="4"
|
||||
class="p-10 text-center text-gray-600"
|
||||
>No items found.</td
|
||||
></tr
|
||||
>
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
39
ui/src/components/StatsCard.svelte
Normal file
39
ui/src/components/StatsCard.svelte
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<script lang="ts">
|
||||
export let title: string;
|
||||
export let value: number | string;
|
||||
export let color: 'cyan' | 'green' | 'pink' | 'yellow' | 'red' = 'cyan';
|
||||
export let icon: string = 'bi-activity';
|
||||
|
||||
const colors = {
|
||||
cyan: 'border-neon-cyan/30 text-neon-cyan shadow-neon-cyan/20',
|
||||
green: 'border-neon-green/30 text-neon-green shadow-neon-green/20',
|
||||
pink: 'border-neon-pink/30 text-neon-pink shadow-neon-pink/20',
|
||||
yellow: 'border-neon-yellow/30 text-neon-yellow shadow-neon-yellow/20',
|
||||
red: 'border-red-500/30 text-red-500 shadow-red-500/20'
|
||||
};
|
||||
|
||||
const bgColors = {
|
||||
cyan: 'bg-neon-cyan/5 hover:bg-neon-cyan/10',
|
||||
green: 'bg-neon-green/5 hover:bg-neon-green/10',
|
||||
pink: 'bg-neon-pink/5 hover:bg-neon-pink/10',
|
||||
yellow: 'bg-neon-yellow/5 hover:bg-neon-yellow/10',
|
||||
red: 'bg-red-500/5 hover:bg-red-500/10'
|
||||
};
|
||||
|
||||
$: baseColor = colors[color];
|
||||
$: bgColor = bgColors[color];
|
||||
</script>
|
||||
|
||||
<div class="rounded-xl border backdrop-blur-sm p-5 transition-all duration-300 hover:scale-[1.02] hover:shadow-[0_0_15px_rgba(0,0,0,0.3)] {baseColor} {bgColor}">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 class="text-gray-400 text-xs font-bold uppercase tracking-widest mb-1">{title}</h3>
|
||||
<div class="text-4xl font-black tracking-tighter tabular-nums drop-shadow-md">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2 rounded-lg bg-black/20 text-xl">
|
||||
<i class="bi {icon}"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
82
ui/src/components/StatsManager.svelte
Normal file
82
ui/src/components/StatsManager.svelte
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import StatsCard from "./StatsCard.svelte";
|
||||
|
||||
let stats = {
|
||||
total_videos: 0,
|
||||
verified_links: 0,
|
||||
missing_count: 0,
|
||||
new_links: 0, // API might not return this directly unless scan happened, let's check API
|
||||
};
|
||||
|
||||
// API returns: { total_videos, verified_links, missing_count, videos: [...] }
|
||||
// "New/Fixed" was calculated client side in old HTML by checking status diff or something?
|
||||
// Old HTML: id="stat-new". But looking at ta_symlink.py:
|
||||
// API /api/status returns totals.
|
||||
// Wait, the python code `api_status` calculates total, linked, missing.
|
||||
// It doesn't seem to persist "new" counts unless a scan JUST ran.
|
||||
// The old HTML says "New / Fixed", but where does it get it?
|
||||
// Old HTML JS: `document.getElementById('stat-new').textContent = 0;` (default)
|
||||
// It updates it? `updateTable` doesn't update stats.
|
||||
// `fetchStatus` updates `stat-total`, `stat-linked`, `stat-error`.
|
||||
// `stat-new` is NOT updated in `fetchStatus` in the original HTML!
|
||||
// It's only 0? Or maybe I missed where it's updated.
|
||||
// Ah, the Python `process_videos` returns new_links count, but that's only after a scan.
|
||||
// The `/api/status` endpoint does NOT return `new_links`.
|
||||
// So likely "New/Fixed" is only relevant after a scan.
|
||||
// I will just omit it or keep it 0 for now.
|
||||
|
||||
let interval: ReturnType<typeof setInterval>;
|
||||
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const res = await fetch("http://localhost:8002/api/status");
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
stats = {
|
||||
total_videos: data.total_videos,
|
||||
verified_links: data.verified_links,
|
||||
missing_count: data.missing_count,
|
||||
new_links: 0, // Placeholder
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Stats fetch error", e);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchStats();
|
||||
interval = setInterval(fetchStats, 5000);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
clearInterval(interval);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8 w-full">
|
||||
<StatsCard
|
||||
title="Total Videos"
|
||||
value={stats.total_videos}
|
||||
color="cyan"
|
||||
icon="bi-collection-play"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Linked & Verified"
|
||||
value={stats.verified_links}
|
||||
color="green"
|
||||
icon="bi-link-45deg"
|
||||
/>
|
||||
<StatsCard
|
||||
title="New / Fixed"
|
||||
value={stats.new_links}
|
||||
color="yellow"
|
||||
icon="bi-stars"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Missing / Error"
|
||||
value={stats.missing_count}
|
||||
color="red"
|
||||
icon="bi-exclamation-triangle"
|
||||
/>
|
||||
</div>
|
||||
188
ui/src/components/TranscodeManager.svelte
Normal file
188
ui/src/components/TranscodeManager.svelte
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import LogViewer from "./LogViewer.svelte";
|
||||
|
||||
let videos: any[] = [];
|
||||
let loading = false;
|
||||
let page = 1;
|
||||
let total = 0;
|
||||
let pages = 1;
|
||||
|
||||
async function fetchVideos(p = 1) {
|
||||
loading = true;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`http://localhost:8002/api/transcode/videos?page=${p}&per_page=100`,
|
||||
);
|
||||
const data = await res.json();
|
||||
videos = data.videos || [];
|
||||
total = data.total;
|
||||
pages = data.pages;
|
||||
page = data.page;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function startTranscode(filepath: string) {
|
||||
if (!confirm("Start transcoding?")) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
"http://localhost:8002/api/transcode/start",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filepath }),
|
||||
},
|
||||
);
|
||||
const d = await res.json();
|
||||
alert(d.message);
|
||||
} catch (e) {
|
||||
alert(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function findMissing() {
|
||||
// Triggers orphan check which populates the missing list
|
||||
if (!confirm("Scan for missing videos?")) return;
|
||||
loading = true;
|
||||
try {
|
||||
const res = await fetch("http://localhost:8002/api/check-orphans", {
|
||||
method: "POST",
|
||||
});
|
||||
const d = await res.json();
|
||||
alert(`Found ${d.count} missing videos.`);
|
||||
fetchVideos(1);
|
||||
} catch (e) {
|
||||
alert(e);
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchVideos();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header Controls -->
|
||||
<div
|
||||
class="flex justify-between items-center bg-cyber-card p-4 rounded-xl border border-gray-800 shadow-lg"
|
||||
>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-white flex items-center gap-2">
|
||||
<i class="bi bi-film text-neon-pink"></i> Transcode Queue
|
||||
</h2>
|
||||
<p class="text-gray-500 text-xs mt-1">
|
||||
Found {total} videos requiring transcode.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
<button
|
||||
class="btn-primary bg-neon-cyan/20 text-neon-cyan border border-neon-cyan/50 hover:bg-neon-cyan/40 px-4 py-2 rounded transition-colors flex items-center gap-2 font-bold"
|
||||
on:click={findMissing}
|
||||
>
|
||||
<i class="bi bi-search"></i> Find Missing
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div class="lg:col-span-2">
|
||||
<div
|
||||
class="bg-black/50 border border-gray-800 rounded-xl overflow-hidden h-[600px] flex flex-col"
|
||||
>
|
||||
<div class="overflow-auto flex-grow scrollbar-thin">
|
||||
<table class="w-full text-left text-xs font-mono">
|
||||
<thead
|
||||
class="bg-gray-900/80 sticky top-0 text-gray-400"
|
||||
>
|
||||
<tr>
|
||||
<th class="p-3">Channel</th>
|
||||
<th class="p-3">Published</th>
|
||||
<th class="p-3">Title</th>
|
||||
<th class="p-3 text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if loading}
|
||||
<tr
|
||||
><td
|
||||
colspan="4"
|
||||
class="p-8 text-center animate-pulse"
|
||||
>Scanning...</td
|
||||
></tr
|
||||
>
|
||||
{:else if videos.length === 0}
|
||||
<tr
|
||||
><td
|
||||
colspan="4"
|
||||
class="p-8 text-center text-gray-500"
|
||||
>Queue empty. No missing videos found.</td
|
||||
></tr
|
||||
>
|
||||
{:else}
|
||||
{#each videos as v}
|
||||
<tr
|
||||
class="border-b border-gray-800/30 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<td class="p-3 text-neon-cyan/80"
|
||||
>{v.channel}</td
|
||||
>
|
||||
<td class="p-3 text-gray-500"
|
||||
>{v.published}</td
|
||||
>
|
||||
<td
|
||||
class="p-3 text-white truncate max-w-[200px]"
|
||||
title={v.title}>{v.title}</td
|
||||
>
|
||||
<td class="p-3 text-right">
|
||||
<button
|
||||
class="text-neon-pink hover:text-white border border-neon-pink/30 hover:bg-neon-pink/20 px-2 py-1 rounded"
|
||||
on:click={() =>
|
||||
startTranscode(v.symlink)}
|
||||
>
|
||||
<i class="bi bi-play-fill"></i> Transcode
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Pagination -->
|
||||
{#if pages > 1}
|
||||
<div
|
||||
class="p-2 border-t border-gray-800 flex justify-center gap-2"
|
||||
>
|
||||
<button
|
||||
disabled={page === 1}
|
||||
on:click={() => fetchVideos(page - 1)}
|
||||
class="px-3 py-1 bg-gray-800 rounded hover:bg-gray-700 disabled:opacity-50"
|
||||
><</button
|
||||
>
|
||||
<span class="text-gray-500 text-xs py-1"
|
||||
>Page {page} of {pages}</span
|
||||
>
|
||||
<button
|
||||
disabled={page === pages}
|
||||
on:click={() => fetchVideos(page + 1)}
|
||||
class="px-3 py-1 bg-gray-800 rounded hover:bg-gray-700 disabled:opacity-50"
|
||||
>></button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-1">
|
||||
<LogViewer
|
||||
endpoint="http://localhost:8002/api/transcode/logs"
|
||||
title="TRANSCODE_LOGS"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
134
ui/src/components/VideoTable.svelte
Normal file
134
ui/src/components/VideoTable.svelte
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
<script lang="ts">
|
||||
export let videos: any[] = [];
|
||||
export let loading = false;
|
||||
|
||||
let searchTerm = "";
|
||||
let statusFilter = "";
|
||||
|
||||
// Channels derived from videos
|
||||
$: channels = [...new Set(videos.map((v) => v.channel))].sort();
|
||||
let channelFilter = "";
|
||||
|
||||
$: filteredVideos = videos.filter((v) => {
|
||||
const matchSearch =
|
||||
searchTerm === "" ||
|
||||
v.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
v.video_id.includes(searchTerm);
|
||||
const matchStatus = statusFilter === "" || v.status === statusFilter;
|
||||
const matchChannel =
|
||||
channelFilter === "" || v.channel === channelFilter;
|
||||
return matchSearch && matchStatus && matchChannel;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="bg-cyber-card border border-gray-800 rounded-xl shadow-lg flex flex-col h-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="p-4 border-b border-gray-800 bg-gray-900/50 flex flex-col sm:flex-row gap-3 justify-between items-center"
|
||||
>
|
||||
<h3 class="font-bold text-white flex items-center gap-2">
|
||||
<i class="bi bi-grid-3x3"></i> Video Matrix
|
||||
</h3>
|
||||
|
||||
<div class="flex gap-2 w-full sm:w-auto overflow-x-auto">
|
||||
<select
|
||||
bind:value={statusFilter}
|
||||
class="bg-black border border-gray-700 text-gray-300 text-xs rounded px-2 py-1 focus:border-neon-cyan focus:outline-none"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="linked">Linked</option>
|
||||
<option value="missing">Missing</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
bind:value={channelFilter}
|
||||
class="bg-black border border-gray-700 text-gray-300 text-xs rounded px-2 py-1 focus:border-neon-cyan focus:outline-none max-w-[150px]"
|
||||
>
|
||||
<option value="">All Channels</option>
|
||||
{#each channels as ch}
|
||||
<option value={ch}>{ch}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchTerm}
|
||||
placeholder="Search..."
|
||||
class="bg-black border border-gray-700 text-gray-300 text-xs rounded pl-8 pr-2 py-1 w-full sm:w-40 focus:border-neon-cyan focus:outline-none transition-all focus:w-48"
|
||||
/>
|
||||
<i
|
||||
class="bi bi-search absolute left-2 top-1.5 text-gray-500 text-xs"
|
||||
></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="overflow-auto flex-grow max-h-[500px] scrollbar-thin scrollbar-thumb-gray-800"
|
||||
>
|
||||
<table class="w-full text-left text-xs font-mono">
|
||||
<thead
|
||||
class="bg-black/50 text-gray-500 sticky top-0 backdrop-blur-sm z-10"
|
||||
>
|
||||
<tr>
|
||||
<th class="p-3 w-8">St</th>
|
||||
<th class="p-3">Published</th>
|
||||
<th class="p-3">Channel</th>
|
||||
<th class="p-3">Title</th>
|
||||
<th class="p-3">ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if loading}
|
||||
<tr
|
||||
><td
|
||||
colspan="5"
|
||||
class="p-8 text-center text-gray-500 animate-pulse"
|
||||
>Scanning matrix...</td
|
||||
></tr
|
||||
>
|
||||
{:else if filteredVideos.length === 0}
|
||||
<tr
|
||||
><td colspan="5" class="p-8 text-center text-gray-500"
|
||||
>No signals found.</td
|
||||
></tr
|
||||
>
|
||||
{:else}
|
||||
{#each filteredVideos as v}
|
||||
<tr
|
||||
class="border-b border-gray-800/50 hover:bg-white/5 transition-colors group"
|
||||
>
|
||||
<td class="p-3">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full {v.status ===
|
||||
'linked'
|
||||
? 'bg-neon-green shadow-[0_0_5px_rgba(57,255,20,0.5)]'
|
||||
: 'bg-red-500 shadow-[0_0_5px_rgba(239,68,68,0.5)]'}"
|
||||
></div>
|
||||
</td>
|
||||
<td class="p-3 text-gray-400 whitespace-nowrap"
|
||||
>{v.published}</td
|
||||
>
|
||||
<td class="p-3 text-neon-cyan/80">{v.channel}</td>
|
||||
<td
|
||||
class="p-3 font-semibold text-white group-hover:text-neon-pink transition-colors truncate max-w-[200px]"
|
||||
title={v.title}>{v.title}</td
|
||||
>
|
||||
<td class="p-3 text-gray-600 select-all"
|
||||
>{v.video_id}</td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="p-2 border-t border-gray-800 bg-black/30 text-right text-[10px] text-gray-500"
|
||||
>
|
||||
Showing {filteredVideos.length} / {videos.length} videos
|
||||
</div>
|
||||
</div>
|
||||
99
ui/src/layouts/Layout.astro
Normal file
99
ui/src/layouts/Layout.astro
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
---
|
||||
import "../styles/global.css";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
}
|
||||
|
||||
const { title } = Astro.props;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="description" content="TA Organizerr Dashboard" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>{title} | TA Organizerr</title>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Inter:wght@400;600;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css"
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
class="min-h-screen flex flex-col relative selection:bg-neon-pink selection:text-white"
|
||||
>
|
||||
<!-- Background Grid Effect -->
|
||||
<div
|
||||
class="fixed inset-0 z-[-1] opacity-20 pointer-events-none"
|
||||
style="background-image: linear-gradient(rgba(0, 243, 255, 0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(0, 243, 255, 0.1) 1px, transparent 1px); background-size: 50px 50px;"
|
||||
>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
class="border-b border-gray-800 bg-cyber-dark/80 backdrop-blur-md sticky top-0 z-50"
|
||||
>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<div class="flex items-center">
|
||||
<a
|
||||
href="/"
|
||||
class="flex items-center group hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<i
|
||||
class="bi bi-collection-play-fill mr-2 text-neon-cyan text-2xl group-hover:text-neon-pink transition-colors"
|
||||
></i>
|
||||
<span
|
||||
class="text-2xl font-bold tracking-tighter italic bg-clip-text text-transparent bg-gradient-to-r from-neon-cyan to-neon-pink pr-2 pb-1"
|
||||
>
|
||||
TA_ORGANIZERR
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="hidden md:block">
|
||||
<div class="ml-10 flex items-baseline space-x-4">
|
||||
<a
|
||||
href="/"
|
||||
class="text-gray-300 hover:text-neon-cyan hover:bg-white/5 px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||
>
|
||||
<i class="bi bi-speedometer2 mr-1"></i> Dashboard
|
||||
</a>
|
||||
<a
|
||||
href="/transcode"
|
||||
class="text-gray-300 hover:text-neon-pink hover:bg-white/5 px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||
>
|
||||
<i class="bi bi-film mr-1"></i> Transcoding
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-900/30 text-neon-green border border-neon-green/30 shadow-[0_0_10px_rgba(57,255,20,0.2)]"
|
||||
>
|
||||
<span
|
||||
class="w-2 h-2 mr-1 bg-neon-green rounded-full animate-pulse"
|
||||
></span>
|
||||
Online
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="flex-grow container mx-auto px-4 py-8">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<footer
|
||||
class="border-t border-gray-800 py-6 mt-8 text-center text-gray-500 text-sm"
|
||||
>
|
||||
<p>TA_ORGANIZERR // SYSTEM_V2 // BUN_POWERED</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
8
ui/src/pages/index.astro
Normal file
8
ui/src/pages/index.astro
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import Dashboard from "../components/Dashboard.svelte";
|
||||
---
|
||||
|
||||
<Layout title="Dashboard">
|
||||
<Dashboard client:only="svelte" />
|
||||
</Layout>
|
||||
8
ui/src/pages/transcode.astro
Normal file
8
ui/src/pages/transcode.astro
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import TranscodeManager from "../components/TranscodeManager.svelte";
|
||||
---
|
||||
|
||||
<Layout title="Transcoding">
|
||||
<TranscodeManager client:only="svelte" />
|
||||
</Layout>
|
||||
75
ui/src/styles/global.css
Normal file
75
ui/src/styles/global.css
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-cyber-dark: #050510;
|
||||
--color-cyber-darker: #020205;
|
||||
--color-cyber-card: #0a0a1a;
|
||||
--color-neon-cyan: #00f3ff;
|
||||
--color-neon-pink: #ff00ff;
|
||||
--color-neon-green: #39ff14;
|
||||
--color-neon-yellow: #ffff00;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-dark: #050510;
|
||||
--bg-card: #0a0a1a;
|
||||
--text-main: #e0e0e0;
|
||||
|
||||
--neon-cyan: #00f3ff;
|
||||
--neon-pink: #ff00ff;
|
||||
--neon-green: #39ff14;
|
||||
--neon-yellow: #ffee00;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-dark);
|
||||
color: var(--text-main);
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-dark);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #333;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--neon-cyan);
|
||||
}
|
||||
|
||||
/* Utilities */
|
||||
.neon-border-cyan {
|
||||
box-shadow: 0 0 5px var(--neon-cyan), inset 0 0 2px var(--neon-cyan);
|
||||
border: 1px solid var(--neon-cyan);
|
||||
}
|
||||
|
||||
.neon-text-cyan {
|
||||
color: var(--neon-cyan);
|
||||
text-shadow: 0 0 5px var(--neon-cyan);
|
||||
}
|
||||
|
||||
.neon-border-pink {
|
||||
box-shadow: 0 0 5px var(--neon-pink), inset 0 0 2px var(--neon-pink);
|
||||
border: 1px solid var(--neon-pink);
|
||||
}
|
||||
|
||||
.neon-text-pink {
|
||||
color: var(--neon-pink);
|
||||
text-shadow: 0 0 5px var(--neon-pink);
|
||||
}
|
||||
|
||||
.glass-panel {
|
||||
background: rgba(10, 10, 26, 0.7);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue