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:
Robin Choice
2026-04-10 11:47:48 +02:00
parent 4dc095463f
commit 8bf72c2482
78 changed files with 8216 additions and 1386 deletions

View File

@@ -4,7 +4,8 @@
"type": "module",
"scripts": {
"dev": "bun run --hot src/index.ts",
"build": "bun build src/index.ts --outdir dist --target bun"
"build": "bun build src/index.ts --outdir dist --target bun",
"seed": "bun run src/scripts/seed.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3",

View File

@@ -8,6 +8,9 @@ import { trackRoutes } from './routes/tracks.js';
import { versionRoutes } from './routes/versions.js';
import { commentRoutes } from './routes/comments.js';
import { shareRoutes } from './routes/share.js';
import { uploadRoutes } from './routes/uploads.js';
import { activityRoutes } from './routes/activity.js';
import { onboardingRoutes } from './routes/onboarding.js';
import type { AppEnv } from './types.js';
const db = createDb(process.env.DATABASE_URL!);
@@ -36,7 +39,10 @@ const app = new Hono<AppEnv>()
.route('/tracks', trackRoutes)
.route('/versions', versionRoutes)
.route('/comments', commentRoutes)
.route('/share', shareRoutes);
.route('/share', shareRoutes)
.route('/uploads', uploadRoutes)
.route('/activity', activityRoutes)
.route('/onboarding', onboardingRoutes);
const port = parseInt(process.env.PORT || '3000');
console.log(`Music Hub API running on port ${port}`);

View File

@@ -0,0 +1,159 @@
/**
* Lightweight demo seed used by the onboarding endpoint.
* Creates one project with one track and three versions, plus a comment and a share link.
*
* Audio source: a synthetic WAV is generated on demand via FFmpeg if no asset is found,
* so this works regardless of whether the rich seed assets exist on disk.
*/
import {
projects,
projectMembers,
tracks,
versions,
comments,
shareLinks,
type Database,
} from '@music-hub/db';
import { createUploadUrl } from '../storage/s3.js';
import { processVersion } from '../services/audio-processor.js';
const ASSET_DIR = '/tmp/musichub-seed';
async function getDemoAudio(name: string, frequency: number, duration: number): Promise<ArrayBuffer> {
const f = Bun.file(`${ASSET_DIR}/${name}`);
if (await f.exists()) return await f.arrayBuffer();
// Synthesize on the fly with ffmpeg
const tmp = `/tmp/musichub-onb-${crypto.randomUUID()}.wav`;
const proc = Bun.spawn([
'ffmpeg',
'-f', 'lavfi',
'-i', `sine=frequency=${frequency}:duration=${duration}`,
'-ac', '2', '-ar', '44100',
'-v', 'quiet',
'-y',
tmp,
]);
await proc.exited;
const bytes = await Bun.file(tmp).arrayBuffer();
await Bun.spawn(['rm', tmp]).exited;
return bytes;
}
async function uploadAudio(
bytes: ArrayBuffer,
projectId: string,
trackId: string,
versionId: string,
filename: string,
): Promise<string> {
const fileKey = `projects/${projectId}/tracks/${trackId}/versions/${versionId}/original/${filename}`;
const uploadUrl = await createUploadUrl(fileKey, 'audio/wav', bytes.byteLength);
const res = await fetch(uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': 'audio/wav' },
body: bytes,
});
if (!res.ok) throw new Error(`Audio upload failed: ${res.status}`);
return fileKey;
}
export async function createDemoProject(db: Database, userId: string): Promise<string> {
// Project
const [project] = await db
.insert(projects)
.values({
name: 'Mein erstes Projekt',
description: 'Ein kleines Demo-Projekt zum Ausprobieren. Du kannst es jederzeit löschen.',
createdById: userId,
})
.returning();
await db.insert(projectMembers).values({
projectId: project.id,
userId,
role: 'owner',
canUpload: true,
canComment: true,
canApprove: true,
});
// Track
const [track] = await db
.insert(tracks)
.values({
projectId: project.id,
name: 'Demo-Track',
description: 'Drei Versionen mit Comments und einer Variante.',
status: 'in_progress',
createdById: userId,
})
.returning();
// Versions
const versionDefs = [
{ label: 'Erster Wurf', frequency: 220, duration: 8, file: 'demo-v1.wav' },
{ label: 'Mehr Bass', frequency: 110, duration: 8, file: 'demo-v2.wav' },
{ label: 'Final Mix', frequency: 330, duration: 8, file: 'demo-v3.wav' },
];
const createdVersionIds: string[] = [];
for (let i = 0; i < versionDefs.length; i++) {
const v = versionDefs[i];
const versionId = crypto.randomUUID();
const bytes = await getDemoAudio(v.file, v.frequency, v.duration);
const fileKey = await uploadAudio(bytes, project.id, track.id, versionId, v.file);
const [version] = await db
.insert(versions)
.values({
id: versionId,
trackId: track.id,
versionNumber: i + 1,
label: v.label,
status: 'uploaded',
originalFileName: v.file,
mimeType: 'audio/wav',
fileSize: bytes.byteLength,
originalFileKey: fileKey,
createdById: userId,
})
.returning();
createdVersionIds.push(version.id);
// Process synchronously so the player has a waveform when the user lands.
await processVersion(db, version.id);
}
// Comments on V2
await db.insert(comments).values([
{
versionId: createdVersionIds[1],
userId,
body: 'Klick auf die Welle bei einer beliebigen Stelle, um einen Kommentar mit Timestamp zu schreiben.',
timestampSeconds: 2.5,
},
{
versionId: createdVersionIds[1],
userId: null,
guestName: 'Demo-Listener',
body: 'So sieht es aus, wenn jemand ohne Account über einen geteilten Link kommentiert.',
timestampSeconds: 5.0,
},
]);
// Share link on V3
const token = Array.from(crypto.getRandomValues(new Uint8Array(32)), (b) =>
b.toString(16).padStart(2, '0'),
).join('');
await db.insert(shareLinks).values({
versionId: createdVersionIds[2],
token,
createdById: userId,
allowComments: true,
allowDownload: false,
});
return project.id;
}

