Initial commit: Music Hub collaboration platform

Full-stack music production collaboration tool with:
- SvelteKit frontend with Design System (CSS vars, 8 shared components)
- Hono API with auth, projects, tracks, versions, comments
- PostgreSQL + Drizzle ORM (8 tables, roles, permissions)
- S3-compatible storage with presigned upload URLs
- wavesurfer.js audio player with waveform visualization
- A/B version comparison with synchronized playback
- Timestamped comments with threading and resolve workflow
- Magic Link authentication with Resend email integration
- Background audio processing (ffmpeg transcode + waveform peaks)
- Role-based access control (Owner, Engineers, Artist, Label, Management, Viewer)
- Toast notifications, skeleton loading, responsive layout
- Docker deployment setup (API + Web + Postgres)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Robin Choice
2026-04-02 13:23:10 +02:00
commit e420ed198b
88 changed files with 7306 additions and 0 deletions

23
apps/web/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
apps/web/.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

3
apps/web/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}

42
apps/web/README.md Normal file
View File

@@ -0,0 +1,42 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project
npx sv create my-app
```
To recreate this project with the same configuration:
```sh
# recreate this project
npx sv@0.13.1 create --template minimal --types ts --no-install apps/web
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

26
apps/web/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "@music-hub/web",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"dependencies": {
"@music-hub/shared": "workspace:*",
"wavesurfer.js": "^7.12.5"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"svelte": "^5.54.0",
"svelte-check": "^4.4.2",
"typescript": "^5.9.3",
"vite": "^7.3.1"
}
}

13
apps/web/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

