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

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>