diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..3fb8050 --- /dev/null +++ b/.env.production.example @@ -0,0 +1,27 @@ +# === PFLICHT === + +# Öffentliche URL der App (ohne Trailing-Slash) +APP_URL=https://hub.mydrugismusic.com + +# Postgres-Passwort — lang und zufällig, z.B.: openssl rand -hex 24 +POSTGRES_PASSWORD=CHANGE_ME + +# Magic-Link-Secret — lang und zufällig, z.B.: openssl rand -hex 32 +MAGIC_LINK_SECRET=CHANGE_ME + +# === EMAIL (ohne = Magic Links nur in API-Log) === + +# Resend API-Key (kostenlos bis 3000 Mails/Mo): https://resend.com +RESEND_API_KEY=re_xxxxxxxxxxxx +EMAIL_FROM=Music Hub + +# === S3 / MINIO (Defaults passen für eingebautes MinIO) === + +S3_ACCESS_KEY=minioadmin +S3_SECRET_KEY=minioadmin +S3_BUCKET=music-hub + +# === OPTIONAL === + +# Externer Port (Coolify mappt das auf die Domain) +PORT=3000 diff --git a/Dockerfile.web b/Dockerfile.web index 10420e2..ade6673 100644 --- a/Dockerfile.web +++ b/Dockerfile.web @@ -12,12 +12,13 @@ COPY --from=install /app/node_modules ./node_modules COPY --from=install /app/packages/shared/node_modules ./packages/shared/node_modules COPY --from=install /app/apps/web/node_modules ./apps/web/node_modules COPY . . -ENV PUBLIC_API_URL=/api RUN cd apps/web && bun run build FROM base AS production COPY --from=build /app/apps/web/build ./build COPY --from=build /app/apps/web/package.json . +COPY --from=build /app/node_modules ./node_modules EXPOSE 3000 ENV NODE_ENV=production +ENV PORT=3000 CMD ["bun", "./build/index.js"] diff --git a/apps/api/package.json b/apps/api/package.json index 67e3129..316f8a3 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 21bd62b..7ec501e 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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() .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}`); diff --git a/apps/api/src/lib/demo-seed.ts b/apps/api/src/lib/demo-seed.ts new file mode 100644 index 0000000..dc06e3d --- /dev/null +++ b/apps/api/src/lib/demo-seed.ts @@ -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 { + 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 { + 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 { + // 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; +} diff --git a/apps/api/src/routes/activity.ts b/apps/api/src/routes/activity.ts new file mode 100644 index 0000000..6e7853b --- /dev/null +++ b/apps/api/src/routes/activity.ts @@ -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() + .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 }); + }); diff --git a/apps/api/src/routes/onboarding.ts b/apps/api/src/routes/onboarding.ts new file mode 100644 index 0000000..bb3ca3f --- /dev/null +++ b/apps/api/src/routes/onboarding.ts @@ -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() + .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); + }); diff --git a/apps/api/src/routes/projects.ts b/apps/api/src/routes/projects.ts index b005ae5..8991d9a 100644 --- a/apps/api/src/routes/projects.ts +++ b/apps/api/src/routes/projects.ts @@ -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( + obj: T, +): Promise { + const coverUrl = obj.coverImageUrl ? await createDownloadUrl(obj.coverImageUrl) : null; + return { ...obj, coverUrl }; +} + export const projectRoutes = new Hono() .use('*', requireAuth) @@ -22,12 +30,19 @@ export const projectRoutes = new Hono() .select({ project: projects, role: projectMembers.role, + trackCount: sql`(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() .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) => { diff --git a/apps/api/src/routes/tracks.ts b/apps/api/src/routes/tracks.ts index 6ec1a2a..c0da08b 100644 --- a/apps/api/src/routes/tracks.ts +++ b/apps/api/src/routes/tracks.ts @@ -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() @@ -24,12 +25,32 @@ export const trackRoutes = new Hono() 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`(select count(*)::int from ${versions} where ${versions.trackId} = ${tracks.id})`, + branchCount: sql`(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) => { diff --git a/apps/api/src/routes/uploads.ts b/apps/api/src/routes/uploads.ts new file mode 100644 index 0000000..f50d453 --- /dev/null +++ b/apps/api/src/routes/uploads.ts @@ -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() + .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 }); + }); diff --git a/apps/api/src/routes/versions.ts b/apps/api/src/routes/versions.ts index 845df71..6797686 100644 --- a/apps/api/src/routes/versions.ts +++ b/apps/api/src/routes/versions.ts @@ -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() 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'); diff --git a/apps/api/src/scripts/seed-rich.ts b/apps/api/src/scripts/seed-rich.ts new file mode 100644 index 0000000..dacf8ad --- /dev/null +++ b/apps/api/src/scripts/seed-rich.ts @@ -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 + */ +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 '); + process.exit(1); +} + +const ASSET_DIR = '/tmp/musichub-seed'; + +async function readBytes(name: string): Promise { + 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 = {}; +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 { + 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(); + + 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); diff --git a/apps/api/src/scripts/seed.ts b/apps/api/src/scripts/seed.ts new file mode 100644 index 0000000..7ac7a5f --- /dev/null +++ b/apps/api/src/scripts/seed.ts @@ -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 [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); diff --git a/apps/api/src/services/email.ts b/apps/api/src/services/email.ts index 3c01d26..e22dc81 100644 --- a/apps/api/src/services/email.ts +++ b/apps/api/src/services/email.ts @@ -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: ` -
-

