Initial commit: Børnetime app scaffold
FastAPI + Celery + Svelte app for English→Danish video translation with voice cloning. Includes diarization, STT, translation, TTS, and audio mixing pipeline.
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
<script>
|
||||
import './app.css';
|
||||
import Dashboard from './routes/Dashboard.svelte';
|
||||
import VideoDetail from './routes/VideoDetail.svelte';
|
||||
import TranscriptEditor from './routes/TranscriptEditor.svelte';
|
||||
import JobQueue from './routes/JobQueue.svelte';
|
||||
|
||||
let route = $state('dashboard');
|
||||
let params = $state({});
|
||||
|
||||
function navigate(path) {
|
||||
const parts = path.split('/');
|
||||
route = parts[0] || 'dashboard';
|
||||
params = {};
|
||||
if (parts[1]) params.id = parts[1];
|
||||
if (parts[2]) params.sub = parts[2];
|
||||
window.history.pushState(null, '', '#' + path);
|
||||
}
|
||||
|
||||
function handlePopState() {
|
||||
const hash = window.location.hash.slice(1) || 'dashboard';
|
||||
const parts = hash.split('/');
|
||||
route = parts[0] || 'dashboard';
|
||||
params = {};
|
||||
if (parts[1]) params.id = parts[1];
|
||||
if (parts[2]) params.sub = parts[2];
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
handlePopState();
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="app-shell">
|
||||
<nav class="topbar">
|
||||
<div class="topbar-inner">
|
||||
<button class="logo" onclick={() => navigate('dashboard')}>
|
||||
<span class="logo-icon">🎬</span>
|
||||
<span class="logo-text">Børnetime</span>
|
||||
</button>
|
||||
<div class="nav-links">
|
||||
<button
|
||||
class="nav-link"
|
||||
class:active={route === 'dashboard'}
|
||||
onclick={() => navigate('dashboard')}
|
||||
>Videos</button>
|
||||
<button
|
||||
class="nav-link"
|
||||
class:active={route === 'jobs'}
|
||||
onclick={() => navigate('jobs')}
|
||||
>Jobs</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="main-content">
|
||||
{#if route === 'dashboard'}
|
||||
<Dashboard {navigate} />
|
||||
{:else if route === 'video'}
|
||||
<VideoDetail id={params.id} {navigate} />
|
||||
{:else if route === 'transcript'}
|
||||
<TranscriptEditor id={params.id} {navigate} />
|
||||
{:else if route === 'jobs'}
|
||||
<JobQueue {navigate} />
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app-shell {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topbar-inner {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
color: var(--accent);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-primary: #0f1117;
|
||||
--bg-secondary: #1a1d27;
|
||||
--bg-tertiary: #242736;
|
||||
--bg-hover: #2a2d3d;
|
||||
--border: #2e3348;
|
||||
--text-primary: #e4e6f0;
|
||||
--text-secondary: #8b8fa3;
|
||||
--text-muted: #565a6e;
|
||||
--accent: #6c5ce7;
|
||||
--accent-hover: #7c6ff0;
|
||||
--accent-subtle: rgba(108, 92, 231, 0.15);
|
||||
--green: #00d68f;
|
||||
--green-bg: rgba(0, 214, 143, 0.12);
|
||||
--yellow: #ffa726;
|
||||
--yellow-bg: rgba(255, 167, 38, 0.12);
|
||||
--red: #ff5252;
|
||||
--red-bg: rgba(255, 82, 82, 0.12);
|
||||
--blue: #4fc3f7;
|
||||
--blue-bg: rgba(79, 195, 247, 0.12);
|
||||
--radius: 10px;
|
||||
--radius-sm: 6px;
|
||||
--shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
const API_BASE = '/api';
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const url = `${API_BASE}${path}`;
|
||||
const config = {
|
||||
headers: { 'Content-Type': 'application/json', ...options.headers },
|
||||
...options,
|
||||
};
|
||||
|
||||
if (config.body && typeof config.body === 'object' && !(config.body instanceof FormData)) {
|
||||
config.body = JSON.stringify(config.body);
|
||||
}
|
||||
|
||||
if (config.body instanceof FormData) {
|
||||
delete config.headers['Content-Type'];
|
||||
}
|
||||
|
||||
const res = await fetch(url, config);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
listVideos: () => request('/videos'),
|
||||
getVideo: (id) => request(`/videos/${id}`),
|
||||
uploadVideo: (file) => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
return request('/videos/upload', { method: 'POST', body: fd });
|
||||
},
|
||||
deleteVideo: (id) => request(`/videos/${id}`, { method: 'DELETE' }),
|
||||
processVideo: (id) => request(`/videos/${id}/process`, { method: 'POST' }),
|
||||
getTranscript: (id) => request(`/videos/${id}/transcript`),
|
||||
updateSegment: (videoId, segmentId, data) =>
|
||||
request(`/videos/${videoId}/transcript/${segmentId}`, { method: 'PUT', body: data }),
|
||||
generateAudio: (id) => request(`/videos/${id}/generate`, { method: 'POST' }),
|
||||
|
||||
listJobs: () => request('/jobs'),
|
||||
getJob: (id) => request(`/jobs/${id}`),
|
||||
cancelJob: (id) => request(`/jobs/${id}/cancel`, { method: 'POST' }),
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
<script>
|
||||
let { src = '', segments = [] } = $props();
|
||||
let videoEl = $state(null);
|
||||
let currentTime = $state(0);
|
||||
|
||||
function onTimeUpdate() {
|
||||
if (videoEl) currentTime = videoEl.currentTime;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="player-wrapper">
|
||||
<video
|
||||
bind:this={videoEl}
|
||||
controls
|
||||
width="100%"
|
||||
{src}
|
||||
ontimeupdate={onTimeUpdate}
|
||||
>
|
||||
Your browser does not support video playback.
|
||||
</video>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.player-wrapper {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
video {
|
||||
display: block;
|
||||
max-height: 400px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script>
|
||||
let { value = 0 } = $props();
|
||||
let pct = $derived(Math.min(Math.max(value * 100, 0), 100));
|
||||
</script>
|
||||
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill" style="width: {pct}%"></div>
|
||||
<span class="progress-label">{Math.round(pct)}%</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.progress-track {
|
||||
height: 20px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent), #8b7cf7);
|
||||
border-radius: 10px;
|
||||
transition: width 0.3s ease;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script>
|
||||
let { segments = [], duration = 0 } = $props();
|
||||
</script>
|
||||
|
||||
<div class="timeline-track">
|
||||
{#each segments as seg}
|
||||
<div
|
||||
class="segment-bar"
|
||||
style="left: {(seg.start_time / duration) * 100}%; width: {((seg.end_time - seg.start_time) / duration) * 100}%"
|
||||
title="{seg.speaker_label}: {seg.english_text?.slice(0, 60)}"
|
||||
>
|
||||
<span class="bar-label">{seg.speaker_label}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.timeline-track {
|
||||
height: 32px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.segment-bar {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
bottom: 2px;
|
||||
background: var(--accent);
|
||||
border-radius: 3px;
|
||||
opacity: 0.7;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 4px;
|
||||
min-width: 20px;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.segment-bar:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,182 @@
|
||||
<script>
|
||||
let { video, navigate, ondelete } = $props();
|
||||
|
||||
const statusLabels = {
|
||||
uploaded: 'Uploaded',
|
||||
queued: 'Queued',
|
||||
processing: 'Processing',
|
||||
review: 'Review',
|
||||
generating: 'Generating',
|
||||
done: 'Done',
|
||||
error: 'Error',
|
||||
};
|
||||
|
||||
const statusColors = {
|
||||
uploaded: 'var(--text-muted)',
|
||||
queued: 'var(--blue)',
|
||||
processing: 'var(--yellow)',
|
||||
review: 'var(--accent)',
|
||||
generating: 'var(--yellow)',
|
||||
done: 'var(--green)',
|
||||
error: 'var(--red)',
|
||||
};
|
||||
|
||||
const statusBg = {
|
||||
uploaded: 'transparent',
|
||||
queued: 'var(--blue-bg)',
|
||||
processing: 'var(--yellow-bg)',
|
||||
review: 'var(--accent-subtle)',
|
||||
generating: 'var(--yellow-bg)',
|
||||
done: 'var(--green-bg)',
|
||||
error: 'var(--red-bg)',
|
||||
};
|
||||
|
||||
function formatDuration(sec) {
|
||||
if (!sec) return '—';
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatDate(d) {
|
||||
return new Date(d).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="card" onclick={() => navigate(`video/${video.id}`)}>
|
||||
<div class="card-thumb">
|
||||
<div class="thumb-placeholder">🎬</div>
|
||||
<span class="duration">{formatDuration(video.duration)}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3 class="card-title" title={video.filename}>{video.filename}</h3>
|
||||
<div class="card-meta">
|
||||
<span
|
||||
class="badge"
|
||||
style="background: {statusBg[video.status] || 'transparent'}; color: {statusColors[video.status] || 'var(--text-muted)'}; border-color: {statusColors[video.status] || 'var(--border)'};"
|
||||
>
|
||||
{statusLabels[video.status] || video.status}
|
||||
</span>
|
||||
<span class="date">{formatDate(video.created_at)}</span>
|
||||
</div>
|
||||
{#if video.status === 'error' && video.error_message}
|
||||
<p class="error-msg">{video.error_message}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="delete-btn" onclick={(e) => { e.stopPropagation(); ondelete?.(); }} title="Delete">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--shadow);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.card-thumb {
|
||||
height: 140px;
|
||||
background: var(--bg-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.thumb-placeholder {
|
||||
font-size: 40px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.duration {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
font-size: 12px;
|
||||
color: var(--red);
|
||||
margin-top: 6px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.card:hover .delete-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: var(--red);
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,8 @@
|
||||
import App from './App.svelte';
|
||||
import { mount } from 'svelte';
|
||||
|
||||
const app = mount(App, {
|
||||
target: document.getElementById('app'),
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,227 @@
|
||||
<script>
|
||||
import { api } from '../lib/api.js';
|
||||
import VideoCard from '../lib/components/VideoCard.svelte';
|
||||
|
||||
let { navigate } = $props();
|
||||
|
||||
let videos = $state([]);
|
||||
let loading = $state(true);
|
||||
let uploading = $state(false);
|
||||
let error = $state(null);
|
||||
let dragOver = $state(false);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const data = await api.listVideos();
|
||||
videos = data.videos;
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload(file) {
|
||||
if (!file) return;
|
||||
uploading = true;
|
||||
try {
|
||||
await api.uploadVideo(file);
|
||||
await load();
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onDrop(e) {
|
||||
e.preventDefault();
|
||||
dragOver = false;
|
||||
const file = e.dataTransfer?.files?.[0];
|
||||
if (file) handleUpload(file);
|
||||
}
|
||||
|
||||
function onDragOver(e) {
|
||||
e.preventDefault();
|
||||
dragOver = true;
|
||||
}
|
||||
|
||||
function onDragLeave() {
|
||||
dragOver = false;
|
||||
}
|
||||
|
||||
function onFileSelected(e) {
|
||||
const file = e.target?.files?.[0];
|
||||
if (file) handleUpload(file);
|
||||
}
|
||||
|
||||
async function onDelete(id) {
|
||||
await api.deleteVideo(id);
|
||||
await load();
|
||||
}
|
||||
|
||||
$effect(() => { load(); });
|
||||
</script>
|
||||
|
||||
<div class="dashboard">
|
||||
<div class="header">
|
||||
<h1>Videos</h1>
|
||||
<button class="btn-refresh" onclick={load}>
|
||||
↻
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="alert error">{error}</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="upload-zone"
|
||||
class:drag-over={dragOver}
|
||||
ondragover={onDragOver}
|
||||
ondragleave={onDragLeave}
|
||||
ondrop={onDrop}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => document.getElementById('file-upload')?.click()}
|
||||
>
|
||||
<input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
accept=".mp4,.mkv,.mov,.avi,.webm,.m4v"
|
||||
hidden
|
||||
onchange={onFileSelected}
|
||||
/>
|
||||
{#if uploading}
|
||||
<span class="upload-hint">Uploading…</span>
|
||||
{:else}
|
||||
<span class="upload-icon">⏫</span>
|
||||
<span class="upload-hint">Drop a video here or click to upload</span>
|
||||
<span class="upload-formats">MP4, MKV, MOV, AVI, WebM, M4V</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="loading">Loading videos…</div>
|
||||
{:else if videos.length === 0}
|
||||
<div class="empty">
|
||||
<span class="empty-icon">🎥</span>
|
||||
<p>No videos yet. Upload one to get started.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each videos as video}
|
||||
<VideoCard {video} {navigate} ondelete={() => onDelete(video.id)} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dashboard {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.btn-refresh {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-refresh:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.upload-zone {
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-bottom: 20px;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.upload-zone:hover, .upload-zone.drag-over {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
display: block;
|
||||
font-size: 28px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
display: block;
|
||||
color: var(--text-secondary);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.upload-formats {
|
||||
display: block;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.loading, .empty {
|
||||
text-align: center;
|
||||
padding: 64px 24px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.alert.error {
|
||||
background: var(--red-bg);
|
||||
color: var(--red);
|
||||
border: 1px solid var(--red);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,265 @@
|
||||
<script>
|
||||
import { api } from '../lib/api.js';
|
||||
import ProgressBar from '../lib/components/ProgressBar.svelte';
|
||||
|
||||
let { navigate } = $props();
|
||||
|
||||
let jobs = $state([]);
|
||||
let loading = $state(true);
|
||||
let polling = $state(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const data = await api.listJobs();
|
||||
jobs = data.jobs;
|
||||
} catch {} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel(jobId, e) {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await api.cancelJob(jobId);
|
||||
await load();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
load();
|
||||
polling = setInterval(load, 3000);
|
||||
return () => clearInterval(polling);
|
||||
});
|
||||
|
||||
const statusColors = {
|
||||
pending: 'var(--blue)',
|
||||
running: 'var(--yellow)',
|
||||
done: 'var(--green)',
|
||||
failed: 'var(--red)',
|
||||
};
|
||||
|
||||
const statusBg = {
|
||||
pending: 'var(--blue-bg)',
|
||||
running: 'var(--yellow-bg)',
|
||||
done: 'var(--green-bg)',
|
||||
failed: 'var(--red-bg)',
|
||||
};
|
||||
|
||||
function formatDate(d) {
|
||||
return new Date(d).toLocaleString('en-GB');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="queue">
|
||||
<div class="header">
|
||||
<h1>Jobs</h1>
|
||||
<button class="btn-refresh" onclick={load}>↻</button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="loading">Loading…</div>
|
||||
{:else if jobs.length === 0}
|
||||
<div class="empty">
|
||||
<span class="empty-icon">📋</span>
|
||||
<p>No jobs yet. Process a video to see jobs here.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
<th>Progress</th>
|
||||
<th>Video</th>
|
||||
<th>Created</th>
|
||||
<th>Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each jobs as job}
|
||||
<tr onclick={() => navigate(`video/${job.video_id}`)}>
|
||||
<td class="cell-id">#{job.id}</td>
|
||||
<td><span class="job-type">{job.job_type}</span></td>
|
||||
<td>
|
||||
<span
|
||||
class="badge"
|
||||
style="background: {statusBg[job.status] || 'transparent'}; color: {statusColors[job.status] || 'var(--text-muted)'};"
|
||||
>
|
||||
{job.status}
|
||||
</span>
|
||||
</td>
|
||||
<td class="cell-progress">
|
||||
<ProgressBar value={job.progress} />
|
||||
</td>
|
||||
<td class="cell-video">#{job.video_id}</td>
|
||||
<td class="cell-date">{formatDate(job.created_at)}</td>
|
||||
<td class="cell-error">
|
||||
{#if job.error_message}
|
||||
<span class="error-text" title={job.error_message}>⚠ {job.error_message.slice(0, 40)}…</span>
|
||||
{:else if job.status === 'pending' || job.status === 'running'}
|
||||
<button class="cancel-btn" onclick={(e) => handleCancel(job.id, e)}>✕ Cancel</button>
|
||||
{:else}—{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.queue {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn-refresh {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-refresh:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.loading, .empty {
|
||||
text-align: center;
|
||||
padding: 64px 24px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
thead th {
|
||||
text-align: left;
|
||||
padding: 12px 14px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
tbody td {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.cell-id {
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.job-type {
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.cell-progress {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.cell-video {
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.cell-date {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cell-error {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: var(--red);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--yellow);
|
||||
color: var(--yellow);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
background: var(--yellow-bg);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,286 @@
|
||||
<script>
|
||||
import { api } from '../lib/api.js';
|
||||
|
||||
let { id, navigate } = $props();
|
||||
|
||||
let segments = $state([]);
|
||||
let video = $state(null);
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
let error = $state(null);
|
||||
let successMsg = $state(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [v, t] = await Promise.all([api.getVideo(id), api.getTranscript(id)]);
|
||||
video = v;
|
||||
segments = t.segments;
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSegment(seg) {
|
||||
saving = true;
|
||||
try {
|
||||
const updated = await api.updateSegment(id, seg.id, {
|
||||
english_text: seg.english_text,
|
||||
danish_text: seg.danish_text,
|
||||
speaker_label: seg.speaker_label,
|
||||
});
|
||||
Object.assign(seg, updated);
|
||||
successMsg = 'Saved';
|
||||
setTimeout(() => successMsg = null, 2000);
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerate() {
|
||||
await api.generateAudio(id);
|
||||
navigate(`video/${id}`);
|
||||
}
|
||||
|
||||
function formatTime(sec) {
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
const ms = Math.floor((sec % 1) * 100);
|
||||
return `${m}:${String(s).padStart(2, '0')}.${String(ms).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
$effect(() => { load(); });
|
||||
</script>
|
||||
|
||||
<div class="editor">
|
||||
<button class="back" onclick={() => navigate(`video/${id}`)}>← Back to Video</button>
|
||||
|
||||
<div class="editor-header">
|
||||
<h1>{video?.filename || 'Transcript'}</h1>
|
||||
{#if video?.status === 'review'}
|
||||
<button class="btn accent" onclick={handleGenerate}>
|
||||
🎙 Confirm & Generate Audio
|
||||
</button>
|
||||
{/if}
|
||||
{#if successMsg}
|
||||
<span class="success-toast">{successMsg}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="alert error">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="loading">Loading transcript…</div>
|
||||
{:else if segments.length === 0}
|
||||
<div class="empty">No segments available.</div>
|
||||
{:else}
|
||||
<div class="segment-list">
|
||||
{#each segments as seg}
|
||||
<div class="segment">
|
||||
<div class="segment-header">
|
||||
<span class="timestamp">{formatTime(seg.start_time)} → {formatTime(seg.end_time)}</span>
|
||||
<span class="duration">({(seg.end_time - seg.start_time).toFixed(1)}s)</span>
|
||||
<span class="speaker-badge">{seg.speaker_label || '?'}</span>
|
||||
<span class="seg-status done">✓ Transcribed</span>
|
||||
</div>
|
||||
|
||||
<div class="segment-texts">
|
||||
<div class="text-block">
|
||||
<label>English</label>
|
||||
<textarea
|
||||
bind:value={seg.english_text}
|
||||
rows="2"
|
||||
onchange={() => saveSegment(seg)}
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="text-block">
|
||||
<label>Danish</label>
|
||||
<textarea
|
||||
bind:value={seg.danish_text}
|
||||
rows="2"
|
||||
onchange={() => saveSegment(seg)}
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.editor {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.back {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
padding: 4px 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.back:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
transition: all 0.15s;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn.accent {
|
||||
background: var(--green);
|
||||
border-color: var(--green);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.btn.accent:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.success-toast {
|
||||
background: var(--green-bg);
|
||||
color: var(--green);
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.alert.error {
|
||||
background: var(--red-bg);
|
||||
color: var(--red);
|
||||
border: 1px solid var(--red);
|
||||
}
|
||||
|
||||
.loading, .empty {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.segment-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.segment {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.segment:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.segment-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.duration {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.speaker-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.seg-status {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--green);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.segment-texts {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.text-block label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.text-block textarea {
|
||||
width: 100%;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.text-block textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,321 @@
|
||||
<script>
|
||||
import { api } from '../lib/api.js';
|
||||
|
||||
let { id, navigate } = $props();
|
||||
|
||||
let video = $state(null);
|
||||
let loading = $state(true);
|
||||
let error = $state(null);
|
||||
let polling = $state(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
video = await api.getVideo(id);
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (polling) return;
|
||||
polling = setInterval(async () => {
|
||||
try {
|
||||
video = await api.getVideo(id);
|
||||
if (video.status === 'review' || video.status === 'done' || video.status === 'error') {
|
||||
clearInterval(polling);
|
||||
polling = null;
|
||||
}
|
||||
} catch {}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
async function handleProcess() {
|
||||
await api.processVideo(id);
|
||||
video = await api.getVideo(id);
|
||||
startPolling();
|
||||
}
|
||||
|
||||
async function handleGenerate() {
|
||||
await api.generateAudio(id);
|
||||
video = await api.getVideo(id);
|
||||
startPolling();
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
await api.deleteVideo(id);
|
||||
navigate('dashboard');
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
try {
|
||||
const jobs = await api.listJobs();
|
||||
const activeJob = jobs.jobs.find(j => j.video_id == id && (j.status === 'pending' || j.status === 'running'));
|
||||
if (activeJob) {
|
||||
await api.cancelJob(activeJob.id);
|
||||
video = await api.getVideo(id);
|
||||
}
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
load();
|
||||
return () => { if (polling) clearInterval(polling); };
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (video && (video.status === 'processing' || video.status === 'generating' || video.status === 'queued')) {
|
||||
startPolling();
|
||||
}
|
||||
});
|
||||
|
||||
function videoUrl(id) {
|
||||
return `/api/videos/${id}/file`;
|
||||
}
|
||||
|
||||
function outputUrl(path) {
|
||||
return path ? `/api/videos/file/${encodeURIComponent(path)}` : null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="detail">
|
||||
<button class="back" onclick={() => navigate('dashboard')}>← Back</button>
|
||||
|
||||
{#if loading}
|
||||
<div class="loading">Loading…</div>
|
||||
{:else if error}
|
||||
<div class="alert error">{error}</div>
|
||||
{:else if video}
|
||||
<div class="detail-header">
|
||||
<h1>{video.filename}</h1>
|
||||
<div class="actions">
|
||||
{#if video.status === 'uploaded' || video.status === 'error'}
|
||||
<button class="btn primary" onclick={handleProcess}>
|
||||
▶ Process
|
||||
</button>
|
||||
{/if}
|
||||
{#if video.status === 'review'}
|
||||
<button class="btn primary" onclick={() => navigate(`transcript/${video.id}`)}>
|
||||
✏ Review Transcript
|
||||
</button>
|
||||
<button class="btn accent" onclick={handleGenerate}>
|
||||
🎙 Generate Audio
|
||||
</button>
|
||||
{/if}
|
||||
{#if video.status === 'done'}
|
||||
<button class="btn primary" onclick={() => navigate(`transcript/${video.id}`)}>
|
||||
✏ View Transcript
|
||||
</button>
|
||||
<a class="btn accent" href={outputUrl(video.output_path)} download>
|
||||
⬇ Download
|
||||
</a>
|
||||
{/if}
|
||||
{#if video.status === 'processing' || video.status === 'generating' || video.status === 'queued'}
|
||||
<span class="processing-badge">
|
||||
{video.status === 'queued' ? '⏳ Queued' : '⏳ Processing…'}
|
||||
</span>
|
||||
<button class="btn cancel" onclick={handleCancel}>✕ Cancel</button>
|
||||
{/if}
|
||||
<button class="btn danger" onclick={handleDelete}>🗑 Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="meta-row">
|
||||
<span class="meta-item">
|
||||
<span class="meta-label">Status</span>
|
||||
<span class="meta-value badge badge-{video.status}">{video.status}</span>
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<span class="meta-label">Duration</span>
|
||||
<span class="meta-value">{video.duration ? Math.floor(video.duration / 60) + 'm ' + Math.floor(video.duration % 60) + 's' : '—'}</span>
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<span class="meta-label">Created</span>
|
||||
<span class="meta-value">{new Date(video.created_at).toLocaleString('en-GB')}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if video.status === 'error' && video.error_message}
|
||||
<div class="alert error">Error: {video.error_message}</div>
|
||||
{/if}
|
||||
|
||||
<div class="video-player">
|
||||
<video controls width="100%" src={videoUrl(video.id)}>
|
||||
Your browser does not support video playback.
|
||||
</video>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.detail {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.back {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
padding: 4px 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.back:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
transition: all 0.15s;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn.primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn.accent {
|
||||
background: var(--green);
|
||||
border-color: var(--green);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.btn.accent:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.btn.danger {
|
||||
border-color: var(--red);
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.btn.danger:hover {
|
||||
background: var(--red-bg);
|
||||
}
|
||||
|
||||
.btn.cancel {
|
||||
border-color: var(--yellow);
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.btn.cancel:hover {
|
||||
background: var(--yellow-bg);
|
||||
}
|
||||
|
||||
.processing-badge {
|
||||
background: var(--yellow-bg);
|
||||
color: var(--yellow);
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.badge-uploaded { color: var(--text-muted); }
|
||||
.badge-queued { color: var(--blue); }
|
||||
.badge-processing { color: var(--yellow); }
|
||||
.badge-review { color: var(--accent); }
|
||||
.badge-generating { color: var(--yellow); }
|
||||
.badge-done { color: var(--green); }
|
||||
.badge-error { color: var(--red); }
|
||||
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.alert.error {
|
||||
background: var(--red-bg);
|
||||
color: var(--red);
|
||||
border: 1px solid var(--red);
|
||||
}
|
||||
|
||||
.video-player {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.video-player video {
|
||||
display: block;
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user