12
apps/web/src/app.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,38 @@
import { toastError } from '$lib/stores/toast.js';
type FetchOptions = {
method?: string;
body?: unknown;
headers?: Record<string, string>;
silent?: boolean;
};
async function request<T>(path: string, options: FetchOptions = {}): Promise<T> {
const { method = 'GET', body, headers = {}, silent = false } = options;
const res = await fetch(`/api/v1${path}`, {
method,
headers: {
'Content-Type': 'application/json',
...headers,
},
credentials: 'include',
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const error = await res.json().catch(() => ({ error: res.statusText }));
const message = error.error || 'Request failed';
if (!silent) toastError(message);
throw new Error(message);
}
return res.json();
}
export const api = {
get: <T>(path: string, silent = false) => request<T>(path, { silent }),
post: <T>(path: string, body?: unknown) => request<T>(path, { method: 'POST', body }),
patch: <T>(path: string, body?: unknown) => request<T>(path, { method: 'PATCH', body }),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
};

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,161 @@
<script lang="ts">
import WaveformPlayer from './WaveformPlayer.svelte';
import Button from '$lib/components/ui/Button.svelte';
let {
versionA,
versionB,
streamUrlA,
streamUrlB,
onClose,
}: {
versionA: { versionNumber: number; label: string | null };
versionB: { versionNumber: number; label: string | null };
streamUrlA: string;
streamUrlB: string;
onClose: () => void;
} = $props();
let playerA = $state<WaveformPlayer>();
let playerB = $state<WaveformPlayer>();
let activePlayer = $state<'A' | 'B'>('A');
let syncing = false;
function handleSeekA(time: number) {
if (syncing) return;
syncing = true;
playerB?.seekToTime(time);
syncing = false;
}
function handleSeekB(time: number) {
if (syncing) return;
syncing = true;
playerA?.seekToTime(time);
syncing = false;
}
function switchTo(player: 'A' | 'B') {
activePlayer = player;
// Sync position, then play the active one
if (player === 'A') {
playerB?.pause();
const time = playerB?.getCurrentTime() || 0;
playerA?.seekToTime(time);
playerA?.play();
} else {
playerA?.pause();
const time = playerA?.getCurrentTime() || 0;
playerB?.seekToTime(time);
playerB?.play();
}
}
const labelA = $derived(`V${versionA.versionNumber}${versionA.label ? ' — ' + versionA.label : ''}`);
const labelB = $derived(`V${versionB.versionNumber}${versionB.label ? ' — ' + versionB.label : ''}`);
</script>
<div class="ab-compare">
<div class="ab-header">
<h2>A/B Compare</h2>
<div class="ab-toggle">
<button class="toggle-btn" class:active={activePlayer === 'A'} onclick={() => switchTo('A')}>A</button>
<button class="toggle-btn" class:active={activePlayer === 'B'} onclick={() => switchTo('B')}>B</button>
</div>
<Button variant="ghost" size="sm" onclick={onClose}>Close</Button>
</div>
<div class="players">
<div class="player-wrapper" class:active={activePlayer === 'A'}>
<WaveformPlayer
bind:this={playerA}
url={streamUrlA}
label="A — {labelA}"
compact
muted={activePlayer !== 'A'}
onSeek={handleSeekA}
/>
</div>
<div class="player-wrapper" class:active={activePlayer === 'B'}>
<WaveformPlayer
bind:this={playerB}
url={streamUrlB}
label="B — {labelB}"
compact
muted={activePlayer !== 'B'}
onSeek={handleSeekB}
/>
</div>
</div>
</div>
<style>
.ab-compare {
background: var(--color-bg-overlay);
border: 1px solid var(--color-accent);
border-radius: var(--radius-lg);
padding: var(--space-5);
}
.ab-header {
display: flex;
align-items: center;
gap: var(--space-4);
margin-bottom: var(--space-4);
}
h2 {
margin: 0;
font-size: var(--text-lg);
flex: 1;
}
.ab-toggle {
display: flex;
background: var(--color-bg-subtle);
border-radius: var(--radius-md);
overflow: hidden;
}
.toggle-btn {
padding: var(--space-2) var(--space-4);
background: none;
border: none;
color: var(--color-text-secondary);
cursor: pointer;
font-weight: 600;
font-size: var(--text-sm);
font-family: inherit;
transition: all var(--transition-fast);
}
.toggle-btn.active {
background: var(--color-accent);
color: #fff;
}
.players {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.player-wrapper {
opacity: 0.5;
transition: opacity var(--transition-base);
}
.player-wrapper.active {
opacity: 1;
}
@media (min-width: 1024px) {
.players {
flex-direction: row;
}
.player-wrapper {
flex: 1;
}
}
</style>

View File

@@ -0,0 +1,246 @@
<script lang="ts">
import { SUPPORTED_EXTENSIONS, MAX_FILE_SIZE } from '@music-hub/shared';
import { api } from '$lib/api/client.js';
let {
trackId,
onUploaded,
}: {
trackId: string;
onUploaded: () => void;
} = $props();
let dragOver = $state(false);
let uploading = $state(false);
let progress = $state(0);
let error = $state('');
let label = $state('');
function handleDragOver(e: DragEvent) {
e.preventDefault();
dragOver = true;
}
function handleDragLeave() {
dragOver = false;
}
function handleDrop(e: DragEvent) {
e.preventDefault();
dragOver = false;
const file = e.dataTransfer?.files[0];
if (file) uploadFile(file);
}
function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (file) uploadFile(file);
input.value = '';
}
async function uploadFile(file: File) {
error = '';
if (file.size > MAX_FILE_SIZE) {
error = 'File too large (max 500 MB)';
return;
}
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
if (!SUPPORTED_EXTENSIONS.includes(ext as any)) {
error = `Unsupported format. Use: ${SUPPORTED_EXTENSIONS.join(', ')}`;
return;
}
uploading = true;
progress = 0;
try {
// 1. Get presigned upload URL
const { uploadUrl, fileKey } = await api.post<{
uploadUrl: string;
fileKey: string;
versionId: string;
}>(`/versions/track/${trackId}/upload-url`, {
fileName: file.name,
mimeType: file.type || 'audio/wav',
fileSize: file.size,
});
// 2. Upload directly to S3
await uploadWithProgress(uploadUrl, file);
// 3. Register version
await api.post(`/versions/track/${trackId}`, {
fileKey,
label: label || undefined,
originalFileName: file.name,
mimeType: file.type || 'audio/wav',
fileSize: file.size,
});
label = '';
onUploaded();
} catch (err) {
error = err instanceof Error ? err.message : 'Upload failed';
} finally {
uploading = false;
progress = 0;
}
}
function uploadWithProgress(url: string, file: File): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', url);
xhr.setRequestHeader('Content-Type', file.type || 'audio/wav');
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
progress = Math.round((e.loaded / e.total) * 100);
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve();
else reject(new Error(`Upload failed: ${xhr.status}`));
};
xhr.onerror = () => reject(new Error('Upload failed'));
xhr.send(file);
});
}
</script>
<div class="upload-section">
<div class="label-input">
<input
type="text"
bind:value={label}
placeholder="Version label (e.g. 'Mix V2', 'Master Final')"
disabled={uploading}
/>
</div>
<div
class="dropzone"
class:dragover={dragOver}
class:uploading
role="button"
tabindex="0"
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
onclick={() => !uploading && document.getElementById(`file-input-${trackId}`)?.click()}
onkeydown={(e) => e.key === 'Enter' && !uploading && document.getElementById(`file-input-${trackId}`)?.click()}
>
<input
id="file-input-{trackId}"
type="file"
accept=".wav,.mp3,.flac,.aiff,.aif"
onchange={handleFileSelect}
hidden
/>
{#if uploading}
<div class="progress-container">
<div class="progress-bar" style="width: {progress}%"></div>
<span class="progress-text">{progress}%</span>
</div>
{:else}
<div class="dropzone-content">
<span class="dropzone-icon">🎵</span>
<p>Drop audio file here or click to browse</p>
<span class="formats">WAV, MP3, FLAC, AIFF — max 500 MB</span>
</div>
{/if}
</div>
{#if error}
<p class="error">{error}</p>
{/if}
</div>
<style>
.upload-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.label-input input {
width: 100%;
padding: 0.6rem 1rem;
border-radius: 8px;
border: 1px solid #333;
background: #0a0a0a;
color: #e0e0e0;
font-size: 0.9rem;
}
.dropzone {
border: 2px dashed #333;
border-radius: 12px;
padding: 2rem;
text-align: center;
cursor: pointer;
transition: all 0.2s;
background: #111;
}
.dropzone:hover,
.dropzone.dragover {
border-color: #6366f1;
background: #1a1a2e;
}
.dropzone.uploading {
cursor: default;
border-color: #6366f1;
}
.dropzone-content p {
margin: 0.5rem 0 0.25rem;
color: #ccc;
}
.dropzone-icon {
font-size: 2rem;
}
.formats {
font-size: 0.8rem;
color: #666;
}
.progress-container {
position: relative;
height: 40px;
background: #222;
border-radius: 8px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: #6366f1;
transition: width 0.2s;
border-radius: 8px;
}
.progress-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #fff;
font-weight: 500;
font-size: 0.9rem;
}
.error {
color: #ef4444;
font-size: 0.9rem;
}
</style>

View File

@@ -0,0 +1,281 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import WaveSurfer from 'wavesurfer.js';
import { formatTime } from '$lib/utils/format.js';
type CommentMarker = {
id: string;
timestampSeconds: number;
body: string;
userName: string;
};
let {
url,
markers = [],
muted = false,
compact = false,
label = '',
onTimeClick,
onReady,
onSeek,
}: {
url: string;
markers?: CommentMarker[];
muted?: boolean;
compact?: boolean;
label?: string;
onTimeClick?: (time: number) => void;
onReady?: (duration: number) => void;
onSeek?: (time: number) => void;
} = $props();
let container: HTMLDivElement;
let ws: WaveSurfer | null = null;
let isPlaying = $state(false);
let currentTime = $state(0);
let duration = $state(0);
let volume = $state(0.8);
$effect(() => {
if (ws) ws.setVolume(muted ? 0 : volume);
});
onMount(() => {
ws = WaveSurfer.create({
container,
waveColor: 'var(--color-bg-subtle, #4a4a5a)',
progressColor: 'var(--color-accent, #6366f1)',
cursorColor: '#818cf8',
cursorWidth: 2,
barWidth: 2,
barGap: 1,
barRadius: 2,
height: compact ? 48 : 80,
normalize: true,
url,
});
ws.on('ready', () => {
duration = ws!.getDuration();
ws!.setVolume(muted ? 0 : volume);
onReady?.(duration);
});
ws.on('timeupdate', (time) => {
currentTime = time;
onSeek?.(time);
});
ws.on('play', () => (isPlaying = true));
ws.on('pause', () => (isPlaying = false));
ws.on('click', (relativeX) => {
if (onTimeClick) {
const clickedTime = relativeX * (ws?.getDuration() || 0);
onTimeClick(clickedTime);
}
});
});
onDestroy(() => {
ws?.destroy();
});
function togglePlay() {
ws?.playPause();
}
function play() { ws?.play(); }
function pause() { ws?.pause(); }
function skip(seconds: number) {
if (!ws) return;
ws.setTime(Math.max(0, Math.min(ws.getDuration(), ws.getCurrentTime() + seconds)));
}
function setVol(v: number) {
volume = v;
if (!muted) ws?.setVolume(v);
}
function seekToTime(time: number) {
ws?.setTime(time);
}
function getCurrentTime(): number {
return ws?.getCurrentTime() || 0;
}
export { seekToTime, play, pause, getCurrentTime };
</script>
<div class="waveform-player" class:compact>
{#if label}
<span class="player-label">{label}</span>
{/if}
<div class="waveform-container">
<div bind:this={container} class="waveform"></div>
{#if duration > 0 && markers.length > 0}
<div class="markers">
{#each markers as marker}
<button
class="marker"
style="left: {(marker.timestampSeconds / duration) * 100}%"
title="{marker.userName}: {marker.body}"
onclick={() => seekToTime(marker.timestampSeconds)}
></button>
{/each}
</div>
{/if}
</div>
<div class="controls">
<div class="controls-left">
{#if !compact}
<button class="control-btn" onclick={() => skip(-10)} title="Back 10s"></button>
{/if}
<button class="control-btn play-btn" onclick={togglePlay}>
{isPlaying ? '⏸' : '▶'}
</button>
{#if !compact}
<button class="control-btn" onclick={() => skip(10)} title="Forward 10s"></button>
{/if}
</div>
<div class="time">
{formatTime(currentTime)} / {formatTime(duration)}
</div>
{#if !compact}
<div class="controls-right">
<input
type="range"
min="0"
max="1"
step="0.05"
value={volume}
oninput={(e) => setVol(Number((e.target as HTMLInputElement).value))}
class="volume-slider"
title="Volume"
/>
</div>
{/if}
</div>
</div>
<style>
.waveform-player {
background: var(--color-bg-overlay);
border-radius: var(--radius-lg);
padding: var(--space-5);
border: 1px solid var(--color-border);
}
.waveform-player.compact {
padding: var(--space-3);
}
.player-label {
display: block;
font-size: var(--text-xs);
color: var(--color-text-tertiary);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--space-2);
}
.waveform-container {
position: relative;
margin-bottom: var(--space-3);
}
.waveform {
cursor: pointer;
}
.markers {
position: absolute;
inset: 0;
pointer-events: none;
}
.marker {
position: absolute;
top: 0;
width: 8px;
height: 100%;
transform: translateX(-50%);
background: rgba(251, 191, 36, 0.2);
border: none;
border-left: 2px solid var(--color-warning);
cursor: pointer;
pointer-events: auto;
padding: 0;
transition: background var(--transition-fast);
}
.marker:hover {
background: rgba(251, 191, 36, 0.4);
}
.controls {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
}
.controls-left {
display: flex;
align-items: center;
gap: var(--space-2);
}
.control-btn {
background: none;
border: none;
color: var(--color-text-secondary);
font-size: 1.1rem;
cursor: pointer;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
transition: all var(--transition-fast);
}
.control-btn:hover {
background: var(--color-bg-subtle);
color: var(--color-text-primary);
}
.play-btn {
font-size: 1.3rem;
padding: var(--space-1) var(--space-3);
background: var(--color-accent);
color: #fff;
border-radius: var(--radius-md);
}
.play-btn:hover {
background: var(--color-accent-hover);
}
.time {
font-size: var(--text-sm);
color: var(--color-text-tertiary);
font-variant-numeric: tabular-nums;
}
.controls-right {
display: flex;
align-items: center;
}
.volume-slider {
width: 80px;
accent-color: var(--color-accent);
}
</style>

View File

@@ -0,0 +1,58 @@
<script lang="ts">
let {
src = null,
name,
size = 'md',
}: {
src?: string | null;
name: string;
size?: 'sm' | 'md' | 'lg';
} = $props();
const initials = $derived(
name
.split(' ')
.map((w) => w[0])
.slice(0, 2)
.join('')
.toUpperCase()
);
const colors = [
'#6366f1', '#8b5cf6', '#ec4899', '#f43f5e',
'#f97316', '#eab308', '#22c55e', '#06b6d4',
];
const color = $derived(colors[name.charCodeAt(0) % colors.length]);
</script>
<div class="avatar {size}" style:background={src ? 'none' : color}>
{#if src}
<img {src} alt={name} />
{:else}
<span>{initials}</span>
{/if}
</div>
<style>
.avatar {
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-full);
font-weight: 600;
color: #fff;
flex-shrink: 0;
overflow: hidden;
}
.sm { width: 24px; height: 24px; font-size: 0.6rem; }
.md { width: 32px; height: 32px; font-size: 0.7rem; }
.lg { width: 40px; height: 40px; font-size: 0.85rem; }
img {
width: 100%;
height: 100%;
object-fit: cover;
}
</style>

View File

@@ -0,0 +1,52 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
variant = 'default',
children,
}: {
variant?: 'default' | 'success' | 'warning' | 'error' | 'accent';
children: Snippet;
} = $props();
</script>
<span class="badge {variant}">
{@render children()}
</span>
<style>
.badge {
display: inline-flex;
align-items: center;
padding: 0.15rem 0.5rem;
border-radius: var(--radius-sm);
font-size: var(--text-xs);
font-weight: 500;
text-transform: capitalize;
}
.default {
background: var(--color-bg-subtle);
color: var(--color-text-secondary);
}
.success {
background: rgba(34, 197, 94, 0.15);
color: var(--color-success);
}
.warning {
background: rgba(251, 191, 36, 0.15);
color: var(--color-warning);
}
.error {
background: rgba(239, 68, 68, 0.15);
color: var(--color-error);
}
.accent {
background: var(--color-accent-subtle);
color: var(--color-accent);
}
</style>

View File

@@ -0,0 +1,129 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
variant = 'primary',
size = 'md',
loading = false,
disabled = false,
href,
type = 'button',
onclick,
children,
}: {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
disabled?: boolean;
href?: string;
type?: 'button' | 'submit';
onclick?: (e: MouseEvent) => void;
children: Snippet;
} = $props();
</script>
{#if href}
<a {href} class="btn {variant} {size}" class:disabled>
{@render children()}
</a>
{:else}
<button
{type}
class="btn {variant} {size}"
disabled={disabled || loading}
{onclick}
>
{#if loading}
<span class="spinner"></span>
{/if}
<span class:hidden={loading}>
{@render children()}
</span>
</button>
{/if}
<style>
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
border-radius: var(--radius-md);
font-family: inherit;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
text-decoration: none;
border: 1px solid transparent;
position: relative;
}
.btn:disabled, .btn.disabled {
opacity: 0.4;
cursor: default;
pointer-events: none;
}
/* Sizes */
.sm { padding: 0.3rem 0.6rem; font-size: var(--text-xs); }
.md { padding: 0.5rem 1rem; font-size: var(--text-sm); }
.lg { padding: 0.65rem 1.25rem; font-size: var(--text-base); }
/* Variants */
.primary {
background: var(--color-accent);
color: #fff;
border-color: var(--color-accent);
}
.primary:hover:not(:disabled) {
background: var(--color-accent-hover);
border-color: var(--color-accent-hover);
}
.secondary {
background: var(--color-bg-subtle);
color: var(--color-text-secondary);
border-color: var(--color-border-hover);
}
.secondary:hover:not(:disabled) {
border-color: var(--color-accent);
color: var(--color-text-primary);
}
.ghost {
background: transparent;
color: var(--color-text-secondary);
}
.ghost:hover:not(:disabled) {
background: var(--color-bg-subtle);
color: var(--color-text-primary);
}
.danger {
background: transparent;
color: var(--color-error);
border-color: var(--color-error);
}
.danger:hover:not(:disabled) {
background: var(--color-error);
color: #fff;
}
.spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.6s linear infinite;
position: absolute;
}
.hidden {
visibility: hidden;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>

View File

@@ -0,0 +1,57 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
icon = '📁',
title,
description,
action,
}: {
icon?: string;
title: string;
description?: string;
action?: Snippet;
} = $props();
</script>
<div class="empty-state">
<span class="icon">{icon}</span>
<h3>{title}</h3>
{#if description}
<p>{description}</p>
{/if}
{#if action}
<div class="action">
{@render action()}
</div>
{/if}
</div>
<style>
.empty-state {
text-align: center;
padding: var(--space-12) var(--space-4);
color: var(--color-text-tertiary);
}
.icon {
font-size: 2.5rem;
display: block;
margin-bottom: var(--space-3);
}
h3 {
margin: 0 0 var(--space-2);
font-size: var(--text-base);
color: var(--color-text-secondary);
}
p {
margin: 0;
font-size: var(--text-sm);
}
.action {
margin-top: var(--space-4);
}
</style>

View File

@@ -0,0 +1,80 @@
<script lang="ts">
let {
type = 'text',
value = $bindable(''),
placeholder = '',
label,
error,
disabled = false,
autofocus = false,
}: {
type?: 'text' | 'email' | 'password';
value?: string;
placeholder?: string;
label?: string;
error?: string;
disabled?: boolean;
autofocus?: boolean;
} = $props();
</script>
<div class="input-group">
{#if label}
<label class="input-label">{label}</label>
{/if}
<input
{type}
bind:value
{placeholder}
{disabled}
{autofocus}
class:has-error={!!error}
/>
{#if error}
<span class="input-error">{error}</span>
{/if}
</div>
<style>
.input-group {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.input-label {
color: var(--color-text-secondary);
font-size: var(--text-sm);
}
input {
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
border: 1px solid var(--color-border-hover);
background: var(--color-bg-base);
color: var(--color-text-primary);
font-size: var(--text-base);
font-family: inherit;
transition: border-color var(--transition-fast);
width: 100%;
}
input:focus {
outline: none;
border-color: var(--color-border-focus);
}
input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
input.has-error {
border-color: var(--color-error);
}
.input-error {
color: var(--color-error);
font-size: var(--text-xs);
}
</style>

View File

@@ -0,0 +1,108 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
open = $bindable(false),
title,
children,
actions,
}: {
open: boolean;
title: string;
children: Snippet;
actions?: Snippet;
} = $props();
function handleBackdrop(e: MouseEvent) {
if (e.target === e.currentTarget) open = false;
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') open = false;
}
</script>
<svelte:window onkeydown={open ? handleKeydown : undefined} />
{#if open}
<div class="backdrop" onclick={handleBackdrop} role="dialog" aria-modal="true">
<div class="modal">
<div class="modal-header">
<h2>{title}</h2>
<button class="close-btn" onclick={() => open = false}>×</button>
</div>
<div class="modal-body">
{@render children()}
</div>
{#if actions}
<div class="modal-actions">
{@render actions()}
</div>
{/if}
</div>
</div>
{/if}
<style>
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: var(--z-modal);
padding: var(--space-4);
backdrop-filter: blur(4px);
}
.modal {
background: var(--color-bg-overlay);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
width: 100%;
max-width: 480px;
max-height: 85vh;
overflow-y: auto;
box-shadow: var(--shadow-lg);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-5) var(--space-6);
border-bottom: 1px solid var(--color-border);
}
.modal-header h2 {
margin: 0;
font-size: var(--text-lg);
}
.close-btn {
background: none;
border: none;
color: var(--color-text-tertiary);
font-size: 1.5rem;
cursor: pointer;
padding: 0;
line-height: 1;
}
.close-btn:hover {
color: var(--color-text-primary);
}
.modal-body {
padding: var(--space-6);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
padding: var(--space-4) var(--space-6);
border-top: 1px solid var(--color-border);
}
</style>

View File

@@ -0,0 +1,36 @@
<script lang="ts">
let {
width = '100%',
height = '1rem',
variant = 'text',
}: {
width?: string;
height?: string;
variant?: 'text' | 'circle' | 'rect';
} = $props();
</script>
<div
class="skeleton {variant}"
style:width
style:height
style:border-radius={variant === 'circle' ? '50%' : variant === 'rect' ? 'var(--radius-md)' : 'var(--radius-sm)'}
></div>
<style>
.skeleton {
background: linear-gradient(
90deg,
var(--color-bg-subtle) 25%,
var(--color-border) 50%,
var(--color-bg-subtle) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>

View File

@@ -0,0 +1,104 @@
<script lang="ts">
import { toasts, removeToast, type ToastType } from '$lib/stores/toast.js';
const icons: Record<ToastType, string> = {
success: '✓',
error: '✕',
info: 'i',
warning: '!',
};
</script>
{#if $toasts.length > 0}
<div class="toast-container">
{#each $toasts as t (t.id)}
<div class="toast {t.type}" role="alert">
<span class="toast-icon">{icons[t.type]}</span>
<span class="toast-message">{t.message}</span>
<button class="toast-close" onclick={() => removeToast(t.id)}>×</button>
</div>
{/each}
</div>
{/if}
<style>
.toast-container {
position: fixed;
bottom: var(--space-6);
right: var(--space-6);
z-index: var(--z-toast);
display: flex;
flex-direction: column;
gap: var(--space-2);
max-width: 400px;
}
.toast {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
background: var(--color-bg-overlay);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
animation: slide-in 0.2s ease;
font-size: var(--text-sm);
}
.toast-icon {
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.7rem;
font-weight: 700;
flex-shrink: 0;
}
.success .toast-icon { background: var(--color-success); color: #000; }
.error .toast-icon { background: var(--color-error); color: #fff; }
.info .toast-icon { background: var(--color-accent); color: #fff; }
.warning .toast-icon { background: var(--color-warning); color: #000; }
.toast-message {
flex: 1;
color: var(--color-text-primary);
}
.toast-close {
background: none;
border: none;
color: var(--color-text-tertiary);
cursor: pointer;
font-size: 1.1rem;
padding: 0;
line-height: 1;
}
.toast-close:hover {
color: var(--color-text-primary);
}
@keyframes slide-in {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@media (max-width: 640px) {
.toast-container {
left: var(--space-4);
right: var(--space-4);
bottom: var(--space-4);
max-width: none;
}
}
</style>

View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@@ -0,0 +1,38 @@
import { writable } from 'svelte/store';
import { api } from '$lib/api/client.js';
type User = {
id: string;
email: string;
name: string;
avatarUrl?: string;
} | null;
export const user = writable<User>(null);
export const authLoading = writable(true);
export async function checkAuth() {
try {
const res = await api.get<{ user: User }>('/auth/me', true);
user.set(res.user);
} catch {
user.set(null);
} finally {
authLoading.set(false);
}
}
export async function sendMagicLink(email: string) {
return api.post('/auth/magic-link', { email });
}
export async function verifyToken(token: string) {
const res = await api.post<{ user: User }>('/auth/verify', { token });
user.set(res.user);
return res.user;
}
export async function logout() {
await api.post('/auth/logout');
user.set(null);
}

View File

@@ -0,0 +1,24 @@
import { writable } from 'svelte/store';
export type ToastType = 'success' | 'error' | 'info' | 'warning';
type Toast = {
id: string;
message: string;
type: ToastType;
};
export const toasts = writable<Toast[]>([]);
export function toast(message: string, type: ToastType = 'info', duration = 4000) {
const id = crypto.randomUUID();
toasts.update((t) => [...t, { id, message, type }]);
setTimeout(() => removeToast(id), duration);
}
export function removeToast(id: string) {
toasts.update((t) => t.filter((x) => x.id !== id));
}
export const toastSuccess = (msg: string) => toast(msg, 'success');
export const toastError = (msg: string) => toast(msg, 'error', 6000);

View File

@@ -0,0 +1,29 @@
export function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
export function timeAgo(dateStr: string): string {
const now = Date.now();
const then = new Date(dateStr).getTime();
const diff = now - then;
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
return new Date(dateStr).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: '2-digit',
});
}

View File

@@ -0,0 +1,144 @@
<script lang="ts">
import { onMount } from 'svelte';
import { checkAuth, user, authLoading } from '$lib/stores/auth.js';
import ToastContainer from '$lib/components/ui/ToastContainer.svelte';
let { children } = $props();
onMount(() => {
checkAuth();
});
</script>
<svelte:head>
<title>Music Hub</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</svelte:head>
{#if $authLoading}
<div class="loading">
<div class="loading-spinner"></div>
</div>
{:else}
{@render children()}
{/if}
<ToastContainer />
<style>
:global(:root) {
/* Background */
--color-bg-base: #0a0a0a;
--color-bg-raised: #111111;
--color-bg-overlay: #1a1a1a;
--color-bg-subtle: #222222;
/* Borders */
--color-border: #2a2a2a;
--color-border-hover: #333333;
--color-border-focus: #6366f1;
/* Text */
--color-text-primary: #f0f0f0;
--color-text-secondary: #a0a0a0;
--color-text-tertiary: #666666;
/* Accent */
--color-accent: #6366f1;
--color-accent-hover: #5558e6;
--color-accent-subtle: #1a1a2e;
/* Semantic */
--color-success: #22c55e;
--color-warning: #fbbf24;
--color-error: #ef4444;
/* Spacing */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
/* Radii */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.6);
/* Typography */
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'SF Mono', 'Fira Code', monospace;
--text-xs: 0.75rem;
--text-sm: 0.85rem;
--text-base: 0.9rem;
--text-lg: 1.1rem;
--text-xl: 1.5rem;
--text-2xl: 2rem;
/* Transitions */
--transition-fast: 150ms ease;
--transition-base: 200ms ease;
/* Z-Index */
--z-dropdown: 100;
--z-modal: 200;
--z-toast: 300;
}
:global(body) {
margin: 0;
font-family: var(--font-sans);
background: var(--color-bg-base);
color: var(--color-text-secondary);
font-size: var(--text-base);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
:global(*) {
box-sizing: border-box;
}
:global(h1, h2, h3) {
color: var(--color-text-primary);
}
:global(a) {
color: var(--color-accent);
text-decoration: none;
}
:global(a:hover) {
color: var(--color-accent-hover);
}
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
}
.loading-spinner {
width: 24px;
height: 24px;
border: 2px solid var(--color-border);
border-top-color: var(--color-accent);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>

View File

@@ -0,0 +1,89 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { user, sendMagicLink } from '$lib/stores/auth.js';
import Button from '$lib/components/ui/Button.svelte';
import Input from '$lib/components/ui/Input.svelte';
let email = $state('');
let sent = $state(false);
let loading = $state(false);
let error = $state('');
$effect(() => {
if ($user) goto('/dashboard');
});
async function handleSubmit(e: Event) {
e.preventDefault();
error = '';
loading = true;
try {
await sendMagicLink(email);
sent = true;
} catch (err) {
error = err instanceof Error ? err.message : 'Something went wrong';
} finally {
loading = false;
}
}
</script>
<div class="login-page">
<div class="login-card">
<h1>Music Hub</h1>
<p class="subtitle">Collaboration for music production</p>
{#if sent}
<div class="success">
<p>Check your email for the login link.</p>
<Button variant="secondary" onclick={() => { sent = false; email = ''; }}>Try again</Button>
</div>
{:else}
<form onsubmit={handleSubmit}>
<Input type="email" bind:value={email} placeholder="your@email.com" {error} />
<Button type="submit" size="lg" {loading}>Send Login Link</Button>
</form>
{/if}
</div>
</div>
<style>
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: var(--space-4);
}
.login-card {
background: var(--color-bg-overlay);
border-radius: var(--radius-lg);
padding: var(--space-10);
width: 100%;
max-width: 400px;
text-align: center;
border: 1px solid var(--color-border);
}
h1 {
margin: 0 0 var(--space-1);
font-size: var(--text-2xl);
}
.subtitle {
color: var(--color-text-tertiary);
margin: 0 0 var(--space-8);
}
form {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.success p {
color: var(--color-success);
margin-bottom: var(--space-4);
}
</style>

View File

@@ -0,0 +1,56 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { verifyToken } from '$lib/stores/auth.js';
let error = $state('');
onMount(async () => {
const token = $page.url.searchParams.get('token');
if (!token) {
error = 'No token provided';
return;
}
try {
await verifyToken(token);
goto('/dashboard');
} catch (err) {
error = err instanceof Error ? err.message : 'Verification failed';
}
});
</script>
<div class="verify-page">
{#if error}
<div class="error-card">
<h2>Login Failed</h2>
<p>{error}</p>
<a href="/">Try again</a>
</div>
{:else}
<p>Verifying...</p>
{/if}
</div>
<style>
.verify-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
color: #888;
}
.error-card {
text-align: center;
}
.error-card h2 {
color: #ef4444;
}
a {
color: #6366f1;
}
</style>

View File

@@ -0,0 +1,182 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { user, logout } from '$lib/stores/auth.js';
import { api } from '$lib/api/client.js';
import Button from '$lib/components/ui/Button.svelte';
import Badge from '$lib/components/ui/Badge.svelte';
import Avatar from '$lib/components/ui/Avatar.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Skeleton from '$lib/components/ui/Skeleton.svelte';
type ProjectMembership = {
project: { id: string; name: string; description?: string; createdAt: string };
role: string;
};
let projects = $state<ProjectMembership[]>([]);
let loading = $state(true);
$effect(() => {
if (!$user) goto('/');
});
onMount(async () => {
try {
const res = await api.get<{ projects: ProjectMembership[] }>('/projects');
projects = res.projects;
} finally {
loading = false;
}
});
async function handleLogout() {
await logout();
goto('/');
}
</script>
<div class="dashboard">
<header>
<h1>Music Hub</h1>
<div class="header-right">
{#if $user}
<Avatar name={$user.name} src={$user.avatarUrl ?? null} size="sm" />
<span class="user-name">{$user.name}</span>
{/if}
<Button variant="ghost" size="sm" onclick={handleLogout}>Logout</Button>
</div>
</header>
<main>
<div class="section-header">
<h2>Projects</h2>
<Button href="/projects/new">New Project</Button>
</div>
{#if loading}
<div class="project-grid">
{#each [1, 2, 3] as _}
<div class="project-card skeleton-card">
<Skeleton width="60%" height="1.2rem" />
<Skeleton width="80%" height="0.9rem" />
<Skeleton width="5rem" height="1.2rem" />
</div>
{/each}
</div>
{:else if projects.length === 0}
<EmptyState
icon="🎵"
title="No projects yet"
description="Create your first project to start collaborating."
>
{#snippet action()}
<Button href="/projects/new">Create Project</Button>
{/snippet}
</EmptyState>
{:else}
<div class="project-grid">
{#each projects as { project, role }}
<a href="/projects/{project.id}" class="project-card">
<h3>{project.name}</h3>
{#if project.description}
<p class="description">{project.description}</p>
{/if}
<Badge>{role.replaceAll('_', ' ')}</Badge>
</a>
{/each}
</div>
{/if}
</main>
</div>
<style>
.dashboard {
max-width: 1000px;
margin: 0 auto;
padding: var(--space-4);
}
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-4) 0;
border-bottom: 1px solid var(--color-border);
margin-bottom: var(--space-8);
}
header h1 {
font-size: var(--text-xl);
margin: 0;
}
.header-right {
display: flex;
align-items: center;
gap: var(--space-3);
}
.user-name {
color: var(--color-text-secondary);
font-size: var(--text-sm);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-6);
}
h2 {
margin: 0;
}
.project-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-4);
}
.project-card {
background: var(--color-bg-overlay);
border-radius: var(--radius-lg);
padding: var(--space-6);
text-decoration: none;
color: inherit;
border: 1px solid var(--color-border);
transition: border-color var(--transition-base), box-shadow var(--transition-base);
}
.project-card:hover {
border-color: var(--color-accent);
box-shadow: var(--shadow-sm);
}
.project-card h3 {
margin: 0 0 var(--space-2);
}
.description {
color: var(--color-text-secondary);
margin: 0 0 var(--space-4);
font-size: var(--text-sm);
}
.skeleton-card {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
@media (max-width: 640px) {
.project-grid {
grid-template-columns: 1fr;
}
.user-name {
display: none;
}
}
</style>

View File

@@ -0,0 +1,218 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { api } from '$lib/api/client.js';
import { toastSuccess } from '$lib/stores/toast.js';
import Button from '$lib/components/ui/Button.svelte';
import Input from '$lib/components/ui/Input.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Skeleton from '$lib/components/ui/Skeleton.svelte';
type Track = {
id: string;
name: string;
description?: string;
createdAt: string;
};
type Project = {
id: string;
name: string;
description?: string;
};
let project = $state<Project | null>(null);
let role = $state('');
let tracks = $state<Track[]>([]);
let newTrackName = $state('');
let showNewTrack = $state(false);
let loading = $state(true);
let creating = $state(false);
const projectId = $page.params.projectId;
onMount(async () => {
try {
const [projectRes, trackRes] = await Promise.all([
api.get<{ project: Project; role: string }>(`/projects/${projectId}`),
api.get<{ tracks: Track[] }>(`/tracks/project/${projectId}`),
]);
project = projectRes.project;
role = projectRes.role;
tracks = trackRes.tracks;
} finally {
loading = false;
}
});
async function createTrack() {
if (!newTrackName.trim()) return;
creating = true;
try {
const res = await api.post<{ track: Track }>(`/tracks/${projectId}`, {
name: newTrackName,
});
tracks = [...tracks, res.track];
newTrackName = '';
showNewTrack = false;
toastSuccess('Track created');
} finally {
creating = false;
}
}
const canUpload = $derived(role === 'owner' || role.includes('engineer'));
</script>
<div class="project-page">
<header>
<a href="/dashboard" class="back">&larr; Projects</a>
{#if loading}
<Skeleton width="200px" height="2rem" />
{:else if project}
<div class="project-header">
<h1>{project.name}</h1>
{#if role === 'owner' || role === 'management'}
<Button variant="ghost" size="sm" href="/projects/{projectId}/settings">Settings</Button>
{/if}
</div>
{#if project.description}
<p class="description">{project.description}</p>
{/if}
{/if}
</header>
<div class="section-header">
<h2>Tracks</h2>
{#if canUpload}
<Button variant="secondary" onclick={() => showNewTrack = !showNewTrack}>
{showNewTrack ? 'Cancel' : 'New Track'}
</Button>
{/if}
</div>
{#if showNewTrack}
<form class="new-track-form" onsubmit={(e) => { e.preventDefault(); createTrack(); }}>
<Input bind:value={newTrackName} placeholder="Track name" autofocus />
<Button type="submit" loading={creating}>Create</Button>
</form>
{/if}
{#if loading}
<div class="track-list">
{#each [1, 2] as _}
<div class="track-item-skeleton">
<Skeleton width="40%" height="1rem" />
</div>
{/each}
</div>
{:else if tracks.length === 0}
<EmptyState
icon="🎶"
title="No tracks yet"
description="Create a track and upload your first audio file."
/>
{:else}
<div class="track-list">
{#each tracks as track}
<a href="/projects/{projectId}/tracks/{track.id}" class="track-item">
<span class="track-name">{track.name}</span>
</a>
{/each}
</div>
{/if}
</div>
<style>
.project-page {
max-width: 800px;
margin: 0 auto;
padding: var(--space-4);
}
header {
margin-bottom: var(--space-8);
}
.back {
color: var(--color-text-tertiary);
text-decoration: none;
font-size: var(--text-sm);
}
.back:hover {
color: var(--color-text-primary);
}
.project-header {
display: flex;
align-items: center;
gap: var(--space-3);
margin-top: var(--space-2);
}
h1 {
margin: 0;
}
.description {
color: var(--color-text-secondary);
margin: var(--space-1) 0 0;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-4);
}
h2 {
margin: 0;
}
.new-track-form {
display: flex;
gap: var(--space-3);
margin-bottom: var(--space-6);
align-items: flex-start;
}
.new-track-form :global(.input-group) {
flex: 1;
}
.track-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.track-item {
display: flex;
align-items: center;
padding: var(--space-4) var(--space-5);
background: var(--color-bg-overlay);
border-radius: var(--radius-md);
text-decoration: none;
color: inherit;
border: 1px solid var(--color-border);
transition: border-color var(--transition-base);
}
.track-item:hover {
border-color: var(--color-accent);
}
.track-name {
color: var(--color-text-primary);
font-weight: 500;
}
.track-item-skeleton {
padding: var(--space-4) var(--space-5);
background: var(--color-bg-overlay);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
}
</style>

View File

@@ -0,0 +1,356 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { api } from '$lib/api/client.js';
import { toastSuccess } from '$lib/stores/toast.js';
import { ROLE_LABELS, PROJECT_ROLES } from '@music-hub/shared';
import Button from '$lib/components/ui/Button.svelte';
import Input from '$lib/components/ui/Input.svelte';
import Avatar from '$lib/components/ui/Avatar.svelte';
import Badge from '$lib/components/ui/Badge.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
type Member = {
id: string;
role: string;
user: { id: string; email: string; name: string; avatarUrl: string | null };
};
type Project = { id: string; name: string; description: string | null };
const projectId = $page.params.projectId!;
let project = $state<Project | null>(null);
let members = $state<Member[]>([]);
let role = $state('');
let loading = $state(true);
// Edit project
let editName = $state('');
let editDesc = $state('');
let saving = $state(false);
// Invite
let inviteEmail = $state('');
let inviteRole = $state('artist');
let inviting = $state(false);
// Archive
let showArchiveModal = $state(false);
const assignableRoles = PROJECT_ROLES.filter((r) => r !== 'owner');
onMount(async () => {
try {
const [projectRes, membersRes] = await Promise.all([
api.get<{ project: Project; role: string }>(`/projects/${projectId}`),
api.get<{ members: Member[] }>(`/projects/${projectId}/members`),
]);
project = projectRes.project;
role = projectRes.role;
editName = project.name;
editDesc = project.description || '';
members = membersRes.members;
} finally {
loading = false;
}
});
async function saveProject() {
saving = true;
try {
await api.patch(`/projects/${projectId}`, {
name: editName,
description: editDesc || undefined,
});
toastSuccess('Project updated');
} finally {
saving = false;
}
}
async function inviteMember() {
if (!inviteEmail.trim()) return;
inviting = true;
try {
await api.post(`/projects/${projectId}/members`, {
email: inviteEmail,
role: inviteRole,
});
inviteEmail = '';
toastSuccess('Member invited');
const res = await api.get<{ members: Member[] }>(`/projects/${projectId}/members`);
members = res.members;
} finally {
inviting = false;
}
}
async function updateRole(memberId: string, newRole: string) {
await api.patch(`/projects/${projectId}/members/${memberId}`, { role: newRole });
toastSuccess('Role updated');
const res = await api.get<{ members: Member[] }>(`/projects/${projectId}/members`);
members = res.members;
}
async function removeMember(memberId: string) {
await api.delete(`/projects/${projectId}/members/${memberId}`);
toastSuccess('Member removed');
members = members.filter((m) => m.id !== memberId);
}
async function archiveProject() {
await api.delete(`/projects/${projectId}`);
toastSuccess('Project archived');
goto('/dashboard');
}
</script>
<div class="settings-page">
<header>
<a href="/projects/{projectId}" class="back">&larr; Back to project</a>
<h1>Settings</h1>
</header>
{#if !loading && project}
<!-- Project Details -->
<section class="section">
<h2>Project Details</h2>
<form onsubmit={(e) => { e.preventDefault(); saveProject(); }}>
<Input label="Name" bind:value={editName} />
<div class="textarea-group">
<label class="textarea-label">Description</label>
<textarea bind:value={editDesc} rows="3" placeholder="Project description..."></textarea>
</div>
<Button type="submit" loading={saving}>Save</Button>
</form>
</section>
<!-- Members -->
<section class="section">
<h2>Members</h2>
<div class="member-list">
{#each members as member}
<div class="member-row">
<Avatar name={member.user.name} src={member.user.avatarUrl} />
<div class="member-info">
<span class="member-name">{member.user.name}</span>
<span class="member-email">{member.user.email}</span>
</div>
{#if member.role === 'owner'}
<Badge variant="accent">Owner</Badge>
{:else if role === 'owner'}
<select
value={member.role}
onchange={(e) => updateRole(member.id, (e.target as HTMLSelectElement).value)}
>
{#each assignableRoles as r}
<option value={r} selected={r === member.role}>{ROLE_LABELS[r]}</option>
{/each}
</select>
<Button variant="ghost" size="sm" onclick={() => removeMember(member.id)}>
<span style="color: var(--color-error)">Remove</span>
</Button>
{:else}
<Badge>{ROLE_LABELS[member.role as keyof typeof ROLE_LABELS] || member.role}</Badge>
{/if}
</div>
{/each}
</div>
<!-- Invite -->
{#if role === 'owner' || role === 'management'}
<form class="invite-form" onsubmit={(e) => { e.preventDefault(); inviteMember(); }}>
<Input type="email" bind:value={inviteEmail} placeholder="email@example.com" />
<select bind:value={inviteRole}>
{#each assignableRoles as r}
<option value={r}>{ROLE_LABELS[r]}</option>
{/each}
</select>
<Button type="submit" loading={inviting} size="sm">Invite</Button>
</form>
{/if}
</section>
<!-- Danger Zone -->
{#if role === 'owner'}
<section class="section danger-zone">
<h2>Danger Zone</h2>
<div class="danger-content">
<div>
<strong>Archive this project</strong>
<p>The project will be hidden from all members.</p>
</div>
<Button variant="danger" onclick={() => showArchiveModal = true}>Archive</Button>
</div>
</section>
{/if}
{/if}
</div>
<Modal bind:open={showArchiveModal} title="Archive Project">
<p>Are you sure you want to archive <strong>{project?.name}</strong>? This will hide it from all members.</p>
{#snippet actions()}
<Button variant="secondary" onclick={() => showArchiveModal = false}>Cancel</Button>
<Button variant="danger" onclick={archiveProject}>Archive</Button>
{/snippet}
</Modal>
<style>
.settings-page {
max-width: 600px;
margin: 0 auto;
padding: var(--space-4);
}
header {
margin-bottom: var(--space-8);
}
.back {
color: var(--color-text-tertiary);
text-decoration: none;
font-size: var(--text-sm);
}
.back:hover {
color: var(--color-text-primary);
}
h1 {
margin: var(--space-2) 0 0;
}
.section {
background: var(--color-bg-overlay);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-6);
margin-bottom: var(--space-6);
}
h2 {
margin: 0 0 var(--space-4);
font-size: var(--text-lg);
}
form {
display: flex;
flex-direction: column;
gap: var(--space-4);
align-items: flex-start;
}
form :global(.input-group) {
width: 100%;
}
.textarea-group {
display: flex;
flex-direction: column;
gap: var(--space-2);
width: 100%;
}
.textarea-label {
color: var(--color-text-secondary);
font-size: var(--text-sm);
}
textarea {
width: 100%;
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
border: 1px solid var(--color-border-hover);
background: var(--color-bg-base);
color: var(--color-text-primary);
font-size: var(--text-base);
font-family: inherit;
resize: vertical;
}
textarea:focus {
outline: none;
border-color: var(--color-border-focus);
}
/* Members */
.member-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.member-row {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
background: var(--color-bg-raised);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
}
.member-info {
flex: 1;
display: flex;
flex-direction: column;
}
.member-name {
color: var(--color-text-primary);
font-weight: 500;
font-size: var(--text-sm);
}
.member-email {
color: var(--color-text-tertiary);
font-size: var(--text-xs);
}
select {
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
border: 1px solid var(--color-border-hover);
background: var(--color-bg-base);
color: var(--color-text-primary);
font-size: var(--text-sm);
font-family: inherit;
}
.invite-form {
display: flex;
gap: var(--space-2);
align-items: flex-start;
flex-direction: row;
}
.invite-form :global(.input-group) {
flex: 1;
}
/* Danger Zone */
.danger-zone {
border-color: rgba(239, 68, 68, 0.3);
}
.danger-zone h2 {
color: var(--color-error);
}
.danger-content {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-4);
}
.danger-content p {
margin: var(--space-1) 0 0;
font-size: var(--text-sm);
color: var(--color-text-tertiary);
}
</style>

View File

@@ -0,0 +1,303 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { api } from '$lib/api/client.js';
import { toastSuccess } from '$lib/stores/toast.js';
import WaveformPlayer from '$lib/components/audio/WaveformPlayer.svelte';
import UploadDropzone from '$lib/components/audio/UploadDropzone.svelte';
import Button from '$lib/components/ui/Button.svelte';
import Skeleton from '$lib/components/ui/Skeleton.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import ABCompare from '$lib/components/audio/ABCompare.svelte';
import VersionInfo from './components/VersionInfo.svelte';
import VersionHistory from './components/VersionHistory.svelte';
import CommentSection from './components/CommentSection.svelte';
type Version = {
id: string;
versionNumber: number;
label: string | null;
notes: string | null;
status: string;
originalFileName: string;
duration: number | null;
createdAt: string;
};
type Comment = {
id: string;
body: string;
timestampSeconds: number | null;
parentId: string | null;
resolvedAt: string | null;
createdAt: string;
user: { id: string; name: string; avatarUrl: string | null };
};
const projectId = $page.params.projectId!;
const trackId = $page.params.trackId!;
let trackName = $state('');
let versions = $state<Version[]>([]);
let selectedVersion = $state<Version | null>(null);
let streamUrl = $state('');
let comments = $state<Comment[]>([]);
let showUpload = $state(false);
let role = $state('');
let loading = $state(true);
let commentTimestamp = $state<number | null>(null);
let playerRef = $state<WaveformPlayer>();
let compareVersion = $state<Version | null>(null);
let compareStreamUrl = $state('');
const canUpload = $derived(role === 'owner' || role.includes('engineer'));
const canApprove = $derived(['owner', 'artist', 'label', 'management'].includes(role));
const canComment = $derived(role !== 'viewer');
onMount(async () => {
try {
const [projectRes, trackVersions, tracksRes] = await Promise.all([
api.get<{ project: any; role: string }>(`/projects/${projectId}`),
api.get<{ versions: Version[] }>(`/versions/track/${trackId}`),
api.get<{ tracks: { id: string; name: string }[] }>(`/tracks/project/${projectId}`),
]);
role = projectRes.role;
trackName = tracksRes.tracks.find((t) => t.id === trackId)?.name || '';
versions = trackVersions.versions;
if (versions.length > 0) await selectVersion(versions[0]);
} finally {
loading = false;
}
});
async function selectVersion(version: Version) {
selectedVersion = version;
const [streamRes, commentRes] = await Promise.all([
api.get<{ url: string }>(`/versions/${version.id}/stream-url`),
api.get<{ comments: Comment[] }>(`/comments/version/${version.id}`),
]);
streamUrl = streamRes.url;
comments = commentRes.comments;
}
async function loadVersions() {
const res = await api.get<{ versions: Version[] }>(`/versions/track/${trackId}`);
versions = res.versions;
if (versions.length > 0) await selectVersion(versions[0]);
}
async function handleApprove() {
if (!selectedVersion) return;
await api.post(`/versions/${selectedVersion.id}/approve`);
toastSuccess('Version approved');
await loadVersions();
}
async function handleReject() {
if (!selectedVersion) return;
await api.post(`/versions/${selectedVersion.id}/reject`);
toastSuccess('Version rejected');
await loadVersions();
}
async function handleComment(body: string, timestamp: number | null, parentId?: string) {
if (!selectedVersion) return;
await api.post(`/comments/version/${selectedVersion.id}`, {
body,
timestampSeconds: timestamp ?? undefined,
parentId,
});
const res = await api.get<{ comments: Comment[] }>(`/comments/version/${selectedVersion.id}`);
comments = res.comments;
toastSuccess('Comment added');
}
async function handleResolve(commentId: string) {
await api.post(`/comments/${commentId}/resolve`);
if (selectedVersion) {
const res = await api.get<{ comments: Comment[] }>(`/comments/version/${selectedVersion.id}`);
comments = res.comments;
}
}
function handleDownload() {
if (!selectedVersion) return;
api.get<{ url: string }>(`/versions/${selectedVersion.id}/download-url`).then((res) => {
window.open(res.url, '_blank');
});
}
async function startCompare(version: Version) {
const res = await api.get<{ url: string }>(`/versions/${version.id}/stream-url`);
compareVersion = version;
compareStreamUrl = res.url;
}
function closeCompare() {
compareVersion = null;
compareStreamUrl = '';
}
</script>
<div class="track-page">
<header>
<a href="/projects/{projectId}" class="back">&larr; Back to project</a>
{#if loading}
<Skeleton width="200px" height="2rem" />
{:else}
<h1>{trackName}</h1>
{/if}
</header>
<!-- Player -->
{#if selectedVersion && streamUrl}
{#key streamUrl}
<WaveformPlayer
bind:this={playerRef}
url={streamUrl}
markers={comments
.filter((c) => c.timestampSeconds !== null)
.map((c) => ({
id: c.id,
timestampSeconds: c.timestampSeconds!,
body: c.body,
userName: c.user.name,
}))}
onTimeClick={(time) => commentTimestamp = Math.round(time * 10) / 10}
/>
{/key}
<VersionInfo
version={selectedVersion}
{canApprove}
onApprove={handleApprove}
onReject={handleReject}
/>
<div class="track-actions">
{#if canUpload}
<Button variant="secondary" size="sm" onclick={() => showUpload = !showUpload}>
{showUpload ? 'Cancel' : 'Upload new version'}
</Button>
{/if}
<Button variant="ghost" size="sm" onclick={handleDownload}>
↓ Download
</Button>
{#if versions.length > 1}
<select
class="compare-select"
onchange={(e) => {
const id = (e.target as HTMLSelectElement).value;
if (id) {
const v = versions.find((v) => v.id === id);
if (v) startCompare(v);
}
(e.target as HTMLSelectElement).value = '';
}}
>
<option value="">Compare with...</option>
{#each versions.filter((v) => v.id !== selectedVersion?.id) as v}
<option value={v.id}>V{v.versionNumber}{v.label ? ` — ${v.label}` : ''}</option>
{/each}
</select>
{/if}
</div>
{/if}
<!-- A/B Compare -->
{#if compareVersion && compareStreamUrl && selectedVersion && streamUrl}
<ABCompare
versionA={selectedVersion}
versionB={compareVersion}
streamUrlA={streamUrl}
streamUrlB={compareStreamUrl}
onClose={closeCompare}
/>
{/if}
{#if showUpload}
<UploadDropzone {trackId} onUploaded={() => { showUpload = false; loadVersions(); toastSuccess('Version uploaded'); }} />
{/if}
{#if loading}
<Skeleton height="80px" variant="rect" />
{:else if versions.length === 0}
<EmptyState
icon="🎵"
title="No versions yet"
description="Upload your first audio file to get started."
>
{#snippet action()}
{#if canUpload}
<Button onclick={() => showUpload = true}>Upload Audio</Button>
{/if}
{/snippet}
</EmptyState>
{/if}
{#if selectedVersion}
<CommentSection
{comments}
{canComment}
bind:commentTimestamp
onSubmit={handleComment}
onResolve={handleResolve}
onSeek={(time) => playerRef?.seekToTime(time)}
/>
{/if}
<VersionHistory
{versions}
selectedId={selectedVersion?.id ?? null}
onSelect={selectVersion}
/>
</div>
<style>
.track-page {
max-width: 800px;
margin: 0 auto;
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-5);
}
header {
margin-bottom: var(--space-2);
}
.back {
color: var(--color-text-tertiary);
text-decoration: none;
font-size: var(--text-sm);
}
.back:hover {
color: var(--color-text-primary);
}
h1 {
margin: var(--space-2) 0 0;
}
.track-actions {
display: flex;
gap: var(--space-2);
align-items: center;
flex-wrap: wrap;
}
.compare-select {
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
border: 1px solid var(--color-border-hover);
background: var(--color-bg-base);
color: var(--color-text-secondary);
font-size: var(--text-sm);
font-family: inherit;
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,130 @@
<script lang="ts">
import Avatar from '$lib/components/ui/Avatar.svelte';
import { formatTime, timeAgo } from '$lib/utils/format.js';
type Comment = {
id: string;
body: string;
timestampSeconds: number | null;
resolvedAt: string | null;
createdAt: string;
user: { id: string; name: string; avatarUrl: string | null };
};
let {
comment,
onSeek,
onResolve,
onReply,
}: {
comment: Comment;
onSeek?: (time: number) => void;
onResolve: (id: string) => void;
onReply?: (id: string) => void;
} = $props();
</script>
<div class="comment" class:resolved={comment.resolvedAt}>
<div class="comment-header">
<Avatar name={comment.user.name} src={comment.user.avatarUrl} size="sm" />
<span class="comment-author">{comment.user.name}</span>
{#if comment.timestampSeconds !== null}
<button
class="comment-timestamp"
onclick={() => onSeek?.(comment.timestampSeconds!)}
>
{formatTime(comment.timestampSeconds)}
</button>
{/if}
<span class="comment-date">{timeAgo(comment.createdAt)}</span>
<div class="comment-actions">
{#if onReply}
<button class="action-btn" onclick={() => onReply?.(comment.id)} title="Reply"></button>
{/if}
{#if !comment.resolvedAt}
<button class="action-btn resolve" onclick={() => onResolve(comment.id)} title="Resolve"></button>
{/if}
</div>
</div>
<p class="comment-body">{comment.body}</p>
</div>
<style>
.comment {
padding: var(--space-3) var(--space-4);
background: var(--color-bg-raised);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
transition: opacity var(--transition-base);
}
.comment.resolved {
opacity: 0.4;
}
.comment-header {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-2);
font-size: var(--text-sm);
}
.comment-author {
color: var(--color-text-primary);
font-weight: 500;
}
.comment-timestamp {
background: rgba(251, 191, 36, 0.15);
border: 1px solid rgba(251, 191, 36, 0.3);
color: var(--color-warning);
border-radius: var(--radius-sm);
padding: 0.05rem 0.4rem;
font-size: var(--text-xs);
cursor: pointer;
font-family: inherit;
transition: background var(--transition-fast);
}
.comment-timestamp:hover {
background: rgba(251, 191, 36, 0.25);
}
.comment-date {
color: var(--color-text-tertiary);
margin-left: auto;
font-size: var(--text-xs);
}
.comment-actions {
display: flex;
gap: var(--space-1);
}
.action-btn {
background: none;
border: 1px solid var(--color-border);
color: var(--color-text-tertiary);
border-radius: var(--radius-sm);
cursor: pointer;
padding: 0 0.3rem;
font-size: var(--text-sm);
transition: all var(--transition-fast);
}
.action-btn:hover {
color: var(--color-text-primary);
border-color: var(--color-border-hover);
}
.action-btn.resolve:hover {
color: var(--color-success);
border-color: var(--color-success);
}
.comment-body {
margin: 0;
font-size: var(--text-sm);
}
</style>

View File

@@ -0,0 +1,193 @@
<script lang="ts">
import Button from '$lib/components/ui/Button.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import CommentItem from './CommentItem.svelte';
import { formatTime } from '$lib/utils/format.js';
type Comment = {
id: string;
body: string;
timestampSeconds: number | null;
parentId: string | null;
resolvedAt: string | null;
createdAt: string;
user: { id: string; name: string; avatarUrl: string | null };
};
let {
comments,
canComment = false,
commentTimestamp = $bindable<number | null>(null),
onSubmit,
onResolve,
onSeek,
}: {
comments: Comment[];
canComment?: boolean;
commentTimestamp: number | null;
onSubmit: (body: string, timestamp: number | null, parentId?: string) => void;
onResolve: (id: string) => void;
onSeek?: (time: number) => void;
} = $props();
let body = $state('');
let replyingTo = $state<string | null>(null);
let submitting = $state(false);
// Group comments: top-level + replies
const topLevel = $derived(comments.filter((c) => !c.parentId));
const replies = $derived((parentId: string) => comments.filter((c) => c.parentId === parentId));
async function handleSubmit() {
if (!body.trim()) return;
submitting = true;
try {
await onSubmit(body, replyingTo ? null : commentTimestamp, replyingTo ?? undefined);
body = '';
commentTimestamp = null;
replyingTo = null;
} finally {
submitting = false;
}
}
function handleReply(id: string) {
replyingTo = id;
commentTimestamp = null;
}
</script>
<div class="comments-section">
<h2>Comments</h2>
{#if canComment}
<form class="comment-form" onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
{#if commentTimestamp !== null}
<span class="timestamp-badge">
<button type="button" class="ts-seek" onclick={() => onSeek?.(commentTimestamp!)}>
{formatTime(commentTimestamp)}
</button>
<button type="button" class="remove-ts" onclick={() => commentTimestamp = null}>×</button>
</span>
{/if}
{#if replyingTo}
<span class="reply-badge">
Replying...
<button type="button" class="remove-ts" onclick={() => replyingTo = null}>×</button>
</span>
{/if}
<div class="comment-input-row">
<input
type="text"
bind:value={body}
placeholder={commentTimestamp !== null
? `Comment at ${formatTime(commentTimestamp)}...`
: replyingTo
? 'Write a reply...'
: 'Add a comment... (click waveform for timestamp)'}
/>
<Button type="submit" size="sm" loading={submitting} disabled={!body.trim()}>Send</Button>
</div>
</form>
{/if}
<div class="comment-list">
{#each topLevel as comment}
<CommentItem {comment} {onSeek} {onResolve} onReply={handleReply} />
{#each replies(comment.id) as reply}
<div class="reply">
<CommentItem comment={reply} {onSeek} {onResolve} />
</div>
{/each}
{/each}
{#if comments.length === 0}
<EmptyState
icon="💬"
title="No comments yet"
description="Click the waveform to leave a timestamped comment."
/>
{/if}
</div>
</div>
<style>
.comments-section {
border-top: 1px solid var(--color-border);
padding-top: var(--space-5);
}
h2 {
margin: 0 0 var(--space-3);
font-size: var(--text-lg);
}
.comment-form {
margin-bottom: var(--space-4);
}
.timestamp-badge, .reply-badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 0.15rem var(--space-2) 0.15rem var(--space-2);
background: rgba(251, 191, 36, 0.15);
border: 1px solid rgba(251, 191, 36, 0.3);
border-radius: var(--radius-sm);
font-size: var(--text-xs);
margin-bottom: var(--space-2);
color: var(--color-warning);
}
.reply-badge {
background: var(--color-accent-subtle);
border-color: var(--color-accent);
color: var(--color-accent);
}
.ts-seek, .remove-ts {
background: none;
border: none;
color: inherit;
cursor: pointer;
padding: 0;
font-family: inherit;
font-size: inherit;
}
.remove-ts {
font-size: 1rem;
line-height: 1;
}
.comment-input-row {
display: flex;
gap: var(--space-2);
}
.comment-input-row input {
flex: 1;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
border: 1px solid var(--color-border-hover);
background: var(--color-bg-base);
color: var(--color-text-primary);
font-size: var(--text-sm);
font-family: inherit;
}
.comment-input-row input:focus {
outline: none;
border-color: var(--color-border-focus);
}
.comment-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.reply {
margin-left: var(--space-8);
}
</style>

View File

@@ -0,0 +1,110 @@
<script lang="ts">
import Badge from '$lib/components/ui/Badge.svelte';
import { timeAgo } from '$lib/utils/format.js';
type Version = {
id: string;
versionNumber: number;
label: string | null;
notes: string | null;
status: string;
originalFileName: string;
duration: number | null;
createdAt: string;
};
let {
versions,
selectedId,
onSelect,
}: {
versions: Version[];
selectedId: string | null;
onSelect: (version: Version) => void | Promise<void>;
} = $props();
const statusVariant = (s: string) =>
({ approved: 'success', rejected: 'error', processing: 'warning', ready: 'accent', uploaded: 'default' } as Record<string, any>)[s] || 'default';
</script>
{#if versions.length > 1}
<div class="version-history">
<h2>Version History</h2>
<div class="version-list">
{#each versions as version}
<button
class="version-item"
class:active={selectedId === version.id}
onclick={() => onSelect(version)}
>
<span class="v-number">V{version.versionNumber}</span>
<span class="v-label">{version.label || version.originalFileName}</span>
<Badge variant={statusVariant(version.status)}>{version.status}</Badge>
<span class="v-date">{timeAgo(version.createdAt)}</span>
</button>
{/each}
</div>
</div>
{/if}
<style>
.version-history {
border-top: 1px solid var(--color-border);
padding-top: var(--space-5);
}
h2 {
margin: 0 0 var(--space-3);
font-size: var(--text-lg);
}
.version-list {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.version-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
background: var(--color-bg-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
color: inherit;
text-align: left;
width: 100%;
font-family: inherit;
font-size: var(--text-sm);
transition: border-color var(--transition-fast);
}
.version-item:hover {
border-color: var(--color-border-hover);
}
.version-item.active {
border-color: var(--color-accent);
background: var(--color-accent-subtle);
}
.v-number {
color: var(--color-text-primary);
font-weight: 600;
min-width: 2rem;
}
.v-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.v-date {
color: var(--color-text-tertiary);
font-size: var(--text-xs);
}
</style>

View File

@@ -0,0 +1,86 @@
<script lang="ts">
import Badge from '$lib/components/ui/Badge.svelte';
import Button from '$lib/components/ui/Button.svelte';
type Version = {
id: string;
versionNumber: number;
label: string | null;
notes: string | null;
status: string;
};
let {
version,
canApprove = false,
onApprove,
onReject,
}: {
version: Version;
canApprove?: boolean;
onApprove: () => void;
onReject: () => void;
} = $props();
const statusVariant = $derived(
({ approved: 'success', rejected: 'error', processing: 'warning', ready: 'accent', uploaded: 'default' } as const)[version.status] || 'default'
);
const showActions = $derived(canApprove && version.status !== 'approved' && version.status !== 'rejected');
</script>
<div class="version-info">
<div class="version-meta">
<span class="version-label">
V{version.versionNumber}
{#if version.label}{version.label}{/if}
</span>
<Badge variant={statusVariant}>{version.status}</Badge>
</div>
{#if showActions}
<div class="version-actions">
<Button variant="ghost" size="sm" onclick={onApprove}>
<span style="color: var(--color-success)">✓ Approve</span>
</Button>
<Button variant="ghost" size="sm" onclick={onReject}>
<span style="color: var(--color-error)">✕ Reject</span>
</Button>
</div>
{/if}
</div>
{#if version.notes}
<p class="version-notes">{version.notes}</p>
{/if}
<style>
.version-info {
display: flex;
justify-content: space-between;
align-items: center;
}
.version-meta {
display: flex;
align-items: center;
gap: var(--space-3);
}
.version-label {
color: var(--color-text-primary);
font-weight: 500;
}
.version-actions {
display: flex;
gap: var(--space-1);
}
.version-notes {
margin: var(--space-2) 0 0;
color: var(--color-text-secondary);
font-size: var(--text-sm);
font-style: italic;
}
</style>

View File

@@ -0,0 +1,108 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { api } from '$lib/api/client.js';
import { toastSuccess } from '$lib/stores/toast.js';
import Button from '$lib/components/ui/Button.svelte';
import Input from '$lib/components/ui/Input.svelte';
let name = $state('');
let description = $state('');
let loading = $state(false);
async function handleSubmit(e: Event) {
e.preventDefault();
loading = true;
try {
const res = await api.post<{ project: { id: string } }>('/projects', {
name,
description: description || undefined,
});
toastSuccess('Project created');
goto(`/projects/${res.project.id}`);
} finally {
loading = false;
}
}
</script>
<div class="page">
<div class="card">
<h1>New Project</h1>
<form onsubmit={handleSubmit}>
<Input label="Name" bind:value={name} placeholder="My Album" />
<div class="textarea-group">
<label class="textarea-label">Description (optional)</label>
<textarea bind:value={description} placeholder="What's this project about?" rows="3"></textarea>
</div>
<div class="actions">
<Button variant="secondary" href="/dashboard">Cancel</Button>
<Button type="submit" {loading}>Create</Button>
</div>
</form>
</div>
</div>
<style>
.page {
display: flex;
justify-content: center;
padding: var(--space-8) var(--space-4);
}
.card {
background: var(--color-bg-overlay);
border-radius: var(--radius-lg);
padding: var(--space-8);
width: 100%;
max-width: 500px;
border: 1px solid var(--color-border);
}
h1 {
margin: 0 0 var(--space-6);
font-size: var(--text-xl);
}
form {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.textarea-group {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.textarea-label {
color: var(--color-text-secondary);
font-size: var(--text-sm);
}
textarea {
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
border: 1px solid var(--color-border-hover);
background: var(--color-bg-base);
color: var(--color-text-primary);
font-size: var(--text-base);
font-family: inherit;
resize: vertical;
}
textarea:focus {
outline: none;
border-color: var(--color-border-focus);
}
.actions {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
margin-top: var(--space-2);
}
</style>

View File

@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

24
apps/web/svelte.config.js Normal file
View File

@@ -0,0 +1,24 @@
import adapter from '@sveltejs/adapter-auto';
import { relative, sep } from 'node:path';
/** @type {import('@sveltejs/kit').Config} */
const config = {
compilerOptions: {
// defaults to rune mode for the project, except for `node_modules`. Can be removed in svelte 6.
runes: ({ filename }) => {
const relativePath = relative(import.meta.dirname, filename);
const pathSegments = relativePath.toLowerCase().split(sep);
const isExternalLibrary = pathSegments.includes('node_modules');
return isExternalLibrary ? undefined : true;
}
},
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter()
}
};
export default config;

20
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}

14
apps/web/vite.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
});