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:
2026-07-10 22:42:23 +02:00
commit 6851b26923
43 changed files with 3390 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Børnetime — Video Translation</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text y='28' font-size='28'>🎬</text></svg>" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
{
"name": "bornetime",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"svelte": "^5.0.0",
"vite": "^6.0.0"
}
}
+141
View File
@@ -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>
+73
View File
@@ -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;
}
+44
View File
@@ -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' }),
};
+35
View File
@@ -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>
+8
View File
@@ -0,0 +1,8 @@
import App from './App.svelte';
import { mount } from 'svelte';
const app = mount(App, {
target: document.getElementById('app'),
});
export default app;
+227
View File
@@ -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>
+265
View File
@@ -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>
+286
View File
@@ -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>
+321
View File
@@ -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>
+5
View File
@@ -0,0 +1,5 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
preprocess: vitePreprocess(),
};
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://api:8000',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
},
});