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>
|
||||
|
||||
Reference in New Issue
Block a user