Initial commit: Tonemark PWA
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:
Giancarmine Salucci
2026-05-06 16:41:25 +02:00
commit 13a96b6efa
68 changed files with 9712 additions and 0 deletions

329
src/routes/+layout.svelte Normal file
View 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
View 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 &amp; 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>

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

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

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

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

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

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

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

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

View 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 &amp; 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>

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

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