View File

@@ -0,0 +1,116 @@
import { Hono } from 'hono';
import { sql } from 'drizzle-orm';
import { requireAuth } from '../middleware/auth.js';
import type { AppEnv } from '../types.js';
type ActivityEvent = {
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;
};
export const activityRoutes = new Hono<AppEnv>()
.use('*', requireAuth)
.get('/', async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const limit = Math.min(parseInt(c.req.query('limit') ?? '40'), 100);
const projectFilter = c.req.query('projectId');
// Helper: only events from projects the user is a member of.
// We use raw sql for the UNION since drizzle's union builder is per-table-shape.
const projectClause = projectFilter ? sql`AND p.id = ${projectFilter}` : sql``;
const rows = await db.execute(sql`
WITH membership AS (
SELECT project_id FROM project_members WHERE user_id = ${userId}
)
SELECT * FROM (
-- New versions
SELECT
'version' as type,
v.id as event_id,
v.created_at as created_at,
u.id as user_id,
u.name as user_name,
u.avatar_url as user_avatar,
NULL::text as guest_name,
p.id as project_id,
p.name as project_name,
t.id as track_id,
t.name as track_name,
v.id as version_id,
v.version_number as version_number,
v.label as version_label,
NULL::text as body,
v.status::text as status,
NULL::real as timestamp_seconds
FROM versions v
JOIN tracks t ON t.id = v.track_id
JOIN projects p ON p.id = t.project_id
JOIN membership m ON m.project_id = p.id
LEFT JOIN users u ON u.id = v.created_by_id
WHERE p.is_archived = false ${projectClause}
UNION ALL
-- New comments
SELECT
'comment' as type,
c.id as event_id,
c.created_at as created_at,
u.id as user_id,
u.name as user_name,
u.avatar_url as user_avatar,
c.guest_name as guest_name,
p.id as project_id,
p.name as project_name,
t.id as track_id,
t.name as track_name,
v.id as version_id,
v.version_number as version_number,
v.label as version_label,
c.body as body,
NULL::text as status,
c.timestamp_seconds as timestamp_seconds
FROM comments c
JOIN versions v ON v.id = c.version_id
JOIN tracks t ON t.id = v.track_id
JOIN projects p ON p.id = t.project_id
JOIN membership m ON m.project_id = p.id
LEFT JOIN users u ON u.id = c.user_id
WHERE p.is_archived = false ${projectClause}
) events
ORDER BY created_at DESC
LIMIT ${limit}
`);
const events: ActivityEvent[] = (rows as unknown as any[]).map((r) => ({
type: r.type as ActivityEvent['type'],
id: r.event_id,
createdAt: typeof r.created_at === 'string' ? r.created_at : new Date(r.created_at).toISOString(),
user: r.user_id
? { id: r.user_id, name: r.user_name, avatarUrl: r.user_avatar }
: null,
guestName: r.guest_name ?? null,
project: { id: r.project_id, name: r.project_name },
track: { id: r.track_id, name: r.track_name },
version: r.version_id
? { id: r.version_id, versionNumber: r.version_number, label: r.version_label }
: undefined,
body: r.body ?? undefined,
status: r.status ?? undefined,
timestampSeconds: r.timestamp_seconds ?? null,
}));
return c.json({ events });
});

View File

@@ -0,0 +1,29 @@
import { Hono } from 'hono';
import { eq } from 'drizzle-orm';
import { projectMembers } from '@music-hub/db';
import { requireAuth } from '../middleware/auth.js';
import { createDemoProject } from '../lib/demo-seed.js';
import type { AppEnv } from '../types.js';
export const onboardingRoutes = new Hono<AppEnv>()
.use('*', requireAuth)
.post('/seed-demo', async (c) => {
const db = c.get('db');
const userId = c.get('userId');
// Refuse to spam users with multiple demos
const existing = await db
.select({ id: projectMembers.id })
.from(projectMembers)
.where(eq(projectMembers.userId, userId))
.limit(1);
if (existing.length > 0) {
// User already has projects — they shouldn't see the welcome modal anyway,
// but be defensive: still create a fresh demo so the action is meaningful.
}
const projectId = await createDemoProject(db, userId);
return c.json({ projectId }, 201);
});

View File

