Full MVP: workspace layout, visual refresh, PWA, production deploy
Major changes since initial commit: Schema: version branching (parentVersionId, branchLabel), share links, guest comments, track status enum (sketch/in_progress/final/released), track sections, cover art for projects and tracks. API: 29+ endpoints — auth, projects, tracks, versions, comments, share links (public + management), uploads (cover), activity feed, onboarding demo seed. Email templates in German with brand styling. Web: SvelteKit 5 workspace layout with persistent sidebar, breadcrumb top-bar, collapsible right panel. SoundCloud-style waveform player with round play button, avatar comment markers, keyboard shortcuts (Space/JKL/C). Full German UI. Cover art with gradient fallback. Track status pills. Activity feed dashboard. Welcome modal with demo-seed trigger. Landing page with 7-section scroll layout. Login on /login. Public /listen/:token page for guest feedback. Visual: Inter Variable font, Magenta→Orange gradient accent, warm dark neutrals, Lucide-style inline SVG icon set, spring animations on modals, glass-effect toasts, responsive from 360px to 2560px+. PWA: manifest, service worker, icons, iOS/Android installable. Production: adapter-node, server-side API proxy hook, docker-compose with Postgres + MinIO + auto-migration + health checks. Env example included. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,9 @@
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@music-hub/shared": "workspace:*",
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"wavesurfer.js": "^7.12.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="text-scale" content="scale" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#f43f5e" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Music Hub" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
||||
35
apps/web/src/hooks.server.ts
Normal file
35
apps/web/src/hooks.server.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
|
||||
/**
|
||||
* Proxy /api requests to the API service in production.
|
||||
* In dev, Vite's proxy handles this — this hook only fires
|
||||
* in the built/deployed SvelteKit server.
|
||||
*/
|
||||
const API_ORIGIN = process.env.API_INTERNAL_URL || 'http://api:3000';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
if (event.url.pathname.startsWith('/api/')) {
|
||||
const target = `${API_ORIGIN}${event.url.pathname}${event.url.search}`;
|
||||
|
||||
const headers = new Headers(event.request.headers);
|
||||
headers.delete('host');
|
||||
|
||||
const res = await fetch(target, {
|
||||
method: event.request.method,
|
||||
headers,
|
||||
body: event.request.method !== 'GET' && event.request.method !== 'HEAD'
|
||||
? event.request.body
|
||||
: undefined,
|
||||
// @ts-expect-error — Bun supports duplex
|
||||
duplex: 'half',
|
||||
});
|
||||
|
||||
return new Response(res.body, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers: res.headers,
|
||||
});
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
@@ -57,12 +57,12 @@
|
||||
|
||||
<div class="ab-compare">
|
||||
<div class="ab-header">
|
||||
<h2>A/B Compare</h2>
|
||||
<h2>A/B-Vergleich</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>
|
||||
<Button variant="ghost" size="sm" onclick={onClose}>Schließen</Button>
|
||||
</div>
|
||||
|
||||
<div class="players">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { SUPPORTED_EXTENSIONS, MAX_FILE_SIZE } from '@music-hub/shared';
|
||||
import { api } from '$lib/api/client.js';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
|
||||
let {
|
||||
trackId,
|
||||
@@ -47,13 +48,13 @@
|
||||
error = '';
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
error = 'File too large (max 500 MB)';
|
||||
error = 'Datei zu groß (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(', ')}`;
|
||||
error = `Format nicht unterstützt. Erlaubt: ${SUPPORTED_EXTENSIONS.join(', ')}`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -89,7 +90,7 @@
|
||||
label = '';
|
||||
onUploaded();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Upload failed';
|
||||
error = err instanceof Error ? err.message : 'Upload fehlgeschlagen';
|
||||
} finally {
|
||||
uploading = false;
|
||||
progress = 0;
|
||||
@@ -124,7 +125,7 @@
|
||||
<input
|
||||
type="text"
|
||||
bind:value={label}
|
||||
placeholder="Version label (e.g. 'Mix V2', 'Master Final')"
|
||||
placeholder="Versions-Bezeichnung (z.B. 'Mix V2', 'Final Master')"
|
||||
disabled={uploading}
|
||||
/>
|
||||
</div>
|
||||
@@ -156,8 +157,8 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="dropzone-content">
|
||||
<span class="dropzone-icon">🎵</span>
|
||||
<p>Drop audio file here or click to browse</p>
|
||||
<span class="dropzone-icon"><Icon name="upload" size={28} /></span>
|
||||
<p>Audio-Datei hier ablegen oder klicken zum Auswählen</p>
|
||||
<span class="formats">WAV, MP3, FLAC, AIFF — max 500 MB</span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -212,7 +213,9 @@
|
||||
}
|
||||
|
||||
.dropzone-icon {
|
||||
font-size: 2rem;
|
||||
color: var(--color-text-tertiary);
|
||||
display: inline-flex;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.formats {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import WaveSurfer from 'wavesurfer.js';
|
||||
import { formatTime } from '$lib/utils/format.js';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
|
||||
type CommentMarker = {
|
||||
id: string;
|
||||
@@ -16,6 +17,8 @@
|
||||
muted = false,
|
||||
compact = false,
|
||||
label = '',
|
||||
initialTime = 0,
|
||||
autoPlay = false,
|
||||
onTimeClick,
|
||||
onReady,
|
||||
onSeek,
|
||||
@@ -25,6 +28,8 @@
|
||||
muted?: boolean;
|
||||
compact?: boolean;
|
||||
label?: string;
|
||||
initialTime?: number;
|
||||
autoPlay?: boolean;
|
||||
onTimeClick?: (time: number) => void;
|
||||
onReady?: (duration: number) => void;
|
||||
onSeek?: (time: number) => void;
|
||||
@@ -33,32 +38,46 @@
|
||||
let container: HTMLDivElement;
|
||||
let ws: WaveSurfer | null = null;
|
||||
let isPlaying = $state(false);
|
||||
let isReady = $state(false);
|
||||
let currentTime = $state(0);
|
||||
let duration = $state(0);
|
||||
let volume = $state(0.8);
|
||||
let showVolume = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (ws) ws.setVolume(muted ? 0 : volume);
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
// Resolve CSS variables to real colors so wavesurfer renders correctly.
|
||||
const styles = getComputedStyle(document.documentElement);
|
||||
const waveColor = styles.getPropertyValue('--color-bg-subtle').trim() || '#262430';
|
||||
const progressColor = styles.getPropertyValue('--color-accent').trim() || '#f43f5e';
|
||||
|
||||
ws = WaveSurfer.create({
|
||||
container,
|
||||
waveColor: 'var(--color-bg-subtle, #4a4a5a)',
|
||||
progressColor: 'var(--color-accent, #6366f1)',
|
||||
cursorColor: '#818cf8',
|
||||
waveColor,
|
||||
progressColor,
|
||||
cursorColor: progressColor,
|
||||
cursorWidth: 2,
|
||||
barWidth: 2,
|
||||
barGap: 1,
|
||||
barRadius: 2,
|
||||
height: compact ? 48 : 80,
|
||||
barGap: 2,
|
||||
barRadius: 3,
|
||||
height: compact ? 56 : 96,
|
||||
normalize: true,
|
||||
url,
|
||||
});
|
||||
|
||||
ws.on('ready', () => {
|
||||
duration = ws!.getDuration();
|
||||
isReady = true;
|
||||
ws!.setVolume(muted ? 0 : volume);
|
||||
if (initialTime > 0 && initialTime < duration) {
|
||||
ws!.setTime(initialTime);
|
||||
}
|
||||
if (autoPlay) {
|
||||
ws!.play().catch(() => {});
|
||||
}
|
||||
onReady?.(duration);
|
||||
});
|
||||
|
||||
@@ -107,75 +126,91 @@
|
||||
return ws?.getCurrentTime() || 0;
|
||||
}
|
||||
|
||||
export { seekToTime, play, pause, getCurrentTime };
|
||||
function getIsPlaying(): boolean {
|
||||
return isPlaying;
|
||||
}
|
||||
|
||||
export { seekToTime, play, pause, togglePlay, getCurrentTime, getIsPlaying };
|
||||
|
||||
function initials(name: string) {
|
||||
return name.trim().split(/\s+/).map((p) => p[0]).slice(0, 2).join('').toUpperCase();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="waveform-player" class:compact>
|
||||
<div class="player" class:compact>
|
||||
{#if label}
|
||||
<span class="player-label">{label}</span>
|
||||
{/if}
|
||||
|
||||
<div class="waveform-container">
|
||||
<div bind:this={container} class="waveform"></div>
|
||||
<div class="player-row">
|
||||
<button
|
||||
class="play-btn"
|
||||
class:compact-btn={compact}
|
||||
onclick={togglePlay}
|
||||
disabled={!isReady}
|
||||
aria-label={isPlaying ? 'Pause' : 'Abspielen'}
|
||||
>
|
||||
<Icon name={isPlaying ? 'pause' : 'play'} size={compact ? 18 : 24} />
|
||||
</button>
|
||||
|
||||
{#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 class="waveform-block">
|
||||
<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)}
|
||||
>
|
||||
<span class="marker-dot">{initials(marker.userName)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</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>
|
||||
<div class="meta-row">
|
||||
<span class="time">{formatTime(currentTime)}</span>
|
||||
<div class="meta-controls">
|
||||
<button class="ctl" onclick={() => skip(-10)} aria-label="10 Sekunden zurück">
|
||||
<Icon name="skip-back" size={14} />
|
||||
</button>
|
||||
<button class="ctl" onclick={() => skip(10)} aria-label="10 Sekunden vor">
|
||||
<Icon name="skip-forward" size={14} />
|
||||
</button>
|
||||
<div class="volume" onmouseenter={() => (showVolume = true)} onmouseleave={() => (showVolume = false)} role="group">
|
||||
<button class="ctl" aria-label="Lautstärke">
|
||||
<Icon name={volume === 0 ? 'volume-off' : 'volume'} size={14} />
|
||||
</button>
|
||||
{#if showVolume}
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={volume}
|
||||
oninput={(e) => setVol(Number((e.target as HTMLInputElement).value))}
|
||||
class="volume-slider"
|
||||
aria-label="Lautstärke einstellen"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<span class="time muted">{formatTime(duration)}</span>
|
||||
</div>
|
||||
{/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 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.player-label {
|
||||
@@ -184,15 +219,62 @@
|
||||
color: var(--color-text-tertiary);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: var(--space-2);
|
||||
letter-spacing: 0.08em;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.player-row {
|
||||
display: flex;
|
||||
gap: var(--space-5);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--gradient-accent);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow:
|
||||
0 1px 0 rgba(255, 255, 255, 0.2) inset,
|
||||
0 8px 24px rgba(244, 63, 94, 0.32);
|
||||
transition:
|
||||
transform var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
padding-left: 4px; /* visual centering for play triangle */
|
||||
}
|
||||
.play-btn:hover:not(:disabled) {
|
||||
transform: scale(1.05);
|
||||
box-shadow:
|
||||
0 1px 0 rgba(255, 255, 255, 0.25) inset,
|
||||
0 12px 36px rgba(244, 63, 94, 0.45);
|
||||
}
|
||||
.play-btn:active:not(:disabled) {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
.play-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.play-btn.compact-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.waveform-block {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.waveform-container {
|
||||
position: relative;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.waveform {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -202,80 +284,129 @@
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.marker {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 8px;
|
||||
height: 100%;
|
||||
top: -6px;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(251, 191, 36, 0.2);
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-left: 2px solid var(--color-warning);
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
padding: 0;
|
||||
transition: background var(--transition-fast);
|
||||
z-index: 2;
|
||||
}
|
||||
.marker::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 22px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 1px;
|
||||
height: calc(100% - 22px + 6px);
|
||||
background: var(--color-accent);
|
||||
opacity: 0.35;
|
||||
pointer-events: none;
|
||||
}
|
||||
.marker-dot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--gradient-accent);
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
border: 2px solid var(--color-bg-raised);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
.marker:hover .marker-dot {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
.marker:hover {
|
||||
background: rgba(251, 191, 36, 0.4);
|
||||
}
|
||||
|
||||
.controls {
|
||||
.meta-row {
|
||||
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;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
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;
|
||||
}
|
||||
.time {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
min-width: 38px;
|
||||
}
|
||||
.time.muted {
|
||||
color: var(--color-text-tertiary);
|
||||
font-weight: 400;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.controls-right {
|
||||
.meta-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.ctl {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-tertiary);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.ctl:hover {
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-subtle);
|
||||
}
|
||||
|
||||
.volume {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.volume-slider {
|
||||
width: 80px;
|
||||
margin-left: var(--space-2);
|
||||
accent-color: var(--color-accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.compact .play-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
@media (max-width: 540px) {
|
||||
.player-row {
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.play-btn {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
}
|
||||
.meta-controls {
|
||||
gap: 0;
|
||||
}
|
||||
.ctl {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
.volume-slider {
|
||||
width: 60px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
176
apps/web/src/lib/components/dashboard/ActivityItem.svelte
Normal file
176
apps/web/src/lib/components/dashboard/ActivityItem.svelte
Normal file
@@ -0,0 +1,176 @@
|
||||
<script lang="ts">
|
||||
import Avatar from '$lib/components/ui/Avatar.svelte';
|
||||
import { formatTime, timeAgo } from '$lib/utils/format.js';
|
||||
|
||||
type Event = {
|
||||
type: 'comment' | 'version' | 'approval';
|
||||
id: string;
|
||||
createdAt: string;
|
||||
user: { id: string | null; name: string; avatarUrl: string | null } | null;
|
||||
guestName: string | null;
|
||||
project: { id: string; name: string };
|
||||
track: { id: string; name: string };
|
||||
version?: { id: string; versionNumber: number; label: string | null };
|
||||
body?: string;
|
||||
status?: string;
|
||||
timestampSeconds?: number | null;
|
||||
};
|
||||
|
||||
let { event }: { event: Event } = $props();
|
||||
|
||||
const displayName = $derived(event.user?.name ?? event.guestName ?? 'Gast');
|
||||
const isGuest = $derived(!event.user);
|
||||
const trackHref = $derived(`/projects/${event.project.id}/tracks/${event.track.id}`);
|
||||
const versionLabel = $derived(
|
||||
event.version
|
||||
? `V${event.version.versionNumber}${event.version.label ? ' · ' + event.version.label : ''}`
|
||||
: '',
|
||||
);
|
||||
</script>
|
||||
|
||||
<a href={trackHref} class="item">
|
||||
<Avatar name={displayName} src={event.user?.avatarUrl ?? null} size="sm" />
|
||||
|
||||
<div class="body">
|
||||
<div class="head">
|
||||
<strong>{displayName}</strong>
|
||||
{#if isGuest}<span class="guest">Gast</span>{/if}
|
||||
|
||||
{#if event.type === 'comment'}
|
||||
<span class="action">kommentierte</span>
|
||||
{:else if event.type === 'version'}
|
||||
{#if event.status === 'approved'}
|
||||
<span class="action ok">gab frei</span>
|
||||
{:else if event.status === 'rejected'}
|
||||
<span class="action err">lehnte ab</span>
|
||||
{:else}
|
||||
<span class="action">lud hoch</span>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<span class="target">
|
||||
{#if event.type === 'version' && event.version}
|
||||
<span class="strong">{versionLabel}</span> in
|
||||
{/if}
|
||||
<span class="strong">{event.project.name}</span>
|
||||
<span class="sep">·</span>
|
||||
<span>{event.track.name}</span>
|
||||
{#if event.type === 'comment' && event.timestampSeconds !== null && event.timestampSeconds !== undefined}
|
||||
<span class="ts">{formatTime(event.timestampSeconds)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<span class="when">{timeAgo(event.createdAt)}</span>
|
||||
</div>
|
||||
|
||||
{#if event.body}
|
||||
<p class="quote">"{event.body}"</p>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<style>
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
border: 1px solid transparent;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.item:hover {
|
||||
background: var(--color-bg-raised);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
.body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
column-gap: 6px;
|
||||
row-gap: 2px;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.head strong {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.strong {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
.action {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
.action.ok {
|
||||
color: var(--color-success);
|
||||
}
|
||||
.action.err {
|
||||
color: var(--color-error);
|
||||
}
|
||||
.target {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
.sep {
|
||||
color: var(--color-text-tertiary);
|
||||
margin: 0 2px;
|
||||
}
|
||||
.ts {
|
||||
color: var(--color-warning);
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: rgba(251, 191, 36, 0.12);
|
||||
border: 1px solid rgba(251, 191, 36, 0.3);
|
||||
padding: 0 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.when {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@media (max-width: 540px) {
|
||||
.item {
|
||||
padding: var(--space-3);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.when {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
order: 99;
|
||||
}
|
||||
}
|
||||
.guest {
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-tertiary);
|
||||
padding: 0 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 10px;
|
||||
}
|
||||
.quote {
|
||||
margin: 4px 0 0;
|
||||
padding-left: var(--space-3);
|
||||
border-left: 2px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
</style>
|
||||
83
apps/web/src/lib/components/dashboard/WelcomeModal.svelte
Normal file
83
apps/web/src/lib/components/dashboard/WelcomeModal.svelte
Normal file
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import { api } from '$lib/api/client.js';
|
||||
import { toastSuccess, toastError } from '$lib/stores/toast.js';
|
||||
|
||||
let { open = $bindable(false) }: { open?: boolean } = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
|
||||
async function loadDemo() {
|
||||
loading = true;
|
||||
try {
|
||||
const res = await api.post<{ projectId: string }>('/onboarding/seed-demo');
|
||||
toastSuccess('Demo-Projekt erstellt');
|
||||
open = false;
|
||||
await goto(`/projects/${res.projectId}`);
|
||||
} catch (e) {
|
||||
toastError(e instanceof Error ? e.message : 'Konnte Demo nicht laden');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startBlank() {
|
||||
open = false;
|
||||
goto('/projects/new');
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:open title="Willkommen bei Music Hub">
|
||||
<div class="welcome">
|
||||
<p class="lede">
|
||||
Starte mit einem <strong>Demo-Projekt</strong> um sofort zu sehen wie alles funktioniert —
|
||||
mit Versionen, Comments, Wellenform-Player und Share-Link.
|
||||
</p>
|
||||
<p class="lede">
|
||||
Oder leg gleich dein <strong>eigenes erstes Projekt</strong> an.
|
||||
</p>
|
||||
<ul class="features">
|
||||
<li>Du kannst das Demo jederzeit löschen</li>
|
||||
<li>Beide Wege bringen dich direkt ins Werkzeug</li>
|
||||
</ul>
|
||||
</div>
|
||||
{#snippet actions()}
|
||||
<Button variant="ghost" onclick={startBlank}>Eigenes Projekt starten</Button>
|
||||
<Button onclick={loadDemo} {loading}>Demo laden</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.welcome {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.lede {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.55;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.lede strong {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.features {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.features li {
|
||||
padding-left: 1em;
|
||||
position: relative;
|
||||
}
|
||||
.features li::before {
|
||||
content: '·';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -18,16 +18,19 @@
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
text-transform: capitalize;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.default {
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-secondary);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.success {
|
||||
@@ -47,6 +50,7 @@
|
||||
|
||||
.accent {
|
||||
background: var(--color-accent-subtle);
|
||||
color: var(--color-accent);
|
||||
color: #fb923c;
|
||||
border-color: rgba(244, 63, 94, 0.3);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -51,11 +51,23 @@
|
||||
border-radius: var(--radius-md);
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.01em;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
color var(--transition-fast),
|
||||
transform var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:active:not(:disabled) {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.btn:disabled, .btn.disabled {
|
||||
@@ -65,29 +77,33 @@
|
||||
}
|
||||
|
||||
/* 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); }
|
||||
.sm { padding: 0.35rem 0.7rem; font-size: var(--text-xs); height: 28px; }
|
||||
.md { padding: 0.5rem 1rem; font-size: var(--text-sm); height: 36px; }
|
||||
.lg { padding: 0.7rem 1.4rem; font-size: var(--text-base); height: 44px; }
|
||||
|
||||
/* Variants */
|
||||
.primary {
|
||||
background: var(--color-accent);
|
||||
background: var(--gradient-accent);
|
||||
color: #fff;
|
||||
border-color: var(--color-accent);
|
||||
border-color: transparent;
|
||||
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.15) inset, 0 4px 14px rgba(244, 63, 94, 0.25);
|
||||
}
|
||||
.primary:hover:not(:disabled) {
|
||||
background: var(--color-accent-hover);
|
||||
border-color: var(--color-accent-hover);
|
||||
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.2) inset, 0 8px 24px rgba(244, 63, 94, 0.35);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.primary:active:not(:disabled) {
|
||||
transform: scale(0.97) translateY(0);
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-secondary);
|
||||
border-color: var(--color-border-hover);
|
||||
background: var(--color-bg-raised);
|
||||
color: var(--color-text-primary);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
.secondary:hover:not(:disabled) {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-overlay);
|
||||
border-color: var(--color-border-hover);
|
||||
}
|
||||
|
||||
.ghost {
|
||||
@@ -95,28 +111,29 @@
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.ghost:hover:not(:disabled) {
|
||||
background: var(--color-bg-subtle);
|
||||
background: var(--color-bg-raised);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.danger {
|
||||
background: transparent;
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
.danger:hover:not(:disabled) {
|
||||
background: var(--color-error);
|
||||
color: #fff;
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #fff;
|
||||
border: 2px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
position: absolute;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
|
||||
86
apps/web/src/lib/components/ui/CoverImage.svelte
Normal file
86
apps/web/src/lib/components/ui/CoverImage.svelte
Normal file
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
src = null,
|
||||
name = '',
|
||||
size = 'md',
|
||||
rounded = 'md',
|
||||
}: {
|
||||
src?: string | null;
|
||||
name?: string;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'fill';
|
||||
rounded?: 'sm' | 'md' | 'lg';
|
||||
} = $props();
|
||||
|
||||
const initials = $derived(
|
||||
name
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((p) => p[0])
|
||||
.slice(0, 2)
|
||||
.join('')
|
||||
.toUpperCase() || '?'
|
||||
);
|
||||
|
||||
// Deterministic gradient angle based on name → variation per project
|
||||
const angle = $derived(
|
||||
name
|
||||
? (Array.from(name).reduce((a, c) => a + c.charCodeAt(0), 0) * 17) % 360
|
||||
: 135
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="cover {size} round-{rounded}">
|
||||
{#if src}
|
||||
<img {src} alt="" loading="lazy" />
|
||||
{:else}
|
||||
<div
|
||||
class="fallback"
|
||||
style="background: linear-gradient({angle}deg, #f43f5e 0%, #fb923c 100%)"
|
||||
>
|
||||
<span>{initials}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.cover {
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: var(--color-bg-subtle);
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
.cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.fallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.xs { width: 20px; height: 20px; }
|
||||
.sm { width: 32px; height: 32px; }
|
||||
.md { width: 48px; height: 48px; }
|
||||
.lg { width: 80px; height: 80px; }
|
||||
.xl { width: 120px; height: 120px; }
|
||||
.fill { width: 100%; height: 100%; aspect-ratio: 1 / 1; }
|
||||
|
||||
.xs .fallback span { font-size: 8px; }
|
||||
.sm .fallback span { font-size: 11px; }
|
||||
.md .fallback span { font-size: 16px; }
|
||||
.lg .fallback span { font-size: 24px; }
|
||||
.xl .fallback span { font-size: 36px; }
|
||||
.fill .fallback span { font-size: clamp(20px, 8cqw, 56px); }
|
||||
|
||||
.round-sm { border-radius: var(--radius-sm); }
|
||||
.round-md { border-radius: var(--radius-md); }
|
||||
.round-lg { border-radius: var(--radius-lg); }
|
||||
</style>
|
||||
125
apps/web/src/lib/components/ui/CoverUpload.svelte
Normal file
125
apps/web/src/lib/components/ui/CoverUpload.svelte
Normal file
@@ -0,0 +1,125 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api/client.js';
|
||||
import { toastError } from '$lib/stores/toast.js';
|
||||
import CoverImage from './CoverImage.svelte';
|
||||
import Icon from './Icon.svelte';
|
||||
|
||||
let {
|
||||
currentUrl = null,
|
||||
name = '',
|
||||
onUploaded,
|
||||
}: {
|
||||
currentUrl?: string | null;
|
||||
name?: string;
|
||||
onUploaded: (key: string) => void | Promise<void>;
|
||||
} = $props();
|
||||
|
||||
let uploading = $state(false);
|
||||
let dragOver = $state(false);
|
||||
const ALLOWED = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
const MAX = 2 * 1024 * 1024;
|
||||
|
||||
async function pickFile(file: File) {
|
||||
if (!ALLOWED.includes(file.type)) {
|
||||
toastError('Nur JPG, PNG oder WebP');
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX) {
|
||||
toastError('Bild zu groß (max 2 MB)');
|
||||
return;
|
||||
}
|
||||
uploading = true;
|
||||
try {
|
||||
const { uploadUrl, key } = await api.post<{ uploadUrl: string; key: string }>(
|
||||
'/uploads/cover',
|
||||
{ fileName: file.name, mimeType: file.type, fileSize: file.size },
|
||||
);
|
||||
const res = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': file.type },
|
||||
body: file,
|
||||
});
|
||||
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
|
||||
await onUploaded(key);
|
||||
} catch (e) {
|
||||
toastError(e instanceof Error ? e.message : 'Upload fehlgeschlagen');
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleChange(e: Event) {
|
||||
const f = (e.target as HTMLInputElement).files?.[0];
|
||||
if (f) pickFile(f);
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragOver = false;
|
||||
const f = e.dataTransfer?.files[0];
|
||||
if (f) pickFile(f);
|
||||
}
|
||||
</script>
|
||||
|
||||
<label
|
||||
class="cover-upload"
|
||||
class:drag={dragOver}
|
||||
ondragover={(e) => { e.preventDefault(); dragOver = true; }}
|
||||
ondragleave={() => (dragOver = false)}
|
||||
ondrop={handleDrop}
|
||||
>
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp" onchange={handleChange} hidden />
|
||||
<CoverImage src={currentUrl} {name} size="xl" rounded="lg" />
|
||||
<div class="overlay">
|
||||
{#if uploading}
|
||||
<span class="spinner"></span>
|
||||
{:else}
|
||||
<Icon name="upload" size={20} />
|
||||
<span class="hint">Bild ändern</span>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<style>
|
||||
.cover-upload {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
}
|
||||
.overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
.cover-upload:hover .overlay,
|
||||
.cover-upload.drag .overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
.hint {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
}
|
||||
.spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
icon = '📁',
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
@@ -15,7 +15,7 @@
|
||||
</script>
|
||||
|
||||
<div class="empty-state">
|
||||
<span class="icon">{icon}</span>
|
||||
{#if icon}<span class="icon">{icon}</span>{/if}
|
||||
<h3>{title}</h3>
|
||||
{#if description}
|
||||
<p>{description}</p>
|
||||
|
||||
146
apps/web/src/lib/components/ui/Icon.svelte
Normal file
146
apps/web/src/lib/components/ui/Icon.svelte
Normal file
@@ -0,0 +1,146 @@
|
||||
<script lang="ts">
|
||||
// Inline icon set — Lucide-inspired strokes, kept tiny.
|
||||
// Add icons here as needed; do not pull a lib.
|
||||
|
||||
type IconName =
|
||||
| 'play' | 'pause' | 'skip-back' | 'skip-forward' | 'volume' | 'volume-off'
|
||||
| 'download' | 'upload' | 'share' | 'plus' | 'check' | 'x' | 'close'
|
||||
| 'chevron-down' | 'chevron-right' | 'more' | 'home' | 'panel' | 'panel-off'
|
||||
| 'git-branch' | 'arrow-up' | 'compare' | 'comment' | 'lock' | 'link'
|
||||
| 'settings' | 'logout' | 'list' | 'graph' | 'menu' | 'search';
|
||||
|
||||
let {
|
||||
name,
|
||||
size = 16,
|
||||
stroke = 2,
|
||||
}: { name: IconName; size?: number; stroke?: number } = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width={stroke}
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#if name === 'play'}
|
||||
<polygon points="6 3 21 12 6 21 6 3" fill="currentColor" />
|
||||
{:else if name === 'pause'}
|
||||
<rect x="6" y="4" width="4" height="16" rx="1" fill="currentColor" />
|
||||
<rect x="14" y="4" width="4" height="16" rx="1" fill="currentColor" />
|
||||
{:else if name === 'skip-back'}
|
||||
<polygon points="19 20 9 12 19 4 19 20" fill="currentColor" />
|
||||
<line x1="5" y1="19" x2="5" y2="5" />
|
||||
{:else if name === 'skip-forward'}
|
||||
<polygon points="5 4 15 12 5 20 5 4" fill="currentColor" />
|
||||
<line x1="19" y1="5" x2="19" y2="19" />
|
||||
{:else if name === 'volume'}
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" fill="currentColor" />
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
|
||||
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
|
||||
{:else if name === 'volume-off'}
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" fill="currentColor" />
|
||||
<line x1="23" y1="9" x2="17" y2="15" />
|
||||
<line x1="17" y1="9" x2="23" y2="15" />
|
||||
{:else if name === 'download'}
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
{:else if name === 'upload'}
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
{:else if name === 'share'}
|
||||
<circle cx="18" cy="5" r="3" />
|
||||
<circle cx="6" cy="12" r="3" />
|
||||
<circle cx="18" cy="19" r="3" />
|
||||
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
|
||||
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
|
||||
{:else if name === 'plus'}
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
{:else if name === 'check'}
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
{:else if name === 'x' || name === 'close'}
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
{:else if name === 'chevron-down'}
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
{:else if name === 'chevron-right'}
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
{:else if name === 'more'}
|
||||
<circle cx="12" cy="12" r="1" fill="currentColor" />
|
||||
<circle cx="19" cy="12" r="1" fill="currentColor" />
|
||||
<circle cx="5" cy="12" r="1" fill="currentColor" />
|
||||
{:else if name === 'home'}
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
<polyline points="9 22 9 12 15 12 15 22" />
|
||||
{:else if name === 'panel'}
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<line x1="15" y1="3" x2="15" y2="21" />
|
||||
{:else if name === 'panel-off'}
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
{:else if name === 'git-branch'}
|
||||
<line x1="6" y1="3" x2="6" y2="15" />
|
||||
<circle cx="18" cy="6" r="3" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<path d="M18 9a9 9 0 0 1-9 9" />
|
||||
{:else if name === 'arrow-up'}
|
||||
<line x1="12" y1="19" x2="12" y2="5" />
|
||||
<polyline points="5 12 12 5 19 12" />
|
||||
{:else if name === 'compare'}
|
||||
<polyline points="16 3 21 3 21 8" />
|
||||
<line x1="4" y1="20" x2="21" y2="3" />
|
||||
<polyline points="21 16 21 21 16 21" />
|
||||
<line x1="15" y1="15" x2="21" y2="21" />
|
||||
<line x1="4" y1="4" x2="9" y2="9" />
|
||||
{:else if name === 'comment'}
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
{:else if name === 'lock'}
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
{:else if name === 'link'}
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
{:else if name === 'settings'}
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
{:else if name === 'logout'}
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<polyline points="16 17 21 12 16 7" />
|
||||
<line x1="21" y1="12" x2="9" y2="12" />
|
||||
{:else if name === 'list'}
|
||||
<line x1="8" y1="6" x2="21" y2="6" />
|
||||
<line x1="8" y1="12" x2="21" y2="12" />
|
||||
<line x1="8" y1="18" x2="21" y2="18" />
|
||||
<line x1="3" y1="6" x2="3.01" y2="6" />
|
||||
<line x1="3" y1="12" x2="3.01" y2="12" />
|
||||
<line x1="3" y1="18" x2="3.01" y2="18" />
|
||||
{:else if name === 'graph'}
|
||||
<circle cx="6" cy="6" r="2" />
|
||||
<circle cx="6" cy="18" r="2" />
|
||||
<circle cx="18" cy="12" r="2" />
|
||||
<line x1="8" y1="7" x2="16" y2="11" />
|
||||
<line x1="8" y1="17" x2="16" y2="13" />
|
||||
{:else if name === 'menu'}
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
{:else if name === 'search'}
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
{/if}
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
svg {
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
@@ -44,24 +44,41 @@
|
||||
|
||||
.input-label {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
input {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
padding: 0.7rem 0.9rem;
|
||||
height: 42px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border-hover);
|
||||
background: var(--color-bg-base);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-raised);
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--text-base);
|
||||
font-size: var(--text-sm);
|
||||
font-family: inherit;
|
||||
transition: border-color var(--transition-fast);
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
background var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
input:hover:not(:disabled) {
|
||||
border-color: var(--color-border-hover);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-border-focus);
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-bg-overlay);
|
||||
box-shadow: 0 0 0 4px rgba(244, 63, 94, 0.12);
|
||||
}
|
||||
|
||||
input:disabled {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import Icon from './Icon.svelte';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
@@ -29,7 +30,9 @@
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h2>{title}</h2>
|
||||
<button class="close-btn" onclick={() => open = false}>×</button>
|
||||
<button class="close-btn" onclick={() => open = false} aria-label="Schließen">
|
||||
<Icon name="x" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{@render children()}
|
||||
@@ -47,13 +50,15 @@
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
background: rgba(8, 6, 14, 0.65);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: var(--z-modal);
|
||||
padding: var(--space-4);
|
||||
backdrop-filter: blur(4px);
|
||||
backdrop-filter: blur(12px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(140%);
|
||||
animation: fade-in 200ms var(--ease-out);
|
||||
}
|
||||
|
||||
.modal {
|
||||
@@ -65,6 +70,16 @@
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-lg);
|
||||
animation: pop-in 280ms var(--ease-spring);
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes pop-in {
|
||||
from { opacity: 0; transform: translateY(8px) scale(0.97); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
|
||||
93
apps/web/src/lib/components/ui/ShortcutsModal.svelte
Normal file
93
apps/web/src/lib/components/ui/ShortcutsModal.svelte
Normal file
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
import Modal from './Modal.svelte';
|
||||
import Button from './Button.svelte';
|
||||
|
||||
let { open = $bindable(false) }: { open?: boolean } = $props();
|
||||
|
||||
const groups: { title: string; rows: [string, string][] }[] = [
|
||||
{
|
||||
title: 'Allgemein',
|
||||
rows: [
|
||||
['/', 'Suche fokussieren'],
|
||||
['?', 'Diese Übersicht öffnen'],
|
||||
['Esc', 'Schließen / abbrechen'],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Player',
|
||||
rows: [
|
||||
['Space', 'Play / Pause'],
|
||||
['K', 'Play / Pause'],
|
||||
['J', '−10 Sekunden'],
|
||||
['L', '+10 Sekunden'],
|
||||
['C', 'Kommentar an aktueller Stelle'],
|
||||
['← →', 'Vorherige / nächste Version'],
|
||||
],
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<Modal bind:open title="Tastatur-Shortcuts">
|
||||
<div class="shortcuts">
|
||||
{#each groups as group}
|
||||
<div class="group">
|
||||
<h3>{group.title}</h3>
|
||||
<table>
|
||||
<tbody>
|
||||
{#each group.rows as [keys, label]}
|
||||
<tr>
|
||||
<td><kbd>{keys}</kbd></td>
|
||||
<td>{label}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#snippet actions()}
|
||||
<Button onclick={() => (open = false)}>Schließen</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.shortcuts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.group h3 {
|
||||
margin: 0 0 var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
td {
|
||||
padding: var(--space-2) 0;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
td:first-child {
|
||||
width: 100px;
|
||||
}
|
||||
td:last-child {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
kbd {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background: var(--color-bg-raised);
|
||||
border: 1px solid var(--color-border-hover);
|
||||
border-bottom-width: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -21,12 +21,12 @@
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-bg-subtle) 25%,
|
||||
var(--color-border) 50%,
|
||||
var(--color-bg-subtle) 75%
|
||||
var(--color-bg-raised) 0%,
|
||||
var(--color-bg-subtle) 50%,
|
||||
var(--color-bg-raised) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
animation: shimmer 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { toasts, removeToast, type ToastType } from '$lib/stores/toast.js';
|
||||
import Icon from './Icon.svelte';
|
||||
|
||||
const icons: Record<ToastType, string> = {
|
||||
success: '✓',
|
||||
error: '✕',
|
||||
info: 'i',
|
||||
warning: '!',
|
||||
const icons: Record<ToastType, 'check' | 'x' | 'comment' | 'comment'> = {
|
||||
success: 'check',
|
||||
error: 'x',
|
||||
info: 'comment',
|
||||
warning: 'comment',
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -13,9 +14,11 @@
|
||||
<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-icon"><Icon name={icons[t.type]} size={12} stroke={3} /></span>
|
||||
<span class="toast-message">{t.message}</span>
|
||||
<button class="toast-close" onclick={() => removeToast(t.id)}>×</button>
|
||||
<button class="toast-close" onclick={() => removeToast(t.id)} aria-label="Schließen">
|
||||
<Icon name="x" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -38,11 +41,13 @@
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--color-bg-overlay);
|
||||
background: rgba(26, 24, 34, 0.85);
|
||||
backdrop-filter: blur(20px) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(150%);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
animation: slide-in 0.2s ease;
|
||||
animation: slide-in 280ms var(--ease-spring);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
@@ -85,11 +90,11 @@
|
||||
@keyframes slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
transform: translateY(12px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
68
apps/web/src/lib/components/ui/TrackStatusPill.svelte
Normal file
68
apps/web/src/lib/components/ui/TrackStatusPill.svelte
Normal file
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import { TRACK_STATUS_LABELS, type TrackStatus } from '@music-hub/shared';
|
||||
|
||||
let {
|
||||
status,
|
||||
size = 'sm',
|
||||
}: { status: TrackStatus; size?: 'sm' | 'md' } = $props();
|
||||
|
||||
const COLORS: Record<TrackStatus, { bg: string; fg: string; border: string }> = {
|
||||
sketch: {
|
||||
bg: 'rgba(155, 150, 168, 0.12)',
|
||||
fg: '#9b96a8',
|
||||
border: 'rgba(155, 150, 168, 0.3)',
|
||||
},
|
||||
in_progress: {
|
||||
bg: 'rgba(251, 146, 60, 0.12)',
|
||||
fg: '#fb923c',
|
||||
border: 'rgba(251, 146, 60, 0.35)',
|
||||
},
|
||||
final: {
|
||||
bg: 'rgba(34, 197, 94, 0.12)',
|
||||
fg: '#22c55e',
|
||||
border: 'rgba(34, 197, 94, 0.35)',
|
||||
},
|
||||
released: {
|
||||
bg: 'rgba(244, 63, 94, 0.12)',
|
||||
fg: '#f43f5e',
|
||||
border: 'rgba(244, 63, 94, 0.4)',
|
||||
},
|
||||
};
|
||||
|
||||
const c = $derived(COLORS[status]);
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="pill {size}"
|
||||
style="background: {c.bg}; color: {c.fg}; border-color: {c.border}"
|
||||
>
|
||||
<span class="dot" style="background: {c.fg}"></span>
|
||||
{TRACK_STATUS_LABELS[status]}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-full);
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sm {
|
||||
padding: 3px 8px 3px 7px;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.md {
|
||||
padding: 5px 11px 5px 10px;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
485
apps/web/src/lib/components/workspace/Sidebar.svelte
Normal file
485
apps/web/src/lib/components/workspace/Sidebar.svelte
Normal file
@@ -0,0 +1,485 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { user, logout } from '$lib/stores/auth.js';
|
||||
import { api } from '$lib/api/client.js';
|
||||
import Avatar from '$lib/components/ui/Avatar.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import CoverImage from '$lib/components/ui/CoverImage.svelte';
|
||||
|
||||
type Project = { id: string; name: string; coverUrl: string | null };
|
||||
type ProjectMembership = { project: Project; role: string; trackCount: number };
|
||||
type TrackStatus = 'sketch' | 'in_progress' | 'final' | 'released';
|
||||
type Track = { id: string; name: string; coverUrl: string | null; status: TrackStatus };
|
||||
|
||||
const STATUS_COLORS: Record<TrackStatus, string> = {
|
||||
sketch: '#9b96a8',
|
||||
in_progress: '#fb923c',
|
||||
final: '#22c55e',
|
||||
released: '#f43f5e',
|
||||
};
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
onClose,
|
||||
}: {
|
||||
open?: boolean;
|
||||
onClose?: () => void;
|
||||
} = $props();
|
||||
|
||||
let projects = $state<ProjectMembership[]>([]);
|
||||
let tracksByProject = $state<Record<string, Track[]>>({});
|
||||
let menuOpen = $state(false);
|
||||
let query = $state('');
|
||||
let searchInput = $state<HTMLInputElement | undefined>();
|
||||
|
||||
const activeProjectId = $derived(($page.params as Record<string, string>).projectId ?? null);
|
||||
const activeTrackId = $derived(($page.params as Record<string, string>).trackId ?? null);
|
||||
|
||||
// Filtered projects: a project matches if its name matches OR any of its loaded tracks match
|
||||
const filtered = $derived.by(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return projects;
|
||||
return projects.filter(({ project }) => {
|
||||
if (project.name.toLowerCase().includes(q)) return true;
|
||||
const tracks = tracksByProject[project.id];
|
||||
return tracks?.some((t) => t.name.toLowerCase().includes(q));
|
||||
});
|
||||
});
|
||||
|
||||
function trackMatches(track: Track) {
|
||||
const q = query.trim().toLowerCase();
|
||||
return !q || track.name.toLowerCase().includes(q);
|
||||
}
|
||||
|
||||
// Whether a given project should auto-expand for search
|
||||
function shouldExpand(projectId: string) {
|
||||
if (activeProjectId === projectId) return true;
|
||||
if (!query.trim()) return false;
|
||||
return tracksByProject[projectId]?.some(trackMatches);
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const res = await api.get<{ projects: ProjectMembership[] }>('/projects', true);
|
||||
projects = res.projects;
|
||||
} catch {
|
||||
// not logged in or error — sidebar stays empty, layout still renders
|
||||
}
|
||||
});
|
||||
|
||||
// Lazy-load tracks when a project becomes active
|
||||
$effect(() => {
|
||||
const id = activeProjectId;
|
||||
if (id && !tracksByProject[id]) {
|
||||
api.get<{ tracks: Track[] }>(`/tracks/project/${id}`, true).then((r) => {
|
||||
tracksByProject = { ...tracksByProject, [id]: r.tracks };
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
async function handleLogout() {
|
||||
await logout();
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
// Expose focus method for global / shortcut
|
||||
export function focusSearch() {
|
||||
searchInput?.focus();
|
||||
searchInput?.select();
|
||||
}
|
||||
|
||||
function handleNavClick() {
|
||||
if (open) onClose?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="sidebar" class:open>
|
||||
<div class="sb-head">
|
||||
<a href="/dashboard" class="logo" onclick={handleNavClick}>Music Hub</a>
|
||||
{#if open}
|
||||
<button class="close" onclick={onClose} aria-label="Schließen">
|
||||
<Icon name="x" size={18} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="search">
|
||||
<input
|
||||
bind:this={searchInput}
|
||||
type="text"
|
||||
bind:value={query}
|
||||
placeholder="Suchen… (/)"
|
||||
aria-label="Suchen"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav class="nav">
|
||||
<a
|
||||
href="/dashboard"
|
||||
class="nav-item"
|
||||
class:active={$page.url.pathname === '/dashboard'}
|
||||
onclick={handleNavClick}
|
||||
>
|
||||
<Icon name="home" size={16} /> Übersicht
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-head">
|
||||
<span>Projekte</span>
|
||||
<a href="/projects/new" class="add" title="Neues Projekt" aria-label="Neues Projekt" onclick={handleNavClick}>
|
||||
<Icon name="plus" size={14} />
|
||||
</a>
|
||||
</div>
|
||||
<ul class="projects">
|
||||
{#each filtered as { project, trackCount } (project.id)}
|
||||
<li>
|
||||
<a
|
||||
href="/projects/{project.id}"
|
||||
class="project"
|
||||
class:active={activeProjectId === project.id}
|
||||
onclick={handleNavClick}
|
||||
>
|
||||
<CoverImage src={project.coverUrl} name={project.name} size="xs" rounded="sm" />
|
||||
<span class="name">{project.name}</span>
|
||||
<span class="count">{trackCount}</span>
|
||||
</a>
|
||||
{#if shouldExpand(project.id) && tracksByProject[project.id]}
|
||||
<ul class="tracks">
|
||||
{#each tracksByProject[project.id].filter(trackMatches) as track (track.id)}
|
||||
<li>
|
||||
<a
|
||||
href="/projects/{project.id}/tracks/{track.id}"
|
||||
class="track"
|
||||
class:active={activeTrackId === track.id}
|
||||
onclick={handleNavClick}
|
||||
>
|
||||
<span class="status-dot" style="background: {STATUS_COLORS[track.status]}"></span>
|
||||
{track.name}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
{#if filtered.length === 0 && query}
|
||||
<li class="empty">Nichts gefunden für "{query}"</li>
|
||||
{:else if projects.length === 0}
|
||||
<li class="empty">Noch keine Projekte</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="user-block">
|
||||
<button class="user" onclick={() => (menuOpen = !menuOpen)}>
|
||||
{#if $user}
|
||||
<Avatar name={$user.name} src={$user.avatarUrl ?? null} size="sm" />
|
||||
<span class="user-name">{$user.name}</span>
|
||||
<span class="chev"><Icon name="more" size={14} /></span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if menuOpen}
|
||||
<div class="menu" role="menu">
|
||||
<a href="/account" onclick={() => { menuOpen = false; handleNavClick(); }}>Konto</a>
|
||||
<button onclick={handleLogout}>Abmelden</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at 0% 0%, rgba(244, 63, 94, 0.08), transparent 70%),
|
||||
var(--color-bg-raised);
|
||||
border-right: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--space-5) 0 var(--space-4);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.sb-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--space-5);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.logo {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
background: var(--gradient-accent);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
text-decoration: none;
|
||||
}
|
||||
.close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-tertiary);
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.search {
|
||||
padding: 0 var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.search input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-base);
|
||||
color: var(--color-text-primary);
|
||||
font-family: inherit;
|
||||
font-size: var(--text-sm);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.search input::placeholder {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
.search input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 4px rgba(244, 63, 94, 0.12);
|
||||
}
|
||||
|
||||
.nav {
|
||||
padding: 0 var(--space-3);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: var(--text-sm);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.nav-item:hover {
|
||||
background: var(--color-bg-overlay);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.nav-item.active {
|
||||
background: var(--color-bg-overlay);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.section {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 var(--space-3);
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 var(--space-3) var(--space-2);
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.add {
|
||||
color: var(--color-text-tertiary);
|
||||
text-decoration: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.add:hover {
|
||||
background: var(--color-bg-overlay);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.projects {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.project {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: var(--text-sm);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.project:hover {
|
||||
background: var(--color-bg-overlay);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.project.active {
|
||||
background: var(--color-bg-overlay);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.count {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.tracks {
|
||||
list-style: none;
|
||||
padding: var(--space-1) 0 var(--space-2) var(--space-6);
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
border-left: 1px solid var(--color-border);
|
||||
margin-left: var(--space-4);
|
||||
}
|
||||
.track {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0.3rem var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-tertiary);
|
||||
text-decoration: none;
|
||||
font-size: var(--text-xs);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.track:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.track.active {
|
||||
color: var(--color-accent);
|
||||
background: var(--color-accent-subtle);
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
.user-block {
|
||||
padding: var(--space-3) var(--space-3) 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin-top: var(--space-3);
|
||||
position: relative;
|
||||
}
|
||||
.user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: var(--space-2);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
.user:hover {
|
||||
background: var(--color-bg-overlay);
|
||||
}
|
||||
.user-name {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
font-size: var(--text-sm);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chev {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
.menu {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 4px);
|
||||
left: var(--space-3);
|
||||
right: var(--space-3);
|
||||
background: var(--color-bg-overlay);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
.menu button,
|
||||
.menu a {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-primary);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
font-family: inherit;
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.menu button:hover,
|
||||
.menu a:hover {
|
||||
background: var(--color-bg-subtle);
|
||||
}
|
||||
|
||||
/* MOBILE — Drawer overlay */
|
||||
@media (max-width: 880px) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
height: 100vh;
|
||||
width: min(280px, 85vw);
|
||||
z-index: 100;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 240ms var(--ease-out);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
.close {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
133
apps/web/src/lib/components/workspace/TopBar.svelte
Normal file
133
apps/web/src/lib/components/workspace/TopBar.svelte
Normal file
@@ -0,0 +1,133 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import { getContext } from 'svelte';
|
||||
|
||||
let {
|
||||
crumbs = [],
|
||||
actions,
|
||||
}: {
|
||||
crumbs?: { label: string; href?: string }[];
|
||||
actions?: Snippet;
|
||||
} = $props();
|
||||
|
||||
const openMobileMenu = getContext<() => void>('openMobileMenu');
|
||||
</script>
|
||||
|
||||
<header class="topbar">
|
||||
<button class="hamburger" onclick={() => openMobileMenu?.()} aria-label="Menü öffnen">
|
||||
<Icon name="menu" size={20} />
|
||||
</button>
|
||||
<nav class="crumbs" aria-label="Breadcrumb">
|
||||
{#each crumbs as crumb, i}
|
||||
{#if crumb.href && i < crumbs.length - 1}
|
||||
<a href={crumb.href}>{crumb.label}</a>
|
||||
{:else}
|
||||
<span class="current">{crumb.label}</span>
|
||||
{/if}
|
||||
{#if i < crumbs.length - 1}
|
||||
<span class="sep">/</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
{#if actions}
|
||||
<div class="actions">
|
||||
{@render actions()}
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4) var(--space-6);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: rgba(10, 9, 16, 0.85);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.crumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.crumbs a {
|
||||
color: var(--color-text-tertiary);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
.crumbs a:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.crumbs .current {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sep {
|
||||
color: var(--color-text-tertiary);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hamburger {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.hamburger:hover {
|
||||
background: var(--color-bg-raised);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.topbar {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
}
|
||||
.hamburger {
|
||||
display: inline-flex;
|
||||
}
|
||||
/* Hide all but the last crumb on tight viewports */
|
||||
.crumbs a,
|
||||
.crumbs .sep {
|
||||
display: none;
|
||||
}
|
||||
.crumbs a:last-of-type,
|
||||
.crumbs .current {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
@media (max-width: 540px) {
|
||||
.topbar {
|
||||
padding: var(--space-3);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
39
apps/web/src/lib/stores/player.ts
Normal file
39
apps/web/src/lib/stores/player.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
type PlayerState = {
|
||||
trackId: string | null;
|
||||
currentTime: number;
|
||||
isPlaying: boolean;
|
||||
};
|
||||
|
||||
export const playerState = writable<PlayerState>({
|
||||
trackId: null,
|
||||
currentTime: 0,
|
||||
isPlaying: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Snapshot the current playhead before changing version (within the same track).
|
||||
*/
|
||||
export function snapshotForTrack(trackId: string, currentTime: number, isPlaying: boolean) {
|
||||
playerState.set({ trackId, currentTime, isPlaying });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a continuation snapshot if we're staying within the same track.
|
||||
* Returns the previously snapshotted time + isPlaying, or null for a fresh start.
|
||||
*/
|
||||
export function continuationFor(trackId: string): { initialTime: number; autoPlay: boolean } | null {
|
||||
let snap: PlayerState | null = null;
|
||||
playerState.subscribe((s) => (snap = s))();
|
||||
if (!snap || (snap as PlayerState).trackId !== trackId) return null;
|
||||
const s = snap as PlayerState;
|
||||
return { initialTime: s.currentTime, autoPlay: s.isPlaying };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset state — call when navigating away from a track entirely.
|
||||
*/
|
||||
export function resetPlayer() {
|
||||
playerState.set({ trackId: null, currentTime: 0, isPlaying: false });
|
||||
}
|
||||
47
apps/web/src/lib/utils/shortcuts.ts
Normal file
47
apps/web/src/lib/utils/shortcuts.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Lightweight global keyboard shortcut helper.
|
||||
*
|
||||
* Usage in a Svelte component:
|
||||
* import { onKey } from '$lib/utils/shortcuts.js';
|
||||
* onKey({
|
||||
* ' ': () => playerRef?.play(),
|
||||
* j: () => skip(-10),
|
||||
* });
|
||||
*
|
||||
* Triggers are skipped when the user is typing in an <input>, <textarea>
|
||||
* or contenteditable element — except for keys explicitly listed in `always`.
|
||||
*/
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
type Handler = (e: KeyboardEvent) => void;
|
||||
type Map = Record<string, Handler>;
|
||||
|
||||
function isTyping(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
const tag = target.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
||||
if (target.isContentEditable) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function onKey(map: Map, options: { always?: string[] } = {}) {
|
||||
const always = new Set(options.always ?? []);
|
||||
function handler(e: KeyboardEvent) {
|
||||
// Modifier keys: ignore for now (we don't have any cmd-shortcuts)
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
|
||||
const key = e.key;
|
||||
const fn = map[key];
|
||||
if (!fn) return;
|
||||
|
||||
if (isTyping(e.target) && !always.has(key)) return;
|
||||
|
||||
e.preventDefault();
|
||||
fn(e);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
});
|
||||
}
|
||||
98
apps/web/src/routes/(app)/+layout.svelte
Normal file
98
apps/web/src/routes/(app)/+layout.svelte
Normal file
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
import { onMount, setContext } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { user, authLoading, checkAuth } from '$lib/stores/auth.js';
|
||||
import Sidebar from '$lib/components/workspace/Sidebar.svelte';
|
||||
import ShortcutsModal from '$lib/components/ui/ShortcutsModal.svelte';
|
||||
import { onKey } from '$lib/utils/shortcuts.js';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let mobileMenuOpen = $state(false);
|
||||
let shortcutsOpen = $state(false);
|
||||
let sidebarRef = $state<Sidebar | undefined>();
|
||||
|
||||
setContext('openMobileMenu', () => (mobileMenuOpen = true));
|
||||
|
||||
onMount(async () => {
|
||||
if ($user === null && !$authLoading) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
if ($authLoading) await checkAuth();
|
||||
if (!$user) goto('/login');
|
||||
});
|
||||
|
||||
onKey({
|
||||
'/': () => sidebarRef?.focusSearch(),
|
||||
'?': () => (shortcutsOpen = true),
|
||||
Escape: () => {
|
||||
if (mobileMenuOpen) mobileMenuOpen = false;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if $authLoading}
|
||||
<div class="loading"><div class="spinner"></div></div>
|
||||
{:else if $user}
|
||||
<div class="workspace">
|
||||
<Sidebar bind:this={sidebarRef} bind:open={mobileMenuOpen} onClose={() => (mobileMenuOpen = false)} />
|
||||
{#if mobileMenuOpen}
|
||||
<button class="backdrop" onclick={() => (mobileMenuOpen = false)} aria-label="Menü schließen"></button>
|
||||
{/if}
|
||||
<main class="main">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<ShortcutsModal bind:open={shortcutsOpen} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
}
|
||||
.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); }
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(8, 6, 14, 0.65);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 99;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.backdrop {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
115
apps/web/src/routes/(app)/account/+page.svelte
Normal file
115
apps/web/src/routes/(app)/account/+page.svelte
Normal file
@@ -0,0 +1,115 @@
|
||||
<script lang="ts">
|
||||
import { user } from '$lib/stores/auth.js';
|
||||
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 Avatar from '$lib/components/ui/Avatar.svelte';
|
||||
import TopBar from '$lib/components/workspace/TopBar.svelte';
|
||||
|
||||
let name = $state('');
|
||||
let saving = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if ($user && !name) name = $user.name;
|
||||
});
|
||||
|
||||
async function save() {
|
||||
if (!name.trim()) return;
|
||||
saving = true;
|
||||
try {
|
||||
const res = await api.patch<{ user: typeof $user }>('/auth/me', { name: name.trim() });
|
||||
user.set(res.user);
|
||||
toastSuccess('Profil gespeichert');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<TopBar crumbs={[{ label: 'Konto' }]} />
|
||||
|
||||
<div class="page">
|
||||
<header>
|
||||
<h1>Konto</h1>
|
||||
<p class="sub">Dein Profil — sichtbar für andere im Projekt.</p>
|
||||
</header>
|
||||
|
||||
{#if $user}
|
||||
<section class="card">
|
||||
<h2>Profil</h2>
|
||||
<div class="profile-row">
|
||||
<Avatar name={$user.name} src={$user.avatarUrl ?? null} size="lg" />
|
||||
<div class="form">
|
||||
<Input label="Anzeige-Name" bind:value={name} />
|
||||
<p class="email-line">E-Mail: <span>{$user.email}</span></p>
|
||||
<Button onclick={save} loading={saving} disabled={!name.trim() || name === $user.name}>
|
||||
Speichern
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
padding: var(--space-6);
|
||||
max-width: 720px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.page {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.profile-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
header {
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 var(--space-1);
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
.sub {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
margin: 0;
|
||||
}
|
||||
.card {
|
||||
background: var(--color-bg-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-6);
|
||||
}
|
||||
h2 {
|
||||
margin: 0 0 var(--space-5);
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
.profile-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.form {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
align-items: flex-start;
|
||||
}
|
||||
.form :global(.input-group) {
|
||||
width: 100%;
|
||||
}
|
||||
.email-line {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
margin: 0;
|
||||
}
|
||||
.email-line span {
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
</style>
|
||||
277
apps/web/src/routes/(app)/dashboard/+page.svelte
Normal file
277
apps/web/src/routes/(app)/dashboard/+page.svelte
Normal file
@@ -0,0 +1,277 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api/client.js';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import CoverImage from '$lib/components/ui/CoverImage.svelte';
|
||||
import TopBar from '$lib/components/workspace/TopBar.svelte';
|
||||
import ActivityItem from '$lib/components/dashboard/ActivityItem.svelte';
|
||||
import WelcomeModal from '$lib/components/dashboard/WelcomeModal.svelte';
|
||||
import { timeAgo } from '$lib/utils/format.js';
|
||||
|
||||
type ProjectMembership = {
|
||||
project: { id: string; name: string; description?: string; updatedAt: string; coverUrl: string | null };
|
||||
role: string;
|
||||
trackCount: number;
|
||||
};
|
||||
|
||||
type Event = {
|
||||
type: 'comment' | 'version' | 'approval';
|
||||
id: string;
|
||||
createdAt: string;
|
||||
user: { id: string | null; name: string; avatarUrl: string | null } | null;
|
||||
guestName: string | null;
|
||||
project: { id: string; name: string };
|
||||
track: { id: string; name: string };
|
||||
version?: { id: string; versionNumber: number; label: string | null };
|
||||
body?: string;
|
||||
status?: string;
|
||||
timestampSeconds?: number | null;
|
||||
};
|
||||
|
||||
let projects = $state<ProjectMembership[]>([]);
|
||||
let events = $state<Event[]>([]);
|
||||
let loading = $state(true);
|
||||
let welcomeOpen = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const [pRes, aRes] = await Promise.all([
|
||||
api.get<{ projects: ProjectMembership[] }>('/projects'),
|
||||
api.get<{ events: Event[] }>('/activity?limit=40'),
|
||||
]);
|
||||
projects = pRes.projects;
|
||||
events = aRes.events;
|
||||
|
||||
if (projects.length === 0) {
|
||||
const dismissed = typeof localStorage !== 'undefined' && localStorage.getItem('welcome-dismissed') === '1';
|
||||
if (!dismissed) welcomeOpen = true;
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!welcomeOpen && typeof localStorage !== 'undefined' && projects.length === 0) {
|
||||
// Don't dismiss permanently if user hasn't acted — leave it for next visit if zero projects
|
||||
}
|
||||
});
|
||||
|
||||
// Group events by day bucket
|
||||
const groupedEvents = $derived.by(() => {
|
||||
const now = Date.now();
|
||||
const startOfToday = new Date(); startOfToday.setHours(0, 0, 0, 0);
|
||||
const startOfYesterday = new Date(startOfToday); startOfYesterday.setDate(startOfYesterday.getDate() - 1);
|
||||
const startOfWeek = new Date(startOfToday); startOfWeek.setDate(startOfWeek.getDate() - 7);
|
||||
|
||||
const buckets: { name: string; events: Event[] }[] = [
|
||||
{ name: 'Heute', events: [] },
|
||||
{ name: 'Gestern', events: [] },
|
||||
{ name: 'Diese Woche', events: [] },
|
||||
{ name: 'Älter', events: [] },
|
||||
];
|
||||
|
||||
for (const e of events) {
|
||||
const t = new Date(e.createdAt).getTime();
|
||||
if (t >= startOfToday.getTime()) buckets[0].events.push(e);
|
||||
else if (t >= startOfYesterday.getTime()) buckets[1].events.push(e);
|
||||
else if (t >= startOfWeek.getTime()) buckets[2].events.push(e);
|
||||
else buckets[3].events.push(e);
|
||||
}
|
||||
return buckets.filter((b) => b.events.length > 0);
|
||||
});
|
||||
</script>
|
||||
|
||||
<TopBar crumbs={[{ label: 'Übersicht' }]}>
|
||||
{#snippet actions()}
|
||||
<Button href="/projects/new" size="sm">Neues Projekt</Button>
|
||||
{/snippet}
|
||||
</TopBar>
|
||||
|
||||
<div class="content">
|
||||
<div class="header">
|
||||
<h1>Übersicht</h1>
|
||||
<p class="sub">Was zuletzt in deinen Projekten passiert ist.</p>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="loading">
|
||||
<Skeleton width="40%" height="1rem" />
|
||||
<Skeleton width="80%" height="3rem" variant="rect" />
|
||||
<Skeleton width="80%" height="3rem" variant="rect" />
|
||||
<Skeleton width="80%" height="3rem" variant="rect" />
|
||||
</div>
|
||||
{:else if events.length === 0 && projects.length === 0}
|
||||
<div class="empty">
|
||||
<h2>Noch nichts hier.</h2>
|
||||
<p>Lade dir ein Demo-Projekt oder leg gleich los.</p>
|
||||
<div class="empty-cta">
|
||||
<Button onclick={() => (welcomeOpen = true)}>Demo laden</Button>
|
||||
<Button variant="ghost" href="/projects/new">Eigenes Projekt</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{#if events.length > 0}
|
||||
<section class="activity">
|
||||
{#each groupedEvents as bucket}
|
||||
<div class="bucket">
|
||||
<h2 class="bucket-title">{bucket.name}</h2>
|
||||
<div class="events">
|
||||
{#each bucket.events as event (event.id + event.type)}
|
||||
<ActivityItem {event} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if projects.length > 0}
|
||||
<section class="projects-block">
|
||||
<h2 class="bucket-title">Deine Projekte</h2>
|
||||
<div class="grid">
|
||||
{#each projects as { project, trackCount }}
|
||||
<a href="/projects/{project.id}" class="card">
|
||||
<CoverImage src={project.coverUrl} name={project.name} size="md" rounded="md" />
|
||||
<div class="card-body">
|
||||
<div class="card-name">{project.name}</div>
|
||||
<div class="card-meta">
|
||||
{trackCount} {trackCount === 1 ? 'Track' : 'Tracks'} · {timeAgo(project.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<WelcomeModal bind:open={welcomeOpen} />
|
||||
|
||||
<style>
|
||||
.content {
|
||||
padding: var(--space-6);
|
||||
max-width: 920px;
|
||||
width: 100%;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.content {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.header {
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
h1 {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
}
|
||||
.header {
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 var(--space-1);
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
.sub {
|
||||
color: var(--color-text-tertiary);
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: var(--space-12) var(--space-4);
|
||||
}
|
||||
.empty h2 {
|
||||
font-size: var(--text-xl);
|
||||
margin: 0 0 var(--space-2);
|
||||
}
|
||||
.empty p {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
margin: 0 0 var(--space-5);
|
||||
}
|
||||
.empty-cta {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.activity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
margin-bottom: var(--space-12);
|
||||
}
|
||||
.bucket-title {
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--color-text-tertiary);
|
||||
font-weight: 600;
|
||||
margin: 0 0 var(--space-3);
|
||||
}
|
||||
.events {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.projects-block {
|
||||
margin-top: var(--space-8);
|
||||
padding-top: var(--space-8);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: var(--color-bg-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.card:hover {
|
||||
border-color: var(--color-border-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.card-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.card-name {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
font-size: var(--text-sm);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.card-meta {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
339
apps/web/src/routes/(app)/projects/[projectId]/+page.svelte
Normal file
339
apps/web/src/routes/(app)/projects/[projectId]/+page.svelte
Normal file
@@ -0,0 +1,339 @@
|
||||
<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';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import CoverImage from '$lib/components/ui/CoverImage.svelte';
|
||||
import TrackStatusPill from '$lib/components/ui/TrackStatusPill.svelte';
|
||||
import TopBar from '$lib/components/workspace/TopBar.svelte';
|
||||
import { timeAgo } from '$lib/utils/format.js';
|
||||
import type { TrackStatus } from '@music-hub/shared';
|
||||
|
||||
type Track = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
versionCount: number;
|
||||
branchCount: number;
|
||||
coverUrl: string | null;
|
||||
status: TrackStatus;
|
||||
section: string | null;
|
||||
};
|
||||
|
||||
type Group = { name: string; tracks: Track[] };
|
||||
|
||||
function groupBySection(list: Track[]): Group[] {
|
||||
const groups = new Map<string, Track[]>();
|
||||
for (const t of list) {
|
||||
const key = t.section?.trim() || 'Mainline';
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(t);
|
||||
}
|
||||
// Stable order: Mainline first, then alphabetical
|
||||
return Array.from(groups.entries())
|
||||
.sort(([a], [b]) => {
|
||||
if (a === 'Mainline') return -1;
|
||||
if (b === 'Mainline') return 1;
|
||||
return a.localeCompare(b);
|
||||
})
|
||||
.map(([name, tracks]) => ({ name, tracks }));
|
||||
}
|
||||
|
||||
type Project = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
coverUrl: string | null;
|
||||
};
|
||||
|
||||
let project = $state<Project | null>(null);
|
||||
let role = $state('');
|
||||
let tracks = $state<Track[]>([]);
|
||||
const grouped = $derived(groupBySection(tracks));
|
||||
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 angelegt');
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
const canUpload = $derived(role === 'owner' || role.includes('engineer'));
|
||||
</script>
|
||||
|
||||
<TopBar
|
||||
crumbs={[
|
||||
{ label: 'Projekte', href: '/dashboard' },
|
||||
{ label: project?.name ?? '…' },
|
||||
]}
|
||||
>
|
||||
{#snippet actions()}
|
||||
{#if !loading && (role === 'owner' || role === 'management')}
|
||||
<Button variant="ghost" size="sm" href="/projects/{projectId}/settings">Einstellungen</Button>
|
||||
{/if}
|
||||
{#if canUpload}
|
||||
<Button size="sm" onclick={() => showNewTrack = !showNewTrack}>
|
||||
{showNewTrack ? 'Abbrechen' : 'Neuer Track'}
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</TopBar>
|
||||
|
||||
<div class="project-page">
|
||||
<header>
|
||||
{#if loading}
|
||||
<Skeleton width="200px" height="2rem" />
|
||||
{:else if project}
|
||||
<div class="project-head">
|
||||
<CoverImage src={project.coverUrl} name={project.name} size="lg" rounded="lg" />
|
||||
<div>
|
||||
<h1>{project.name}</h1>
|
||||
{#if project.description}
|
||||
<p class="description">{project.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#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}>Anlegen</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
|
||||
title="Noch keine Tracks"
|
||||
description="Lege einen Track an und lade dein erstes Audio hoch."
|
||||
/>
|
||||
{:else}
|
||||
{#each grouped as group}
|
||||
<section class="section">
|
||||
<h2 class="section-title">
|
||||
{group.name}
|
||||
<span class="section-count">{group.tracks.length}</span>
|
||||
</h2>
|
||||
<div class="track-list">
|
||||
{#each group.tracks as track}
|
||||
<a href="/projects/{projectId}/tracks/{track.id}" class="track-item">
|
||||
<CoverImage src={track.coverUrl} name={track.name} size="sm" rounded="sm" />
|
||||
<span class="track-name">{track.name}</span>
|
||||
<TrackStatusPill status={track.status} />
|
||||
<span class="track-meta">
|
||||
{track.versionCount} {track.versionCount === 1 ? 'Version' : 'Versionen'}
|
||||
{#if track.branchCount > 0}
|
||||
<span class="branch-pill"><Icon name="git-branch" size={11} /> {track.branchCount} {track.branchCount === 1 ? 'Variante' : 'Varianten'}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="track-time">{timeAgo(track.updatedAt)}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.project-page {
|
||||
padding: var(--space-6) var(--space-6) var(--space-12);
|
||||
max-width: 1100px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.project-page {
|
||||
padding: var(--space-4) var(--space-4) var(--space-12);
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
.project-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-5);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.project-head > div {
|
||||
min-width: 0;
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
@media (max-width: 540px) {
|
||||
.project-head {
|
||||
gap: var(--space-3);
|
||||
}
|
||||
h1 {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--color-text-secondary);
|
||||
margin: var(--space-1) 0 0;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
.section-title {
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--color-text-tertiary);
|
||||
font-weight: 600;
|
||||
margin: 0 0 var(--space-3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.section-count {
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-tertiary);
|
||||
padding: 1px 7px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 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: grid;
|
||||
grid-template-columns: auto 1fr auto auto auto;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--color-bg-overlay);
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
border: 1px solid var(--color-border);
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.track-meta {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.track-item {
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-areas:
|
||||
'cover name status'
|
||||
'cover meta time';
|
||||
row-gap: 4px;
|
||||
}
|
||||
.track-item :global(.cover) { grid-area: cover; }
|
||||
.track-item .track-name { grid-area: name; }
|
||||
.track-item :global(.pill) { grid-area: status; justify-self: end; }
|
||||
.track-item .track-meta { grid-area: meta; margin-left: 0; }
|
||||
.track-item .track-time { grid-area: time; min-width: auto; }
|
||||
}
|
||||
|
||||
.branch-pill {
|
||||
background: var(--color-accent-subtle);
|
||||
border: 1px solid rgba(244, 63, 94, 0.4);
|
||||
color: #fb923c;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.track-time {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
min-width: 80px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.track-item:hover {
|
||||
border-color: var(--color-border-hover);
|
||||
background: var(--color-bg-overlay);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -10,6 +10,8 @@
|
||||
import Avatar from '$lib/components/ui/Avatar.svelte';
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import CoverUpload from '$lib/components/ui/CoverUpload.svelte';
|
||||
import TopBar from '$lib/components/workspace/TopBar.svelte';
|
||||
|
||||
type Member = {
|
||||
id: string;
|
||||
@@ -17,7 +19,7 @@
|
||||
user: { id: string; email: string; name: string; avatarUrl: string | null };
|
||||
};
|
||||
|
||||
type Project = { id: string; name: string; description: string | null };
|
||||
type Project = { id: string; name: string; description: string | null; coverUrl: string | null; coverImageUrl: string | null };
|
||||
|
||||
const projectId = $page.params.projectId!;
|
||||
|
||||
@@ -57,6 +59,12 @@
|
||||
}
|
||||
});
|
||||
|
||||
async function saveCover(key: string) {
|
||||
const res = await api.patch<{ project: Project }>(`/projects/${projectId}`, { coverImageUrl: key });
|
||||
project = res.project;
|
||||
toastSuccess('Cover gespeichert');
|
||||
}
|
||||
|
||||
async function saveProject() {
|
||||
saving = true;
|
||||
try {
|
||||
@@ -64,7 +72,7 @@
|
||||
name: editName,
|
||||
description: editDesc || undefined,
|
||||
});
|
||||
toastSuccess('Project updated');
|
||||
toastSuccess('Projekt gespeichert');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
@@ -79,7 +87,7 @@
|
||||
role: inviteRole,
|
||||
});
|
||||
inviteEmail = '';
|
||||
toastSuccess('Member invited');
|
||||
toastSuccess('Person eingeladen');
|
||||
const res = await api.get<{ members: Member[] }>(`/projects/${projectId}/members`);
|
||||
members = res.members;
|
||||
} finally {
|
||||
@@ -89,47 +97,57 @@
|
||||
|
||||
async function updateRole(memberId: string, newRole: string) {
|
||||
await api.patch(`/projects/${projectId}/members/${memberId}`, { role: newRole });
|
||||
toastSuccess('Role updated');
|
||||
toastSuccess('Rolle aktualisiert');
|
||||
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');
|
||||
toastSuccess('Person entfernt');
|
||||
members = members.filter((m) => m.id !== memberId);
|
||||
}
|
||||
|
||||
async function archiveProject() {
|
||||
await api.delete(`/projects/${projectId}`);
|
||||
toastSuccess('Project archived');
|
||||
toastSuccess('Projekt archiviert');
|
||||
goto('/dashboard');
|
||||
}
|
||||
</script>
|
||||
|
||||
<TopBar
|
||||
crumbs={[
|
||||
{ label: 'Projekte', href: '/dashboard' },
|
||||
{ label: project?.name ?? '…', href: `/projects/${projectId}` },
|
||||
{ label: 'Einstellungen' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div class="settings-page">
|
||||
<header>
|
||||
<a href="/projects/{projectId}" class="back">← Back to project</a>
|
||||
<h1>Settings</h1>
|
||||
<h1>Einstellungen</h1>
|
||||
</header>
|
||||
|
||||
{#if !loading && project}
|
||||
<!-- Project Details -->
|
||||
<!-- Projekt-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>
|
||||
<h2>Projekt-Details</h2>
|
||||
<div class="cover-row">
|
||||
<CoverUpload currentUrl={project.coverUrl} name={project.name} onUploaded={saveCover} />
|
||||
<form class="details-form" onsubmit={(e) => { e.preventDefault(); saveProject(); }}>
|
||||
<Input label="Name" bind:value={editName} />
|
||||
<div class="textarea-group">
|
||||
<label class="textarea-label">Beschreibung</label>
|
||||
<textarea bind:value={editDesc} rows="3" placeholder="Worum geht's in diesem Projekt?"></textarea>
|
||||
</div>
|
||||
<Button type="submit" loading={saving}>Speichern</Button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Members -->
|
||||
<!-- Mitwirkende -->
|
||||
<section class="section">
|
||||
<h2>Members</h2>
|
||||
<h2>Mitwirkende</h2>
|
||||
|
||||
<div class="member-list">
|
||||
{#each members as member}
|
||||
@@ -140,7 +158,7 @@
|
||||
<span class="member-email">{member.user.email}</span>
|
||||
</div>
|
||||
{#if member.role === 'owner'}
|
||||
<Badge variant="accent">Owner</Badge>
|
||||
<Badge variant="accent">Besitzer</Badge>
|
||||
{:else if role === 'owner'}
|
||||
<select
|
||||
value={member.role}
|
||||
@@ -151,7 +169,7 @@
|
||||
{/each}
|
||||
</select>
|
||||
<Button variant="ghost" size="sm" onclick={() => removeMember(member.id)}>
|
||||
<span style="color: var(--color-error)">Remove</span>
|
||||
<span style="color: var(--color-error)">Entfernen</span>
|
||||
</Button>
|
||||
{:else}
|
||||
<Badge>{ROLE_LABELS[member.role as keyof typeof ROLE_LABELS] || member.role}</Badge>
|
||||
@@ -163,64 +181,62 @@
|
||||
<!-- 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" />
|
||||
<Input type="email" bind:value={inviteEmail} placeholder="name@email.de" />
|
||||
<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>
|
||||
<Button type="submit" loading={inviting} size="sm">Einladen</Button>
|
||||
</form>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Danger Zone -->
|
||||
<!-- Achtung-Zone -->
|
||||
{#if role === 'owner'}
|
||||
<section class="section danger-zone">
|
||||
<h2>Danger Zone</h2>
|
||||
<h2>Vorsicht</h2>
|
||||
<div class="danger-content">
|
||||
<div>
|
||||
<strong>Archive this project</strong>
|
||||
<p>The project will be hidden from all members.</p>
|
||||
<strong>Projekt archivieren</strong>
|
||||
<p>Das Projekt verschwindet für alle Beteiligten.</p>
|
||||
</div>
|
||||
<Button variant="danger" onclick={() => showArchiveModal = true}>Archive</Button>
|
||||
<Button variant="danger" onclick={() => showArchiveModal = true}>Archivieren</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>
|
||||
<Modal bind:open={showArchiveModal} title="Projekt archivieren">
|
||||
<p>Sicher dass du <strong>{project?.name}</strong> archivieren willst? Es verschwindet dann für alle Beteiligten.</p>
|
||||
{#snippet actions()}
|
||||
<Button variant="secondary" onclick={() => showArchiveModal = false}>Cancel</Button>
|
||||
<Button variant="danger" onclick={archiveProject}>Archive</Button>
|
||||
<Button variant="secondary" onclick={() => showArchiveModal = false}>Abbrechen</Button>
|
||||
<Button variant="danger" onclick={archiveProject}>Archivieren</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.settings-page {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-4);
|
||||
max-width: 720px;
|
||||
padding: var(--space-6);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.settings-page {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.section {
|
||||
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;
|
||||
margin: 0;
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
|
||||
.section {
|
||||
@@ -236,12 +252,26 @@
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
form {
|
||||
form,
|
||||
.details-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
align-items: flex-start;
|
||||
}
|
||||
.cover-row {
|
||||
display: flex;
|
||||
gap: var(--space-6);
|
||||
align-items: flex-start;
|
||||
}
|
||||
.cover-row .details-form {
|
||||
flex: 1;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.cover-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
form :global(.input-group) {
|
||||
width: 100%;
|
||||
@@ -0,0 +1,924 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { api } from '$lib/api/client.js';
|
||||
import { user } from '$lib/stores/auth.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 Modal from '$lib/components/ui/Modal.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 TopBar from '$lib/components/workspace/TopBar.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import CoverImage from '$lib/components/ui/CoverImage.svelte';
|
||||
import CoverUpload from '$lib/components/ui/CoverUpload.svelte';
|
||||
import TrackStatusPill from '$lib/components/ui/TrackStatusPill.svelte';
|
||||
import { onKey } from '$lib/utils/shortcuts.js';
|
||||
import { snapshotForTrack, continuationFor } from '$lib/stores/player.js';
|
||||
import { TRACK_STATUSES, TRACK_STATUS_LABELS, type TrackStatus } from '@music-hub/shared';
|
||||
import VersionInfo from './components/VersionInfo.svelte';
|
||||
import VersionGraph from './components/VersionGraph.svelte';
|
||||
import ShareModal from './components/ShareModal.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;
|
||||
parentVersionId?: string | null;
|
||||
branchLabel?: string | null;
|
||||
};
|
||||
|
||||
type GraphNode = {
|
||||
id: string;
|
||||
parentVersionId: string | null;
|
||||
branchLabel: string | null;
|
||||
versionNumber: number;
|
||||
label: string | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type Comment = {
|
||||
id: string;
|
||||
body: string;
|
||||
timestampSeconds: number | null;
|
||||
parentId: string | null;
|
||||
resolvedAt: string | null;
|
||||
createdAt: string;
|
||||
guestName?: string | null;
|
||||
user: { id: string; name: string; avatarUrl: string | null } | null;
|
||||
};
|
||||
|
||||
const projectId = ($page.params as Record<string, string>).projectId;
|
||||
const trackId = ($page.params as Record<string, string>).trackId;
|
||||
|
||||
let projectName = $state('');
|
||||
let trackName = $state('');
|
||||
let trackStatus = $state<TrackStatus>('in_progress');
|
||||
let trackSection = $state<string | null>(null);
|
||||
let trackCoverUrl = $state<string | null>(null);
|
||||
let coverEditOpen = $state(false);
|
||||
let statusMenuOpen = $state(false);
|
||||
let nextInitialTime = $state(0);
|
||||
let nextAutoPlay = $state(false);
|
||||
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('');
|
||||
let graphNodes = $state<GraphNode[]>([]);
|
||||
let branchFromId = $state<string | null>(null);
|
||||
let branchLabelInput = $state('');
|
||||
let shareOpen = $state(false);
|
||||
let panelTab = $state<'versions' | 'comments'>('versions');
|
||||
let panelOpen = $state(true);
|
||||
let editVersionOpen = $state(false);
|
||||
let editVersionLabel = $state('');
|
||||
let editVersionNotes = $state('');
|
||||
let savingVersion = $state(false);
|
||||
|
||||
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, treeRes] = await Promise.all([
|
||||
api.get<{ project: { name: string }; role: string }>(`/projects/${projectId}`),
|
||||
api.get<{ versions: Version[] }>(`/versions/track/${trackId}`),
|
||||
api.get<{ tracks: { id: string; name: string; coverUrl: string | null; status: TrackStatus; section: string | null }[] }>(`/tracks/project/${projectId}`),
|
||||
api.get<{ nodes: GraphNode[] }>(`/versions/track/${trackId}/tree`),
|
||||
]);
|
||||
|
||||
projectName = projectRes.project.name;
|
||||
role = projectRes.role;
|
||||
const t = tracksRes.tracks.find((t) => t.id === trackId) as any;
|
||||
trackName = t?.name || '';
|
||||
trackCoverUrl = t?.coverUrl ?? null;
|
||||
trackStatus = (t?.status ?? 'in_progress') as TrackStatus;
|
||||
trackSection = t?.section ?? null;
|
||||
versions = trackVersions.versions;
|
||||
graphNodes = treeRes.nodes;
|
||||
|
||||
if (versions.length > 0) await selectVersion(versions[0]);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function selectVersion(version: Version) {
|
||||
// Snapshot current playhead so the new version picks up where we left off
|
||||
if (playerRef && selectedVersion) {
|
||||
snapshotForTrack(trackId, playerRef.getCurrentTime(), playerRef.getIsPlaying());
|
||||
}
|
||||
const cont = continuationFor(trackId);
|
||||
nextInitialTime = cont?.initialTime ?? 0;
|
||||
nextAutoPlay = cont?.autoPlay ?? false;
|
||||
|
||||
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 setTrackStatus(s: TrackStatus) {
|
||||
trackStatus = s;
|
||||
statusMenuOpen = false;
|
||||
await api.patch(`/tracks/${trackId}`, { status: s });
|
||||
toastSuccess(`Status: ${TRACK_STATUS_LABELS[s]}`);
|
||||
}
|
||||
|
||||
async function loadVersions() {
|
||||
const [res, treeRes] = await Promise.all([
|
||||
api.get<{ versions: Version[] }>(`/versions/track/${trackId}`),
|
||||
api.get<{ nodes: GraphNode[] }>(`/versions/track/${trackId}/tree`),
|
||||
]);
|
||||
versions = res.versions;
|
||||
graphNodes = treeRes.nodes;
|
||||
if (versions.length > 0) await selectVersion(versions[0]);
|
||||
}
|
||||
|
||||
async function handlePromote() {
|
||||
if (!selectedVersion) return;
|
||||
await api.post(`/versions/${selectedVersion.id}/promote`);
|
||||
toastSuccess('Als Hauptversion festgelegt');
|
||||
await loadVersions();
|
||||
}
|
||||
|
||||
function startBranch(id: string) {
|
||||
branchFromId = id;
|
||||
branchLabelInput = '';
|
||||
showUpload = true;
|
||||
}
|
||||
|
||||
async function handleApprove() {
|
||||
if (!selectedVersion) return;
|
||||
await api.post(`/versions/${selectedVersion.id}/approve`);
|
||||
toastSuccess('Version freigegeben');
|
||||
await loadVersions();
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
if (!selectedVersion) return;
|
||||
await api.post(`/versions/${selectedVersion.id}/reject`);
|
||||
toastSuccess('Version abgelehnt');
|
||||
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('Kommentar gespeichert');
|
||||
}
|
||||
|
||||
async function handleEditComment(id: string, body: string) {
|
||||
await api.patch(`/comments/${id}`, { body });
|
||||
if (selectedVersion) {
|
||||
const res = await api.get<{ comments: Comment[] }>(`/comments/version/${selectedVersion.id}`);
|
||||
comments = res.comments;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteComment(id: string) {
|
||||
if (!confirm('Diesen Kommentar wirklich löschen?')) return;
|
||||
await api.delete(`/comments/${id}`);
|
||||
if (selectedVersion) {
|
||||
const res = await api.get<{ comments: Comment[] }>(`/comments/version/${selectedVersion.id}`);
|
||||
comments = res.comments;
|
||||
}
|
||||
}
|
||||
|
||||
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 = '';
|
||||
}
|
||||
|
||||
async function saveTrackCover(key: string) {
|
||||
const res = await api.patch<{ track: { coverImageUrl: string | null } }>(`/tracks/${trackId}`, { coverImageUrl: key });
|
||||
// Reload list to refresh signed URL via /tracks/project/:id
|
||||
const tracksRes = await api.get<{ tracks: { id: string; coverUrl: string | null }[] }>(`/tracks/project/${projectId}`);
|
||||
trackCoverUrl = tracksRes.tracks.find((t) => t.id === trackId)?.coverUrl ?? null;
|
||||
coverEditOpen = false;
|
||||
toastSuccess('Cover gespeichert');
|
||||
}
|
||||
|
||||
function openVersionEdit() {
|
||||
if (!selectedVersion) return;
|
||||
editVersionLabel = selectedVersion.label ?? '';
|
||||
editVersionNotes = selectedVersion.notes ?? '';
|
||||
editVersionOpen = true;
|
||||
}
|
||||
|
||||
async function saveVersion() {
|
||||
if (!selectedVersion) return;
|
||||
savingVersion = true;
|
||||
try {
|
||||
await api.patch(`/versions/${selectedVersion.id}`, {
|
||||
label: editVersionLabel.trim() || null,
|
||||
notes: editVersionNotes.trim() || null,
|
||||
});
|
||||
toastSuccess('Version aktualisiert');
|
||||
editVersionOpen = false;
|
||||
await loadVersions();
|
||||
} finally {
|
||||
savingVersion = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTrack() {
|
||||
if (!confirm(`Track "${trackName}" mit allen Versionen und Kommentaren wirklich löschen? Das kann nicht rückgängig gemacht werden.`)) return;
|
||||
await api.delete(`/tracks/${trackId}`);
|
||||
toastSuccess('Track gelöscht');
|
||||
window.location.href = `/projects/${projectId}`;
|
||||
}
|
||||
|
||||
function jumpVersion(direction: 1 | -1) {
|
||||
if (versions.length === 0 || !selectedVersion) return;
|
||||
const idx = versions.findIndex((v) => v.id === selectedVersion!.id);
|
||||
const next = versions[idx + direction];
|
||||
if (next) selectVersion(next);
|
||||
}
|
||||
|
||||
function focusComment() {
|
||||
if (!playerRef) return;
|
||||
commentTimestamp = Math.round(playerRef.getCurrentTime() * 10) / 10;
|
||||
panelTab = 'comments';
|
||||
panelOpen = true;
|
||||
setTimeout(() => {
|
||||
const input = document.querySelector<HTMLInputElement>('.comments-section input[type="text"]');
|
||||
input?.focus();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
onKey({
|
||||
' ': () => playerRef?.togglePlay(),
|
||||
k: () => playerRef?.togglePlay(),
|
||||
j: () => playerRef && playerRef.seekToTime(Math.max(0, playerRef.getCurrentTime() - 10)),
|
||||
l: () => playerRef && playerRef.seekToTime(playerRef.getCurrentTime() + 10),
|
||||
c: () => focusComment(),
|
||||
ArrowLeft: () => jumpVersion(-1),
|
||||
ArrowRight: () => jumpVersion(1),
|
||||
Escape: () => {
|
||||
if (compareVersion) closeCompare();
|
||||
},
|
||||
});
|
||||
|
||||
async function deleteVersion() {
|
||||
if (!selectedVersion) return;
|
||||
if (!confirm(`Version V${selectedVersion.versionNumber} wirklich löschen? Das kann nicht rückgängig gemacht werden.`)) return;
|
||||
await api.delete(`/versions/${selectedVersion.id}`);
|
||||
toastSuccess('Version gelöscht');
|
||||
await loadVersions();
|
||||
}
|
||||
</script>
|
||||
|
||||
<TopBar
|
||||
crumbs={[
|
||||
{ label: 'Projekte', href: '/dashboard' },
|
||||
{ label: projectName || '…', href: `/projects/${projectId}` },
|
||||
{ label: trackName || '…' },
|
||||
]}
|
||||
>
|
||||
{#snippet actions()}
|
||||
{#if canUpload}
|
||||
<Button size="sm" variant="ghost" onclick={() => { branchFromId = null; branchLabelInput = ''; showUpload = !showUpload; }}>
|
||||
<Icon name="upload" size={14} /> Hochladen
|
||||
</Button>
|
||||
{/if}
|
||||
<Button size="sm" variant="ghost" onclick={() => (shareOpen = true)}>
|
||||
<Icon name="share" size={14} /> Teilen
|
||||
</Button>
|
||||
<button class="panel-toggle" class:open={panelOpen} onclick={() => (panelOpen = !panelOpen)} title="Seitenleiste umschalten" aria-label="Seitenleiste umschalten">
|
||||
<Icon name="panel" size={16} />
|
||||
</button>
|
||||
{/snippet}
|
||||
</TopBar>
|
||||
|
||||
<div class="track-workspace">
|
||||
<main class="player-area">
|
||||
{#if loading}
|
||||
<div class="loading-block">
|
||||
<Skeleton width="60%" height="2rem" />
|
||||
<Skeleton height="120px" variant="rect" />
|
||||
</div>
|
||||
{:else if versions.length === 0}
|
||||
<EmptyState
|
||||
title="Noch keine Version"
|
||||
description="Lade dein erstes Audio hoch — wir kümmern uns um Wellenform und Vorschau."
|
||||
>
|
||||
{#snippet action()}
|
||||
{#if canUpload}
|
||||
<Button onclick={() => (showUpload = true)}>Audio hochladen</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else if selectedVersion && streamUrl}
|
||||
<div class="track-head">
|
||||
<button class="track-cover-btn" onclick={() => canUpload && (coverEditOpen = true)} disabled={!canUpload} aria-label="Cover ändern">
|
||||
<CoverImage src={trackCoverUrl} name={trackName} size="lg" rounded="lg" />
|
||||
</button>
|
||||
<div class="title-block">
|
||||
<h1>{trackName}</h1>
|
||||
<div class="meta-row">
|
||||
<button class="status-trigger" onclick={() => canUpload && (statusMenuOpen = !statusMenuOpen)} disabled={!canUpload}>
|
||||
<TrackStatusPill status={trackStatus} size="md" />
|
||||
</button>
|
||||
{#if statusMenuOpen}
|
||||
<div class="status-menu" role="menu">
|
||||
{#each TRACK_STATUSES as s}
|
||||
<button onclick={() => setTrackStatus(s)} class:active={s === trackStatus}>
|
||||
<TrackStatusPill status={s} />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if trackSection}
|
||||
<span class="section-tag">{trackSection}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<VersionInfo
|
||||
version={selectedVersion}
|
||||
{canApprove}
|
||||
onApprove={handleApprove}
|
||||
onReject={handleReject}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="player-card">
|
||||
{#key streamUrl}
|
||||
<WaveformPlayer
|
||||
bind:this={playerRef}
|
||||
url={streamUrl}
|
||||
initialTime={nextInitialTime}
|
||||
autoPlay={nextAutoPlay}
|
||||
markers={comments
|
||||
.filter((c) => c.timestampSeconds !== null)
|
||||
.map((c) => ({
|
||||
id: c.id,
|
||||
timestampSeconds: c.timestampSeconds!,
|
||||
body: c.body,
|
||||
userName: c.user?.name ?? c.guestName ?? 'Gast',
|
||||
}))}
|
||||
onTimeClick={(time) => (commentTimestamp = Math.round(time * 10) / 10)}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<Button variant="ghost" size="sm" onclick={handleDownload}>
|
||||
<Icon name="download" size={14} /> Download Original
|
||||
</Button>
|
||||
{#if canUpload}
|
||||
<Button variant="ghost" size="sm" onclick={openVersionEdit}>
|
||||
<Icon name="settings" size={14} /> Bearbeiten
|
||||
</Button>
|
||||
{/if}
|
||||
{#if role === 'owner'}
|
||||
<Button variant="ghost" size="sm" onclick={deleteVersion}>
|
||||
<span class="danger-text"><Icon name="x" size={14} /> Version löschen</span>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onclick={deleteTrack}>
|
||||
<span class="danger-text"><Icon name="x" size={14} /> Track löschen</span>
|
||||
</Button>
|
||||
{/if}
|
||||
{#if canApprove && selectedVersion.branchLabel}
|
||||
<Button variant="ghost" size="sm" onclick={handlePromote}>
|
||||
<Icon name="arrow-up" size={14} /> Als Hauptversion
|
||||
</Button>
|
||||
{/if}
|
||||
{#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="">Vergleichen mit…</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}
|
||||
|
||||
{#if showUpload}
|
||||
<div class="upload-zone">
|
||||
{#if branchFromId}
|
||||
<div class="branch-banner">
|
||||
<span>Variante von <strong>V{graphNodes.find((n) => n.id === branchFromId)?.versionNumber}</strong></span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={branchLabelInput}
|
||||
placeholder="Name der Variante (z.B. 'andere Vocals')"
|
||||
/>
|
||||
<button class="cancel-branch" onclick={() => (branchFromId = null)}>×</button>
|
||||
</div>
|
||||
{/if}
|
||||
<UploadDropzone
|
||||
{trackId}
|
||||
parentVersionId={branchFromId}
|
||||
branchLabel={branchFromId ? branchLabelInput || 'Variante' : null}
|
||||
onUploaded={() => {
|
||||
showUpload = false;
|
||||
branchFromId = null;
|
||||
loadVersions();
|
||||
toastSuccess('Version hochgeladen');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if compareVersion && compareStreamUrl && selectedVersion && streamUrl}
|
||||
<div class="compare-overlay" role="dialog" aria-modal="true">
|
||||
<ABCompare
|
||||
versionA={selectedVersion}
|
||||
versionB={compareVersion}
|
||||
streamUrlA={streamUrl}
|
||||
streamUrlB={compareStreamUrl}
|
||||
onClose={closeCompare}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
{#if panelOpen}
|
||||
<aside class="side-panel">
|
||||
<div class="tabs">
|
||||
<button class:active={panelTab === 'versions'} onclick={() => (panelTab = 'versions')}>
|
||||
Versionen <span class="badge">{versions.length}</span>
|
||||
</button>
|
||||
<button class:active={panelTab === 'comments'} onclick={() => (panelTab = 'comments')}>
|
||||
Kommentare <span class="badge">{comments.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
{#if panelTab === 'versions'}
|
||||
{#if graphNodes.length === 0}
|
||||
<p class="muted">Noch keine Versionen.</p>
|
||||
{:else}
|
||||
<VersionGraph
|
||||
nodes={graphNodes}
|
||||
selectedId={selectedVersion?.id ?? null}
|
||||
onSelect={(id) => {
|
||||
const v = versions.find((v) => v.id === id);
|
||||
if (v) selectVersion(v);
|
||||
}}
|
||||
onBranch={canUpload ? startBranch : undefined}
|
||||
/>
|
||||
{/if}
|
||||
{:else if selectedVersion}
|
||||
<CommentSection
|
||||
{comments}
|
||||
{canComment}
|
||||
currentUserId={$user?.id ?? null}
|
||||
bind:commentTimestamp
|
||||
onSubmit={handleComment}
|
||||
onResolve={handleResolve}
|
||||
onEdit={handleEditComment}
|
||||
onDelete={handleDeleteComment}
|
||||
onSeek={(time) => playerRef?.seekToTime(time)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if selectedVersion}
|
||||
<ShareModal bind:open={shareOpen} versionId={selectedVersion.id} />
|
||||
{/if}
|
||||
|
||||
<Modal bind:open={coverEditOpen} title="Track-Cover ändern">
|
||||
<div class="cover-modal">
|
||||
<CoverUpload currentUrl={trackCoverUrl} name={trackName} onUploaded={saveTrackCover} />
|
||||
</div>
|
||||
{#snippet actions()}
|
||||
<Button onclick={() => (coverEditOpen = false)}>Schließen</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<Modal bind:open={editVersionOpen} title="Version bearbeiten">
|
||||
<div class="edit-form">
|
||||
<label>
|
||||
<span class="lbl">Bezeichnung</span>
|
||||
<input type="text" bind:value={editVersionLabel} placeholder="z.B. 'Mehr Bass'" />
|
||||
</label>
|
||||
<label>
|
||||
<span class="lbl">Notizen</span>
|
||||
<textarea bind:value={editVersionNotes} rows="4" placeholder="Was hat sich geändert?"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
{#snippet actions()}
|
||||
<Button variant="ghost" onclick={() => (editVersionOpen = false)}>Abbrechen</Button>
|
||||
<Button onclick={saveVersion} loading={savingVersion}>Speichern</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.track-workspace {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.player-area {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: var(--space-6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
overflow-y: auto;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.player-area {
|
||||
padding: var(--space-4);
|
||||
gap: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.track-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.track-head h1 {
|
||||
margin: 0;
|
||||
font-size: var(--text-2xl);
|
||||
word-break: break-word;
|
||||
}
|
||||
@media (max-width: 540px) {
|
||||
.track-head h1 {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
}
|
||||
.title-block {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
position: relative;
|
||||
}
|
||||
.status-trigger {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
.status-trigger:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
.status-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
background: var(--color-bg-overlay);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.status-menu button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.status-menu button:hover {
|
||||
background: var(--color-bg-raised);
|
||||
}
|
||||
.status-menu button.active {
|
||||
background: var(--color-bg-subtle);
|
||||
}
|
||||
.section-tag {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
.track-cover-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-lg);
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
.track-cover-btn:not(:disabled):hover {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
.track-cover-btn:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
.cover-modal {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--space-3) 0;
|
||||
}
|
||||
|
||||
.player-card {
|
||||
background: var(--color-bg-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
}
|
||||
@media (max-width: 540px) {
|
||||
.player-card {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.toolbar :global(.btn) {
|
||||
flex: 1 0 calc(50% - var(--space-1));
|
||||
min-width: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
.toolbar .compare-select {
|
||||
flex: 1 0 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.danger-text {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.edit-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.edit-form .lbl {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.edit-form input,
|
||||
.edit-form textarea {
|
||||
padding: 0.7rem 0.9rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-raised);
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--text-sm);
|
||||
font-family: inherit;
|
||||
}
|
||||
.edit-form input:focus,
|
||||
.edit-form textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 4px rgba(244, 63, 94, 0.12);
|
||||
}
|
||||
|
||||
.compare-select {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-raised);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.compare-select:hover {
|
||||
border-color: var(--color-border-hover);
|
||||
}
|
||||
|
||||
.upload-zone {
|
||||
background: var(--color-bg-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
}
|
||||
.branch-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--color-accent-subtle);
|
||||
border: 1px solid rgba(244, 63, 94, 0.3);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.branch-banner input {
|
||||
flex: 1;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
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;
|
||||
}
|
||||
.cancel-branch {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-tertiary);
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.compare-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(8, 6, 14, 0.7);
|
||||
backdrop-filter: blur(10px);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
/* SIDE PANEL */
|
||||
.side-panel {
|
||||
width: 320px;
|
||||
flex-shrink: 0;
|
||||
border-left: 1px solid var(--color-border);
|
||||
background: var(--color-bg-raised);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.tabs button {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
padding: var(--space-4) var(--space-3);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all var(--transition-fast);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.tabs button:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.tabs button.active {
|
||||
color: var(--color-text-primary);
|
||||
border-bottom-color: var(--color-accent);
|
||||
}
|
||||
.badge {
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-tertiary);
|
||||
padding: 0.05rem 0.45rem;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.tabs button.active .badge {
|
||||
background: var(--color-accent-subtle);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-5);
|
||||
}
|
||||
.muted {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.panel-toggle {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-tertiary);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.panel-toggle:hover {
|
||||
color: var(--color-text-primary);
|
||||
border-color: var(--color-border-hover);
|
||||
}
|
||||
.panel-toggle.open {
|
||||
color: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-accent-subtle);
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.side-panel {
|
||||
width: 280px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
.track-workspace {
|
||||
flex-direction: column;
|
||||
}
|
||||
.side-panel {
|
||||
width: 100%;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Avatar from '$lib/components/ui/Avatar.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import { formatTime, timeAgo } from '$lib/utils/format.js';
|
||||
|
||||
type Comment = {
|
||||
@@ -14,18 +15,38 @@
|
||||
|
||||
let {
|
||||
comment,
|
||||
currentUserId = null,
|
||||
onSeek,
|
||||
onResolve,
|
||||
onReply,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
comment: Comment;
|
||||
currentUserId?: string | null;
|
||||
onSeek?: (time: number) => void;
|
||||
onResolve: (id: string) => void;
|
||||
onReply?: (id: string) => void;
|
||||
onEdit?: (id: string, body: string) => Promise<void>;
|
||||
onDelete?: (id: string) => Promise<void>;
|
||||
} = $props();
|
||||
|
||||
const displayName = $derived(comment.user?.name ?? comment.guestName ?? 'Gast');
|
||||
const isGuest = $derived(!comment.user);
|
||||
const isMine = $derived(!!currentUserId && comment.user?.id === currentUserId);
|
||||
|
||||
let editing = $state(false);
|
||||
let editBody = $state('');
|
||||
|
||||
function startEdit() {
|
||||
editBody = comment.body;
|
||||
editing = true;
|
||||
}
|
||||
async function saveEdit() {
|
||||
if (!editBody.trim() || !onEdit) return;
|
||||
await onEdit(comment.id, editBody.trim());
|
||||
editing = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="comment" class:resolved={comment.resolvedAt}>
|
||||
@@ -43,14 +64,38 @@
|
||||
<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>
|
||||
<button class="action-btn" onclick={() => onReply?.(comment.id)} title="Antworten" aria-label="Antworten">
|
||||
<Icon name="comment" size={12} />
|
||||
</button>
|
||||
{/if}
|
||||
{#if isMine && onEdit && !editing}
|
||||
<button class="action-btn" onclick={startEdit} title="Bearbeiten" aria-label="Bearbeiten">
|
||||
<Icon name="settings" size={12} />
|
||||
</button>
|
||||
{/if}
|
||||
{#if isMine && onDelete}
|
||||
<button class="action-btn danger" onclick={() => onDelete?.(comment.id)} title="Löschen" aria-label="Löschen">
|
||||
<Icon name="x" size={12} />
|
||||
</button>
|
||||
{/if}
|
||||
{#if !comment.resolvedAt}
|
||||
<button class="action-btn resolve" onclick={() => onResolve(comment.id)} title="Resolve">✓</button>
|
||||
<button class="action-btn resolve" onclick={() => onResolve(comment.id)} title="Erledigt" aria-label="Erledigt">
|
||||
<Icon name="check" size={12} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<p class="comment-body">{comment.body}</p>
|
||||
{#if editing}
|
||||
<div class="edit">
|
||||
<textarea bind:value={editBody} rows="2"></textarea>
|
||||
<div class="edit-actions">
|
||||
<button class="link" onclick={() => (editing = false)}>Abbrechen</button>
|
||||
<button class="link save" onclick={saveEdit} disabled={!editBody.trim()}>Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="comment-body">{comment.body}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -126,6 +171,51 @@
|
||||
color: var(--color-success);
|
||||
border-color: var(--color-success);
|
||||
}
|
||||
.action-btn.danger:hover {
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.edit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.edit textarea {
|
||||
width: 100%;
|
||||
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-family: inherit;
|
||||
font-size: var(--text-sm);
|
||||
resize: vertical;
|
||||
}
|
||||
.edit-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-tertiary);
|
||||
font-family: inherit;
|
||||
font-size: var(--text-xs);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.link:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.link.save {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
.link:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.guest-tag {
|
||||
font-size: var(--text-xs);
|
||||
@@ -18,17 +18,23 @@
|
||||
let {
|
||||
comments,
|
||||
canComment = false,
|
||||
currentUserId = null,
|
||||
commentTimestamp = $bindable<number | null>(null),
|
||||
onSubmit,
|
||||
onResolve,
|
||||
onSeek,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
comments: Comment[];
|
||||
canComment?: boolean;
|
||||
currentUserId?: string | null;
|
||||
commentTimestamp: number | null;
|
||||
onSubmit: (body: string, timestamp: number | null, parentId?: string) => void;
|
||||
onResolve: (id: string) => void;
|
||||
onSeek?: (time: number) => void;
|
||||
onEdit?: (id: string, body: string) => Promise<void>;
|
||||
onDelete?: (id: string) => Promise<void>;
|
||||
} = $props();
|
||||
|
||||
let body = $state('');
|
||||
@@ -94,10 +100,10 @@
|
||||
|
||||
<div class="comment-list">
|
||||
{#each topLevel as comment}
|
||||
<CommentItem {comment} {onSeek} {onResolve} onReply={handleReply} />
|
||||
<CommentItem {comment} {currentUserId} {onSeek} {onResolve} {onEdit} {onDelete} onReply={handleReply} />
|
||||
{#each replies(comment.id) as reply}
|
||||
<div class="reply">
|
||||
<CommentItem comment={reply} {onSeek} {onResolve} />
|
||||
<CommentItem comment={reply} {currentUserId} {onSeek} {onResolve} {onEdit} {onDelete} />
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
@@ -0,0 +1,322 @@
|
||||
<script lang="ts">
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import { api } from '$lib/api/client.js';
|
||||
import { toastSuccess } from '$lib/stores/toast.js';
|
||||
import { timeAgo } from '$lib/utils/format.js';
|
||||
|
||||
type ShareLink = {
|
||||
id: string;
|
||||
token: string;
|
||||
expiresAt: string | null;
|
||||
allowComments: boolean;
|
||||
allowDownload: boolean;
|
||||
hasPassword: boolean;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
versionId,
|
||||
}: {
|
||||
open: boolean;
|
||||
versionId: string;
|
||||
} = $props();
|
||||
|
||||
let tab = $state<'create' | 'manage'>('create');
|
||||
let allowComments = $state(true);
|
||||
let allowDownload = $state(false);
|
||||
let password = $state('');
|
||||
let creating = $state(false);
|
||||
let createdUrl = $state('');
|
||||
|
||||
let links = $state<ShareLink[]>([]);
|
||||
let loadingLinks = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (open && tab === 'manage') loadLinks();
|
||||
});
|
||||
|
||||
async function loadLinks() {
|
||||
loadingLinks = true;
|
||||
try {
|
||||
const res = await api.get<{ links: ShareLink[] }>(`/share/version/${versionId}`);
|
||||
links = res.links;
|
||||
} finally {
|
||||
loadingLinks = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function create() {
|
||||
creating = true;
|
||||
try {
|
||||
const res = await api.post<{ link: { token: string } }>(
|
||||
`/share/version/${versionId}`,
|
||||
{
|
||||
allowComments,
|
||||
allowDownload,
|
||||
password: password || undefined,
|
||||
},
|
||||
);
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
createdUrl = `${origin}/listen/${res.link.token}`;
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copy(url: string) {
|
||||
await navigator.clipboard.writeText(url);
|
||||
toastSuccess('Link kopiert');
|
||||
}
|
||||
|
||||
async function revoke(linkId: string) {
|
||||
if (!confirm('Diesen Link wirklich widerrufen? Niemand kann ihn dann mehr öffnen.')) return;
|
||||
await api.delete(`/share/${linkId}`);
|
||||
toastSuccess('Link widerrufen');
|
||||
await loadLinks();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
createdUrl = '';
|
||||
password = '';
|
||||
}
|
||||
|
||||
function urlFor(token: string) {
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
return `${origin}/listen/${token}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:open title="Teilen">
|
||||
<div class="tabs">
|
||||
<button class:active={tab === 'create'} onclick={() => (tab = 'create')}>Neuer Link</button>
|
||||
<button class:active={tab === 'manage'} onclick={() => (tab = 'manage')}>Aktive Links</button>
|
||||
</div>
|
||||
|
||||
{#if tab === 'create'}
|
||||
{#if !createdUrl}
|
||||
<div class="form">
|
||||
<label class="row">
|
||||
<input type="checkbox" bind:checked={allowComments} />
|
||||
<span>Kommentare erlauben (auch ohne Account)</span>
|
||||
</label>
|
||||
<label class="row">
|
||||
<input type="checkbox" bind:checked={allowDownload} />
|
||||
<span>Download des Originals erlauben</span>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Passwort (optional)</span>
|
||||
<input type="text" bind:value={password} placeholder="leer = kein Passwort" />
|
||||
</label>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="result">
|
||||
<p>Link erstellt:</p>
|
||||
<input type="text" readonly value={createdUrl} onclick={(e) => (e.target as HTMLInputElement).select()} />
|
||||
<Button size="sm" onclick={() => copy(createdUrl)}>Kopieren</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if loadingLinks}
|
||||
<p class="muted">Lädt…</p>
|
||||
{:else if links.length === 0}
|
||||
<p class="muted">Keine aktiven Links für diese Version.</p>
|
||||
{:else}
|
||||
<ul class="link-list">
|
||||
{#each links as link}
|
||||
<li>
|
||||
<div class="link-meta">
|
||||
<span class="token">/listen/{link.token.slice(0, 12)}…</span>
|
||||
<div class="flags">
|
||||
{#if link.hasPassword}<span class="flag"><Icon name="lock" size={11} /> Passwort</span>{/if}
|
||||
{#if link.allowComments}<span class="flag">Kommentare</span>{/if}
|
||||
{#if link.allowDownload}<span class="flag">Download</span>{/if}
|
||||
<span class="flag age">erstellt {timeAgo(link.createdAt)}</span>
|
||||
{#if link.expiresAt}<span class="flag expire">läuft {timeAgo(link.expiresAt)}</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="link-actions">
|
||||
<button class="icon-btn" onclick={() => copy(urlFor(link.token))} title="Link kopieren" aria-label="Kopieren">
|
||||
<Icon name="link" size={14} />
|
||||
</button>
|
||||
<button class="icon-btn danger" onclick={() => revoke(link.id)} title="Widerrufen" aria-label="Widerrufen">
|
||||
<Icon name="x" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#snippet actions()}
|
||||
{#if tab === 'create'}
|
||||
{#if !createdUrl}
|
||||
<Button variant="ghost" onclick={() => (open = false)}>Abbrechen</Button>
|
||||
<Button loading={creating} onclick={create}>Link erzeugen</Button>
|
||||
{:else}
|
||||
<Button variant="ghost" onclick={reset}>Weiteren erzeugen</Button>
|
||||
<Button onclick={() => { open = false; reset(); }}>Fertig</Button>
|
||||
{/if}
|
||||
{:else}
|
||||
<Button onclick={() => (open = false)}>Schließen</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
margin-bottom: var(--space-5);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.tabs button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-tertiary);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: var(--text-sm);
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.tabs button:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.tabs button.active {
|
||||
color: var(--color-text-primary);
|
||||
border-bottom-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.field input,
|
||||
.result input {
|
||||
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-family: inherit;
|
||||
font-size: var(--text-sm);
|
||||
width: 100%;
|
||||
}
|
||||
.result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.result p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
text-align: center;
|
||||
padding: var(--space-4) 0;
|
||||
}
|
||||
|
||||
.link-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.link-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: var(--color-bg-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.link-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.token {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.flags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.flag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.flag.expire {
|
||||
color: var(--color-warning);
|
||||
border-color: rgba(251, 191, 36, 0.3);
|
||||
}
|
||||
.link-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.icon-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-tertiary);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.icon-btn:hover {
|
||||
color: var(--color-text-primary);
|
||||
border-color: var(--color-border-hover);
|
||||
}
|
||||
.icon-btn.danger:hover {
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
</style>
|
||||
@@ -68,7 +68,7 @@
|
||||
</script>
|
||||
|
||||
<div class="graph">
|
||||
<h2>Version Graph</h2>
|
||||
<h2>Versionen</h2>
|
||||
{#if nodes.length === 0}
|
||||
<p class="empty">Noch keine Versionen.</p>
|
||||
{:else}
|
||||
@@ -117,14 +117,14 @@
|
||||
font-size="12"
|
||||
fill="#ccc"
|
||||
>
|
||||
{p.node.label || p.node.branchLabel || (p.col === 0 ? 'main' : 'branch')}
|
||||
{p.node.label || p.node.branchLabel || (p.col === 0 ? 'Hauptversion' : 'Variante')}
|
||||
</text>
|
||||
</g>
|
||||
{/each}
|
||||
</svg>
|
||||
{#if onBranch && selectedId}
|
||||
<button class="branch-btn" onclick={() => onBranch?.(selectedId!)}>
|
||||
⑂ Neue Variante von dieser Version
|
||||
Neue Variante von dieser Version
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
|
||||
type Version = {
|
||||
id: string;
|
||||
@@ -26,6 +27,14 @@
|
||||
({ approved: 'success', rejected: 'error', processing: 'warning', ready: 'accent', uploaded: 'default' } as const)[version.status] || 'default'
|
||||
);
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
uploaded: 'hochgeladen',
|
||||
processing: 'wird verarbeitet',
|
||||
ready: 'bereit',
|
||||
approved: 'freigegeben',
|
||||
rejected: 'abgelehnt',
|
||||
};
|
||||
|
||||
const showActions = $derived(canApprove && version.status !== 'approved' && version.status !== 'rejected');
|
||||
</script>
|
||||
|
||||
@@ -35,16 +44,16 @@
|
||||
V{version.versionNumber}
|
||||
{#if version.label} — {version.label}{/if}
|
||||
</span>
|
||||
<Badge variant={statusVariant}>{version.status}</Badge>
|
||||
<Badge variant={statusVariant}>{STATUS_LABEL[version.status] || 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>
|
||||
<span class="ok"><Icon name="check" size={14} /> Freigeben</span>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onclick={onReject}>
|
||||
<span style="color: var(--color-error)">✕ Reject</span>
|
||||
<span class="err"><Icon name="x" size={14} /> Ablehnen</span>
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -77,6 +86,14 @@
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.ok, .err {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.ok { color: var(--color-success); }
|
||||
.err { color: var(--color-error); }
|
||||
|
||||
.version-notes {
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
@@ -4,6 +4,7 @@
|
||||
import { toastSuccess } from '$lib/stores/toast.js';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import TopBar from '$lib/components/workspace/TopBar.svelte';
|
||||
|
||||
let name = $state('');
|
||||
let description = $state('');
|
||||
@@ -17,7 +18,7 @@
|
||||
name,
|
||||
description: description || undefined,
|
||||
});
|
||||
toastSuccess('Project created');
|
||||
toastSuccess('Projekt erstellt');
|
||||
goto(`/projects/${res.project.id}`);
|
||||
} finally {
|
||||
loading = false;
|
||||
@@ -25,21 +26,28 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<TopBar
|
||||
crumbs={[
|
||||
{ label: 'Projekte', href: '/dashboard' },
|
||||
{ label: 'Neues Projekt' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div class="page">
|
||||
<div class="card">
|
||||
<h1>New Project</h1>
|
||||
<h1>Neues Projekt</h1>
|
||||
|
||||
<form onsubmit={handleSubmit}>
|
||||
<Input label="Name" bind:value={name} placeholder="My Album" />
|
||||
<Input label="Name" bind:value={name} placeholder="Mein 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>
|
||||
<label class="textarea-label">Beschreibung (optional)</label>
|
||||
<textarea bind:value={description} placeholder="Worum geht's in diesem Projekt?" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<Button variant="secondary" href="/dashboard">Cancel</Button>
|
||||
<Button type="submit" {loading}>Create</Button>
|
||||
<Button variant="secondary" href="/dashboard">Abbrechen</Button>
|
||||
<Button type="submit" {loading}>Anlegen</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -49,7 +57,7 @@
|
||||
.page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--space-8) var(--space-4);
|
||||
padding: var(--space-12) var(--space-4);
|
||||
}
|
||||
|
||||
.card {
|
||||
@@ -1,10 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { checkAuth, user, authLoading } from '$lib/stores/auth.js';
|
||||
import { page } from '$app/stores';
|
||||
import { checkAuth, authLoading } from '$lib/stores/auth.js';
|
||||
import ToastContainer from '$lib/components/ui/ToastContainer.svelte';
|
||||
import '@fontsource-variable/inter';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// Public routes that should never block on auth check
|
||||
const isPublic = $derived(
|
||||
$page.url.pathname === '/' ||
|
||||
$page.url.pathname === '/login' ||
|
||||
$page.url.pathname.startsWith('/listen/'),
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
checkAuth();
|
||||
});
|
||||
@@ -15,7 +24,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</svelte:head>
|
||||
|
||||
{#if $authLoading}
|
||||
{#if $authLoading && !isPublic}
|
||||
<div class="loading">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
@@ -27,33 +36,35 @@
|
||||
|
||||
<style>
|
||||
:global(:root) {
|
||||
/* Background */
|
||||
--color-bg-base: #0a0a0a;
|
||||
--color-bg-raised: #111111;
|
||||
--color-bg-overlay: #1a1a1a;
|
||||
--color-bg-subtle: #222222;
|
||||
/* Background — warm neutrals */
|
||||
--color-bg-base: #0a0910;
|
||||
--color-bg-raised: #131119;
|
||||
--color-bg-overlay: #1a1822;
|
||||
--color-bg-subtle: #221f2c;
|
||||
|
||||
/* Borders */
|
||||
--color-border: #2a2a2a;
|
||||
--color-border-hover: #333333;
|
||||
--color-border-focus: #6366f1;
|
||||
--color-border: #24222e;
|
||||
--color-border-hover: #32303c;
|
||||
--color-border-focus: #f43f5e;
|
||||
|
||||
/* Text */
|
||||
--color-text-primary: #f0f0f0;
|
||||
--color-text-secondary: #a0a0a0;
|
||||
--color-text-tertiary: #666666;
|
||||
--color-text-primary: #f4f0ec;
|
||||
--color-text-secondary: #9b96a8;
|
||||
--color-text-tertiary: #5e596b;
|
||||
|
||||
/* Accent */
|
||||
--color-accent: #6366f1;
|
||||
--color-accent-hover: #5558e6;
|
||||
--color-accent-subtle: #1a1a2e;
|
||||
/* Accent — warm magenta → orange */
|
||||
--color-accent: #f43f5e;
|
||||
--color-accent-2: #fb923c;
|
||||
--color-accent-hover: #e11d48;
|
||||
--color-accent-subtle: #2a121c;
|
||||
--gradient-accent: linear-gradient(135deg, #f43f5e 0%, #fb923c 100%);
|
||||
|
||||
/* Semantic */
|
||||
--color-success: #22c55e;
|
||||
--color-warning: #fbbf24;
|
||||
--color-error: #ef4444;
|
||||
|
||||
/* Spacing */
|
||||
/* Spacing — fluid scale */
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
@@ -63,31 +74,39 @@
|
||||
--space-8: 2rem;
|
||||
--space-10: 2.5rem;
|
||||
--space-12: 3rem;
|
||||
--space-16: 4rem;
|
||||
--space-20: 5rem;
|
||||
|
||||
/* Radii */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
--radius-xl: 20px;
|
||||
--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);
|
||||
/* Shadows — soft + warm */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 24px 60px rgba(0, 0, 0, 0.55);
|
||||
--shadow-glow: 0 0 0 1px rgba(244, 63, 94, 0.4), 0 8px 32px rgba(244, 63, 94, 0.18);
|
||||
|
||||
/* Typography */
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-mono: 'SF Mono', 'Fira Code', monospace;
|
||||
/* Typography — Inter first, system never */
|
||||
--font-sans: 'Inter Variable', 'Inter', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.85rem;
|
||||
--text-base: 0.9rem;
|
||||
--text-lg: 1.1rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-base: 0.9375rem;
|
||||
--text-lg: 1.125rem;
|
||||
--text-xl: 1.5rem;
|
||||
--text-2xl: 2rem;
|
||||
--text-3xl: 2.75rem;
|
||||
--text-4xl: 3.75rem;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-base: 200ms ease;
|
||||
/* Transitions — opinionated easing */
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
--transition-fast: 120ms var(--ease-out);
|
||||
--transition-base: 200ms var(--ease-out);
|
||||
|
||||
/* Z-Index */
|
||||
--z-dropdown: 100;
|
||||
@@ -95,31 +114,76 @@
|
||||
--z-toast: 300;
|
||||
}
|
||||
|
||||
:global(html) {
|
||||
background: var(--color-bg-base);
|
||||
}
|
||||
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
background: var(--color-bg-base);
|
||||
background:
|
||||
radial-gradient(ellipse 900px 500px at 12% -10%, rgba(244, 63, 94, 0.10), transparent 55%),
|
||||
radial-gradient(ellipse 700px 400px at 92% 110%, rgba(251, 146, 60, 0.06), transparent 60%),
|
||||
var(--color-bg-base);
|
||||
background-attachment: fixed;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.5;
|
||||
line-height: 1.55;
|
||||
font-feature-settings: 'cv11', 'ss01', 'ss03';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
min-height: 100vh;
|
||||
/* Avoid iOS rubber-band white flash */
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
:global(*) {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:global(h1, h2, h3) {
|
||||
:global(h1, h2, h3, h4) {
|
||||
color: var(--color-text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:global(h1) {
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
:global(a) {
|
||||
color: var(--color-accent);
|
||||
color: var(--color-text-primary);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
:global(a:hover) {
|
||||
color: var(--color-accent-hover);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
:global(*:focus) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:global(*:focus-visible) {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
:global(::selection) {
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:global(*),
|
||||
:global(*::before),
|
||||
:global(*::after) {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
|
||||
@@ -1,89 +1,701 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { user, sendMagicLink } from '$lib/stores/auth.js';
|
||||
import { user } 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;
|
||||
}
|
||||
}
|
||||
// TODO: Demo-Token ist statisch. Phase 2: dynamisch via /api/v1/share/demo laden
|
||||
// oder beim Seed in eine Settings-Tabelle schreiben.
|
||||
const DEMO_SHARE_TOKEN = '0b75a672afaa14aa5d97c5af0343f93edd3aa78e5b3ce30d1d695977ac4a3fc1';
|
||||
</script>
|
||||
|
||||
<div class="login-page">
|
||||
<div class="login-card">
|
||||
<h1>Music Hub</h1>
|
||||
<p class="subtitle">Collaboration for music production</p>
|
||||
<svelte:head>
|
||||
<title>Music Hub — Versionen für Musik. Ohne Chaos.</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Music Hub macht Schluss mit dem Versions-Chaos in der Musikproduktion. Jede Version sauber an einem Ort, Feedback per Klick auf die Wellenform, teilen ohne Account."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
{#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 class="page">
|
||||
<!-- NAV -->
|
||||
<nav class="nav">
|
||||
<a href="/" class="logo">Music Hub</a>
|
||||
<div class="nav-right">
|
||||
{#if $user}
|
||||
<Button href="/dashboard" size="sm">Zum Dashboard</Button>
|
||||
{:else}
|
||||
<a href="/login" class="nav-link">Einloggen</a>
|
||||
<Button href="/login" size="sm">Kostenlos starten</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 1. HERO -->
|
||||
<section class="hero">
|
||||
<div class="hero-text">
|
||||
<p class="eyebrow">Für Producer, Artists, Studios</p>
|
||||
<h1>
|
||||
Versionen für Musik.<br />
|
||||
<span class="grad">Ohne Chaos.</span>
|
||||
</h1>
|
||||
<p class="lede">
|
||||
Schluss mit "Final_v3_REAL.wav". Jede Version deines Tracks an einem Ort,
|
||||
Feedback direkt auf der Wellenform, und dein Artist hört rein —
|
||||
ohne Account, ohne Anmeldung, ohne Stress.
|
||||
</p>
|
||||
<div class="hero-cta">
|
||||
<Button href="/login" size="lg">Kostenlos starten</Button>
|
||||
<a href="/listen/{DEMO_SHARE_TOKEN}" target="_blank" rel="noopener" class="cta-secondary">
|
||||
Live-Demo ansehen <span class="arrow">→</span>
|
||||
</a>
|
||||
</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>
|
||||
|
||||
<!-- HERO MOCKUP -->
|
||||
<div class="hero-mockup" aria-hidden="true">
|
||||
<svg viewBox="0 0 480 360" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#f43f5e" />
|
||||
<stop offset="100%" stop-color="#fb923c" />
|
||||
</linearGradient>
|
||||
<linearGradient id="wave-fade" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#f43f5e" stop-opacity="0.8" />
|
||||
<stop offset="100%" stop-color="#fb923c" stop-opacity="0.4" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Card background -->
|
||||
<rect x="20" y="20" width="440" height="320" rx="16" fill="#1a1822" stroke="#24222e" />
|
||||
|
||||
<!-- Header -->
|
||||
<text x="40" y="55" fill="#9b96a8" font-family="Inter Variable, system-ui" font-size="11" font-weight="500">HAUPTMIX · V2</text>
|
||||
<circle cx="430" cy="50" r="5" fill="#22c55e" />
|
||||
<text x="412" y="54" fill="#22c55e" font-family="Inter Variable, system-ui" font-size="10" text-anchor="end">ready</text>
|
||||
|
||||
<!-- Waveform -->
|
||||
<g transform="translate(40, 80)">
|
||||
{#each Array(60) as _, i}
|
||||
{@const h = 8 + Math.abs(Math.sin(i * 0.4)) * 38 + Math.abs(Math.cos(i * 0.7)) * 12}
|
||||
<rect
|
||||
x={i * 6.6}
|
||||
y={(60 - h) / 2}
|
||||
width="3"
|
||||
height={h}
|
||||
rx="1.5"
|
||||
fill={i < 28 ? 'url(#wave-fade)' : '#32303c'}
|
||||
/>
|
||||
{/each}
|
||||
<!-- Comment marker -->
|
||||
<circle cx="155" cy="-4" r="6" fill="url(#grad)" />
|
||||
<line x1="155" y1="2" x2="155" y2="62" stroke="#f43f5e" stroke-width="1.5" stroke-dasharray="2 2" opacity="0.6" />
|
||||
</g>
|
||||
|
||||
<!-- Comment bubble -->
|
||||
<g transform="translate(40, 165)">
|
||||
<rect width="400" height="48" rx="10" fill="#221f2c" stroke="#24222e" />
|
||||
<circle cx="22" cy="24" r="11" fill="url(#grad)" />
|
||||
<text x="22" y="28" text-anchor="middle" fill="#fff" font-size="10" font-family="Inter Variable, system-ui" font-weight="600">A</text>
|
||||
<text x="42" y="20" fill="#f4f0ec" font-family="Inter Variable, system-ui" font-size="11" font-weight="500">Anna</text>
|
||||
<text x="78" y="20" fill="#fb923c" font-family="Inter Variable, system-ui" font-size="9">1:17</text>
|
||||
<text x="42" y="36" fill="#9b96a8" font-family="Inter Variable, system-ui" font-size="10">Vocals etwas weiter nach vorn ziehen?</text>
|
||||
</g>
|
||||
|
||||
<!-- Mini graph -->
|
||||
<g transform="translate(40, 235)">
|
||||
<text x="0" y="-4" fill="#9b96a8" font-family="Inter Variable, system-ui" font-size="10" font-weight="500">VERSIONEN</text>
|
||||
<!-- mainline -->
|
||||
<line x1="20" y1="20" x2="20" y2="80" stroke="#32303c" stroke-width="2" />
|
||||
<!-- branch -->
|
||||
<path d="M 20 50 C 20 60, 80 60, 80 70" stroke="#fb923c" stroke-width="2" fill="none" />
|
||||
<circle cx="20" cy="20" r="9" fill="#1a1822" stroke="#32303c" stroke-width="2" />
|
||||
<text x="20" y="24" text-anchor="middle" fill="#9b96a8" font-size="9" font-family="Inter Variable, system-ui">1</text>
|
||||
<circle cx="20" cy="50" r="9" fill="url(#grad)" />
|
||||
<text x="20" y="54" text-anchor="middle" fill="#fff" font-size="9" font-family="Inter Variable, system-ui" font-weight="600">2</text>
|
||||
<circle cx="20" cy="80" r="9" fill="#1a1822" stroke="#32303c" stroke-width="2" />
|
||||
<text x="20" y="84" text-anchor="middle" fill="#9b96a8" font-size="9" font-family="Inter Variable, system-ui">4</text>
|
||||
<circle cx="80" cy="70" r="9" fill="#1a1822" stroke="#fb923c" stroke-width="2" />
|
||||
<text x="80" y="74" text-anchor="middle" fill="#fb923c" font-size="9" font-family="Inter Variable, system-ui">3</text>
|
||||
<text x="38" y="24" fill="#5e596b" font-family="Inter Variable, system-ui" font-size="10">main · Erster Wurf</text>
|
||||
<text x="38" y="54" fill="#9b96a8" font-family="Inter Variable, system-ui" font-size="10">main · Mehr Bass</text>
|
||||
<text x="98" y="74" fill="#fb923c" font-family="Inter Variable, system-ui" font-size="10">vocals-neu</text>
|
||||
<text x="38" y="84" fill="#5e596b" font-family="Inter Variable, system-ui" font-size="10">main · Final</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 2. PROBLEM -->
|
||||
<section class="problem">
|
||||
<h2>Wenn dir <code>Final_v3_REAL.wav</code> bekannt vorkommt, wissen wir, wovon wir reden.</h2>
|
||||
<p class="lede center">
|
||||
Gemeinsam an einem Track zu arbeiten ist 2026 immer noch eine Mischung aus Dropbox-Links,
|
||||
WhatsApp-Sprachnachrichten und der leisen Hoffnung, dass alle die richtige Datei haben.
|
||||
</p>
|
||||
<div class="problem-grid">
|
||||
<div class="problem-card">
|
||||
<span class="icon">🎙️</span>
|
||||
<h3>Sprachnachrichten statt Comments</h3>
|
||||
<p>Wertvolles Feedback verschwindet im WhatsApp-Verlauf. Keine Historie, kein Kontext, kein Status.</p>
|
||||
</div>
|
||||
<div class="problem-card">
|
||||
<span class="icon">📎</span>
|
||||
<h3>Dropbox-Links pro Version</h3>
|
||||
<p>Niemand weiß welche Version aktuell ist. Niemand weiß welche freigegeben wurde. Niemand traut sich zu fragen.</p>
|
||||
</div>
|
||||
<div class="problem-card">
|
||||
<span class="icon">✅</span>
|
||||
<h3>Approval per Bauchgefühl</h3>
|
||||
<p>Was war jetzt der finale Mix? Wer hat freigegeben? Wann? Niemand weiß es mehr — und der Master ist morgen fällig.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 3. LÖSUNG -->
|
||||
<section class="solution">
|
||||
<p class="eyebrow center">So funktioniert's</p>
|
||||
<h2 class="center">Drei Dinge. <span class="grad">Eine ruhige Produktion.</span></h2>
|
||||
|
||||
<div class="solution-row">
|
||||
<div class="solution-text">
|
||||
<h3>① Jede Version bleibt erhalten</h3>
|
||||
<p>
|
||||
Lade eine neue Version hoch — die alte verschwindet nicht.
|
||||
Probier eine Variante mit anderen Vocals, mehr Bass, neuem Mix:
|
||||
alles bleibt nebeneinander, du springst in einem Klick zwischen ihnen,
|
||||
und du verlierst niemals einen Stand, der dir gefallen hat.
|
||||
</p>
|
||||
</div>
|
||||
<div class="solution-visual">
|
||||
<svg viewBox="0 0 200 140" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<line x1="40" y1="20" x2="40" y2="120" stroke="#32303c" stroke-width="2" />
|
||||
<path d="M 40 60 C 40 75, 120 75, 120 90" stroke="#fb923c" stroke-width="2" fill="none" />
|
||||
<path d="M 120 90 C 120 100, 40 100, 40 110" stroke="#fb923c" stroke-width="2" fill="none" stroke-dasharray="3 3" />
|
||||
<circle cx="40" cy="20" r="10" fill="#1a1822" stroke="#32303c" stroke-width="2" />
|
||||
<circle cx="40" cy="60" r="10" fill="url(#grad)" />
|
||||
<circle cx="120" cy="90" r="10" fill="#1a1822" stroke="#fb923c" stroke-width="2" />
|
||||
<circle cx="40" cy="110" r="10" fill="url(#grad)" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="solution-row reverse">
|
||||
<div class="solution-text">
|
||||
<h3>② Feedback direkt auf der Wellenform</h3>
|
||||
<p>
|
||||
Kein "bei ungefähr 1:30 ist die Snare zu laut" mehr. Klick auf die Stelle in der Welle,
|
||||
schreib was dir auffällt, fertig. Der andere sieht deine Anmerkung an genau dieser Sekunde,
|
||||
klickt drauf, springt hin, hört es selbst.
|
||||
</p>
|
||||
</div>
|
||||
<div class="solution-visual">
|
||||
<svg viewBox="0 0 200 140" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
{#each Array(28) as _, i}
|
||||
{@const h = 6 + Math.abs(Math.sin(i * 0.5)) * 32 + Math.abs(Math.cos(i * 0.8)) * 8}
|
||||
<rect
|
||||
x={10 + i * 6.7}
|
||||
y={(80 - h) / 2 + 30}
|
||||
width="3"
|
||||
height={h}
|
||||
rx="1.5"
|
||||
fill={i < 14 ? 'url(#wave-fade)' : '#32303c'}
|
||||
/>
|
||||
{/each}
|
||||
<circle cx="100" cy="20" r="8" fill="url(#grad)" />
|
||||
<line x1="100" y1="28" x2="100" y2="105" stroke="#f43f5e" stroke-width="1.5" stroke-dasharray="2 2" opacity="0.6" />
|
||||
<text x="100" y="24" text-anchor="middle" fill="#fff" font-size="9" font-family="Inter Variable, system-ui" font-weight="600">!</text>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="solution-row">
|
||||
<div class="solution-text">
|
||||
<h3>③ Teilen ohne Anmeldung</h3>
|
||||
<p>
|
||||
Schick deinem Artist, deinem Label, deiner Mama einen Link.
|
||||
Sie öffnen ihn, hören rein, kommentieren — ganz ohne Account, ohne Passwort,
|
||||
ohne irgendwas zu installieren. Du siehst ihre Anmerkungen direkt in deinem Editor.
|
||||
</p>
|
||||
</div>
|
||||
<div class="solution-visual">
|
||||
<svg viewBox="0 0 200 140" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<rect x="20" y="40" width="160" height="60" rx="10" fill="#1a1822" stroke="#32303c" stroke-width="2" />
|
||||
<text x="35" y="64" fill="#9b96a8" font-family="Inter Variable, system-ui" font-size="10">music-hub.app/listen/</text>
|
||||
<text x="35" y="80" fill="url(#grad)" font-family="JetBrains Mono, monospace" font-size="11" font-weight="600">a7b3...4f9c</text>
|
||||
<g transform="translate(150, 60)">
|
||||
<circle r="14" fill="url(#grad)" />
|
||||
<text y="5" text-anchor="middle" fill="#fff" font-size="14" font-weight="700">↗</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 4. WER ES IST (Two-Sided) -->
|
||||
<section class="two-sided">
|
||||
<h2 class="center">Für beide Seiten gemacht.</h2>
|
||||
<div class="cards">
|
||||
<div class="persona-card">
|
||||
<p class="eyebrow">Für Producer & Tontechniker</p>
|
||||
<h3>Endlich Ordnung im Track-Ordner</h3>
|
||||
<ul>
|
||||
<li>Keine "Final_REAL_v3_master.wav" mehr — jede Version hat ihren Platz</li>
|
||||
<li>Zwei Versionen direkt nebeneinander vergleichen, im Browser</li>
|
||||
<li>Sehe schwarz auf weiß, was freigegeben wurde und von wem</li>
|
||||
<li>Deine Daten gehören dir — auch zum Selber-Hosten</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="persona-card">
|
||||
<p class="eyebrow">Für Artists, Labels & Kunden</p>
|
||||
<h3>Feedback geben, ohne Hürden</h3>
|
||||
<ul>
|
||||
<li>Link öffnen reicht — kein Account, kein Passwort, nichts</li>
|
||||
<li>Klick auf die Welle, schreib was du denkst — fertig</li>
|
||||
<li>Du siehst immer die aktuelle Version, ohne nachzufragen</li>
|
||||
<li>Funktioniert im Bus, im Studio, auf der Couch</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 5. TRUST -->
|
||||
<section class="trust">
|
||||
<p class="trust-line">
|
||||
<strong>Open Source</strong> · <strong>Self-Hostable</strong> · <strong>Daten in der EU</strong>
|
||||
</p>
|
||||
<p class="stack">
|
||||
Gebaut mit SvelteKit · Hono · PostgreSQL · MinIO · FFmpeg
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- 6. FINAL CTA -->
|
||||
<section class="final-cta">
|
||||
<h2>In aktiver Entwicklung.<br /><span class="grad">Sei dabei.</span></h2>
|
||||
<p class="lede center">
|
||||
Music Hub ist im Beta-Stadium. Probier den aktuellen Build aus, gib Feedback,
|
||||
präg die Roadmap mit. Gratis, ohne Verpflichtung.
|
||||
</p>
|
||||
<div class="hero-cta center">
|
||||
<Button href="/login" size="lg">Account anlegen</Button>
|
||||
<!-- TODO: GitHub-Link sobald Repo öffentlich -->
|
||||
<a href="https://git.mydrugismusic.com/robin/music-hub" target="_blank" rel="noopener" class="cta-secondary">
|
||||
Auf Git anschauen <span class="arrow">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 7. FOOTER -->
|
||||
<footer class="footer">
|
||||
<div class="footer-grid">
|
||||
<div class="footer-brand">
|
||||
<p class="logo">Music Hub</p>
|
||||
<p class="footer-tag">Versionen für Musik. Ohne Chaos.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Produkt</h4>
|
||||
<a href="/login">Einloggen</a>
|
||||
<a href="/listen/{DEMO_SHARE_TOKEN}" target="_blank" rel="noopener">Live-Demo</a>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Open Source</h4>
|
||||
<a href="#">Repository</a>
|
||||
<a href="#">Self-Hosting</a>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Rechtliches</h4>
|
||||
<a href="#">Datenschutz</a>
|
||||
<a href="#">Impressum</a>
|
||||
</div>
|
||||
</div>
|
||||
<p class="copy">© 2026 Music Hub</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.login-page {
|
||||
.page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--space-6);
|
||||
}
|
||||
|
||||
/* NAV */
|
||||
.nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-5) 0;
|
||||
}
|
||||
.logo {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
background: var(--gradient-accent);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav-right {
|
||||
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);
|
||||
}
|
||||
.nav-link {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav-link:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.success p {
|
||||
color: var(--color-success);
|
||||
margin-bottom: var(--space-4);
|
||||
/* SECTIONS */
|
||||
section {
|
||||
padding: var(--space-16) 0;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--color-text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
margin: 0 0 var(--space-4);
|
||||
}
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
.grad {
|
||||
background: var(--gradient-accent);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: var(--text-3xl);
|
||||
line-height: 1.1;
|
||||
margin: 0 0 var(--space-5);
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.lede {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-lg);
|
||||
max-width: 56ch;
|
||||
line-height: 1.55;
|
||||
margin: 0 0 var(--space-8);
|
||||
}
|
||||
.lede.center {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* HERO */
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 1fr;
|
||||
gap: var(--space-12);
|
||||
align-items: center;
|
||||
padding: var(--space-10) 0 var(--space-16);
|
||||
}
|
||||
.hero h1 {
|
||||
font-size: clamp(2.5rem, 5.5vw, 4rem);
|
||||
line-height: 1.04;
|
||||
letter-spacing: -0.035em;
|
||||
font-weight: 700;
|
||||
margin: 0 0 var(--space-5);
|
||||
}
|
||||
.hero .lede {
|
||||
font-size: var(--text-lg);
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
.hero-cta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.hero-cta.center {
|
||||
justify-content: center;
|
||||
}
|
||||
.cta-secondary {
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: var(--text-sm);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
.cta-secondary:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
.cta-secondary .arrow {
|
||||
display: inline-block;
|
||||
transition: transform var(--transition-base);
|
||||
}
|
||||
.cta-secondary:hover .arrow {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.hero-mockup svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
filter: drop-shadow(0 24px 60px rgba(244, 63, 94, 0.18));
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-8px); }
|
||||
}
|
||||
|
||||
/* PROBLEM */
|
||||
.problem h2 {
|
||||
text-align: center;
|
||||
max-width: 22ch;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
.problem h2 code {
|
||||
background: var(--color-bg-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.1em 0.4em;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85em;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
.problem-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-5);
|
||||
margin-top: var(--space-10);
|
||||
}
|
||||
.problem-card {
|
||||
background: var(--color-bg-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-6);
|
||||
transition: border-color var(--transition-base), transform var(--transition-base);
|
||||
}
|
||||
.problem-card:hover {
|
||||
border-color: var(--color-border-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.problem-card .icon {
|
||||
font-size: 1.8rem;
|
||||
display: block;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.problem-card h3 {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
.problem-card p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* SOLUTION */
|
||||
.solution h2 {
|
||||
margin-bottom: var(--space-12);
|
||||
}
|
||||
.solution-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 1fr;
|
||||
gap: var(--space-12);
|
||||
align-items: center;
|
||||
padding: var(--space-8) 0;
|
||||
}
|
||||
.solution-row.reverse {
|
||||
direction: rtl;
|
||||
}
|
||||
.solution-row.reverse > * {
|
||||
direction: ltr;
|
||||
}
|
||||
.solution-text h3 {
|
||||
font-size: var(--text-xl);
|
||||
margin: 0 0 var(--space-3);
|
||||
}
|
||||
.solution-text p {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
.solution-visual {
|
||||
background: var(--color-bg-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-6);
|
||||
}
|
||||
.solution-visual svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
/* TWO-SIDED */
|
||||
.two-sided h2 {
|
||||
margin-bottom: var(--space-10);
|
||||
}
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.persona-card {
|
||||
background: var(--color-bg-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-8);
|
||||
transition: border-color var(--transition-base);
|
||||
}
|
||||
.persona-card:hover {
|
||||
border-color: var(--color-border-hover);
|
||||
}
|
||||
.persona-card h3 {
|
||||
font-size: var(--text-xl);
|
||||
margin: 0 0 var(--space-5);
|
||||
}
|
||||
.persona-card ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.persona-card li {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
padding-left: 1.4em;
|
||||
position: relative;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.persona-card li::before {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--color-accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* TRUST */
|
||||
.trust {
|
||||
text-align: center;
|
||||
border-top: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: var(--space-10) 0;
|
||||
}
|
||||
.trust-line {
|
||||
margin: 0 0 var(--space-2);
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
.trust-line strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
.stack {
|
||||
margin: 0;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* FINAL CTA */
|
||||
.final-cta {
|
||||
text-align: center;
|
||||
}
|
||||
.final-cta h2 {
|
||||
font-size: clamp(2rem, 4vw, 3rem);
|
||||
}
|
||||
|
||||
/* FOOTER */
|
||||
.footer {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding: var(--space-10) 0 var(--space-8);
|
||||
margin-top: var(--space-12);
|
||||
}
|
||||
.footer-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr 1fr;
|
||||
gap: var(--space-8);
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
.footer-brand .logo {
|
||||
margin: 0 0 var(--space-2);
|
||||
}
|
||||
.footer-tag {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
margin: 0;
|
||||
}
|
||||
.footer h4 {
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
margin: 0 0 var(--space-3);
|
||||
font-weight: 600;
|
||||
}
|
||||
.footer a {
|
||||
display: block;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
text-decoration: none;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.footer a:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.copy {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
padding-top: var(--space-6);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* MOBILE */
|
||||
@media (max-width: 880px) {
|
||||
.hero,
|
||||
.solution-row,
|
||||
.solution-row.reverse,
|
||||
.cards {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
.solution-row.reverse {
|
||||
direction: ltr;
|
||||
}
|
||||
.problem-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.footer-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
section {
|
||||
padding: var(--space-12) 0;
|
||||
}
|
||||
.hero h1 {
|
||||
font-size: clamp(2rem, 8vw, 2.75rem);
|
||||
}
|
||||
h2 {
|
||||
font-size: clamp(1.75rem, 6vw, 2.25rem);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.footer-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.hero-cta {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.hero-cta.center {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,14 +9,14 @@
|
||||
onMount(async () => {
|
||||
const token = $page.url.searchParams.get('token');
|
||||
if (!token) {
|
||||
error = 'No token provided';
|
||||
error = 'Kein Token angegeben';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await verifyToken(token);
|
||||
goto('/dashboard');
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Verification failed';
|
||||
error = err instanceof Error ? err.message : 'Login fehlgeschlagen';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -24,12 +24,12 @@
|
||||
<div class="verify-page">
|
||||
{#if error}
|
||||
<div class="error-card">
|
||||
<h2>Login Failed</h2>
|
||||
<h2>Login fehlgeschlagen</h2>
|
||||
<p>{error}</p>
|
||||
<a href="/">Try again</a>
|
||||
<a href="/login">Erneut versuchen</a>
|
||||
</div>
|
||||
{:else}
|
||||
<p>Verifying...</p>
|
||||
<p>Login läuft…</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
<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>
|
||||
112
apps/web/src/routes/login/+page.svelte
Normal file
112
apps/web/src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,112 @@
|
||||
<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 : 'Etwas ist schiefgelaufen';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="login-page">
|
||||
<a href="/" class="back">← Zurück</a>
|
||||
|
||||
<div class="login-card">
|
||||
<p class="brand">Music Hub</p>
|
||||
<h1>Einloggen</h1>
|
||||
<p class="card-sub">Magic Link per E-Mail. Kein Passwort, keine Hürden.</p>
|
||||
|
||||
{#if sent}
|
||||
<div class="success">
|
||||
<p>📬 Check deine E-Mails — der Link ist unterwegs.</p>
|
||||
<Button variant="secondary" onclick={() => { sent = false; email = ''; }}>Andere Adresse</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<form onsubmit={handleSubmit}>
|
||||
<Input type="email" bind:value={email} placeholder="deine@email.de" {error} />
|
||||
<Button type="submit" size="lg" {loading}>Login-Link senden</Button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.login-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: var(--space-8) var(--space-4);
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.back {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
text-decoration: none;
|
||||
}
|
||||
.back:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: var(--color-bg-overlay);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-10);
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: 0 20px 60px rgba(244, 63, 94, 0.08);
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
color: var(--color-text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2em;
|
||||
font-size: var(--text-xs);
|
||||
margin: 0 0 var(--space-2);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 var(--space-1);
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
|
||||
.card-sub {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
margin: 0 0 var(--space-6);
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.success p {
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
</style>
|
||||
@@ -1,218 +0,0 @@
|
||||
<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">← 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>
|
||||
@@ -1,436 +0,0 @@
|
||||
<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 VersionGraph from './components/VersionGraph.svelte';
|
||||
import ShareModal from './components/ShareModal.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;
|
||||
parentVersionId?: string | null;
|
||||
branchLabel?: string | null;
|
||||
};
|
||||
|
||||
type GraphNode = {
|
||||
id: string;
|
||||
parentVersionId: string | null;
|
||||
branchLabel: string | null;
|
||||
versionNumber: number;
|
||||
label: string | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type Comment = {
|
||||
id: string;
|
||||
body: string;
|
||||
timestampSeconds: number | null;
|
||||
parentId: string | null;
|
||||
resolvedAt: string | null;
|
||||
createdAt: string;
|
||||
guestName?: string | null;
|
||||
user: { id: string; name: string; avatarUrl: string | null } | 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('');
|
||||
let graphNodes = $state<GraphNode[]>([]);
|
||||
let viewMode = $state<'list' | 'graph'>('list');
|
||||
let branchFromId = $state<string | null>(null);
|
||||
let branchLabelInput = $state('');
|
||||
let shareOpen = $state(false);
|
||||
|
||||
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, treeRes] = 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}`),
|
||||
api.get<{ nodes: GraphNode[] }>(`/versions/track/${trackId}/tree`),
|
||||
]);
|
||||
|
||||
role = projectRes.role;
|
||||
trackName = tracksRes.tracks.find((t) => t.id === trackId)?.name || '';
|
||||
versions = trackVersions.versions;
|
||||
graphNodes = treeRes.nodes;
|
||||
|
||||
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, treeRes] = await Promise.all([
|
||||
api.get<{ versions: Version[] }>(`/versions/track/${trackId}`),
|
||||
api.get<{ nodes: GraphNode[] }>(`/versions/track/${trackId}/tree`),
|
||||
]);
|
||||
versions = res.versions;
|
||||
graphNodes = treeRes.nodes;
|
||||
if (versions.length > 0) await selectVersion(versions[0]);
|
||||
}
|
||||
|
||||
async function handlePromote() {
|
||||
if (!selectedVersion) return;
|
||||
await api.post(`/versions/${selectedVersion.id}/promote`);
|
||||
toastSuccess('Version übernommen');
|
||||
await loadVersions();
|
||||
}
|
||||
|
||||
function startBranch(id: string) {
|
||||
branchFromId = id;
|
||||
branchLabelInput = '';
|
||||
showUpload = true;
|
||||
}
|
||||
|
||||
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">← 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 ?? c.guestName ?? 'Gast',
|
||||
}))}
|
||||
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={() => { branchFromId = null; branchLabelInput = ''; showUpload = !showUpload; }}>
|
||||
{showUpload ? 'Cancel' : 'Upload new version'}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button variant="ghost" size="sm" onclick={handleDownload}>
|
||||
↓ Download
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onclick={() => (shareOpen = true)}>
|
||||
↗ Share
|
||||
</Button>
|
||||
{#if canApprove && selectedVersion.branchLabel}
|
||||
<Button variant="ghost" size="sm" onclick={handlePromote}>
|
||||
⤴ Übernehmen (Mainline)
|
||||
</Button>
|
||||
{/if}
|
||||
{#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}
|
||||
{#if branchFromId}
|
||||
<div class="branch-banner">
|
||||
<span>Neue Variante von <strong>V{graphNodes.find((n) => n.id === branchFromId)?.versionNumber}</strong></span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={branchLabelInput}
|
||||
placeholder="Branch-Name (z.B. 'vocals-neu')"
|
||||
/>
|
||||
<button class="cancel-branch" onclick={() => (branchFromId = null)}>×</button>
|
||||
</div>
|
||||
{/if}
|
||||
<UploadDropzone
|
||||
{trackId}
|
||||
parentVersionId={branchFromId}
|
||||
branchLabel={branchFromId ? branchLabelInput || 'branch' : null}
|
||||
onUploaded={() => { showUpload = false; branchFromId = null; 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}
|
||||
|
||||
{#if versions.length > 1}
|
||||
<div class="view-toggle">
|
||||
<button class:active={viewMode === 'list'} onclick={() => (viewMode = 'list')}>Liste</button>
|
||||
<button class:active={viewMode === 'graph'} onclick={() => (viewMode = 'graph')}>Graph</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if viewMode === 'graph'}
|
||||
<VersionGraph
|
||||
nodes={graphNodes}
|
||||
selectedId={selectedVersion?.id ?? null}
|
||||
onSelect={(id) => {
|
||||
const v = versions.find((v) => v.id === id);
|
||||
if (v) selectVersion(v);
|
||||
}}
|
||||
onBranch={canUpload ? startBranch : undefined}
|
||||
/>
|
||||
{:else}
|
||||
<VersionHistory
|
||||
{versions}
|
||||
selectedId={selectedVersion?.id ?? null}
|
||||
onSelect={selectVersion}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if selectedVersion}
|
||||
<ShareModal bind:open={shareOpen} versionId={selectedVersion.id} />
|
||||
{/if}
|
||||
</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;
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.view-toggle button {
|
||||
background: var(--color-bg-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-xs);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.view-toggle button.active {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
.branch-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--color-accent-subtle);
|
||||
border: 1px solid var(--color-accent);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.branch-banner input {
|
||||
flex: 1;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
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;
|
||||
}
|
||||
.cancel-branch {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-tertiary);
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -1,127 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import { api } from '$lib/api/client.js';
|
||||
import { toastSuccess } from '$lib/stores/toast.js';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
versionId,
|
||||
}: {
|
||||
open: boolean;
|
||||
versionId: string;
|
||||
} = $props();
|
||||
|
||||
let allowComments = $state(true);
|
||||
let allowDownload = $state(false);
|
||||
let password = $state('');
|
||||
let creating = $state(false);
|
||||
let createdUrl = $state('');
|
||||
|
||||
async function create() {
|
||||
creating = true;
|
||||
try {
|
||||
const res = await api.post<{ link: { token: string } }>(
|
||||
`/share/version/${versionId}`,
|
||||
{
|
||||
allowComments,
|
||||
allowDownload,
|
||||
password: password || undefined,
|
||||
},
|
||||
);
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
createdUrl = `${origin}/listen/${res.link.token}`;
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copy() {
|
||||
await navigator.clipboard.writeText(createdUrl);
|
||||
toastSuccess('Link kopiert');
|
||||
}
|
||||
|
||||
function reset() {
|
||||
createdUrl = '';
|
||||
password = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:open title="Link teilen">
|
||||
{#if !createdUrl}
|
||||
<div class="form">
|
||||
<label class="row">
|
||||
<input type="checkbox" bind:checked={allowComments} />
|
||||
<span>Kommentare erlauben (auch ohne Account)</span>
|
||||
</label>
|
||||
<label class="row">
|
||||
<input type="checkbox" bind:checked={allowDownload} />
|
||||
<span>Download des Originals erlauben</span>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Passwort (optional)</span>
|
||||
<input type="text" bind:value={password} placeholder="leer = kein Passwort" />
|
||||
</label>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="result">
|
||||
<p>Link erstellt:</p>
|
||||
<input type="text" readonly value={createdUrl} onclick={(e) => (e.target as HTMLInputElement).select()} />
|
||||
<Button size="sm" onclick={copy}>Kopieren</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#snippet actions()}
|
||||
{#if !createdUrl}
|
||||
<Button variant="ghost" onclick={() => (open = false)}>Abbrechen</Button>
|
||||
<Button loading={creating} onclick={create}>Link erzeugen</Button>
|
||||
{:else}
|
||||
<Button variant="ghost" onclick={reset}>Weiteren erzeugen</Button>
|
||||
<Button onclick={() => { open = false; reset(); }}>Fertig</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.field input,
|
||||
.result input {
|
||||
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-family: inherit;
|
||||
font-size: var(--text-sm);
|
||||
width: 100%;
|
||||
}
|
||||
.result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.result p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
</style>
|
||||
51
apps/web/src/service-worker.ts
Normal file
51
apps/web/src/service-worker.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/// <reference types="@sveltejs/kit" />
|
||||
/// <reference no-default-lib="true"/>
|
||||
/// <reference lib="esnext" />
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
// Minimal service worker — primarily exists so the PWA install prompt
|
||||
// is offered on iOS and Android. We cache the app shell + static assets
|
||||
// for offline-aware behaviour, but never cache API responses.
|
||||
|
||||
import { build, files, version } from '$service-worker';
|
||||
|
||||
const sw = self as unknown as ServiceWorkerGlobalScope;
|
||||
const CACHE = `musichub-${version}`;
|
||||
const ASSETS = [...build, ...files];
|
||||
|
||||
sw.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE).then((cache) => cache.addAll(ASSETS)).then(() => sw.skipWaiting()),
|
||||
);
|
||||
});
|
||||
|
||||
sw.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
|
||||
.then(() => sw.clients.claim()),
|
||||
);
|
||||
});
|
||||
|
||||
sw.addEventListener('fetch', (event) => {
|
||||
const req = event.request;
|
||||
if (req.method !== 'GET') return;
|
||||
|
||||
const url = new URL(req.url);
|
||||
|
||||
// Don't intercept API or S3 traffic
|
||||
if (url.pathname.startsWith('/api/') || url.hostname !== sw.location.hostname) return;
|
||||
|
||||
// Cache-first for built assets, network-first for everything else
|
||||
if (ASSETS.includes(url.pathname)) {
|
||||
event.respondWith(
|
||||
caches.match(req).then((cached) => cached ?? fetch(req)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith(
|
||||
fetch(req).catch(() => caches.match(req).then((c) => c ?? new Response('', { status: 504 }))),
|
||||
);
|
||||
});
|
||||
BIN
apps/web/static/apple-touch-icon.png
Normal file
BIN
apps/web/static/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
BIN
apps/web/static/favicon-16.png
Normal file
BIN
apps/web/static/favicon-16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 447 B |
BIN
apps/web/static/favicon-32.png
Normal file
BIN
apps/web/static/favicon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 958 B |
BIN
apps/web/static/icon-192.png
Normal file
BIN
apps/web/static/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
apps/web/static/icon-512.png
Normal file
BIN
apps/web/static/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.6 KiB |
33
apps/web/static/manifest.webmanifest
Normal file
33
apps/web/static/manifest.webmanifest
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "Music Hub",
|
||||
"short_name": "Music Hub",
|
||||
"description": "Versionen für Musik. Ohne Chaos.",
|
||||
"start_url": "/dashboard",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#0a0910",
|
||||
"theme_color": "#f43f5e",
|
||||
"lang": "de",
|
||||
"categories": ["music", "productivity"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import adapter from '@sveltejs/adapter-auto';
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
import { relative, sep } from 'node:path';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
@@ -17,7 +17,7 @@ const config = {
|
||||
// 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()
|
||||
adapter: adapter({ out: 'build' })
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user