Initial commit: Tonemark PWA
Some checks failed
Build & Push Docker Image / build-and-push (push) Failing after 11s
Some checks failed
Build & Push Docker Image / build-and-push (push) Failing after 11s
Tonemark is a SvelteKit PWA for transcribing YouTube videos, audio and video files, and microphone recordings using a local Whisper backend. Features: - Dark glassmorphic UI with electric-lime accent (5 switchable themes) - Rail nav (desktop) / tab bar (mobile) layout - Drop zone, YouTube URL input, and live audio recording inputs - Audio mode waveform cards (none / standard / aggressive / auto) - Real-time transcription progress with animated waveform - Job queue with SSE streaming updates - Push notifications on job completion - PWA with native SvelteKit service worker - SRT / TXT / MD / JSON transcript downloads Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
329
src/routes/+layout.svelte
Normal file
329
src/routes/+layout.svelte
Normal file
@@ -0,0 +1,329 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { page } from '$app/stores';
|
||||
import { accent } from '$lib/accent.js';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// Initialize accent (triggers subscriber which sets CSS vars)
|
||||
// The store subscriber handles everything; just subscribing here keeps it alive.
|
||||
$effect(() => { void $accent; });
|
||||
|
||||
// Push notification setup
|
||||
onMount(async () => {
|
||||
if (!browser || !('serviceWorker' in navigator) || !('PushManager' in window)) return;
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const res = await fetch('/api/push');
|
||||
if (!res.ok) return;
|
||||
const { publicKey } = await res.json();
|
||||
const existing = await reg.pushManager.getSubscription();
|
||||
const sub =
|
||||
existing ??
|
||||
(await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey).buffer as ArrayBuffer
|
||||
}));
|
||||
await fetch('/api/push', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
endpoint: sub.endpoint,
|
||||
keys: {
|
||||
p256dh: arrayBufferToBase64(sub.getKey('p256dh')!),
|
||||
auth: arrayBufferToBase64(sub.getKey('auth')!)
|
||||
}
|
||||
})
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[push] setup failed:', err);
|
||||
}
|
||||
});
|
||||
|
||||
function urlBase64ToUint8Array(base64: string): Uint8Array {
|
||||
const pad = '='.repeat((4 - (base64.length % 4)) % 4);
|
||||
const b64 = (base64 + pad).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const raw = atob(b64);
|
||||
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
|
||||
}
|
||||
function arrayBufferToBase64(buf: ArrayBuffer): string {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buf)));
|
||||
}
|
||||
|
||||
// Derive active nav item from current path
|
||||
const active = $derived.by(() => {
|
||||
const path = $page.url.pathname;
|
||||
if (path.startsWith('/jobs')) return 'queue';
|
||||
if (path.startsWith('/settings')) return 'settings';
|
||||
return 'home';
|
||||
});
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
k: 'home',
|
||||
label: 'Home',
|
||||
href: '/',
|
||||
icon: 'M2 7L8 2.5L14 7V13.5H10V9.5H6V13.5H2V7Z'
|
||||
},
|
||||
{
|
||||
k: 'queue',
|
||||
label: 'Queue',
|
||||
href: '/jobs',
|
||||
icon: 'M2 4H14M2 8H14M2 12H10',
|
||||
stroke: true
|
||||
},
|
||||
{
|
||||
k: 'settings',
|
||||
label: 'Settings',
|
||||
href: '/settings',
|
||||
icon: 'M8 5.5A2.5 2.5 0 1 1 8 10.5A2.5 2.5 0 0 1 8 5.5ZM8 1V3M8 13V15M3.5 3.5L4.9 4.9M11.1 11.1L12.5 12.5M1 8H3M13 8H15M3.5 12.5L4.9 11.1M11.1 4.9L12.5 3.5',
|
||||
stroke: true
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="app-shell">
|
||||
<!-- ── Rail nav (desktop) ─────────────────────────────── -->
|
||||
<nav class="rail" aria-label="Main navigation">
|
||||
<!-- Logo -->
|
||||
<a href="/" class="logo" aria-label="Tonemark home">
|
||||
<div class="logo-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 32 32" fill="none" aria-hidden="true">
|
||||
<rect x="6" y="8" width="4" height="16" rx="2" fill="#0c0d10" />
|
||||
<rect x="14" y="12" width="4" height="8" rx="2" fill="#0c0d10" />
|
||||
<rect x="22" y="6" width="4" height="20" rx="2" fill="#0c0d10" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="logo-name">Tonemark<span class="logo-dot">·</span></span>
|
||||
</a>
|
||||
|
||||
<!-- Nav items -->
|
||||
<div class="nav-items">
|
||||
{#each navItems as item}
|
||||
<a
|
||||
href={item.href}
|
||||
class="nav-item"
|
||||
class:active={active === item.k}
|
||||
aria-current={active === item.k ? 'page' : undefined}
|
||||
>
|
||||
{#if active === item.k}
|
||||
<div class="nav-indicator" aria-hidden="true"></div>
|
||||
{/if}
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path
|
||||
d={item.icon}
|
||||
fill={item.stroke ? 'none' : 'currentColor'}
|
||||
stroke={item.stroke ? 'currentColor' : 'none'}
|
||||
stroke-width="1.4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
{item.label}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="rail-spacer"></div>
|
||||
|
||||
<!-- Status dot -->
|
||||
<div class="status-pill">
|
||||
<div class="status-dot"></div>
|
||||
<span>whisper-large-v3</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- ── Main content ───────────────────────────────────── -->
|
||||
<main class="main-content">
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
<!-- ── Tab bar (mobile) ───────────────────────────────── -->
|
||||
<nav class="tabbar" aria-label="Mobile navigation">
|
||||
{#each navItems as item}
|
||||
<a href={item.href} class="tab-item" class:active={active === item.k}>
|
||||
<svg width="22" height="22" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path
|
||||
d={item.icon}
|
||||
fill={item.stroke ? 'none' : 'currentColor'}
|
||||
stroke={item.stroke ? 'currentColor' : 'none'}
|
||||
stroke-width="1.4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app-shell {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
/* ── Rail ─────────────────────────────────────────────── */
|
||||
.rail {
|
||||
width: var(--rail-width);
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px 14px;
|
||||
background: rgba(255, 255, 255, 0.015);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 8px 28px;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
}
|
||||
.logo-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.logo-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.logo-dot {
|
||||
color: rgba(232, 233, 236, 0.4);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.nav-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 9px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
position: relative;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.nav-item:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text);
|
||||
}
|
||||
.nav-item.active {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #fff;
|
||||
}
|
||||
.nav-indicator {
|
||||
position: absolute;
|
||||
left: -14px;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.rail-spacer { flex: 1; }
|
||||
|
||||
.status-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: #5dd47a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Main content ─────────────────────────────────────── */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── Tab bar (mobile only) ────────────────────────────── */
|
||||
.tabbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Responsive ───────────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.rail {
|
||||
display: none;
|
||||
}
|
||||
.app-shell {
|
||||
flex-direction: column;
|
||||
padding-bottom: 72px;
|
||||
}
|
||||
.tabbar {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
background: rgba(12, 13, 16, 0.9);
|
||||
backdrop-filter: blur(20px);
|
||||
padding: 10px 0 max(30px, env(safe-area-inset-bottom));
|
||||
justify-content: space-around;
|
||||
z-index: 100;
|
||||
}
|
||||
.tab-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: rgba(232, 233, 236, 0.4);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.tab-item.active {
|
||||
color: var(--accent);
|
||||
}
|
||||
.tab-item span {
|
||||
font-size: 9.5px;
|
||||
font-family: var(--font-mono);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
588
src/routes/+page.svelte
Normal file
588
src/routes/+page.svelte
Normal file
@@ -0,0 +1,588 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { Job, AudioMode } from '$lib/types.js';
|
||||
import SourceIcon from '$lib/components/SourceIcon.svelte';
|
||||
import Waveform from '$lib/components/Waveform.svelte';
|
||||
import RecordButton from '$lib/components/RecordButton.svelte';
|
||||
|
||||
const ACCENT = '#cdf24e';
|
||||
|
||||
let url = $state('');
|
||||
let audioMode = $state<AudioMode>('auto');
|
||||
let jobs = $state<Job[]>([]);
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
let dragOver = $state(false);
|
||||
let fileInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
const modes: {
|
||||
value: AudioMode;
|
||||
label: string;
|
||||
sub: string;
|
||||
pattern: 'flat' | 'medium' | 'aggressive' | 'auto';
|
||||
}[] = [
|
||||
{ value: 'none', label: 'None', sub: 'Raw', pattern: 'flat' },
|
||||
{ value: 'standard', label: 'Standard', sub: 'Balanced', pattern: 'medium' },
|
||||
{ value: 'aggressive', label: 'Aggressive', sub: 'Noisy sources', pattern: 'aggressive' },
|
||||
{ value: 'auto', label: 'Auto', sub: 'Detect', pattern: 'auto' }
|
||||
];
|
||||
|
||||
onMount(async () => {
|
||||
await loadJobs();
|
||||
setInterval(loadJobs, 5000);
|
||||
});
|
||||
|
||||
async function loadJobs() {
|
||||
try {
|
||||
const res = await fetch('/api/jobs');
|
||||
if (res.ok) jobs = await res.json();
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function submit(formData: FormData) {
|
||||
submitting = true;
|
||||
error = '';
|
||||
try {
|
||||
const res = await fetch('/api/jobs', { method: 'POST', body: formData });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const { id } = await res.json();
|
||||
window.location.href = `/jobs/${id}`;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitUrl() {
|
||||
if (!url.trim()) return;
|
||||
const fd = new FormData();
|
||||
fd.append('url', url.trim());
|
||||
fd.append('audioMode', audioMode);
|
||||
await submit(fd);
|
||||
}
|
||||
|
||||
async function handleFile(file: File) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fd.append('audioMode', audioMode);
|
||||
await submit(fd);
|
||||
}
|
||||
|
||||
async function handleRecording(blob: Blob, filename: string) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', new File([blob], filename, { type: blob.type }));
|
||||
fd.append('audioMode', audioMode);
|
||||
await submit(fd);
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragOver = false;
|
||||
const file = e.dataTransfer?.files[0];
|
||||
if (file) handleFile(file);
|
||||
}
|
||||
|
||||
function jobKind(job: Job): 'youtube' | 'audio' | 'video' | 'file' {
|
||||
const s = job.source ?? '';
|
||||
if (s.includes('youtube') || s.includes('youtu.be')) return 'youtube';
|
||||
if (/\.(mp3|m4a|wav|ogg|flac|aac)$/i.test(s)) return 'audio';
|
||||
if (/\.(mp4|mov|mkv|webm|avi)$/i.test(s)) return 'video';
|
||||
return 'file';
|
||||
}
|
||||
|
||||
function jobMeta(job: Job): string {
|
||||
const parts: string[] = [];
|
||||
if (job.source && !job.source.startsWith('http')) parts.push(job.source.split('/').pop() ?? '');
|
||||
if (job.audioMode) parts.push(job.audioMode);
|
||||
if (job.status === 'done') parts.push('done');
|
||||
else parts.push(job.status);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
// Decorative waveform bars for the drop zone (80 bars)
|
||||
const DROPZONE_BARS = 80;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Tonemark</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<!-- ── Header ─────────────────────────────────────────── -->
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<div class="label">New transcription</div>
|
||||
<h1 class="page-title">What would you like to transcribe?</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ── Input row: drop + url ──────────────────────────── -->
|
||||
<div class="input-row">
|
||||
<!-- 01 · Drop zone -->
|
||||
<div
|
||||
class="dropzone glass"
|
||||
class:drag-over={dragOver}
|
||||
ondragover={(e) => { e.preventDefault(); dragOver = true; }}
|
||||
ondragleave={() => (dragOver = false)}
|
||||
ondrop={onDrop}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Drop audio or video file"
|
||||
onkeydown={(e) => e.key === 'Enter' && fileInput?.click()}
|
||||
onclick={() => fileInput?.click()}
|
||||
>
|
||||
<div class="dropzone-text">
|
||||
<div class="label" style="color: {ACCENT}; margin-bottom: 14px;">01 · Drop a file</div>
|
||||
<div class="dropzone-headline">
|
||||
Drop audio or video here, or <span class="browse-link" style="color: {ACCENT}; border-bottom: 1px dashed {ACCENT};">browse</span>
|
||||
</div>
|
||||
<div class="dropzone-hint mono">
|
||||
.mp3 .m4a .wav .mp4 .mov .webm — up to 4 GB
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Decorative waveform -->
|
||||
<div class="dropzone-wave">
|
||||
<Waveform bars={DROPZONE_BARS} progress={0} {ACCENT} height={38} />
|
||||
</div>
|
||||
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept="video/*,audio/*"
|
||||
class="sr-only"
|
||||
onchange={(e) => {
|
||||
const f = (e.target as HTMLInputElement).files?.[0];
|
||||
if (f) handleFile(f);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 02 · URL input -->
|
||||
<div class="url-card glass">
|
||||
<div class="label" style="margin-bottom: 14px;">02 · Or paste a URL</div>
|
||||
|
||||
<div class="url-input-row">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style="flex-shrink:0; color: rgba(232,233,236,0.5)">
|
||||
<rect x="2" y="4" width="12" height="8" rx="1.5" stroke="currentColor" stroke-width="1.3"/>
|
||||
<path d="M7 6.5L10 8L7 9.5V6.5Z" fill="currentColor"/>
|
||||
</svg>
|
||||
<input
|
||||
type="url"
|
||||
bind:value={url}
|
||||
placeholder="youtube.com/watch?v=…"
|
||||
class="url-input mono"
|
||||
onkeydown={(e) => e.key === 'Enter' && submitUrl()}
|
||||
aria-label="Video or YouTube URL"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="url-footer">
|
||||
<span class="mono" style="font-size:12px; color: var(--text-muted);">
|
||||
YouTube · Vimeo · Loom · direct .mp4
|
||||
</span>
|
||||
<button
|
||||
class="btn-fetch"
|
||||
style="background: {ACCENT}; color: #0c0d10;"
|
||||
onclick={submitUrl}
|
||||
disabled={submitting || !url.trim()}
|
||||
>
|
||||
{submitting ? 'Starting…' : 'Fetch'}
|
||||
{#if !submitting}
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M2 6h7M6 3l3 3-3 3" stroke="#0c0d10" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 03 · Record audio -->
|
||||
<div class="record-card glass">
|
||||
<div class="label" style="margin-bottom: 4px;">03 · Record audio</div>
|
||||
<div class="record-sub">Tap record, speak, and we'll transcribe on stop.</div>
|
||||
<RecordButton
|
||||
accent={ACCENT}
|
||||
{audioMode}
|
||||
ondone={handleRecording}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="error-banner" role="alert">{error}</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── 04 · Audio processing modes ────────────────────── -->
|
||||
<section class="modes-section glass">
|
||||
<div class="modes-header">
|
||||
<div>
|
||||
<div class="label" style="margin-bottom: 4px;">04 · Audio processing</div>
|
||||
<div style="font-size: 14px; color: var(--text-muted);">
|
||||
How aggressive should noise reduction & normalisation be?
|
||||
</div>
|
||||
</div>
|
||||
<span class="auto-badge" style="background: color-mix(in oklab, {ACCENT} 12%, transparent); color: {ACCENT};">
|
||||
Auto recommended
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="modes-grid">
|
||||
{#each modes as m}
|
||||
{@const on = audioMode === m.value}
|
||||
<button
|
||||
class="mode-card"
|
||||
class:active={on}
|
||||
style={on
|
||||
? `background: color-mix(in oklab, ${ACCENT} 12%, transparent); border-color: color-mix(in oklab, ${ACCENT} 50%, transparent);`
|
||||
: ''}
|
||||
onclick={() => (audioMode = m.value)}
|
||||
aria-pressed={on}
|
||||
>
|
||||
<div class="mode-wave">
|
||||
<Waveform bars={17} height={14} pattern={m.pattern} accent={ACCENT} progress={on ? 100 : 0} />
|
||||
</div>
|
||||
<div class="mode-label" class:active-label={on}>{m.label}</div>
|
||||
<div class="mode-sub mono">{m.sub}</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Recent jobs ─────────────────────────────────────── -->
|
||||
{#if jobs.length > 0}
|
||||
<section>
|
||||
<div class="recents-header">
|
||||
<h2 class="recents-title">Recent transcriptions</h2>
|
||||
<span class="mono" style="font-size: 12px; color: var(--text-muted);">{jobs.length} total</span>
|
||||
</div>
|
||||
|
||||
<div class="glass recents-list">
|
||||
{#each jobs as job, i}
|
||||
<a
|
||||
href="/jobs/{job.id}"
|
||||
class="recent-item"
|
||||
class:first={i === 0}
|
||||
>
|
||||
<SourceIcon kind={jobKind(job)} size={36} accent={ACCENT} />
|
||||
<div class="recent-text">
|
||||
<div class="recent-title">{job.title || job.source}</div>
|
||||
<div class="recent-meta mono">{jobMeta(job)}</div>
|
||||
</div>
|
||||
{#if job.status !== 'done' && job.status !== 'failed' && job.status !== 'cancelled'}
|
||||
<div class="recent-progress mono" style="color: {ACCENT}">{job.progress}%</div>
|
||||
{/if}
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" style="color: var(--text-dim); flex-shrink:0">
|
||||
<path d="M5 3l4 4-4 4" stroke="currentColor" stroke-width="1.4" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
padding: 32px 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 6px 0 0;
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* ── Input row ─────────────────────────────────────────── */
|
||||
.input-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* ── Drop zone ─────────────────────────────────────────── */
|
||||
.dropzone {
|
||||
padding: 24px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-height: 200px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(205, 242, 78, 0.04),
|
||||
rgba(255, 255, 255, 0.015)
|
||||
) !important;
|
||||
border: 1px dashed rgba(205, 242, 78, 0.3) !important;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.dropzone.drag-over {
|
||||
border-color: rgba(205, 242, 78, 0.7) !important;
|
||||
background: rgba(205, 242, 78, 0.07) !important;
|
||||
}
|
||||
|
||||
.dropzone-headline {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.3;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.browse-link {
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
|
||||
.dropzone-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dropzone-wave {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* ── URL card ──────────────────────────────────────────── */
|
||||
.url-card {
|
||||
padding: 22px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.url-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.url-input {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.url-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.url-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-fetch {
|
||||
padding: 9px 16px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
transition: filter 0.15s;
|
||||
}
|
||||
.btn-fetch:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
.btn-fetch:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Record card ───────────────────────────────────────── */
|
||||
.record-card {
|
||||
padding: 22px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.record-sub {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin-top: -10px;
|
||||
}
|
||||
|
||||
/* ── Error ─────────────────────────────────────────────── */
|
||||
.error-banner {
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 90, 90, 0.08);
|
||||
border: 1px solid rgba(255, 90, 90, 0.2);
|
||||
color: #ff8a8a;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── Audio modes ───────────────────────────────────────── */
|
||||
.modes-section {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.modes-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.auto-badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.modes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mode-card {
|
||||
padding: 14px 14px 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.mode-card:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.mode-wave {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.mode-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: rgba(232, 233, 236, 0.85);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.mode-label.active-label {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.mode-sub {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Recents ───────────────────────────────────────────── */
|
||||
.recents-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.recents-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.recents-list {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.recent-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 14px 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.recent-item.first {
|
||||
border-top: none;
|
||||
}
|
||||
.recent-item:hover {
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
.recent-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.recent-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.recent-meta {
|
||||
font-size: 11.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.recent-progress {
|
||||
font-size: 11.5px;
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ── Utilities ─────────────────────────────────────────── */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ── Responsive ────────────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.page {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
.input-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.modes-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
45
src/routes/api/jobs/+server.ts
Normal file
45
src/routes/api/jobs/+server.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { createJob, listJobs } from '$lib/server/db.js';
|
||||
import { startYouTubeJob, startUploadJob } from '$lib/server/pipeline.js';
|
||||
import type { AudioMode } from '$lib/types.js';
|
||||
|
||||
export async function GET() {
|
||||
return json(listJobs());
|
||||
}
|
||||
|
||||
export async function POST({ request }) {
|
||||
const contentType = request.headers.get('content-type') ?? '';
|
||||
|
||||
let url: string | null = null;
|
||||
let audioMode: AudioMode = 'auto';
|
||||
let language: string | undefined;
|
||||
let fileBuffer: Buffer | null = null;
|
||||
let filename = 'upload';
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
const body = await request.json();
|
||||
url = body.url ?? null;
|
||||
audioMode = body.audioMode ?? 'auto';
|
||||
language = body.language;
|
||||
} else if (contentType.includes('multipart/form-data')) {
|
||||
const form = await request.formData();
|
||||
url = form.get('url')?.toString() ?? null;
|
||||
audioMode = (form.get('audioMode')?.toString() as AudioMode) ?? 'auto';
|
||||
language = form.get('language')?.toString();
|
||||
const file = form.get('file');
|
||||
if (file instanceof File) {
|
||||
fileBuffer = Buffer.from(await file.arrayBuffer());
|
||||
filename = file.name;
|
||||
}
|
||||
} else {
|
||||
throw error(415, 'Unsupported content type');
|
||||
}
|
||||
|
||||
if (!url && !fileBuffer) throw error(400, 'Provide url or file');
|
||||
|
||||
const jobId = url
|
||||
? await startYouTubeJob(url, audioMode, language)
|
||||
: await startUploadJob(fileBuffer!, filename, audioMode, language);
|
||||
|
||||
return json({ id: jobId }, { status: 201 });
|
||||
}
|
||||
18
src/routes/api/jobs/[id]/+server.ts
Normal file
18
src/routes/api/jobs/[id]/+server.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { getJob, setJobStatus } from '$lib/server/db.js';
|
||||
|
||||
export async function GET({ params }) {
|
||||
const job = getJob(params.id);
|
||||
if (!job) throw error(404, 'Job not found');
|
||||
return json(job);
|
||||
}
|
||||
|
||||
export async function DELETE({ params }) {
|
||||
const job = getJob(params.id);
|
||||
if (!job) throw error(404, 'Job not found');
|
||||
if (job.status === 'done' || job.status === 'failed') {
|
||||
throw error(409, 'Job already completed');
|
||||
}
|
||||
setJobStatus(params.id, 'cancelled', 0);
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
37
src/routes/api/jobs/[id]/download/[format]/+server.ts
Normal file
37
src/routes/api/jobs/[id]/download/[format]/+server.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { getJob } from '$lib/server/db.js';
|
||||
import { existsSync } from 'fs';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
srt: 'text/plain',
|
||||
txt: 'text/plain',
|
||||
md: 'text/markdown',
|
||||
json: 'application/json'
|
||||
};
|
||||
|
||||
export async function GET({ params }) {
|
||||
const { id, format } = params;
|
||||
if (!MIME[format]) throw error(400, `Unknown format: ${format}`);
|
||||
|
||||
const job = getJob(id);
|
||||
if (!job) throw error(404, 'Job not found');
|
||||
if (job.status !== 'done') throw error(409, 'Transcript not ready yet');
|
||||
|
||||
if (!job.outputDir) throw error(500, 'Output directory not set');
|
||||
|
||||
const safeTitle =
|
||||
(job.title ?? id).replace(/[^\w\s-]/g, '').replace(/\s+/g, '_').slice(0, 80) || id;
|
||||
const filePath = join(job.outputDir, `${safeTitle}.${format}`);
|
||||
|
||||
if (!existsSync(filePath)) throw error(404, `${format} file not found`);
|
||||
|
||||
const content = await readFile(filePath);
|
||||
return new Response(content.buffer as ArrayBuffer, {
|
||||
headers: {
|
||||
'Content-Type': MIME[format],
|
||||
'Content-Disposition': `attachment; filename="${safeTitle}.${format}"`
|
||||
}
|
||||
});
|
||||
}
|
||||
34
src/routes/api/jobs/[id]/reprocess/+server.ts
Normal file
34
src/routes/api/jobs/[id]/reprocess/+server.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { getJob, updateJob } from '$lib/server/db.js';
|
||||
import { deduplicateSegments } from '$lib/server/postprocess.js';
|
||||
import { writeOutputs } from '$lib/server/formatter.js';
|
||||
import type { Segment } from '$lib/types.js';
|
||||
|
||||
/** POST /api/jobs/[id]/reprocess — re-run post-processing and regenerate all output files. */
|
||||
export async function POST({ params }) {
|
||||
const job = getJob(params.id);
|
||||
if (!job) throw error(404, 'Job not found');
|
||||
|
||||
if (!job.segmentsJson) {
|
||||
throw error(422, 'No segments stored for this job — cannot reprocess');
|
||||
}
|
||||
|
||||
try {
|
||||
const rawSegments = JSON.parse(job.segmentsJson) as Segment[];
|
||||
const segments = deduplicateSegments(rawSegments);
|
||||
|
||||
const paths = await writeOutputs(segments, job.title, job.id);
|
||||
const outputDir = paths.srt.replace(/\/[^/]+$/, '');
|
||||
|
||||
updateJob({
|
||||
id: job.id,
|
||||
segmentsJson: JSON.stringify(segments),
|
||||
outputDir
|
||||
});
|
||||
|
||||
return json({ ok: true, paths });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw error(500, `Reprocess failed: ${message}`);
|
||||
}
|
||||
}
|
||||
51
src/routes/api/jobs/[id]/stream/+server.ts
Normal file
51
src/routes/api/jobs/[id]/stream/+server.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { getJob } from '$lib/server/db.js';
|
||||
import { subscribeProgress } from '$lib/server/pipeline.js';
|
||||
|
||||
export async function GET({ params, request }) {
|
||||
const job = getJob(params.id);
|
||||
if (!job) throw error(404, 'Job not found');
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const enc = new TextEncoder();
|
||||
|
||||
function send(data: string) {
|
||||
controller.enqueue(enc.encode(`data: ${data}\n\n`));
|
||||
}
|
||||
|
||||
// Send current status immediately
|
||||
send(JSON.stringify({ type: 'status', status: job.status, progress: job.progress }));
|
||||
|
||||
if (job.status === 'done' || job.status === 'failed' || job.status === 'cancelled') {
|
||||
send(JSON.stringify({ type: 'done', status: job.status }));
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const unsub = subscribeProgress(params.id, (data) => {
|
||||
send(data);
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
if (parsed.type === 'done' || parsed.type === 'error') {
|
||||
controller.close();
|
||||
unsub();
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
request.signal.addEventListener('abort', () => {
|
||||
unsub();
|
||||
controller.close();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive'
|
||||
}
|
||||
});
|
||||
}
|
||||
16
src/routes/api/push/+server.ts
Normal file
16
src/routes/api/push/+server.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { savePushSubscription, getAllSubscriptions } from '$lib/server/db.js';
|
||||
import { getVapidPublicKey } from '$lib/server/push.js';
|
||||
|
||||
export async function GET() {
|
||||
const key = getVapidPublicKey();
|
||||
if (!key) throw error(503, 'Push notifications not configured (VAPID keys missing)');
|
||||
return json({ publicKey: key });
|
||||
}
|
||||
|
||||
export async function POST({ request }) {
|
||||
const { endpoint, keys } = await request.json();
|
||||
if (!endpoint || !keys?.p256dh || !keys?.auth) throw error(400, 'Invalid subscription');
|
||||
savePushSubscription({ endpoint, p256dh: keys.p256dh, auth: keys.auth });
|
||||
return new Response(null, { status: 201 });
|
||||
}
|
||||
54
src/routes/api/webhook/[jobId]/+server.ts
Normal file
54
src/routes/api/webhook/[jobId]/+server.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { getJob, updateJob, setJobStatus } from '$lib/server/db.js';
|
||||
import { deduplicateSegments } from '$lib/server/postprocess.js';
|
||||
import { writeOutputs } from '$lib/server/formatter.js';
|
||||
import { sendNotification } from '$lib/server/push.js';
|
||||
import { cleanupJobTmp } from '$lib/server/downloader.js';
|
||||
import { emitProgress } from '$lib/server/pipeline.js';
|
||||
import type { Segment, WhisperJob } from '$lib/types.js';
|
||||
|
||||
export async function POST({ params, request }) {
|
||||
const jobId = params.jobId;
|
||||
const job = getJob(jobId);
|
||||
if (!job) throw error(404, 'Job not found');
|
||||
|
||||
const whisperJob = (await request.json()) as WhisperJob;
|
||||
|
||||
if (whisperJob.status === 'failed' || whisperJob.status === 'cancelled') {
|
||||
const msg = whisperJob.error ?? `Whisper job ${whisperJob.status}`;
|
||||
updateJob({ id: jobId, status: 'failed', error: msg });
|
||||
emitProgress(jobId, { type: 'error', message: msg });
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
try {
|
||||
setJobStatus(jobId, 'processing', 90);
|
||||
emitProgress(jobId, { type: 'status', status: 'processing', progress: 90 });
|
||||
|
||||
const rawSegments = whisperJob.segments as Segment[];
|
||||
const segments = deduplicateSegments(rawSegments);
|
||||
|
||||
const paths = await writeOutputs(segments, job.title, jobId);
|
||||
const outputDir = paths.srt.replace(/\/[^/]+$/, '');
|
||||
|
||||
updateJob({
|
||||
id: jobId,
|
||||
status: 'done',
|
||||
progress: 100,
|
||||
segmentsJson: JSON.stringify(segments),
|
||||
outputDir
|
||||
});
|
||||
|
||||
emitProgress(jobId, { type: 'done', status: 'done' });
|
||||
|
||||
await sendNotification(jobId, '✅ Transcript ready', job.title);
|
||||
await cleanupJobTmp(jobId);
|
||||
|
||||
return json({ ok: true });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
updateJob({ id: jobId, status: 'failed', error: message });
|
||||
emitProgress(jobId, { type: 'error', message });
|
||||
return json({ ok: false, error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
209
src/routes/jobs/+page.svelte
Normal file
209
src/routes/jobs/+page.svelte
Normal file
@@ -0,0 +1,209 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { Job } from '$lib/types.js';
|
||||
import SourceIcon from '$lib/components/SourceIcon.svelte';
|
||||
import Waveform from '$lib/components/Waveform.svelte';
|
||||
|
||||
const ACCENT = '#cdf24e';
|
||||
|
||||
let jobs = $state<Job[]>([]);
|
||||
let loading = $state(true);
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
done: '#cdf24e',
|
||||
failed: '#ff6b6b',
|
||||
cancelled: 'rgba(232,233,236,0.3)',
|
||||
transcribing: '#80c7f7',
|
||||
preparing: '#fbc94b',
|
||||
downloading: '#a78bfa',
|
||||
pending: 'rgba(232,233,236,0.4)'
|
||||
};
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
pending: 'Pending',
|
||||
downloading: 'Downloading',
|
||||
preparing: 'Preparing',
|
||||
transcribing: 'Transcribing',
|
||||
processing: 'Processing',
|
||||
done: 'Done',
|
||||
failed: 'Failed',
|
||||
cancelled: 'Cancelled'
|
||||
};
|
||||
|
||||
function jobKind(job: Job): 'youtube' | 'audio' | 'video' | 'file' {
|
||||
const s = job.source ?? '';
|
||||
if (s.includes('youtube') || s.includes('youtu.be')) return 'youtube';
|
||||
if (/\.(mp3|m4a|wav|ogg|flac|aac)$/i.test(s)) return 'audio';
|
||||
if (/\.(mp4|mov|mkv|webm|avi)$/i.test(s)) return 'video';
|
||||
return 'file';
|
||||
}
|
||||
|
||||
function relativeTime(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
if (diff < 60_000) return 'just now';
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
||||
return `${Math.floor(diff / 86_400_000)}d ago`;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
const res = await fetch('/api/jobs');
|
||||
if (res.ok) jobs = await res.json();
|
||||
loading = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Queue — Tonemark</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<h1 class="page-title">Queue</h1>
|
||||
|
||||
{#if loading}
|
||||
<div class="loading">Loading…</div>
|
||||
{:else if jobs.length === 0}
|
||||
<div class="glass empty">
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" style="opacity:.4">
|
||||
<rect x="6" y="14" width="3" height="12" rx="1.5" fill="currentColor"/>
|
||||
<rect x="11" y="9" width="3" height="17" rx="1.5" fill="currentColor"/>
|
||||
<rect x="16" y="6" width="3" height="20" rx="1.5" fill="currentColor"/>
|
||||
<rect x="21" y="11" width="3" height="15" rx="1.5" fill="currentColor"/>
|
||||
<rect x="26" y="16" width="3" height="10" rx="1.5" fill="currentColor"/>
|
||||
</svg>
|
||||
<p>No transcription jobs yet.</p>
|
||||
<a href="/" class="link">Start one →</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="job-list">
|
||||
{#each jobs as job}
|
||||
<a href="/jobs/{job.id}" class="glass job-row">
|
||||
<SourceIcon kind={jobKind(job)} size={36} accent={ACCENT} />
|
||||
|
||||
<div class="job-info">
|
||||
<div class="job-name">{job.title || job.source}</div>
|
||||
<div class="job-meta mono">
|
||||
<span style="color: {statusColor[job.status] ?? 'rgba(232,233,236,0.5)'}">
|
||||
{statusLabel[job.status] ?? job.status}
|
||||
</span>
|
||||
{#if job.createdAt}
|
||||
<span>·</span>
|
||||
<span>{relativeTime(job.createdAt)}</span>
|
||||
{/if}
|
||||
{#if job.audioMode}
|
||||
<span>·</span>
|
||||
<span>{job.audioMode}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !['done', 'failed', 'cancelled'].includes(job.status)}
|
||||
<div class="job-wave">
|
||||
<Waveform bars={40} progress={job.progress} accent={ACCENT} height={28} pattern="medium" />
|
||||
</div>
|
||||
{:else if job.status === 'done'}
|
||||
<div class="job-pct mono" style="color: {ACCENT}">{job.progress}%</div>
|
||||
{:else}
|
||||
<div class="job-pct mono" style="color: {statusColor[job.status]}">{job.status}</div>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
padding: 32px 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.loading {
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 48px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.job-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.job-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px 20px;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.job-row:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.job-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.job-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.job-meta {
|
||||
font-size: 11.5px;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.job-wave {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.job-pct {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
615
src/routes/jobs/[id]/+page.svelte
Normal file
615
src/routes/jobs/[id]/+page.svelte
Normal file
@@ -0,0 +1,615 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import type { Job, Segment } from '$lib/types.js';
|
||||
import SourceIcon from '$lib/components/SourceIcon.svelte';
|
||||
import Waveform from '$lib/components/Waveform.svelte';
|
||||
|
||||
const ACCENT = '#cdf24e';
|
||||
|
||||
const jobId = $derived($page.params.id);
|
||||
let job = $state<Job | null>(null);
|
||||
let segments = $state<Segment[]>([]);
|
||||
let error = $state('');
|
||||
let chunkInfo = $state({ chunk: 0, total: 0 });
|
||||
let eventSource: EventSource | null = null;
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
pending: 'Pending',
|
||||
downloading: 'Downloading…',
|
||||
preparing: 'Preparing audio…',
|
||||
transcribing: 'Transcribing…',
|
||||
processing: 'Post-processing…',
|
||||
done: 'Done',
|
||||
failed: 'Failed',
|
||||
cancelled: 'Cancelled'
|
||||
};
|
||||
|
||||
// Pipeline stages derived from job status
|
||||
const pipelineStages = $derived.by(() => {
|
||||
const status = job?.status ?? 'pending';
|
||||
const stages = [
|
||||
{ k: 'fetch', label: 'Fetch source' },
|
||||
{ k: 'extract', label: 'Extract audio track' },
|
||||
{ k: 'process', label: `Audio processing · ${job?.audioMode ?? 'auto'}` },
|
||||
{ k: 'transcribe', label: 'Transcribing' },
|
||||
{ k: 'finalize', label: 'Format & save' }
|
||||
];
|
||||
const order = ['pending', 'downloading', 'preparing', 'transcribing', 'processing', 'done'];
|
||||
const idx = order.indexOf(status);
|
||||
return stages.map((s, i) => ({
|
||||
...s,
|
||||
done: i < idx - 1 || status === 'done',
|
||||
active: i === idx - 1 && status !== 'done' && status !== 'failed',
|
||||
pending: i > idx - 1 && status !== 'done'
|
||||
}));
|
||||
});
|
||||
|
||||
function jobKind(job: Job): 'youtube' | 'audio' | 'video' | 'file' {
|
||||
const s = job.source ?? '';
|
||||
if (s.includes('youtube') || s.includes('youtu.be')) return 'youtube';
|
||||
if (/\.(mp3|m4a|wav|ogg|flac|aac)$/i.test(s)) return 'audio';
|
||||
if (/\.(mp4|mov|mkv|webm|avi)$/i.test(s)) return 'video';
|
||||
return 'file';
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await loadJob();
|
||||
if (job && !['done', 'failed', 'cancelled'].includes(job.status)) {
|
||||
openStream();
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => eventSource?.close());
|
||||
|
||||
async function loadJob() {
|
||||
const res = await fetch(`/api/jobs/${jobId}`);
|
||||
if (!res.ok) {
|
||||
error = 'Job not found';
|
||||
return;
|
||||
}
|
||||
job = await res.json();
|
||||
if (job?.segmentsJson) {
|
||||
try {
|
||||
segments = JSON.parse(job.segmentsJson);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
function openStream() {
|
||||
eventSource = new EventSource(`/api/jobs/${jobId}/stream`);
|
||||
eventSource.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.type === 'progress') {
|
||||
chunkInfo = { chunk: data.chunk ?? 0, total: data.total ?? 0 };
|
||||
if (job) job = { ...job, progress: data.progress ?? job.progress, status: 'transcribing' };
|
||||
} else if (data.type === 'status') {
|
||||
if (job) job = { ...job, status: data.status, progress: data.progress ?? job.progress };
|
||||
} else if (data.type === 'done') {
|
||||
eventSource?.close();
|
||||
loadJob();
|
||||
} else if (data.type === 'error') {
|
||||
if (job) job = { ...job, status: 'failed', error: data.message };
|
||||
eventSource?.close();
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
}
|
||||
|
||||
function secToTimestamp(sec: number): string {
|
||||
const h = Math.floor(sec / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
return h > 0
|
||||
? `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||
: `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const formats = ['srt', 'txt', 'md', 'json'] as const;
|
||||
const isActive = $derived(!job || !['done', 'failed', 'cancelled'].includes(job.status));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{job?.title ?? 'Job'} — Tonemark</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page">
|
||||
{#if error}
|
||||
<div class="error-banner" role="alert">{error}</div>
|
||||
{:else if !job}
|
||||
<div class="loading" aria-busy="true">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" style="animation: spin 1s linear infinite">
|
||||
<circle cx="10" cy="10" r="8" stroke="var(--text-muted)" stroke-width="2" fill="none" stroke-dasharray="30 14"/>
|
||||
</svg>
|
||||
Loading…
|
||||
</div>
|
||||
{:else}
|
||||
<!-- ── Breadcrumb ─────────────────────────────────────── -->
|
||||
<div class="breadcrumb mono">
|
||||
<a href="/" class="crumb-link">Home</a>
|
||||
<span>›</span>
|
||||
<span style="color: #fff">{job.id.slice(0, 8)}</span>
|
||||
</div>
|
||||
|
||||
<!-- ── Job header ────────────────────────────────────── -->
|
||||
<div class="job-header">
|
||||
<SourceIcon kind={jobKind(job)} size={52} accent={ACCENT} />
|
||||
<div class="job-header-text">
|
||||
<h1 class="job-title">{job.title || job.source}</h1>
|
||||
<div class="job-meta mono">
|
||||
{job.source?.includes('http') ? job.source : (job.source ?? '')}
|
||||
{#if job.audioMode}· {job.audioMode}{/if}
|
||||
{#if job.meanVolume != null}· {job.meanVolume.toFixed(1)} dBFS{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if isActive}
|
||||
<form method="POST" action="/api/jobs/{job.id}?_method=DELETE">
|
||||
<button type="button" class="btn-cancel" aria-label="Cancel job">Cancel</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── Progress block ────────────────────────────────── -->
|
||||
{#if isActive || job.status === 'done'}
|
||||
<div class="progress-card glass">
|
||||
<!-- Waveform coloured by progress -->
|
||||
<div class="progress-wave">
|
||||
<Waveform
|
||||
bars={140}
|
||||
progress={job.progress}
|
||||
accent={ACCENT}
|
||||
height={80}
|
||||
pattern="default"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="progress-footer">
|
||||
<div class="progress-left">
|
||||
<span class="progress-pct mono">
|
||||
{job.progress}<span style="color: var(--text-dim); font-weight: 400">%</span>
|
||||
</span>
|
||||
<span class="progress-status">{statusLabel[job.status] ?? job.status}</span>
|
||||
</div>
|
||||
{#if chunkInfo.total > 0}
|
||||
<span class="progress-chunks mono">
|
||||
chunk {chunkInfo.chunk} / {chunkInfo.total}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="progress-bar-track">
|
||||
<div
|
||||
class="progress-bar-fill"
|
||||
style="width: {job.progress}%; background: {ACCENT}; box-shadow: 0 0 12px {ACCENT}80;"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Error ─────────────────────────────────────────── -->
|
||||
{#if job.error}
|
||||
<div class="error-banner" role="alert">{job.error}</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Two-column: pipeline + downloads/transcript ───── -->
|
||||
<div class="two-col">
|
||||
<!-- Pipeline stages -->
|
||||
<div class="glass stage-card">
|
||||
<div class="label" style="margin-bottom: 16px;">Pipeline</div>
|
||||
<div class="stages">
|
||||
{#each pipelineStages as stage}
|
||||
<div class="stage-row">
|
||||
<div
|
||||
class="stage-dot"
|
||||
style={stage.done
|
||||
? `background: ${ACCENT};`
|
||||
: stage.active
|
||||
? `background: transparent; border: 2px solid ${ACCENT};`
|
||||
: 'background: rgba(255,255,255,0.05);'}
|
||||
>
|
||||
{#if stage.done}
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
|
||||
<path d="M2 5l2 2 4-4" stroke="#0c0d10" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
{:else if stage.active}
|
||||
<div class="stage-dot-inner" style="background: {ACCENT}"></div>
|
||||
{/if}
|
||||
</div>
|
||||
<span
|
||||
class="stage-label"
|
||||
style={stage.pending ? 'color: var(--text-dim)' : stage.active ? 'color: #fff; font-weight: 500' : ''}
|
||||
>
|
||||
{@html stage.label}
|
||||
</span>
|
||||
{#if stage.active}
|
||||
<span class="mono" style="font-size: 11.5px; color: {ACCENT}">{job.progress}%</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Downloads or live preview -->
|
||||
<div class="glass side-card">
|
||||
{#if job.status === 'done'}
|
||||
<div class="label" style="margin-bottom: 16px;">Download transcript</div>
|
||||
<div class="dl-grid">
|
||||
{#each formats as fmt, i}
|
||||
<a
|
||||
href="/api/jobs/{job.id}/download/{fmt}"
|
||||
download
|
||||
class="dl-btn mono"
|
||||
style={i === 0
|
||||
? `background: color-mix(in oklab, ${ACCENT} 12%, transparent); color: ${ACCENT}; border-color: color-mix(in oklab, ${ACCENT} 30%, transparent);`
|
||||
: ''}
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 11 11" fill="none">
|
||||
<path d="M5.5 1v7M2 5l3.5 3.5L9 5M1.5 9.5h8" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
{fmt.toUpperCase()}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if job.outputDir}
|
||||
<div class="output-dir mono">{job.outputDir}</div>
|
||||
{/if}
|
||||
{:else if isActive}
|
||||
<div class="live-header">
|
||||
<div class="label">Live preview</div>
|
||||
<div class="streaming-badge" style="color: {ACCENT}">
|
||||
<div class="stream-dot" style="background: {ACCENT}; animation: pulse 1.4s infinite"></div>
|
||||
Streaming
|
||||
</div>
|
||||
</div>
|
||||
{#if segments.length > 0}
|
||||
{@const last = segments[segments.length - 1]}
|
||||
<div class="live-text">
|
||||
<span class="mono" style="color: var(--text-dim); margin-right: 8px;">
|
||||
{secToTimestamp(last.start)}
|
||||
</span>
|
||||
{last.text}<span style="color: {ACCENT}; animation: blink 1s infinite; margin-left: 3px;">▍</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div style="font-size: 13px; color: var(--text-muted); font-style: italic;">
|
||||
Waiting for segments…
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Transcript viewer ──────────────────────────────── -->
|
||||
{#if segments.length > 0}
|
||||
<section class="glass transcript-card">
|
||||
<div class="transcript-header">
|
||||
<div class="label">Transcript</div>
|
||||
<span class="mono" style="font-size: 12px; color: var(--text-muted);">
|
||||
{segments.length} segments
|
||||
</span>
|
||||
</div>
|
||||
<div class="transcript-body">
|
||||
{#each segments as seg}
|
||||
<div class="seg-row">
|
||||
<span class="seg-ts mono">{secToTimestamp(seg.start)}</span>
|
||||
<p class="seg-text">{seg.text}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
padding: 32px 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
/* ── Loading / errors ──────────────────────────────────── */
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 90, 90, 0.08);
|
||||
border: 1px solid rgba(255, 90, 90, 0.2);
|
||||
color: #ff8a8a;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── Breadcrumb ─────────────────────────────────────────── */
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.crumb-link {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
.crumb-link:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ── Job header ─────────────────────────────────────────── */
|
||||
.job-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.job-header-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.job-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.job-meta {
|
||||
font-size: 12.5px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 90, 90, 0.3);
|
||||
background: rgba(255, 90, 90, 0.08);
|
||||
color: #ff8a8a;
|
||||
font-size: 12.5px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-cancel:hover {
|
||||
background: rgba(255, 90, 90, 0.15);
|
||||
}
|
||||
|
||||
/* ── Progress card ──────────────────────────────────────── */
|
||||
.progress-card {
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.progress-wave {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.progress-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.progress-left {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.progress-pct {
|
||||
font-size: 36px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.progress-status {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.progress-chunks {
|
||||
font-size: 12.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.progress-bar-track {
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
/* ── Two column ─────────────────────────────────────────── */
|
||||
.two-col {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.stage-card,
|
||||
.side-card {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
/* ── Pipeline stages ────────────────────────────────────── */
|
||||
.stages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.stage-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.stage-dot {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stage-dot-inner {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.stage-label {
|
||||
flex: 1;
|
||||
font-size: 13.5px;
|
||||
color: rgba(232, 233, 236, 0.85);
|
||||
}
|
||||
|
||||
/* ── Live preview / downloads ───────────────────────────── */
|
||||
.live-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.streaming-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.stream-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.live-text {
|
||||
font-size: 13.5px;
|
||||
line-height: 1.7;
|
||||
color: rgba(232, 233, 236, 0.85);
|
||||
}
|
||||
|
||||
.dl-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dl-btn {
|
||||
padding: 11px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
color: rgba(232, 233, 236, 0.85);
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.dl-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.output-dir {
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ── Transcript ─────────────────────────────────────────── */
|
||||
.transcript-card {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.transcript-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.transcript-body {
|
||||
max-height: 480px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.seg-row {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.seg-ts {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
margin-top: 3px;
|
||||
width: 50px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.seg-text {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
color: rgba(232, 233, 236, 0.85);
|
||||
}
|
||||
|
||||
/* ── Responsive ─────────────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.page {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
.job-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
.two-col {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.dl-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
100
src/routes/settings/+page.svelte
Normal file
100
src/routes/settings/+page.svelte
Normal file
@@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
import { accent, ACCENT_OPTIONS } from '$lib/accent.js';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Settings — Tonemark</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<h1 class="page-title">Settings</h1>
|
||||
|
||||
<div class="glass section">
|
||||
<div class="label" style="margin-bottom: 20px;">Accent colour</div>
|
||||
<div class="swatch-row">
|
||||
{#each ACCENT_OPTIONS as opt}
|
||||
<button
|
||||
class="swatch"
|
||||
class:active={$accent.value === opt.value}
|
||||
style="--c: {opt.value}"
|
||||
onclick={() => accent.set(opt)}
|
||||
title={opt.label}
|
||||
aria-label={opt.label}
|
||||
aria-pressed={$accent.value === opt.value}
|
||||
>
|
||||
{#if $accent.value === opt.value}
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M2 6l3 3 5-5" stroke="#000" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
<span class="swatch-label">{opt.label}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
padding: 32px 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.swatch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--c);
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.15s, border-color 0.15s;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.swatch:hover {
|
||||
transform: scale(1.12);
|
||||
}
|
||||
|
||||
.swatch.active {
|
||||
border-color: rgba(255,255,255,0.5);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.swatch-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-right: 6px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
35
src/routes/share/+server.ts
Normal file
35
src/routes/share/+server.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { redirect, error } from '@sveltejs/kit';
|
||||
import { startYouTubeJob, startUploadJob } from '$lib/server/pipeline.js';
|
||||
import type { AudioMode } from '$lib/types.js';
|
||||
|
||||
// Web Share Target: GET with ?url=... (from text/url share)
|
||||
export async function GET({ url }) {
|
||||
const shareUrl = url.searchParams.get('url') ?? url.searchParams.get('text');
|
||||
if (!shareUrl) redirect(302, '/');
|
||||
|
||||
const jobId = await startYouTubeJob(shareUrl, 'auto');
|
||||
redirect(302, `/jobs/${jobId}`);
|
||||
}
|
||||
|
||||
// Web Share Target: POST multipart (from file share or URL share via form)
|
||||
export async function POST({ request }) {
|
||||
const form = await request.formData();
|
||||
|
||||
const shareUrl =
|
||||
form.get('url')?.toString() ?? form.get('text')?.toString();
|
||||
const file = form.get('media') ?? form.get('file');
|
||||
const audioMode: AudioMode = 'auto';
|
||||
|
||||
if (shareUrl) {
|
||||
const jobId = await startYouTubeJob(shareUrl, audioMode);
|
||||
redirect(302, `/jobs/${jobId}`);
|
||||
}
|
||||
|
||||
if (file instanceof File) {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const jobId = await startUploadJob(buffer, file.name, audioMode);
|
||||
redirect(302, `/jobs/${jobId}`);
|
||||
}
|
||||
|
||||
throw error(400, 'No URL or file provided');
|
||||
}
|
||||
Reference in New Issue
Block a user