@@ -1,16 +1,24 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { eq, and } from 'drizzle-orm';
import { eq, and, sql } from 'drizzle-orm';
import {
createProjectSchema,
updateProjectSchema,
inviteMemberSchema,
updateMemberSchema,
} from '@music-hub/shared';
import { projects, projectMembers, users } from '@music-hub/db';
import { projects, projectMembers, users, tracks } from '@music-hub/db';
import { requireAuth } from '../middleware/auth.js';
import { createDownloadUrl } from '../storage/s3.js';
import type { AppEnv } from '../types.js';
async function withCoverUrl<T extends { coverImageUrl?: string | null }>(
obj: T,
): Promise<T & { coverUrl: string | null }> {
const coverUrl = obj.coverImageUrl ? await createDownloadUrl(obj.coverImageUrl) : null;
return { ...obj, coverUrl };
}
export const projectRoutes = new Hono<AppEnv>()
.use('*', requireAuth)
@@ -22,12 +30,19 @@ export const projectRoutes = new Hono<AppEnv>()
.select({
project: projects,
role: projectMembers.role,
trackCount: sql<number>`(select count(*)::int from ${tracks} where ${tracks.projectId} = ${projects.id})`,
})
.from(projectMembers)
.innerJoin(projects, eq(projects.id, projectMembers.projectId))
.where(and(eq(projectMembers.userId, userId), eq(projects.isArchived, false)));
return c.json({ projects: memberships });
const enriched = await Promise.all(
memberships.map(async (m) => ({
...m,
project: await withCoverUrl(m.project),
})),
);
return c.json({ projects: enriched });
})
.post('/', zValidator('json', createProjectSchema), async (c) => {
@@ -73,7 +88,7 @@ export const projectRoutes = new Hono<AppEnv>()
.where(eq(projects.id, projectId))
.limit(1);
return c.json({ project, role: membership.role });
return c.json({ project: await withCoverUrl(project), role: membership.role });
})
.patch('/:id', zValidator('json', updateProjectSchema), async (c) => {

View File

@@ -1,9 +1,10 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { eq, and, asc } from 'drizzle-orm';
import { eq, and, asc, sql } from 'drizzle-orm';
import { createTrackSchema, updateTrackSchema } from '@music-hub/shared';
import { tracks, projectMembers } from '@music-hub/db';
import { tracks, projectMembers, versions } from '@music-hub/db';
import { requireAuth } from '../middleware/auth.js';
import { createDownloadUrl } from '../storage/s3.js';
import type { AppEnv } from '../types.js';
export const trackRoutes = new Hono<AppEnv>()
@@ -24,12 +25,32 @@ export const trackRoutes = new Hono<AppEnv>()
if (!membership) return c.json({ error: 'Not found' }, 404);
const projectTracks = await db
.select()
.select({
id: tracks.id,
projectId: tracks.projectId,
name: tracks.name,
description: tracks.description,
coverImageUrl: tracks.coverImageUrl,
status: tracks.status,
section: tracks.section,
sortOrder: tracks.sortOrder,
createdById: tracks.createdById,
createdAt: tracks.createdAt,
updatedAt: tracks.updatedAt,
versionCount: sql<number>`(select count(*)::int from ${versions} where ${versions.trackId} = ${tracks.id})`,
branchCount: sql<number>`(select count(distinct ${versions.branchLabel})::int from ${versions} where ${versions.trackId} = ${tracks.id} and ${versions.branchLabel} is not null)`,
})
.from(tracks)
.where(eq(tracks.projectId, projectId))
.orderBy(asc(tracks.sortOrder), asc(tracks.createdAt));
return c.json({ tracks: projectTracks });
const enriched = await Promise.all(
projectTracks.map(async (t) => ({
...t,
coverUrl: t.coverImageUrl ? await createDownloadUrl(t.coverImageUrl) : null,
})),
);
return c.json({ tracks: enriched });
})
.post('/:projectId', zValidator('json', createTrackSchema), async (c) => {

View File

@@ -0,0 +1,17 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { coverUploadSchema } from '@music-hub/shared';
import { requireAuth } from '../middleware/auth.js';
import { createUploadUrl } from '../storage/s3.js';
import type { AppEnv } from '../types.js';
export const uploadRoutes = new Hono<AppEnv>()
.use('*', requireAuth)
.post('/cover', zValidator('json', coverUploadSchema), async (c) => {
const { fileName, mimeType, fileSize } = c.req.valid('json');
const ext = fileName.split('.').pop()?.toLowerCase() || 'jpg';
const key = `covers/${crypto.randomUUID()}.${ext}`;
const uploadUrl = await createUploadUrl(key, mimeType, fileSize);
return c.json({ uploadUrl, key });
});

View File

@@ -1,7 +1,7 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { eq, and, desc, asc, sql } from 'drizzle-orm';
import { requestUploadUrlSchema, createVersionSchema } from '@music-hub/shared';
import { requestUploadUrlSchema, createVersionSchema, updateVersionSchema } from '@music-hub/shared';
import { tracks, versions, projectMembers } from '@music-hub/db';
import { requireAuth } from '../middleware/auth.js';
import { createUploadUrl, createDownloadUrl } from '../storage/s3.js';
@@ -115,6 +115,82 @@ export const versionRoutes = new Hono<AppEnv>()
return c.json({ version }, 201);
})
// Update label/notes/branchLabel
.patch('/:id', zValidator('json', updateVersionSchema), async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const versionId = c.req.param('id');
const input = c.req.valid('json');
const [version] = await db
.select()
.from(versions)
.where(eq(versions.id, versionId))
.limit(1);
if (!version) return c.json({ error: 'Not found' }, 404);
const [track] = await db
.select()
.from(tracks)
.where(eq(tracks.id, version.trackId))
.limit(1);
const [membership] = await db
.select()
.from(projectMembers)
.where(
and(eq(projectMembers.projectId, track!.projectId), eq(projectMembers.userId, userId)),
)
.limit(1);
if (!membership || !membership.canUpload) {
return c.json({ error: 'Forbidden' }, 403);
}
const [updated] = await db
.update(versions)
.set(input)
.where(eq(versions.id, versionId))
.returning();
return c.json({ version: updated });
})
// Delete version
.delete('/:id', async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const versionId = c.req.param('id');
const [version] = await db
.select()
.from(versions)
.where(eq(versions.id, versionId))
.limit(1);
if (!version) return c.json({ error: 'Not found' }, 404);
const [track] = await db
.select()
.from(tracks)
.where(eq(tracks.id, version.trackId))
.limit(1);
const [membership] = await db
.select()
.from(projectMembers)
.where(
and(eq(projectMembers.projectId, track!.projectId), eq(projectMembers.userId, userId)),
)
.limit(1);
if (!membership || membership.role !== 'owner') {
return c.json({ error: 'Forbidden' }, 403);
}
await db.delete(versions).where(eq(versions.id, versionId));
return c.json({ message: 'Version deleted' });
})
// Get version tree (graph) for a track
.get('/track/:trackId/tree', async (c) => {
const db = c.get('db');

View File

@@ -0,0 +1,391 @@
/**
* Reicher Demo-Seed: 4 Projekte mit verschiedenen Charakteren, mehreren Tracks,
* Versionen, Mitgliedern, Comments und Share-Links.
*
* Voraussetzung: Audio-Dateien und Cover-PNGs liegen in /tmp/musichub-seed/
* bass.wav drums.wav hi.wav lead.wav mix.wav pad.wav vox.wav warm.wav
* sunset.png lichtjahre.png rooftop.png wave.png
*
* Usage:
* bun run apps/api/src/scripts/seed-rich.ts <email>
*/
import { eq } from 'drizzle-orm';
import {
createDb,
users,
projects,
projectMembers,
tracks,
versions,
comments,
shareLinks,
} from '@music-hub/db';
import { createUploadUrl } from '../storage/s3.js';
import { processVersion } from '../services/audio-processor.js';
const email = process.argv[2];
if (!email) {
console.error('Usage: bun run seed-rich.ts <email>');
process.exit(1);
}
const ASSET_DIR = '/tmp/musichub-seed';
async function readBytes(name: string): Promise<ArrayBuffer> {
const f = Bun.file(`${ASSET_DIR}/${name}`);
if (!(await f.exists())) throw new Error(`Missing asset: ${name}`);
return await f.arrayBuffer();
}
const db = createDb(process.env.DATABASE_URL!);
// === USER SETUP ===
console.log(`→ User ${email}`);
let [me] = await db.select().from(users).where(eq(users.email, email)).limit(1);
if (!me) {
[me] = await db.insert(users).values({ email, name: email.split('@')[0] }).returning();
console.log(` created ${me.id}`);
} else {
console.log(` exists ${me.id}`);
}
// Co-Mitglieder
const memberDefs = [
{ email: 'anna@example.com', name: 'Anna Berger', role: 'artist' as const },
{ email: 'jonas@example.com', name: 'Jonas Klein', role: 'mixing_engineer' as const },
{ email: 'lina@example.com', name: 'Lina Roth', role: 'mastering_engineer' as const },
{ email: 'felix@example.com', name: 'Felix Lang', role: 'label' as const },
];
const collaborators: Record<string, typeof users.$inferSelect> = {};
for (const m of memberDefs) {
let [u] = await db.select().from(users).where(eq(users.email, m.email)).limit(1);
if (!u) [u] = await db.insert(users).values({ email: m.email, name: m.name }).returning();
collaborators[m.email] = u;
}
console.log(`${memberDefs.length} Mitwirkende vorbereitet`);
// === COVER UPLOAD HELPER ===
async function uploadCover(filename: string): Promise<string> {
const bytes = await readBytes(filename);
const key = `covers/${crypto.randomUUID()}.png`;
const url = await createUploadUrl(key, 'image/png', bytes.byteLength);
const res = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'image/png' },
body: bytes,
});
if (!res.ok) throw new Error(`Cover upload failed: ${res.status}`);
return key;
}
// === PROJECT FACTORY ===
type TrackSpec = {
name: string;
description?: string;
versions: VersionSpec[];
comments?: CommentSpec[];
};
type VersionSpec = {
label: string;
audio: string;
status?: 'ready' | 'approved' | 'rejected';
parentLabel?: string;
branchLabel?: string;
notes?: string;
createdBy?: string;
};
type CommentSpec = {
versionLabel: string;
body: string;
at?: number;
by?: string; // email or 'guest:Name'
};
type ProjectSpec = {
name: string;
description: string;
cover: string;
members: { email: string; role: any }[];
tracks: TrackSpec[];
shareVersionLabel?: { trackName: string; versionLabel: string };
};
async function createProject(spec: ProjectSpec) {
console.log(`\n📀 ${spec.name}`);
const coverKey = await uploadCover(spec.cover);
const [project] = await db
.insert(projects)
.values({
name: spec.name,
description: spec.description,
coverImageUrl: coverKey,
createdById: me.id,
})
.returning();
await db.insert(projectMembers).values({
projectId: project.id,
userId: me.id,
role: 'owner',
canUpload: true,
canComment: true,
canApprove: true,
});
for (const m of spec.members) {
const u = collaborators[m.email];
if (!u) continue;
await db.insert(projectMembers).values({
projectId: project.id,
userId: u.id,
role: m.role,
canUpload: m.role.includes('engineer') || m.role === 'owner',
canComment: true,
canApprove: m.role === 'artist' || m.role === 'label' || m.role === 'management',
});
}
let shareToken: string | null = null;
for (const trackSpec of spec.tracks) {
const [track] = await db
.insert(tracks)
.values({
projectId: project.id,
name: trackSpec.name,
description: trackSpec.description,
createdById: me.id,
})
.returning();
console.log(` 🎵 ${trackSpec.name}`);
// Versionen in Reihenfolge anlegen, parent-Lookup über Label
const versionMap = new Map<string, string>();
for (let i = 0; i < trackSpec.versions.length; i++) {
const v = trackSpec.versions[i];
const audioBytes = await readBytes(v.audio);
const versionId = crypto.randomUUID();
const fileName = `${v.audio}`;
const fileKey = `projects/${project.id}/tracks/${track.id}/versions/${versionId}/original/${fileName}`;
const uploadUrl = await createUploadUrl(fileKey, 'audio/wav', audioBytes.byteLength);
const res = await fetch(uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': 'audio/wav' },
body: audioBytes,
});
if (!res.ok) throw new Error(`Audio upload failed: ${res.status}`);
const parentVersionId = v.parentLabel ? versionMap.get(v.parentLabel) ?? null : null;
const createdById = v.createdBy
? collaborators[v.createdBy]?.id ?? me.id
: me.id;
const [version] = await db
.insert(versions)
.values({
id: versionId,
trackId: track.id,
versionNumber: i + 1,
label: v.label,
notes: v.notes,
status: 'uploaded',
parentVersionId,
branchLabel: v.branchLabel ?? null,
originalFileName: fileName,
mimeType: 'audio/wav',
fileSize: audioBytes.byteLength,
originalFileKey: fileKey,
createdById,
})
.returning();
versionMap.set(v.label, version.id);
console.log(` V${i + 1} ${v.label}${v.branchLabel ? ` (Variante: ${v.branchLabel})` : ''}`);
await processVersion(db, version.id);
if (v.status && v.status !== 'ready') {
await db
.update(versions)
.set({ status: v.status })
.where(eq(versions.id, version.id));
}
}
// Comments
for (const c of trackSpec.comments ?? []) {
const versionId = versionMap.get(c.versionLabel);
if (!versionId) continue;
const isGuest = c.by?.startsWith('guest:');
const userId = isGuest
? null
: c.by
? collaborators[c.by]?.id ?? me.id
: me.id;
const guestName = isGuest ? c.by!.slice(6) : null;
await db.insert(comments).values({
versionId,
userId,
guestName,
body: c.body,
timestampSeconds: c.at ?? null,
});
}
// Share-Link auf eine bestimmte Version
if (
spec.shareVersionLabel &&
spec.shareVersionLabel.trackName === trackSpec.name
) {
const versionId = versionMap.get(spec.shareVersionLabel.versionLabel);
if (versionId) {
const token = Array.from(crypto.getRandomValues(new Uint8Array(32)), (b) =>
b.toString(16).padStart(2, '0'),
).join('');
await db.insert(shareLinks).values({
versionId,
token,
createdById: me.id,
allowComments: true,
allowDownload: false,
});
shareToken = token;
}
}
}
if (shareToken) {
console.log(` 🔗 Share: http://localhost:5173/listen/${shareToken}`);
}
}
// === PROJEKT-KATALOG ===
await createProject({
name: 'Sunset Drive',
description: 'Synthwave-EP. Mainline ist V3, V4 ist eine Variante mit anderem Bass.',
cover: 'sunset.png',
members: [
{ email: 'anna@example.com', role: 'artist' },
{ email: 'jonas@example.com', role: 'mixing_engineer' },
{ email: 'lina@example.com', role: 'mastering_engineer' },
],
tracks: [
{
name: 'Hauptmix',
description: 'Der zentrale Track der EP. Drei Iterationen plus eine Vocal-Variante.',
versions: [
{ label: 'Erster Wurf', audio: 'pad.wav' },
{ label: 'Mehr Bass', audio: 'bass.wav', notes: 'Sub etwas runter, Punch nach vorne' },
{ label: 'Final Mix', audio: 'mix.wav', status: 'approved', notes: 'Freigegeben von Lina nach Mastering' },
{ label: 'Andere Vocals', audio: 'vox.wav', parentLabel: 'Mehr Bass', branchLabel: 'vocals-alt', notes: 'Variante mit dem zweiten Vocal-Take', createdBy: 'jonas@example.com' },
],
comments: [
{ versionLabel: 'Mehr Bass', at: 3.2, by: 'anna@example.com', body: 'Hier ist es nice — aber bei 0:05 fehlt mir noch was im Mid-Range.' },
{ versionLabel: 'Mehr Bass', at: 5.5, body: 'Verstanden, ziehe ich nach. EQ-Boost bei 800Hz.' },
{ versionLabel: 'Final Mix', at: 1.8, by: 'lina@example.com', body: 'Master sitzt. -1 LUFS, dynamic range bleibt erhalten.' },
{ versionLabel: 'Andere Vocals', at: 2.0, by: 'anna@example.com', body: 'Yes! Das ist die richtige Energie für den Refrain.' },
],
},
{
name: 'Drums',
description: 'Drum-Bus für den Hauptmix.',
versions: [
{ label: 'Punchy', audio: 'drums.wav', createdBy: 'jonas@example.com' },
{ label: 'Mehr Air', audio: 'hi.wav', notes: 'Top-Hi-Hats lauter, Reverb-Tail dazu' },
],
comments: [
{ versionLabel: 'Punchy', at: 1.5, by: 'anna@example.com', body: 'Snare ist perfekt hier.' },
],
},
{
name: 'Lead Synth',
description: 'Hauptmotiv. Sitzt zwischen Vox und Pad.',
versions: [
{ label: 'V1', audio: 'lead.wav' },
],
},
],
shareVersionLabel: { trackName: 'Hauptmix', versionLabel: 'Final Mix' },
});
await createProject({
name: 'Lichtjahre',
description: 'Indie-Single. Acoustic-Demo, dann Studio-Take, dann Mix.',
cover: 'lichtjahre.png',
members: [
{ email: 'anna@example.com', role: 'artist' },
{ email: 'felix@example.com', role: 'label' },
],
tracks: [
{
name: 'Lichtjahre',
description: 'Hauptsong der Single.',
versions: [
{ label: 'Acoustic Demo', audio: 'warm.wav', notes: 'Aufgenommen am Küchentisch' },
{ label: 'Studio Take', audio: 'pad.wav', notes: 'Voller Mix mit Band' },
{ label: 'Mix V1', audio: 'mix.wav', createdBy: 'jonas@example.com' },
],
comments: [
{ versionLabel: 'Studio Take', at: 4.0, by: 'felix@example.com', body: 'Geht in die Richtung. Bei 0:08 könnten wir noch ein Break einbauen.' },
{ versionLabel: 'Studio Take', at: 7.2, body: 'Notiert.' },
{ versionLabel: 'Mix V1', at: 2.5, by: 'guest:Sarah (A&R)', body: 'Ich bin begeistert. Bei wann ist das fertig?' },
],
},
{
name: 'B-Side',
description: 'Instrumental-Version für Sync-Lizenz.',
versions: [
{ label: 'Instrumental', audio: 'pad.wav' },
],
},
],
shareVersionLabel: { trackName: 'Lichtjahre', versionLabel: 'Mix V1' },
});
await createProject({
name: 'Rooftop Session',
description: 'Live-Aufnahme vom letzten Sommer-Gig. Soundcheck + ein Take pro Song.',
cover: 'rooftop.png',
members: [
{ email: 'jonas@example.com', role: 'recording_engineer' },
],
tracks: [
{
name: 'Soundcheck',
versions: [{ label: 'Take 1', audio: 'drums.wav', createdBy: 'jonas@example.com' }],
},
{
name: 'Opener',
versions: [
{ label: 'Live Take', audio: 'bass.wav' },
{ label: 'Roh-Mix', audio: 'mix.wav', notes: 'Schneller Mix für die Band-Doku' },
],
comments: [
{ versionLabel: 'Live Take', at: 6.0, by: 'jonas@example.com', body: 'Crowd-Geräusche bewusst drin gelassen.' },
],
},
{
name: 'Encore',
versions: [{ label: 'Live Take', audio: 'lead.wav' }],
},
],
});
await createProject({
name: 'Wave Goodbye',
description: 'Closing-Track für die EP. Noch früh in der Entwicklung.',
cover: 'wave.png',
members: [
{ email: 'anna@example.com', role: 'artist' },
],
tracks: [
{
name: 'Skizze',
versions: [{ label: 'V1', audio: 'pad.wav', notes: 'Erste Idee, sehr roh' }],
},
],
});
console.log('\n✅ Fertig.');
console.log(` Login als ${email} via Magic Link.`);
process.exit(0);

