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:
wander 2026-02-04 15:30:04 -05:00
parent 985a05858a
commit aa94920650
62 changed files with 6589 additions and 18 deletions

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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"
>&lt;</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"
>&gt;</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>

View 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>

View 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
View 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>

View 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
View 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);
}