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:
58
apps/web/src/lib/components/ui/Avatar.svelte
Normal file
58
apps/web/src/lib/components/ui/Avatar.svelte
Normal 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>
|
||||
52
apps/web/src/lib/components/ui/Badge.svelte
Normal file
52
apps/web/src/lib/components/ui/Badge.svelte
Normal 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>
|
||||
129
apps/web/src/lib/components/ui/Button.svelte
Normal file
129
apps/web/src/lib/components/ui/Button.svelte
Normal 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>
|
||||
57
apps/web/src/lib/components/ui/EmptyState.svelte
Normal file
57
apps/web/src/lib/components/ui/EmptyState.svelte
Normal 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>
|
||||
80
apps/web/src/lib/components/ui/Input.svelte
Normal file
80
apps/web/src/lib/components/ui/Input.svelte
Normal 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>
|
||||
108
apps/web/src/lib/components/ui/Modal.svelte
Normal file
108
apps/web/src/lib/components/ui/Modal.svelte
Normal 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>
|
||||
36
apps/web/src/lib/components/ui/Skeleton.svelte
Normal file
36
apps/web/src/lib/components/ui/Skeleton.svelte
Normal 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>
|
||||
104
apps/web/src/lib/components/ui/ToastContainer.svelte
Normal file
104
apps/web/src/lib/components/ui/ToastContainer.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user