View File

@@ -0,0 +1,159 @@
/**
* Demo seed script.
*
* Usage:
* ffmpeg -f lavfi -i "sine=frequency=440:duration=8" -ac 2 /tmp/musichub-demo.wav
* bun run apps/api/src/scripts/seed.ts demo@example.com [/tmp/musichub-demo.wav]
*
* Creates user (if missing), a demo project with 1 track and 3 versions
* (V2 mainline, V3 as branch off V2), 2 sample comments and 1 share link.
*/
import { eq } from 'drizzle-orm';
import { createDb, users, projects, projectMembers, tracks, versions, comments, shareLinks } from '@music-hub/db';
import { createUploadUrl } from '../storage/s3.js';
import { processVersion } from '../services/audio-processor.js';
const email = process.argv[2];
const audioPath = process.argv[3] || '/tmp/musichub-demo.wav';
if (!email) {
console.error('Usage: bun run seed.ts <email> [audioPath]');
process.exit(1);
}
const audioFile = Bun.file(audioPath);
if (!(await audioFile.exists())) {
console.error(`Audio file missing: ${audioPath}`);
console.error('Generate one first:');
console.error(` ffmpeg -f lavfi -i "sine=frequency=440:duration=8" -ac 2 ${audioPath}`);
process.exit(1);
}
const audioBytes = await audioFile.arrayBuffer();
const audioSize = audioBytes.byteLength;
const db = createDb(process.env.DATABASE_URL!);
console.log(`→ User ${email}`);
let [user] = await db.select().from(users).where(eq(users.email, email)).limit(1);
if (!user) {
[user] = await db.insert(users).values({ email, name: email.split('@')[0] }).returning();
console.log(` created ${user.id}`);
} else {
console.log(` exists ${user.id}`);
}
console.log('→ Projekt "Demo: Sunset Drive"');
const [project] = await db
.insert(projects)
.values({
name: 'Demo: Sunset Drive',
description: 'Synthwave-Track in Arbeit. Probier den Graph, lade eine neue Variante hoch oder teile den Link.',
createdById: user.id,
})
.returning();
await db.insert(projectMembers).values({
projectId: project.id,
userId: user.id,
role: 'owner',
canUpload: true,
canComment: true,
canApprove: true,
});
console.log('→ Track "Hauptmix"');
const [track] = await db
.insert(tracks)
.values({
projectId: project.id,
name: 'Hauptmix',
description: 'Der Hauptmix mit zwei Mainline-Versionen und einer Vocals-Variante.',
createdById: user.id,
})
.returning();
async function uploadVersion(opts: {
versionNumber: number;
label: string;
parentVersionId?: string | null;
branchLabel?: string | null;
}) {
const versionId = crypto.randomUUID();
const fileName = `demo-v${opts.versionNumber}.wav`;
const fileKey = `projects/${project.id}/tracks/${track.id}/versions/${versionId}/original/${fileName}`;
const uploadUrl = await createUploadUrl(fileKey, 'audio/wav', audioSize);
const res = await fetch(uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': 'audio/wav' },
body: audioBytes,
});
if (!res.ok) throw new Error(`S3 upload failed: ${res.status} ${await res.text()}`);
const [version] = await db
.insert(versions)
.values({
id: versionId,
trackId: track.id,
versionNumber: opts.versionNumber,
label: opts.label,
status: 'uploaded',
parentVersionId: opts.parentVersionId ?? null,
branchLabel: opts.branchLabel ?? null,
originalFileName: fileName,
mimeType: 'audio/wav',
fileSize: audioSize,
originalFileKey: fileKey,
createdById: user.id,
})
.returning();
console.log(` V${opts.versionNumber} (${opts.branchLabel ?? 'main'}) → processing`);
await processVersion(db, version.id);
return version;
}
const v1 = await uploadVersion({ versionNumber: 1, label: 'Erster Wurf' });
const v2 = await uploadVersion({ versionNumber: 2, label: 'Mehr Bass' });
const v3 = await uploadVersion({
versionNumber: 3,
label: 'Vocals neu',
parentVersionId: v2.id,
branchLabel: 'vocals-neu',
});
console.log('→ Comments');
await db.insert(comments).values([
{
versionId: v2.id,
userId: user.id,
body: 'Bei 0:03 sitzt der Drop, finde ich richtig stark.',
timestampSeconds: 3.0,
},
{
versionId: v2.id,
userId: null,
guestName: 'Anna (Artist)',
body: 'Können wir die Vocals etwas weiter nach vorne ziehen?',
timestampSeconds: 5.5,
},
]);
console.log('→ Share-Link');
const token = Array.from(crypto.getRandomValues(new Uint8Array(32)), (b) =>
b.toString(16).padStart(2, '0'),
).join('');
await db.insert(shareLinks).values({
versionId: v2.id,
token,
createdById: user.id,
allowComments: true,
allowDownload: false,
});
console.log('\n✅ Done.');
console.log(` Login als ${email} via Magic Link.`);
console.log(` Share-Link: http://localhost:5173/listen/${token}`);
await new Promise((r) => setTimeout(r, 200));
process.exit(0);

