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>