Music Hub

-

Click the button below to log in:

+
+

Music Hub

+

Klick auf den Button um dich einzuloggen:

Log in to Music Hub -

This link expires in 15 minutes.

-

If you didn't request this, ignore this email.

+ font-weight: 600; + margin: 0 0 1.5rem; + ">Einloggen +

Der Link läuft in 15 Minuten ab.

+

Wenn du das nicht angefordert hast, ignorier diese Mail einfach.

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

Music Hub

-

${inviterName} invited you to collaborate on "${projectName}".

+
+

Music Hub

+

${inviterName} hat dich eingeladen, am Projekt "${projectName}" mitzuarbeiten.

Open Music Hub + font-weight: 600; + ">Projekt öffnen
`, }); diff --git a/apps/web/package.json b/apps/web/package.json index 5c85383..063aebd 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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": { diff --git a/apps/web/src/app.html b/apps/web/src/app.html index 6a2bb58..9e3cd21 100644 --- a/apps/web/src/app.html +++ b/apps/web/src/app.html @@ -1,9 +1,17 @@ - + - - + + + + + + + + + + %sveltekit.head% diff --git a/apps/web/src/hooks.server.ts b/apps/web/src/hooks.server.ts new file mode 100644 index 0000000..7f499ac --- /dev/null +++ b/apps/web/src/hooks.server.ts @@ -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); +}; diff --git a/apps/web/src/lib/components/audio/ABCompare.svelte b/apps/web/src/lib/components/audio/ABCompare.svelte index 0baf5f9..a698e35 100644 --- a/apps/web/src/lib/components/audio/ABCompare.svelte +++ b/apps/web/src/lib/components/audio/ABCompare.svelte @@ -57,12 +57,12 @@
-

A/B Compare

+

A/B-Vergleich

- +
diff --git a/apps/web/src/lib/components/audio/UploadDropzone.svelte b/apps/web/src/lib/components/audio/UploadDropzone.svelte index f2c3f45..1d6733b 100644 --- a/apps/web/src/lib/components/audio/UploadDropzone.svelte +++ b/apps/web/src/lib/components/audio/UploadDropzone.svelte @@ -1,6 +1,7 @@ -
+
{#if label} {label} {/if} -
-
+
+ - {#if duration > 0 && markers.length > 0} -
- {#each markers as marker} - - {/each} +
+
+
+ + {#if duration > 0 && markers.length > 0} +
+ {#each markers as marker} + + {/each} +
+ {/if}
- {/if} -
-
-
{#if !compact} - - {/if} - - {#if !compact} - +
+ {formatTime(currentTime)} +
+ + +
(showVolume = true)} onmouseleave={() => (showVolume = false)} role="group"> + + {#if showVolume} + setVol(Number((e.target as HTMLInputElement).value))} + class="volume-slider" + aria-label="Lautstärke einstellen" + /> + {/if} +
+
+ {formatTime(duration)} +
{/if}
- -
- {formatTime(currentTime)} / {formatTime(duration)} -
- - {#if !compact} -
- setVol(Number((e.target as HTMLInputElement).value))} - class="volume-slider" - title="Volume" - /> -
- {/if}
diff --git a/apps/web/src/lib/components/dashboard/ActivityItem.svelte b/apps/web/src/lib/components/dashboard/ActivityItem.svelte new file mode 100644 index 0000000..8e4283e --- /dev/null +++ b/apps/web/src/lib/components/dashboard/ActivityItem.svelte @@ -0,0 +1,176 @@ + + + + + +
+
+ {displayName} + {#if isGuest}Gast{/if} + + {#if event.type === 'comment'} + kommentierte + {:else if event.type === 'version'} + {#if event.status === 'approved'} + gab frei + {:else if event.status === 'rejected'} + lehnte ab + {:else} + lud hoch + {/if} + {/if} + + + {#if event.type === 'version' && event.version} + {versionLabel} in + {/if} + {event.project.name} + · + {event.track.name} + {#if event.type === 'comment' && event.timestampSeconds !== null && event.timestampSeconds !== undefined} + {formatTime(event.timestampSeconds)} + {/if} + + + {timeAgo(event.createdAt)} +
+ + {#if event.body} +

"{event.body}"

+ {/if} +
+
+ + diff --git a/apps/web/src/lib/components/dashboard/WelcomeModal.svelte b/apps/web/src/lib/components/dashboard/WelcomeModal.svelte new file mode 100644 index 0000000..26f475c --- /dev/null +++ b/apps/web/src/lib/components/dashboard/WelcomeModal.svelte @@ -0,0 +1,83 @@ + + + +
+

+ Starte mit einem Demo-Projekt um sofort zu sehen wie alles funktioniert — + mit Versionen, Comments, Wellenform-Player und Share-Link. +

+

+ Oder leg gleich dein eigenes erstes Projekt an. +

+
    +
  • Du kannst das Demo jederzeit löschen
  • +
  • Beide Wege bringen dich direkt ins Werkzeug
  • +
+
+ {#snippet actions()} + + + {/snippet} +
+ + diff --git a/apps/web/src/lib/components/ui/Badge.svelte b/apps/web/src/lib/components/ui/Badge.svelte index aebacaa..fa7417d 100644 --- a/apps/web/src/lib/components/ui/Badge.svelte +++ b/apps/web/src/lib/components/ui/Badge.svelte @@ -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); } diff --git a/apps/web/src/lib/components/ui/Button.svelte b/apps/web/src/lib/components/ui/Button.svelte index a38074d..5ec01e4 100644 --- a/apps/web/src/lib/components/ui/Button.svelte +++ b/apps/web/src/lib/components/ui/Button.svelte @@ -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 { diff --git a/apps/web/src/lib/components/ui/CoverImage.svelte b/apps/web/src/lib/components/ui/CoverImage.svelte new file mode 100644 index 0000000..9179321 --- /dev/null +++ b/apps/web/src/lib/components/ui/CoverImage.svelte @@ -0,0 +1,86 @@ + + +
+ {#if src} + + {:else} +
+ {initials} +
+ {/if} +
+ + diff --git a/apps/web/src/lib/components/ui/CoverUpload.svelte b/apps/web/src/lib/components/ui/CoverUpload.svelte new file mode 100644 index 0000000..5784bb9 --- /dev/null +++ b/apps/web/src/lib/components/ui/CoverUpload.svelte @@ -0,0 +1,125 @@ + + + + + diff --git a/apps/web/src/lib/components/ui/EmptyState.svelte b/apps/web/src/lib/components/ui/EmptyState.svelte index c25a02a..0c58608 100644 --- a/apps/web/src/lib/components/ui/EmptyState.svelte +++ b/apps/web/src/lib/components/ui/EmptyState.svelte @@ -2,7 +2,7 @@ import type { Snippet } from 'svelte'; let { - icon = '📁', + icon, title, description, action, @@ -15,7 +15,7 @@
- {icon} + {#if icon}{icon}{/if}

{title}

{#if description}

{description}

diff --git a/apps/web/src/lib/components/ui/Icon.svelte b/apps/web/src/lib/components/ui/Icon.svelte new file mode 100644 index 0000000..c72535e --- /dev/null +++ b/apps/web/src/lib/components/ui/Icon.svelte @@ -0,0 +1,146 @@ + + + + + diff --git a/apps/web/src/lib/components/ui/Input.svelte b/apps/web/src/lib/components/ui/Input.svelte index a413174..2874db7 100644 --- a/apps/web/src/lib/components/ui/Input.svelte +++ b/apps/web/src/lib/components/ui/Input.svelte @@ -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 { diff --git a/apps/web/src/lib/components/ui/Modal.svelte b/apps/web/src/lib/components/ui/Modal.svelte index 1c9eecf..37be748 100644 --- a/apps/web/src/lib/components/ui/Modal.svelte +++ b/apps/web/src/lib/components/ui/Modal.svelte @@ -1,5 +1,6 @@ + + +
+ {#each groups as group} +
+

{group.title}

+ + + {#each group.rows as [keys, label]} + + + + + {/each} + +
{keys}{label}
+
+ {/each} +
+ {#snippet actions()} + + {/snippet} +
+ + diff --git a/apps/web/src/lib/components/ui/Skeleton.svelte b/apps/web/src/lib/components/ui/Skeleton.svelte index df195d9..2a8cbab 100644 --- a/apps/web/src/lib/components/ui/Skeleton.svelte +++ b/apps/web/src/lib/components/ui/Skeleton.svelte @@ -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 { diff --git a/apps/web/src/lib/components/ui/ToastContainer.svelte b/apps/web/src/lib/components/ui/ToastContainer.svelte index 5c1fbce..ab6c034 100644 --- a/apps/web/src/lib/components/ui/ToastContainer.svelte +++ b/apps/web/src/lib/components/ui/ToastContainer.svelte @@ -1,11 +1,12 @@ @@ -13,9 +14,11 @@
{#each $toasts as t (t.id)} {/each}
@@ -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); } } diff --git a/apps/web/src/lib/components/ui/TrackStatusPill.svelte b/apps/web/src/lib/components/ui/TrackStatusPill.svelte new file mode 100644 index 0000000..ac72307 --- /dev/null +++ b/apps/web/src/lib/components/ui/TrackStatusPill.svelte @@ -0,0 +1,68 @@ + + + + + {TRACK_STATUS_LABELS[status]} + + + diff --git a/apps/web/src/lib/components/workspace/Sidebar.svelte b/apps/web/src/lib/components/workspace/Sidebar.svelte new file mode 100644 index 0000000..47f5da2 --- /dev/null +++ b/apps/web/src/lib/components/workspace/Sidebar.svelte @@ -0,0 +1,485 @@ + + + + + diff --git a/apps/web/src/lib/components/workspace/TopBar.svelte b/apps/web/src/lib/components/workspace/TopBar.svelte new file mode 100644 index 0000000..fcbcda5 --- /dev/null +++ b/apps/web/src/lib/components/workspace/TopBar.svelte @@ -0,0 +1,133 @@ + + +
+ + + + {#if actions} +
+ {@render actions()} +
+ {/if} +
+ + diff --git a/apps/web/src/lib/stores/player.ts b/apps/web/src/lib/stores/player.ts new file mode 100644 index 0000000..c99288b --- /dev/null +++ b/apps/web/src/lib/stores/player.ts @@ -0,0 +1,39 @@ +import { writable } from 'svelte/store'; + +type PlayerState = { + trackId: string | null; + currentTime: number; + isPlaying: boolean; +}; + +export const playerState = writable({ + 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 }); +} diff --git a/apps/web/src/lib/utils/shortcuts.ts b/apps/web/src/lib/utils/shortcuts.ts new file mode 100644 index 0000000..2f69a21 --- /dev/null +++ b/apps/web/src/lib/utils/shortcuts.ts @@ -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 , -
- - +

Projekt-Details

+
+ +
{ e.preventDefault(); saveProject(); }}> + +
+ + +
+ +
+
- +
-

Members

+

Mitwirkende

{#each members as member} @@ -140,7 +158,7 @@ {member.user.email}
{#if member.role === 'owner'} - Owner + Besitzer {:else if role === 'owner'} + - + {/if}
- + {#if role === 'owner'}
-

Danger Zone

+

Vorsicht

- Archive this project -

The project will be hidden from all members.

+ Projekt archivieren +

Das Projekt verschwindet für alle Beteiligten.

- +
{/if} {/if}
- -

Are you sure you want to archive {project?.name}? This will hide it from all members.

+ +

Sicher dass du {project?.name} archivieren willst? Es verschwindet dann für alle Beteiligten.

{#snippet actions()} - - + + {/snippet}
diff --git a/apps/web/src/routes/projects/[projectId]/tracks/[trackId]/components/CommentItem.svelte b/apps/web/src/routes/(app)/projects/[projectId]/tracks/[trackId]/components/CommentItem.svelte similarity index 57% rename from apps/web/src/routes/projects/[projectId]/tracks/[trackId]/components/CommentItem.svelte rename to apps/web/src/routes/(app)/projects/[projectId]/tracks/[trackId]/components/CommentItem.svelte index 24faab1..aa21322 100644 --- a/apps/web/src/routes/projects/[projectId]/tracks/[trackId]/components/CommentItem.svelte +++ b/apps/web/src/routes/(app)/projects/[projectId]/tracks/[trackId]/components/CommentItem.svelte @@ -1,5 +1,6 @@
@@ -43,14 +64,38 @@ {timeAgo(comment.createdAt)}
{#if onReply} - + + {/if} + {#if isMine && onEdit && !editing} + + {/if} + {#if isMine && onDelete} + {/if} {#if !comment.resolvedAt} - + {/if}
-

{comment.body}

+ {#if editing} +
+ +
+ + +
+
+ {:else} +

{comment.body}

+ {/if}
diff --git a/apps/web/src/routes/projects/[projectId]/tracks/[trackId]/components/VersionGraph.svelte b/apps/web/src/routes/(app)/projects/[projectId]/tracks/[trackId]/components/VersionGraph.svelte similarity index 97% rename from apps/web/src/routes/projects/[projectId]/tracks/[trackId]/components/VersionGraph.svelte rename to apps/web/src/routes/(app)/projects/[projectId]/tracks/[trackId]/components/VersionGraph.svelte index 5797a9f..d0f98d8 100644 --- a/apps/web/src/routes/projects/[projectId]/tracks/[trackId]/components/VersionGraph.svelte +++ b/apps/web/src/routes/(app)/projects/[projectId]/tracks/[trackId]/components/VersionGraph.svelte @@ -68,7 +68,7 @@
-

Version Graph

+

Versionen

{#if nodes.length === 0}

Noch keine Versionen.

{: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')} {/each} {#if onBranch && selectedId} {/if} {/if} diff --git a/apps/web/src/routes/projects/[projectId]/tracks/[trackId]/components/VersionHistory.svelte b/apps/web/src/routes/(app)/projects/[projectId]/tracks/[trackId]/components/VersionHistory.svelte similarity index 100% rename from apps/web/src/routes/projects/[projectId]/tracks/[trackId]/components/VersionHistory.svelte rename to apps/web/src/routes/(app)/projects/[projectId]/tracks/[trackId]/components/VersionHistory.svelte diff --git a/apps/web/src/routes/projects/[projectId]/tracks/[trackId]/components/VersionInfo.svelte b/apps/web/src/routes/(app)/projects/[projectId]/tracks/[trackId]/components/VersionInfo.svelte similarity index 73% rename from apps/web/src/routes/projects/[projectId]/tracks/[trackId]/components/VersionInfo.svelte rename to apps/web/src/routes/(app)/projects/[projectId]/tracks/[trackId]/components/VersionInfo.svelte index 0916e7b..d08b089 100644 --- a/apps/web/src/routes/projects/[projectId]/tracks/[trackId]/components/VersionInfo.svelte +++ b/apps/web/src/routes/(app)/projects/[projectId]/tracks/[trackId]/components/VersionInfo.svelte @@ -1,6 +1,7 @@ @@ -35,16 +44,16 @@ V{version.versionNumber} {#if version.label} — {version.label}{/if} - {version.status} + {STATUS_LABEL[version.status] || version.status}
{#if showActions}
{/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); diff --git a/apps/web/src/routes/projects/new/+page.svelte b/apps/web/src/routes/(app)/projects/new/+page.svelte similarity index 75% rename from apps/web/src/routes/projects/new/+page.svelte rename to apps/web/src/routes/(app)/projects/new/+page.svelte index 9f21e60..1d6ab04 100644 --- a/apps/web/src/routes/projects/new/+page.svelte +++ b/apps/web/src/routes/(app)/projects/new/+page.svelte @@ -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 @@ } + +
-

New Project

+

Neues Projekt

- +
- - + +
- - + +
@@ -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 { diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte index 64ac6c5..ddc8e72 100644 --- a/apps/web/src/routes/+layout.svelte +++ b/apps/web/src/routes/+layout.svelte @@ -1,10 +1,19 @@ -