View File

@@ -17,23 +17,23 @@ export async function sendMagicLinkEmail(email: string, token: string) {
await resend.emails.send({
from: fromEmail,
to: email,
subject: 'Your Music Hub Login Link',
subject: 'Dein Login-Link für Music Hub',
html: `
<div style="font-family: -apple-system, sans-serif; max-width: 400px; margin: 0 auto; padding: 2rem;">
<h1 style="font-size: 1.5rem; color: #f0f0f0;">Music Hub</h1>
<p style="color: #a0a0a0;">Click the button below to log in:</p>
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 460px; margin: 0 auto; padding: 2.5rem 2rem; color: #f4f0ec; background: #0a0910;">
<h1 style="font-size: 1.6rem; margin: 0 0 1rem; background: linear-gradient(135deg, #f43f5e, #fb923c); -webkit-background-clip: text; background-clip: text; color: transparent; display: inline-block;">Music Hub</h1>
<p style="color: #9b96a8; line-height: 1.55; margin: 0 0 1.5rem;">Klick auf den Button um dich einzuloggen:</p>
<a href="${url}" style="
display: inline-block;
padding: 0.75rem 1.5rem;
background: #6366f1;
padding: 0.8rem 1.6rem;
background: linear-gradient(135deg, #f43f5e, #fb923c);
color: #fff;
border-radius: 8px;
border-radius: 10px;
text-decoration: none;
font-weight: 500;
margin: 1rem 0;
">Log in to Music Hub</a>
<p style="color: #666; font-size: 0.85rem;">This link expires in 15 minutes.</p>
<p style="color: #666; font-size: 0.85rem;">If you didn't request this, ignore this email.</p>
font-weight: 600;
margin: 0 0 1.5rem;
">Einloggen</a>
<p style="color: #5e596b; font-size: 0.85rem; margin: 0 0 0.5rem;">Der Link läuft in 15 Minuten ab.</p>
<p style="color: #5e596b; font-size: 0.85rem; margin: 0;">Wenn du das nicht angefordert hast, ignorier diese Mail einfach.</p>
</div>
`,
});
@@ -50,21 +50,20 @@ export async function sendInviteEmail(email: string, projectName: string, invite
await resend.emails.send({
from: fromEmail,
to: email,
subject: `You've been invited to "${projectName}" on Music Hub`,
subject: `${inviterName} hat dich zu "${projectName}" eingeladen`,
html: `
<div style="font-family: -apple-system, sans-serif; max-width: 400px; margin: 0 auto; padding: 2rem;">
<h1 style="font-size: 1.5rem; color: #f0f0f0;">Music Hub</h1>
<p style="color: #a0a0a0;">${inviterName} invited you to collaborate on <strong>"${projectName}"</strong>.</p>
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 460px; margin: 0 auto; padding: 2.5rem 2rem; color: #f4f0ec; background: #0a0910;">
<h1 style="font-size: 1.6rem; margin: 0 0 1rem; background: linear-gradient(135deg, #f43f5e, #fb923c); -webkit-background-clip: text; background-clip: text; color: transparent; display: inline-block;">Music Hub</h1>
<p style="color: #9b96a8; line-height: 1.55; margin: 0 0 1.5rem;"><strong style="color: #f4f0ec;">${inviterName}</strong> hat dich eingeladen, am Projekt <strong style="color: #f4f0ec;">"${projectName}"</strong> mitzuarbeiten.</p>
<a href="${url}" style="
display: inline-block;
padding: 0.75rem 1.5rem;
background: #6366f1;
padding: 0.8rem 1.6rem;
background: linear-gradient(135deg, #f43f5e, #fb923c);
color: #fff;
border-radius: 8px;
border-radius: 10px;
text-decoration: none;
font-weight: 500;
margin: 1rem 0;
">Open Music Hub</a>
font-weight: 600;
">Projekt öffnen</a>
</div>
`,
});

View File

@@ -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": {

View File

@@ -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">

View 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);
};

