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