View File

@@ -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">

View File

@@ -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 {

View File

@@ -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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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 {

View 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>

View 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>

View File

@@ -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>

View 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>

View File

@@ -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 {

View File

@@ -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 {

View 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>

View File

@@ -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 {

View File

@@ -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);
}
}

View 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>

View 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>

View 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>

View 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 });
}

View 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);
});
}

View 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>

View 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>

View 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>

View 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>

View File

@@ -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">&larr; 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%;

View File

@@ -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>

View File

@@ -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);

View File

@@ -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}

View File

@@ -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>

View File

@@ -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}

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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">&larr; Projects</a>
{#if loading}
<Skeleton width="200px" height="2rem" />
{:else if project}
<div class="project-header">
<h1>{project.name}</h1>
{#if role === 'owner' || role === 'management'}
<Button variant="ghost" size="sm" href="/projects/{projectId}/settings">Settings</Button>
{/if}
</div>
{#if project.description}
<p class="description">{project.description}</p>
{/if}
{/if}
</header>
<div class="section-header">
<h2>Tracks</h2>
{#if canUpload}
<Button variant="secondary" onclick={() => showNewTrack = !showNewTrack}>
{showNewTrack ? 'Cancel' : 'New Track'}
</Button>
{/if}
</div>
{#if showNewTrack}
<form class="new-track-form" onsubmit={(e) => { e.preventDefault(); createTrack(); }}>
<Input bind:value={newTrackName} placeholder="Track name" autofocus />
<Button type="submit" loading={creating}>Create</Button>
</form>
{/if}
{#if loading}
<div class="track-list">
{#each [1, 2] as _}
<div class="track-item-skeleton">
<Skeleton width="40%" height="1rem" />
</div>
{/each}
</div>
{:else if tracks.length === 0}
<EmptyState
icon="🎶"
title="No tracks yet"
description="Create a track and upload your first audio file."
/>
{:else}
<div class="track-list">
{#each tracks as track}
<a href="/projects/{projectId}/tracks/{track.id}" class="track-item">
<span class="track-name">{track.name}</span>
</a>
{/each}
</div>
{/if}
</div>
<style>
.project-page {
max-width: 800px;
margin: 0 auto;
padding: var(--space-4);
}
header {
margin-bottom: var(--space-8);
}
.back {
color: var(--color-text-tertiary);
text-decoration: none;
font-size: var(--text-sm);
}
.back:hover {
color: var(--color-text-primary);
}
.project-header {
display: flex;
align-items: center;
gap: var(--space-3);
margin-top: var(--space-2);
}
h1 {
margin: 0;
}
.description {
color: var(--color-text-secondary);
margin: var(--space-1) 0 0;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-4);
}
h2 {
margin: 0;
}
.new-track-form {
display: flex;
gap: var(--space-3);
margin-bottom: var(--space-6);
align-items: flex-start;
}
.new-track-form :global(.input-group) {
flex: 1;
}
.track-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.track-item {
display: flex;
align-items: center;
padding: var(--space-4) var(--space-5);
background: var(--color-bg-overlay);
border-radius: var(--radius-md);
text-decoration: none;
color: inherit;
border: 1px solid var(--color-border);
transition: border-color var(--transition-base);
}
.track-item:hover {
border-color: var(--color-accent);
}
.track-name {
color: var(--color-text-primary);
font-weight: 500;
}
.track-item-skeleton {
padding: var(--space-4) var(--space-5);
background: var(--color-bg-overlay);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
}
</style>

View File

@@ -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">&larr; Back to project</a>
{#if loading}
<Skeleton width="200px" height="2rem" />
{:else}
<h1>{trackName}</h1>
{/if}
</header>
<!-- Player -->
{#if selectedVersion && streamUrl}
{#key streamUrl}
<WaveformPlayer
bind:this={playerRef}
url={streamUrl}
markers={comments
.filter((c) => c.timestampSeconds !== null)
.map((c) => ({
id: c.id,
timestampSeconds: c.timestampSeconds!,
body: c.body,
userName: c.user?.name ?? 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>

View File

@@ -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>

View 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 }))),
);
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 958 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

View 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"
}
]
}

View File

@@ -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' })
}
};