Compare commits

..

10 Commits

Author SHA1 Message Date
Robin Choice
ea3a63211d chore: session checkpoint 2026-04-23 2026-04-23 11:26:56 +02:00
Robin Choice
69d41d0b70 feat: onboarding flow + mobile bottom nav + mobile polish
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 11:12:24 +02:00
Robin Choice
06f0a43532 feat: reject with feedback, email alerts, SSE real-time updates
Reject modal now requires a reason — stored as an auto-comment on the
version so context stays in the thread. Email alert fires on first play
of a shared link (fire-and-forget, no-op without RESEND_API_KEY). SSE
endpoint per track broadcasts version:new, version:status and
comment:new events; track page subscribes and reloads data live.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 10:28:58 +02:00
Robin Choice
df571df567 feat: listen analytics — track who heard what and when
Add listen_events table to record opens, plays and listen duration per
share link. Public POST/PATCH endpoints for fire-and-forget tracking
from the listen page; authenticated GET analytics endpoint aggregates
per version. Listen page gains optional name prompt after first play
and sendBeacon on unload. Track page gains Analytics tab with stats
grid and per-listener event list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 10:21:56 +02:00
Robin Choice
e5d0b00761 feat: PWA Phase 2 — push notifications
Add web push notification support: push_subscriptions table (migration
0007), VAPID-based push service, subscribe/unsubscribe API routes, SW
push+notificationclick handlers, and subscribe UI on account page.
Triggers: new version uploaded (all project members) and version
approved/rejected (uploader).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 10:09:22 +02:00
Robin Choice
9bad5c704a chore: session checkpoint 2026-04-16 2026-04-16 22:22:09 +02:00
Robin Choice
e58a7c250e feat: PWA Phase 1 — offline audio download and playback
- API: GET /versions/:id/audio?quality=stream|original (server proxy for SW caching)
- API: GET /versions/:id/waveform-data (server proxy for offline waveform)
- SW: cache-first from musichub-offline-v1 for proxied audio/waveform endpoints
- Client: IDB-backed offline store (idb lib) with progress-tracked download
- UI: per-version offline download button with stream/original quality picker
- UI: /offline page with storage estimate and remove-all action
- Manifest: shortcuts for Dashboard + Offline-Tracks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 22:17:01 +02:00
Robin Choice
e642e63fdc fix: disable SSR for authenticated app pages
WaveSurfer.js accesses window/AudioContext at module load time,
causing SSR to throw undefined on hard reload of track pages.
All (app) routes are auth-gated and fetch data client-side — SSR
provides no benefit here.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 21:17:59 +02:00
Robin Choice
c949d6b829 fix: security hardening and stem multi-select
- Remove public /migrate endpoint (startup migration handles it)
- Add membership + canUpload check to POST /versions/track/:trackId
- Add membership check to stream-url, download-url, waveform endpoints
- Scope member PATCH/DELETE to projectId to prevent cross-project mutation
- Add auth + membership check to POST /comments/:id/resolve
- Add secure: true to session cookie in production
- Hash magic link tokens before storing (was plaintext)
- Return generic error message instead of err.message
- Fix stem multi-file-select: replace hidden attr with CSS offscreen
  (Safari/WebKit drops multiple selection on display:none file inputs)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 21:04:22 +02:00
Robin Choice
afcb818dd4 chore: rename CLAUDE.md to AGENTS.md, add symlink and docs/templates
Adds tool-agnostic AGENTS.md as canonical context file, CLAUDE.md symlink for
Claude Code compatibility, and docs/templates/ with spec.md + adr.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 15:24:02 +02:00
50 changed files with 2738 additions and 193 deletions

View File

@@ -6,3 +6,8 @@ S3_BUCKET=music-hub
APP_URL=http://localhost:5173
API_URL=http://localhost:3000
MAGIC_LINK_SECRET=change-me-in-production
# Push Notifications (VAPID)
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_EMAIL=admin@musichub.app

View File

@@ -25,3 +25,8 @@ S3_BUCKET=music-hub
# Externer Port (Coolify mappt das auf die Domain)
PORT=3000
# Push Notifications (VAPID)
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_EMAIL=admin@musichub.app

49
AGENTS.md Normal file
View File

@@ -0,0 +1,49 @@
# Music Hub
Webapp für Label-Kollaboration. Stack: SvelteKit + Hono + Postgres.
## Aktueller Stand
<!-- Zuletzt aktualisiert: 2026-04-23 via /save -->
**Sprint / Phase:** UX-Qualität — Onboarding + Mobile abgeschlossen, deployed
**Zuletzt implementiert:**
- PWA Phase 2: Push Notifications (VAPID, `push_subscriptions`-Tabelle, SW push-Handler)
- Listen Analytics (`listen_events`-Tabelle, IP-Hashing, sendBeacon, AnalyticsPanel)
- Reject with Feedback (Modal mit Pflichtbegründung → Auto-Kommentar `❌`)
- SSE Real-time (`EventSource`, In-Memory Pub/Sub, `version:new`/`version:status`/`comment:new`)
- Onboarding Flow (`OnboardingFlow.svelte`): 3-Step Overlay mit Rollen-Picker, Demo/Projekt-Wahl, Invite
- Bottom Navigation (`BottomNav.svelte`): Nur ≤640px, safe-area-aware, öffnet Sidebar-Drawer
- Mobile-Polish: scrollbare Tabs, TopBar-Labels auf ≤480px ausgeblendet
**Als nächstes:**
- Phase 3: Background Sync für Uploads (IDB-Queue, SW sync-Handler)
- RESEND_API_KEY setzen für echten E-Mail-Versand
- Onboarding-Role für Backend-Personalisierung nutzen (aktuell nur localStorage)
**Offene Punkte:**
- RESEND_API_KEY fehlt noch (E-Mails nur geloggt)
- DB `is_public` nach STEM-Tests wieder auf privat
## Decisions
`docs/decisions/` — Architecture Decision Records für nicht-offensichtliche Entscheidungen.
Template: `docs/templates/adr.md`
Anlegen wenn: Alternative verworfen, Constraint akzeptiert, Richtungsentscheidung getroffen.
## Specs
`specs/` — ein File pro Sprint oder Feature, bevor Code geschrieben wird.
Template: `docs/templates/spec.md`
Konvention:
- Neues Sprint/Feature → erst `specs/sprint-N.md` oder `specs/feature-name.md` anlegen
- Kanban-Task verlinkt auf die Spec-Datei
- Aktive Spec steht im `## Aktueller Stand`
## Kanban
Board-ID: `cfddb658-6f5b-4d36-b311-369307a5fc51`
Konvention: Bei Session-Start `get-board-info` aufrufen und offene Tasks zeigen. Aktive Tasks nach In Progress ziehen, erledigte nach Done.

View File

@@ -1,45 +0,0 @@
# Music Hub
Webapp für Label-Kollaboration. Stack: SvelteKit + Hono + Postgres.
## Aktueller Stand
<!-- Zuletzt aktualisiert: 2026-04-13 via /save -->
**Sprint / Phase:** Deploy + erster Klienten-Test
**Zuletzt implementiert:**
- App live auf hub.mydrugismusic.com (Registrierung, Login funktionieren)
- Coolify-Deploy via Webhook-Script (kein UI nötig, im Memory dokumentiert)
- DATABASE_URL auf public port umgestellt (interner Coolify-Hostname war nicht erreichbar)
- README geschrieben und gepusht
**Als nächstes:**
- RESEND_API_KEY setzen → echter E-Mail-Versand
- App-Bugs fixen (User: „man kann quasi nichts machen außer Profil/Karte")
- DB `is_public` nach Tests wieder deaktivieren
**Offene Punkte:**
- Interner Coolify-Netzwerkfehler (API→DB via UUID-Hostname) ungeklärt
## Decisions
`docs/decisions/` — Architecture Decision Records für nicht-offensichtliche Entscheidungen.
Template: `~/.claude/templates/adr.md`
Anlegen wenn: Alternative verworfen, Constraint akzeptiert, Richtungsentscheidung getroffen.
## Specs
`specs/` — ein File pro Sprint oder Feature, bevor Code geschrieben wird.
Template: `~/.claude/templates/spec.md`
Konvention:
- Neues Sprint/Feature → erst `specs/sprint-N.md` oder `specs/feature-name.md` anlegen
- Kanban-Task verlinkt auf die Spec-Datei
- Aktive Spec steht im `## Aktueller Stand`
## Kanban
Board-ID: `cfddb658-6f5b-4d36-b311-369307a5fc51`
Konvention: Bei Session-Start `get-board-info` aufrufen und offene Tasks zeigen. Aktive Tasks nach In Progress ziehen, erledigte nach Done.

1
CLAUDE.md Symbolic link
View File

@@ -0,0 +1 @@
AGENTS.md

View File

@@ -16,6 +16,7 @@
"drizzle-orm": "^0.44",
"fflate": "^0.8.2",
"hono": "^4",
"resend": "^6.10.0"
"resend": "^6.10.0",
"web-push": "^3.6.7"
}
}

View File

@@ -12,6 +12,8 @@ import { uploadRoutes } from './routes/uploads.js';
import { activityRoutes } from './routes/activity.js';
import { onboardingRoutes } from './routes/onboarding.js';
import { stemRoutes } from './routes/stems.js';
import { pushRoutes } from './routes/push.js';
import { sseRoutes } from './routes/sse.js';
import type { AppEnv } from './types.js';
const db = createDb(process.env.DATABASE_URL!);
@@ -65,37 +67,9 @@ const app = new Hono<AppEnv>()
})
.onError((err, c) => {
console.error('Unhandled error:', err);
return c.json({ error: err.message }, 500);
return c.json({ error: 'Internal server error' }, 500);
})
.get('/health', (c) => c.json({ status: 'ok' }))
.get('/migrate', async (c) => {
try {
const fs = await import('fs');
const pathMod = await import('path');
const { sql: dsql } = await import('drizzle-orm');
const folder = pathMod.resolve(process.cwd(), 'packages/db/src/migrations');
const journal = JSON.parse(fs.readFileSync(pathMod.join(folder, 'meta', '_journal.json'), 'utf8'));
const results: string[] = [];
for (const entry of journal.entries) {
const sqlFile = pathMod.join(folder, `${entry.tag}.sql`);
if (!fs.existsSync(sqlFile)) { results.push(`skip: ${entry.tag} (not found)`); continue; }
const rawSql = fs.readFileSync(sqlFile, 'utf8');
const statements = rawSql.split('--> statement-breakpoint').map((s: string) => s.trim()).filter(Boolean);
for (const stmt of statements) {
try {
await db.execute(dsql.raw(stmt));
} catch (err: any) {
if (err.message?.includes('already exists') || err.message?.includes('duplicate')) continue;
results.push(`error in ${entry.tag}: ${err.message?.slice(0, 150)}`);
}
}
results.push(`ok: ${entry.tag}`);
}
return c.json({ status: 'ok', results });
} catch (err: any) {
return c.json({ status: 'error', message: err.message }, 500);
}
})
.basePath('/api/v1')
.route('/auth', authRoutes)
.route('/projects', projectRoutes)
@@ -106,7 +80,9 @@ const app = new Hono<AppEnv>()
.route('/uploads', uploadRoutes)
.route('/activity', activityRoutes)
.route('/onboarding', onboardingRoutes)
.route('/stems', stemRoutes);
.route('/stems', stemRoutes)
.route('/push', pushRoutes)
.route('/sse', sseRoutes);
const port = parseInt(process.env.PORT || '3000');
console.log(`Music Hub API running on port ${port}`);

View File

@@ -16,6 +16,7 @@ async function createSession(c: any, db: any, userId: string) {
setCookie(c, 'session', sessionToken, {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: 30 * 24 * 60 * 60,
});
@@ -64,11 +65,12 @@ export const authRoutes = new Hono<AppEnv>()
const db = c.get('db');
const token = generateToken();
const tokenHash = await hashToken(token);
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 min
await db.insert(magicLinks).values({
email,
token,
token: tokenHash,
expiresAt,
});
@@ -81,10 +83,11 @@ export const authRoutes = new Hono<AppEnv>()
const { token } = c.req.valid('json');
const db = c.get('db');
const tokenHash = await hashToken(token);
const [link] = await db
.select()
.from(magicLinks)
.where(eq(magicLinks.token, token))
.where(eq(magicLinks.token, tokenHash))
.limit(1);
if (!link || link.expiresAt < new Date() || link.usedAt) {

View File

@@ -4,6 +4,7 @@ import { eq, and, asc } from 'drizzle-orm';
import { createCommentSchema, updateCommentSchema } from '@music-hub/shared';
import { comments, versions, tracks, projectMembers, users } from '@music-hub/db';
import { requireAuth } from '../middleware/auth.js';
import { publish } from '../services/sse.js';
import type { AppEnv } from '../types.js';
export const commentRoutes = new Hono<AppEnv>()
@@ -109,6 +110,8 @@ export const commentRoutes = new Hono<AppEnv>()
})
.returning();
publish(track!.id, { type: 'comment:new', data: { versionId, commentId: comment.id } });
return c.json({ comment }, 201);
},
)
@@ -158,15 +161,29 @@ export const commentRoutes = new Hono<AppEnv>()
// Resolve comment
.post('/:id/resolve', async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const commentId = c.req.param('id');
const [comment] = await db.select().from(comments).where(eq(comments.id, commentId)).limit(1);
if (!comment) return c.json({ error: 'Not found' }, 404);
const [version] = await db.select().from(versions).where(eq(versions.id, comment.versionId)).limit(1);
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.canComment && !membership.canApprove)) {
return c.json({ error: 'Forbidden' }, 403);
}
const [updated] = await db
.update(comments)
.set({ resolvedAt: new Date() })
.where(eq(comments.id, commentId))
.returning();
if (!updated) return c.json({ error: 'Not found' }, 404);
return c.json({ comment: updated });
});

View File

@@ -266,7 +266,7 @@ export const projectRoutes = new Hono<AppEnv>()
const [updated] = await db
.update(projectMembers)
.set({ role: newRole, ...defaults })
.where(eq(projectMembers.id, memberId))
.where(and(eq(projectMembers.id, memberId), eq(projectMembers.projectId, projectId)))
.returning();
return c.json({ member: updated });
@@ -294,7 +294,7 @@ export const projectRoutes = new Hono<AppEnv>()
return c.json({ error: 'Forbidden' }, 403);
}
await db.delete(projectMembers).where(eq(projectMembers.id, memberId));
await db.delete(projectMembers).where(and(eq(projectMembers.id, memberId), eq(projectMembers.projectId, projectId)));
return c.json({ message: 'Member removed' });
});

View File

@@ -0,0 +1,49 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { eq, and } from 'drizzle-orm';
import { subscribePushSchema } from '@music-hub/shared';
import { pushSubscriptions } from '@music-hub/db';
import { requireAuth } from '../middleware/auth.js';
import type { AppEnv } from '../types.js';
export const pushRoutes = new Hono<AppEnv>()
.use('*', requireAuth)
.get('/vapid-public-key', (c) => {
const key = process.env.VAPID_PUBLIC_KEY;
if (!key) return c.json({ error: 'Push not configured' }, 503);
return c.json({ key });
})
.post('/subscribe', zValidator('json', subscribePushSchema), async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const { endpoint, keys, userAgent } = c.req.valid('json');
await db
.insert(pushSubscriptions)
.values({ userId, endpoint, p256dh: keys.p256dh, auth: keys.auth, userAgent })
.onConflictDoUpdate({
target: pushSubscriptions.endpoint,
set: { userId, p256dh: keys.p256dh, auth: keys.auth, userAgent },
});
return c.json({ ok: true }, 201);
})
.delete('/subscribe', async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const body = await c.req.json().catch(() => ({}));
const endpoint = body?.endpoint;
if (endpoint) {
await db
.delete(pushSubscriptions)
.where(and(eq(pushSubscriptions.userId, userId), eq(pushSubscriptions.endpoint, endpoint)));
} else {
await db.delete(pushSubscriptions).where(eq(pushSubscriptions.userId, userId));
}
return c.json({ ok: true });
});

View File

@@ -1,9 +1,10 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { eq, and, asc } from 'drizzle-orm';
import { createShareLinkSchema, guestCommentSchema } from '@music-hub/shared';
import { eq, and, asc, desc, inArray } from 'drizzle-orm';
import { createShareLinkSchema, guestCommentSchema, updateListenEventSchema } from '@music-hub/shared';
import {
shareLinks,
listenEvents,
versions,
tracks,
projects,
@@ -13,6 +14,12 @@ import {
} from '@music-hub/db';
import { requireAuth } from '../middleware/auth.js';
import { createDownloadUrl } from '../storage/s3.js';
import { sendListenAlertEmail } from '../services/email.js';
async function hashIp(ip: string): Promise<string> {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(ip + 'musichub-salt'));
return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, 16);
}
import type { AppEnv } from '../types.js';
function generateToken(): string {
@@ -262,4 +269,154 @@ export const shareRoutes = new Hono<AppEnv>()
.returning();
return c.json({ comment }, 201);
})
// --- Listen tracking (public, no auth) ---
.post('/public/:token/listen', async (c) => {
const db = c.get('db');
const token = c.req.param('token');
const [link] = await db
.select({ id: shareLinks.id, expiresAt: shareLinks.expiresAt })
.from(shareLinks)
.where(eq(shareLinks.token, token))
.limit(1);
if (!link) return c.json({ error: 'Not found' }, 404);
if (link.expiresAt && link.expiresAt < new Date()) return c.json({ error: 'Expired' }, 410);
const ip = c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? c.req.header('cf-connecting-ip') ?? 'unknown';
const ipHash = await hashIp(ip);
const userAgent = (c.req.header('user-agent') ?? '').slice(0, 500);
const [event] = await db
.insert(listenEvents)
.values({ shareLinkId: link.id, ipHash, userAgent })
.returning({ id: listenEvents.id });
return c.json({ eventId: event.id }, 201);
})
.patch('/public/:token/listen/:eventId', zValidator('json', updateListenEventSchema), async (c) => {
const db = c.get('db');
const token = c.req.param('token');
const eventId = c.req.param('eventId');
const input = c.req.valid('json');
const [link] = await db
.select({ id: shareLinks.id })
.from(shareLinks)
.where(eq(shareLinks.token, token))
.limit(1);
if (!link) return c.json({ error: 'Not found' }, 404);
const [event] = await db
.select()
.from(listenEvents)
.where(and(eq(listenEvents.id, eventId), eq(listenEvents.shareLinkId, link.id)))
.limit(1);
if (!event) return c.json({ error: 'Not found' }, 404);
const isFirstPlay = input.firstPlay && !event.firstPlayAt;
await db
.update(listenEvents)
.set({
...(input.listenerName !== undefined ? { listenerName: input.listenerName } : {}),
...(isFirstPlay ? { firstPlayAt: new Date() } : {}),
...(input.listenSeconds !== undefined ? { listenSeconds: input.listenSeconds } : {}),
...(input.completed !== undefined ? { completed: input.completed } : {}),
})
.where(eq(listenEvents.id, eventId));
if (isFirstPlay) {
// Fire-and-forget: alert link creator by email
Promise.resolve().then(async () => {
try {
const [fullLink] = await db
.select({ createdById: shareLinks.createdById, versionId: shareLinks.versionId })
.from(shareLinks)
.where(eq(shareLinks.id, link.id))
.limit(1);
if (!fullLink) return;
const [creator] = await db
.select({ email: users.email, name: users.name })
.from(users)
.where(eq(users.id, fullLink.createdById))
.limit(1);
if (!creator) return;
const [version] = await db.select().from(versions).where(eq(versions.id, fullLink.versionId)).limit(1);
const [track] = version ? await db.select().from(tracks).where(eq(tracks.id, version.trackId)).limit(1) : [null];
const [project] = track ? await db.select().from(projects).where(eq(projects.id, track.projectId)).limit(1) : [null];
if (!track || !project) return;
const listenerName = input.listenerName ?? event.listenerName;
const trackUrl = `${process.env.APP_URL}/projects/${track.projectId}/tracks/${track.id}`;
await sendListenAlertEmail(creator.email, listenerName ?? null, track.name, project.name, trackUrl);
} catch { /* non-critical */ }
});
}
return c.json({ ok: true });
})
// --- Analytics (authenticated) ---
.get('/version/:versionId/analytics', requireAuth, async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const versionId = c.req.param('versionId');
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) return c.json({ error: 'Forbidden' }, 403);
const links = await db
.select({ id: shareLinks.id })
.from(shareLinks)
.where(eq(shareLinks.versionId, versionId));
if (links.length === 0) return c.json({ totalOpens: 0, totalPlays: 0, uniqueListeners: 0, avgListenSeconds: 0, completionRate: 0, events: [] });
const linkIds = links.map((l) => l.id);
const events = await db
.select()
.from(listenEvents)
.where(inArray(listenEvents.shareLinkId, linkIds))
.orderBy(desc(listenEvents.openedAt));
const totalOpens = events.length;
const played = events.filter((e) => e.firstPlayAt !== null);
const totalPlays = played.length;
const uniqueListeners = new Set(events.map((e) => e.ipHash)).size;
const avgListenSeconds = played.length > 0
? Math.round(played.reduce((s, e) => s + e.listenSeconds, 0) / played.length)
: 0;
const completionRate = totalPlays > 0
? Math.round((events.filter((e) => e.completed).length / totalPlays) * 100)
: 0;
return c.json({
totalOpens,
totalPlays,
uniqueListeners,
avgListenSeconds,
completionRate,
events: events.map((e) => ({
id: e.id,
listenerName: e.listenerName,
openedAt: e.openedAt,
firstPlayAt: e.firstPlayAt,
listenSeconds: e.listenSeconds,
completed: e.completed,
})),
});
});

View File

@@ -0,0 +1,49 @@
import { Hono } from 'hono';
import { eq, and } from 'drizzle-orm';
import { tracks, projectMembers } from '@music-hub/db';
import { requireAuth } from '../middleware/auth.js';
import { subscribe } from '../services/sse.js';
import type { AppEnv } from '../types.js';
export const sseRoutes = new Hono<AppEnv>()
.use('*', requireAuth)
.get('/track/:trackId', async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const trackId = c.req.param('trackId');
const [track] = await db.select().from(tracks).where(eq(tracks.id, trackId)).limit(1);
if (!track) return c.json({ error: 'Not found' }, 404);
const [membership] = await db
.select()
.from(projectMembers)
.where(and(eq(projectMembers.projectId, track.projectId), eq(projectMembers.userId, userId)))
.limit(1);
if (!membership) return c.json({ error: 'Forbidden' }, 403);
const { readable, writable } = new TransformStream();
const writer = writable.getWriter();
const encoder = new TextEncoder();
const send = (data: string) => writer.write(encoder.encode(data)).catch(() => {});
const unsubscribe = subscribe(trackId, send);
// Initial ping
send(': connected\n\n');
c.req.raw.signal.addEventListener('abort', () => {
unsubscribe();
writer.close().catch(() => {});
});
return new Response(readable, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
},
});
});

View File

@@ -1,11 +1,13 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { eq, and, desc, asc, sql } from 'drizzle-orm';
import { requestUploadUrlSchema, createVersionSchema, updateVersionSchema } from '@music-hub/shared';
import { tracks, versions, projectMembers } from '@music-hub/db';
import { requestUploadUrlSchema, createVersionSchema, updateVersionSchema, rejectVersionSchema } from '@music-hub/shared';
import { tracks, versions, projectMembers, comments } from '@music-hub/db';
import { requireAuth } from '../middleware/auth.js';
import { createUploadUrl, createDownloadUrl } from '../storage/s3.js';
import { createUploadUrl, createDownloadUrl, getObjectBuffer } from '../storage/s3.js';
import { processVersion } from '../services/audio-processor.js';
import { notifyProjectMembers, notifyUser } from '../services/push.js';
import { publish } from '../services/sse.js';
import type { AppEnv } from '../types.js';
export const versionRoutes = new Hono<AppEnv>()
@@ -81,6 +83,23 @@ export const versionRoutes = new Hono<AppEnv>()
const [track] = await db.select().from(tracks).where(eq(tracks.id, trackId)).limit(1);
if (!track) return c.json({ error: 'Not found' }, 404);
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 expectedPrefix = `projects/${track.projectId}/tracks/${trackId}/`;
if (!input.fileKey.startsWith(expectedPrefix)) {
return c.json({ error: 'Forbidden' }, 403);
}
// Get next version number
const [latest] = await db
.select({ maxVersion: sql<number>`coalesce(max(${versions.versionNumber}), 0)` })
@@ -112,6 +131,14 @@ export const versionRoutes = new Hono<AppEnv>()
console.error(`[Worker] Failed: ${err.message}`),
);
notifyProjectMembers(db, track.projectId, userId, {
title: 'Neue Version',
body: `${track.name} — V${versionNumber} hochgeladen`,
url: `/projects/${track.projectId}/tracks/${trackId}`,
}).catch(() => {});
publish(trackId, { type: 'version:new', data: { versionId: version.id, versionNumber, trackId } });
return c.json({ version }, 201);
})
@@ -269,16 +296,20 @@ export const versionRoutes = new Hono<AppEnv>()
// Get stream URL
.get('/:id/stream-url', 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);
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) return c.json({ error: 'Not found' }, 404);
const key = version.streamFileKey || version.originalFileKey;
const url = await createDownloadUrl(key);
@@ -288,16 +319,20 @@ export const versionRoutes = new Hono<AppEnv>()
// Get download URL
.get('/:id/download-url', 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);
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) return c.json({ error: 'Not found' }, 404);
const url = await createDownloadUrl(version.originalFileKey);
return c.json({ url });
})
@@ -305,17 +340,19 @@ export const versionRoutes = new Hono<AppEnv>()
// Get waveform data
.get('/:id/waveform', 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);
const [version] = await db.select().from(versions).where(eq(versions.id, versionId)).limit(1);
if (!version || !version.waveformDataKey) return c.json({ error: 'Not found' }, 404);
if (!version || !version.waveformDataKey) {
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) return c.json({ error: 'Not found' }, 404);
const url = await createDownloadUrl(version.waveformDataKey);
return c.json({ url });
@@ -359,14 +396,84 @@ export const versionRoutes = new Hono<AppEnv>()
.where(eq(versions.id, versionId))
.returning();
notifyUser(db, version.createdById, {
title: 'Version freigegeben',
body: `${track!.name} V${version.versionNumber} wurde freigegeben`,
url: `/projects/${track!.projectId}/tracks/${version.trackId}`,
}).catch(() => {});
publish(version.trackId, { type: 'version:status', data: { versionId, status: 'approved' } });
return c.json({ version: updated });
})
// Reject version
.post('/:id/reject', async (c) => {
// Proxy audio for offline download
.get('/:id/audio', async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const versionId = c.req.param('id');
const quality = c.req.query('quality') === 'original' ? 'original' : 'stream';
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) return c.json({ error: 'Not found' }, 404);
const useOriginal = quality === 'original' || !version.streamFileKey;
const key = useOriginal ? version.originalFileKey : version.streamFileKey!;
const contentType = useOriginal ? (version.mimeType || 'audio/wav') : 'audio/mpeg';
const buffer = await getObjectBuffer(key);
return new Response(buffer, {
headers: {
'Content-Type': contentType,
'Content-Length': String(buffer.byteLength),
'Cache-Control': 'private, max-age=3600',
'ETag': `"${versionId}-${quality}"`,
},
});
})
// Proxy waveform peaks for offline
.get('/:id/waveform-data', 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 || !version.waveformDataKey) 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) return c.json({ error: 'Not found' }, 404);
const buffer = await getObjectBuffer(version.waveformDataKey);
return new Response(buffer, {
headers: {
'Content-Type': 'application/json',
'Content-Length': String(buffer.byteLength),
'Cache-Control': 'private, max-age=86400',
'ETag': `"${versionId}-waveform"`,
},
});
})
// Reject version (requires reason — posted as a comment)
.post('/:id/reject', zValidator('json', rejectVersionSchema), async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const versionId = c.req.param('id');
const { reason } = c.req.valid('json');
const [version] = await db
.select()
@@ -400,5 +507,21 @@ export const versionRoutes = new Hono<AppEnv>()
.where(eq(versions.id, versionId))
.returning();
await db.insert(comments).values({
versionId,
userId,
body: `❌ Abgelehnt: ${reason}`,
timestampSeconds: null,
parentId: null,
});
notifyUser(db, version.createdById, {
title: 'Version abgelehnt',
body: `${track!.name} V${version.versionNumber}: ${reason.slice(0, 80)}`,
url: `/projects/${track!.projectId}/tracks/${version.trackId}`,
}).catch(() => {});
publish(version.trackId, { type: 'version:status', data: { versionId, status: 'rejected' } });
return c.json({ version: updated });
});

View File

@@ -39,6 +39,46 @@ export async function sendMagicLinkEmail(email: string, token: string) {
});
}
export async function sendListenAlertEmail(
to: string,
listenerName: string | null,
trackName: string,
projectName: string,
trackUrl: string,
) {
const who = listenerName ?? 'Jemand';
if (!resend) {
console.log(`[DEV] Listen alert: ${who} hat "${trackName}" gehört — ${to}`);
return;
}
await resend.emails.send({
from: fromEmail,
to,
subject: `${who} hat "${trackName}" gehört`,
html: `
<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 0.5rem;">
<strong style="color: #f4f0ec;">${who}</strong> hat deinen Track gehört:
</p>
<p style="color: #f4f0ec; font-size: 1.1rem; font-weight: 600; margin: 0 0 0.25rem;">${trackName}</p>
<p style="color: #5e596b; font-size: 0.85rem; margin: 0 0 1.5rem;">${projectName}</p>
<a href="${trackUrl}" style="
display: inline-block;
padding: 0.8rem 1.6rem;
background: linear-gradient(135deg, #f43f5e, #fb923c);
color: #fff;
border-radius: 10px;
text-decoration: none;
font-weight: 600;
">Analytics ansehen</a>
</div>
`,
});
}
export async function sendInviteEmail(email: string, projectName: string, inviterName: string) {
const url = `${process.env.APP_URL}`;

View File

@@ -0,0 +1,100 @@
import webpush from 'web-push';
import { eq, inArray } from 'drizzle-orm';
import { pushSubscriptions, projectMembers } from '@music-hub/db';
export type PushPayload = {
title: string;
body: string;
url?: string;
icon?: string;
};
function initVapid() {
const pub = process.env.VAPID_PUBLIC_KEY;
const priv = process.env.VAPID_PRIVATE_KEY;
const email = process.env.VAPID_EMAIL || 'admin@musichub.app';
if (pub && priv) {
webpush.setVapidDetails(`mailto:${email}`, pub, priv);
}
}
initVapid();
async function send(
sub: { endpoint: string; p256dh: string; auth: string },
payload: PushPayload,
): Promise<boolean> {
try {
await webpush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
JSON.stringify(payload),
);
return true;
} catch (err: any) {
if (err.statusCode === 410 || err.statusCode === 404) return false; // subscription gone
console.error('[Push] send error:', err.message);
return true;
}
}
export async function notifyProjectMembers(
db: any,
projectId: string,
excludeUserId: string,
payload: PushPayload,
): Promise<void> {
if (!process.env.VAPID_PUBLIC_KEY) return;
const members = await db
.select({ userId: projectMembers.userId })
.from(projectMembers)
.where(eq(projectMembers.projectId, projectId));
const userIds = members
.map((m: { userId: string }) => m.userId)
.filter((id: string) => id !== excludeUserId);
if (userIds.length === 0) return;
const subs = await db
.select()
.from(pushSubscriptions)
.where(inArray(pushSubscriptions.userId, userIds));
const stale: string[] = [];
await Promise.all(
subs.map(async (sub: typeof pushSubscriptions.$inferSelect) => {
const ok = await send(sub, payload);
if (!ok) stale.push(sub.id);
}),
);
if (stale.length > 0) {
await db.delete(pushSubscriptions).where(inArray(pushSubscriptions.id, stale));
}
}
export async function notifyUser(
db: any,
userId: string,
payload: PushPayload,
): Promise<void> {
if (!process.env.VAPID_PUBLIC_KEY) return;
const subs = await db
.select()
.from(pushSubscriptions)
.where(eq(pushSubscriptions.userId, userId));
const stale: string[] = [];
await Promise.all(
subs.map(async (sub: typeof pushSubscriptions.$inferSelect) => {
const ok = await send(sub, payload);
if (!ok) stale.push(sub.id);
}),
);
if (stale.length > 0) {
await db.delete(pushSubscriptions).where(inArray(pushSubscriptions.id, stale));
}
}

View File

@@ -0,0 +1,21 @@
type SseClient = (data: string) => void;
const channels = new Map<string, Set<SseClient>>();
export function subscribe(trackId: string, send: SseClient): () => void {
if (!channels.has(trackId)) channels.set(trackId, new Set());
channels.get(trackId)!.add(send);
return () => {
channels.get(trackId)?.delete(send);
if (channels.get(trackId)?.size === 0) channels.delete(trackId);
};
}
export function publish(trackId: string, event: { type: string; data: unknown }) {
const clients = channels.get(trackId);
if (!clients || clients.size === 0) return;
const msg = `event: ${event.type}\ndata: ${JSON.stringify(event.data)}\n\n`;
for (const send of clients) {
try { send(msg); } catch { /* client gone */ }
}
}

View File

@@ -14,6 +14,7 @@
"@fontsource-variable/inter": "^5.2.8",
"@music-hub/shared": "workspace:*",
"@sveltejs/adapter-node": "^5.5.4",
"idb": "^8.0.3",
"wavesurfer.js": "^7.12.5"
},
"devDependencies": {

View File

@@ -34,5 +34,5 @@ export const api = {
get: <T>(path: string, silent = false) => request<T>(path, { silent }),
post: <T>(path: string, body?: unknown) => request<T>(path, { method: 'POST', body }),
patch: <T>(path: string, body?: unknown) => request<T>(path, { method: 'PATCH', body }),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
delete: <T>(path: string, body?: unknown) => request<T>(path, { method: 'DELETE', body }),
};

View File

@@ -124,7 +124,7 @@
accept="audio/*"
multiple
onchange={handleFileSelect}
hidden
style="position:absolute;width:1px;height:1px;opacity:0;pointer-events:none;"
/>
<div class="dropzone-content">
<span class="icon"><Icon name="upload" size={24} /></span>

View File

@@ -22,6 +22,9 @@
onTimeClick,
onReady,
onSeek,
onPlay,
onPause,
onFinish,
}: {
url: string;
markers?: CommentMarker[];
@@ -33,6 +36,9 @@
onTimeClick?: (time: number) => void;
onReady?: (duration: number) => void;
onSeek?: (time: number) => void;
onPlay?: () => void;
onPause?: () => void;
onFinish?: () => void;
} = $props();
let container: HTMLDivElement;
@@ -86,8 +92,9 @@
onSeek?.(time);
});
ws.on('play', () => (isPlaying = true));
ws.on('pause', () => (isPlaying = false));
ws.on('play', () => { isPlaying = true; onPlay?.(); });
ws.on('pause', () => { isPlaying = false; onPause?.(); });
ws.on('finish', () => { isPlaying = false; onFinish?.(); });
ws.on('click', (relativeX) => {
if (onTimeClick) {

View File

@@ -0,0 +1,567 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { fly } from 'svelte/transition';
import { api } from '$lib/api/client.js';
import { toastError, toastSuccess } from '$lib/stores/toast.js';
import Button from '$lib/components/ui/Button.svelte';
let { open = $bindable(false) }: { open?: boolean } = $props();
let step = $state(0);
let dir = $state(1);
let selectedRole = $state('');
let projectName = $state('');
let inviteEmail = $state('');
let loading = $state(false);
let createdProjectId = $state('');
const roles = [
{ id: 'producer', label: 'Produzent', icon: '🎛️', desc: 'Beats, Tracks, Stems' },
{ id: 'label', label: 'Label / A&R', icon: '🏢', desc: 'Releases & Feedback' },
{ id: 'artist', label: 'Artist', icon: '🎤', desc: 'Aufnahmen & Kollaboration' },
{ id: 'manager', label: 'Manager', icon: '📋', desc: 'Team & Koordination' },
{ id: 'engineer', label: 'Mix Engineer', icon: '🎚️', desc: 'Mixen & Mastern' },
{ id: 'other', label: 'Anderes', icon: '✨', desc: 'Ich entdecke die Platform' },
];
function pickRole(id: string) {
selectedRole = id;
if (typeof localStorage !== 'undefined') {
localStorage.setItem('onboarding-role', id);
}
advance();
}
function advance() {
dir = 1;
step++;
}
function back() {
dir = -1;
step--;
}
async function loadDemo() {
loading = true;
try {
const res = await api.post<{ projectId: string }>('/onboarding/seed-demo');
createdProjectId = res.projectId;
advance();
} catch {
toastError('Demo konnte nicht geladen werden');
} finally {
loading = false;
}
}
async function createProject() {
const name = projectName.trim();
if (!name) return;
loading = true;
try {
const res = await api.post<{ project: { id: string } }>('/projects', { name });
createdProjectId = res.project.id;
advance();
} catch {
toastError('Projekt konnte nicht erstellt werden');
} finally {
loading = false;
}
}
async function sendInvite() {
const email = inviteEmail.trim();
if (!email || !createdProjectId) return;
loading = true;
try {
await api.post(`/projects/${createdProjectId}/members`, {
email,
role: 'collaborator',
canUpload: true,
canComment: true,
canApprove: false,
});
toastSuccess(`Einladung an ${email} gesendet`);
finish();
} catch {
toastError('Einladung konnte nicht gesendet werden');
} finally {
loading = false;
}
}
function finish() {
if (typeof localStorage !== 'undefined') {
localStorage.setItem('onboarding-done', '1');
}
open = false;
if (createdProjectId) {
goto(`/projects/${createdProjectId}`);
}
}
</script>
{#if open}
<div class="overlay" role="dialog" aria-modal="true" aria-label="Onboarding">
<div class="panel">
<div class="panel-inner">
<!-- Header -->
<div class="panel-head">
<div class="dots">
{#each [0, 1, 2] as i}
<div class="dot" class:active={step === i} class:done={step > i}></div>
{/each}
</div>
{#if step < 2}
<button class="skip-btn" onclick={finish}>Überspringen</button>
{/if}
</div>
<!-- Step content -->
<div class="steps-viewport">
{#key step}
<div
class="step"
in:fly={{ x: dir * 56, opacity: 0, duration: 300, delay: 60 }}
out:fly={{ x: dir * -56, opacity: 0, duration: 220 }}
>
{#if step === 0}
<div class="step-header">
<div class="wordmark">Music Hub</div>
<h1>Willkommen!</h1>
<p class="lead">Wie würdest du dich beschreiben?</p>
</div>
<div class="roles">
{#each roles as role}
<button
class="role-tile"
class:selected={selectedRole === role.id}
onclick={() => pickRole(role.id)}
>
<span class="role-icon">{role.icon}</span>
<span class="role-label">{role.label}</span>
<span class="role-desc">{role.desc}</span>
</button>
{/each}
</div>
{:else if step === 1}
<div class="step-header">
<h1>Wie möchtest du starten?</h1>
<p class="lead">Du kannst das Demo jederzeit löschen</p>
</div>
<div class="choices">
<button class="choice-card" onclick={loadDemo} disabled={loading}>
<span class="choice-emoji">🎵</span>
<div class="choice-text">
<strong>Demo-Projekt laden</strong>
<span>Sofort loslegen mit echten Beispiel-Tracks, Versionen und Kommentaren</span>
</div>
<span class="choice-arrow"></span>
</button>
<div class="or-divider"><span>oder</span></div>
<div class="new-row">
<input
class="name-input"
type="text"
placeholder="Projekttitel eingeben …"
bind:value={projectName}
onkeydown={(e) => e.key === 'Enter' && createProject()}
autofocus
/>
<Button
onclick={createProject}
{loading}
disabled={loading || !projectName.trim()}
>Erstellen →</Button>
</div>
</div>
<button class="back-btn" onclick={back}> Zurück</button>
{:else if step === 2}
<div class="step-header">
<div class="check-icon"></div>
<h1>Fast fertig!</h1>
<p class="lead">Lade jemanden ein — oder starte direkt alleine.</p>
</div>
<div class="invite-block">
<input
class="name-input"
type="email"
placeholder="E-Mail-Adresse eingeben …"
bind:value={inviteEmail}
onkeydown={(e) => e.key === 'Enter' && sendInvite()}
autofocus
/>
<Button
onclick={sendInvite}
{loading}
disabled={loading || !inviteEmail.trim()}
>Einladen</Button>
</div>
<div class="finish-row">
<button class="skip-link" onclick={finish}>Überspringen Zum Projekt</button>
</div>
{/if}
</div>
{/key}
</div>
</div>
</div>
</div>
{/if}
<style>
.overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-4);
}
.panel {
background: var(--color-bg-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl, 20px);
width: 100%;
max-width: 620px;
max-height: 90vh;
overflow: hidden;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.5);
}
.panel-inner {
padding: var(--space-6) var(--space-7);
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.dots {
display: flex;
gap: 6px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-border);
transition: background 0.25s, transform 0.25s;
}
.dot.active {
background: var(--color-accent);
transform: scale(1.25);
}
.dot.done {
background: var(--color-accent);
opacity: 0.4;
}
.skip-btn {
background: none;
border: none;
color: var(--color-text-tertiary);
font-size: var(--text-sm);
cursor: pointer;
padding: 0;
transition: color var(--transition-fast);
}
.skip-btn:hover {
color: var(--color-text-secondary);
}
.steps-viewport {
position: relative;
min-height: 320px;
overflow: hidden;
}
.step {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
gap: var(--space-5);
overflow-y: auto;
}
/* Step headers */
.step-header {
text-align: center;
}
.wordmark {
font-size: var(--text-sm);
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-accent);
margin-bottom: var(--space-3);
}
.step-header h1 {
font-size: var(--text-2xl);
font-weight: 700;
margin: 0 0 var(--space-2);
color: var(--color-text-primary);
letter-spacing: -0.02em;
}
.lead {
color: var(--color-text-tertiary);
font-size: var(--text-sm);
margin: 0;
}
.check-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 50%;
background: color-mix(in srgb, var(--color-accent) 20%, transparent);
color: var(--color-accent);
font-size: 1.4rem;
font-weight: 700;
margin: 0 auto var(--space-3);
}
/* Role tiles */
.roles {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-3);
}
.role-tile {
background: var(--color-bg);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-lg, 12px);
padding: var(--space-4) var(--space-3);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
cursor: pointer;
transition: all var(--transition-fast);
text-align: center;
}
.role-tile:hover {
border-color: var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 8%, var(--color-bg));
transform: translateY(-2px);
}
.role-tile.selected {
border-color: var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 12%, var(--color-bg));
}
.role-icon {
font-size: 1.75rem;
line-height: 1;
}
.role-label {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-primary);
}
.role-desc {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
line-height: 1.3;
}
/* Choice cards (step 1) */
.choices {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.choice-card {
display: flex;
align-items: center;
gap: var(--space-4);
background: var(--color-bg);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-lg, 12px);
padding: var(--space-4) var(--space-5);
cursor: pointer;
text-align: left;
transition: all var(--transition-fast);
}
.choice-card:hover:not(:disabled) {
border-color: var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 8%, var(--color-bg));
transform: translateY(-1px);
}
.choice-card:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.choice-emoji {
font-size: 2rem;
flex-shrink: 0;
}
.choice-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.choice-text strong {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-primary);
}
.choice-text span {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
line-height: 1.4;
}
.choice-arrow {
color: var(--color-text-tertiary);
font-size: 1.1rem;
flex-shrink: 0;
}
.or-divider {
display: flex;
align-items: center;
gap: var(--space-3);
color: var(--color-text-tertiary);
font-size: var(--text-xs);
}
.or-divider::before,
.or-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--color-border);
}
.new-row {
display: flex;
gap: var(--space-3);
}
.name-input {
flex: 1;
background: var(--color-bg);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-md);
padding: 0 var(--space-3);
height: 2.5rem;
font-size: var(--text-sm);
color: var(--color-text-primary);
outline: none;
transition: border-color var(--transition-fast);
font-family: inherit;
}
.name-input:focus {
border-color: var(--color-accent);
}
.name-input::placeholder {
color: var(--color-text-tertiary);
}
.back-btn {
background: none;
border: none;
color: var(--color-text-tertiary);
font-size: var(--text-sm);
cursor: pointer;
padding: 0;
align-self: flex-start;
transition: color var(--transition-fast);
}
.back-btn:hover {
color: var(--color-text-secondary);
}
/* Invite step */
.invite-block {
display: flex;
gap: var(--space-3);
}
.finish-row {
display: flex;
justify-content: center;
}
.skip-link {
background: none;
border: none;
color: var(--color-text-tertiary);
font-size: var(--text-sm);
cursor: pointer;
padding: 0;
transition: color var(--transition-fast);
}
.skip-link:hover {
color: var(--color-text-secondary);
}
/* Mobile */
@media (max-width: 640px) {
.panel-inner {
padding: var(--space-5) var(--space-4);
}
.roles {
grid-template-columns: repeat(2, 1fr);
}
.step-header h1 {
font-size: var(--text-xl);
}
.choice-card {
padding: var(--space-3) var(--space-4);
}
.new-row {
flex-direction: column;
}
.invite-block {
flex-direction: column;
}
}
</style>

View File

@@ -7,7 +7,8 @@
| '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' | 'music';
| 'settings' | 'logout' | 'list' | 'graph' | 'menu' | 'search' | 'music'
| 'cloud-download' | 'cloud-check';
let {
name,
@@ -138,6 +139,13 @@
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
{:else if name === 'cloud-download'}
<polyline points="8 17 12 21 16 17" />
<line x1="12" y1="12" x2="12" y2="21" />
<path d="M20.88 18.09A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.29" />
{:else if name === 'cloud-check'}
<polyline points="20 6 9 17 4 12" />
<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9z" />
{/if}
</svg>

View File

@@ -0,0 +1,115 @@
<script lang="ts">
import { page } from '$app/stores';
import { getContext } from 'svelte';
import Icon from '$lib/components/ui/Icon.svelte';
const openMobileMenu = getContext<() => void>('openMobileMenu');
const isActive = (path: string) => $page.url.pathname === path || $page.url.pathname.startsWith(path + '/');
</script>
<nav class="bottom-nav" aria-label="Hauptnavigation">
<a href="/dashboard" class="nav-item" class:active={$page.url.pathname === '/dashboard'} aria-label="Übersicht">
<Icon name="home" size={22} />
<span>Übersicht</span>
</a>
<button class="nav-item" onclick={() => openMobileMenu?.()} aria-label="Projekte">
<Icon name="list" size={22} />
<span>Projekte</span>
</button>
<a href="/projects/new" class="nav-item nav-new" aria-label="Neues Projekt">
<span class="plus-ring">
<Icon name="plus" size={20} />
</span>
<span>Neu</span>
</a>
<a href="/account" class="nav-item" class:active={isActive('/account')} aria-label="Konto">
<Icon name="settings" size={22} />
<span>Konto</span>
</a>
</nav>
<style>
.bottom-nav {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 90;
background: rgba(10, 9, 16, 0.92);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-top: 1px solid var(--color-border);
padding: 0 var(--space-2) env(safe-area-inset-bottom, 0);
height: calc(56px + env(safe-area-inset-bottom, 0px));
align-items: stretch;
justify-content: space-around;
gap: 0;
}
.nav-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
flex: 1;
min-width: 0;
padding: var(--space-2) var(--space-1);
color: var(--color-text-tertiary);
text-decoration: none;
font-size: 10px;
font-weight: 500;
letter-spacing: 0.02em;
transition: color var(--transition-fast);
background: none;
border: none;
cursor: pointer;
font-family: inherit;
}
.nav-item:hover,
.nav-item.active {
color: var(--color-text-primary);
}
.nav-item.active {
color: var(--color-accent);
}
.nav-new {
color: var(--color-text-secondary);
}
.plus-ring {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--color-accent);
display: flex;
align-items: center;
justify-content: center;
color: white;
transition: transform var(--transition-fast), opacity var(--transition-fast);
margin-bottom: -2px;
}
.nav-new:hover .plus-ring {
transform: scale(1.08);
opacity: 0.9;
}
.nav-new span:not(.plus-ring) {
font-size: 9px;
}
@media (max-width: 640px) {
.bottom-nav {
display: flex;
}
}
</style>

View File

@@ -0,0 +1,60 @@
import { openDB, type DBSchema, type IDBPDatabase } from 'idb';
export type OfflineQuality = 'stream' | 'original';
export interface OfflineVersionRecord {
versionId: string;
trackId: string;
projectId: string;
title: string;
versionNumber: number;
downloadedAt: number;
sizeBytes: number;
mimeType: string;
quality: OfflineQuality;
}
interface MusicHubDB extends DBSchema {
offlineVersions: {
key: string;
value: OfflineVersionRecord;
indexes: {
byTrack: string;
byProject: string;
};
};
}
let _db: IDBPDatabase<MusicHubDB> | null = null;
export async function getDb(): Promise<IDBPDatabase<MusicHubDB>> {
if (_db) return _db;
_db = await openDB<MusicHubDB>('musichub', 1, {
upgrade(db) {
const store = db.createObjectStore('offlineVersions', { keyPath: 'versionId' });
store.createIndex('byTrack', 'trackId');
store.createIndex('byProject', 'projectId');
},
});
return _db;
}
export async function getAllOfflineVersions(): Promise<OfflineVersionRecord[]> {
const db = await getDb();
return db.getAll('offlineVersions');
}
export async function getOfflineVersion(versionId: string): Promise<OfflineVersionRecord | undefined> {
const db = await getDb();
return db.get('offlineVersions', versionId);
}
export async function saveOfflineVersion(record: OfflineVersionRecord): Promise<void> {
const db = await getDb();
await db.put('offlineVersions', record);
}
export async function deleteOfflineVersion(versionId: string): Promise<void> {
const db = await getDb();
await db.delete('offlineVersions', versionId);
}

View File

@@ -0,0 +1,133 @@
import {
getAllOfflineVersions,
getOfflineVersion,
saveOfflineVersion,
deleteOfflineVersion,
type OfflineVersionRecord,
type OfflineQuality,
} from '$lib/offline/db.js';
export type { OfflineVersionRecord, OfflineQuality };
const OFFLINE_CACHE = 'musichub-offline-v1';
// Reactive state
let _versions = $state<OfflineVersionRecord[]>([]);
export const offlineVersions = {
get value() {
return _versions;
},
};
export async function initOfflineStore() {
_versions = await getAllOfflineVersions();
}
export function isOffline(versionId: string): boolean {
return _versions.some((v) => v.versionId === versionId);
}
export async function downloadForOffline(
versionId: string,
quality: OfflineQuality,
meta: { trackId: string; projectId: string; title: string; versionNumber: number },
onProgress?: (pct: number) => void,
): Promise<void> {
const audioUrl = `/api/v1/versions/${versionId}/audio?quality=${quality}`;
const waveformUrl = `/api/v1/versions/${versionId}/waveform-data`;
const cache = await caches.open(OFFLINE_CACHE);
// Download audio with progress tracking
const audioRes = await fetch(audioUrl);
if (!audioRes.ok) throw new Error(`Audio-Download fehlgeschlagen: ${audioRes.status}`);
const contentLength = Number(audioRes.headers.get('Content-Length') ?? '0');
let loaded = 0;
const reader = audioRes.body!.getReader();
const chunks: Uint8Array[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
loaded += value.byteLength;
if (contentLength > 0 && onProgress) {
onProgress(Math.min(99, Math.round((loaded / contentLength) * 100)));
}
}
const audioBuffer = new Uint8Array(chunks.reduce((acc, c) => acc + c.byteLength, 0));
let offset = 0;
for (const chunk of chunks) {
audioBuffer.set(chunk, offset);
offset += chunk.byteLength;
}
const audioBlob = new Blob([audioBuffer], {
type: audioRes.headers.get('Content-Type') ?? 'audio/mpeg',
});
await cache.put(
new Request(audioUrl),
new Response(audioBlob, {
headers: {
'Content-Type': audioBlob.type,
'Content-Length': String(audioBlob.size),
'Cache-Control': 'private, max-age=3600',
},
}),
);
// Download waveform (smaller, no progress needed)
try {
const waveformRes = await fetch(waveformUrl);
if (waveformRes.ok) {
await cache.put(new Request(waveformUrl), waveformRes.clone());
}
} catch {
// Waveform is optional — player falls back to native rendering
}
const record: OfflineVersionRecord = {
versionId,
trackId: meta.trackId,
projectId: meta.projectId,
title: meta.title,
versionNumber: meta.versionNumber,
downloadedAt: Date.now(),
sizeBytes: audioBlob.size,
mimeType: audioBlob.type,
quality,
};
await saveOfflineVersion(record);
_versions = await getAllOfflineVersions();
if (onProgress) onProgress(100);
}
export async function removeOffline(versionId: string): Promise<void> {
const cache = await caches.open(OFFLINE_CACHE);
for (const quality of ['stream', 'original'] as const) {
await cache.delete(new Request(`/api/v1/versions/${versionId}/audio?quality=${quality}`));
}
await cache.delete(new Request(`/api/v1/versions/${versionId}/waveform-data`));
await deleteOfflineVersion(versionId);
_versions = await getAllOfflineVersions();
}
export async function getOfflineAudioUrl(versionId: string): Promise<string | null> {
const record = await getOfflineVersion(versionId);
if (!record) return null;
const cache = await caches.open(OFFLINE_CACHE);
const response = await cache.match(
new Request(`/api/v1/versions/${versionId}/audio?quality=${record.quality}`),
);
if (!response) return null;
const blob = await response.blob();
return URL.createObjectURL(blob);
}

View File

@@ -0,0 +1,70 @@
import { api } from '$lib/api/client.js';
export type PushState = 'unsupported' | 'denied' | 'subscribed' | 'unsubscribed';
let _state = $state<PushState>('unsupported');
let _loading = $state(false);
export const pushStore = {
get state() { return _state; },
get loading() { return _loading; },
};
export async function initPush(): Promise<void> {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
if (Notification.permission === 'denied') { _state = 'denied'; return; }
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
_state = sub ? 'subscribed' : 'unsubscribed';
}
export async function subscribePush(): Promise<void> {
if (_loading) return;
_loading = true;
try {
const { key } = await api.get<{ key: string }>('/push/vapid-public-key');
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(key),
});
const json = sub.toJSON();
await api.post('/push/subscribe', {
endpoint: sub.endpoint,
keys: { p256dh: json.keys!.p256dh, auth: json.keys!.auth },
userAgent: navigator.userAgent.slice(0, 200),
});
_state = 'subscribed';
} catch (err: any) {
if (Notification.permission === 'denied') _state = 'denied';
console.error('[Push] subscribe error:', err.message);
} finally {
_loading = false;
}
}
export async function unsubscribePush(): Promise<void> {
if (_loading) return;
_loading = true;
try {
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
if (sub) {
await api.delete('/push/subscribe', { endpoint: sub.endpoint });
await sub.unsubscribe();
}
_state = 'unsubscribed';
} finally {
_loading = false;
}
}
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(base64);
const arr = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
return arr;
}

View File

@@ -0,0 +1,40 @@
type SseHandler = (event: { type: string; data: unknown }) => void;
let es: EventSource | null = null;
let currentTrackId: string | null = null;
let handler: SseHandler | null = null;
export function connectTrackSse(trackId: string, onEvent: SseHandler): () => void {
if (currentTrackId === trackId && es?.readyState === EventSource.OPEN) {
handler = onEvent;
return () => disconnect();
}
disconnect();
currentTrackId = trackId;
handler = onEvent;
es = new EventSource(`/api/v1/sse/track/${trackId}`, { withCredentials: true });
const types = ['version:new', 'version:status', 'comment:new'];
for (const type of types) {
es.addEventListener(type, (e: MessageEvent) => {
try {
handler?.({ type, data: JSON.parse(e.data) });
} catch { /* ignore malformed */ }
});
}
es.onerror = () => {
// Browser auto-reconnects EventSource — nothing to do
};
return () => disconnect();
}
function disconnect() {
es?.close();
es = null;
currentTrackId = null;
handler = null;
}

View File

@@ -3,6 +3,7 @@
import { goto } from '$app/navigation';
import { user, authLoading, checkAuth } from '$lib/stores/auth.js';
import Sidebar from '$lib/components/workspace/Sidebar.svelte';
import BottomNav from '$lib/components/workspace/BottomNav.svelte';
import ShortcutsModal from '$lib/components/ui/ShortcutsModal.svelte';
import { onKey } from '$lib/utils/shortcuts.js';
@@ -43,6 +44,7 @@
<main class="main">
{@render children()}
</main>
<BottomNav />
</div>
<ShortcutsModal bind:open={shortcutsOpen} />
@@ -79,6 +81,12 @@
flex-direction: column;
}
@media (max-width: 640px) {
.main {
padding-bottom: calc(56px + env(safe-area-inset-bottom, 0px));
}
}
.backdrop {
position: fixed;
inset: 0;

View File

@@ -0,0 +1 @@
export const ssr = false;

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { user } from '$lib/stores/auth.js';
import { api } from '$lib/api/client.js';
import { toastSuccess } from '$lib/stores/toast.js';
@@ -6,10 +7,13 @@
import Input from '$lib/components/ui/Input.svelte';
import Avatar from '$lib/components/ui/Avatar.svelte';
import TopBar from '$lib/components/workspace/TopBar.svelte';
import { pushStore, initPush, subscribePush, unsubscribePush } from '$lib/stores/push.js';
let name = $state('');
let saving = $state(false);
onMount(() => initPush());
$effect(() => {
if ($user && !name) name = $user.name;
});
@@ -35,6 +39,25 @@
<p class="sub">Dein Profil — sichtbar für andere im Projekt.</p>
</header>
{#if pushStore.state !== 'unsupported'}
<section class="card">
<h2>Benachrichtigungen</h2>
{#if pushStore.state === 'denied'}
<p class="push-hint">Push-Benachrichtigungen wurden im Browser blockiert. Bitte in den Browser-Einstellungen erlauben.</p>
{:else if pushStore.state === 'subscribed'}
<div class="push-row">
<span class="push-active">Push-Benachrichtigungen aktiv</span>
<Button size="sm" variant="ghost" onclick={unsubscribePush} loading={pushStore.loading}>Deaktivieren</Button>
</div>
{:else}
<div class="push-row">
<span class="push-desc">Werde benachrichtigt wenn neue Versionen hochgeladen oder freigegeben werden.</span>
<Button size="sm" onclick={subscribePush} loading={pushStore.loading}>Aktivieren</Button>
</div>
{/if}
</section>
{/if}
{#if $user}
<section class="card">
<h2>Profil</h2>
@@ -112,4 +135,28 @@
color: var(--color-text-primary);
font-family: var(--font-mono);
}
.push-row {
display: flex;
align-items: center;
gap: var(--space-4);
flex-wrap: wrap;
}
.push-active {
color: var(--color-text-secondary);
font-size: var(--text-sm);
flex: 1;
}
.push-desc {
color: var(--color-text-tertiary);
font-size: var(--text-sm);
flex: 1;
}
.push-hint {
color: var(--color-text-tertiary);
font-size: var(--text-sm);
margin: 0;
}
.card + .card {
margin-top: var(--space-5);
}
</style>

View File

@@ -6,7 +6,7 @@
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 OnboardingFlow from '$lib/components/dashboard/OnboardingFlow.svelte';
import { timeAgo } from '$lib/utils/format.js';
type ProjectMembership = {
@@ -44,8 +44,8 @@
events = aRes.events;
if (projects.length === 0) {
const dismissed = typeof localStorage !== 'undefined' && localStorage.getItem('welcome-dismissed') === '1';
if (!dismissed) welcomeOpen = true;
const done = typeof localStorage !== 'undefined' && localStorage.getItem('onboarding-done') === '1';
if (!done) welcomeOpen = true;
}
} finally {
loading = false;
@@ -148,7 +148,7 @@
{/if}
</div>
<WelcomeModal bind:open={welcomeOpen} />
<OnboardingFlow bind:open={welcomeOpen} />
<style>
.content {

View File

@@ -0,0 +1,170 @@
<script lang="ts">
import { onMount } from 'svelte';
import TopBar from '$lib/components/workspace/TopBar.svelte';
import Button from '$lib/components/ui/Button.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import { offlineVersions, removeOffline, initOfflineStore } from '$lib/stores/offline.js';
let storageUsed = $state(0);
let storageQuota = $state(0);
let removing = $state<string | null>(null);
onMount(async () => {
await initOfflineStore();
if ('storage' in navigator && 'estimate' in navigator.storage) {
const estimate = await navigator.storage.estimate();
storageUsed = estimate.usage ?? 0;
storageQuota = estimate.quota ?? 0;
}
});
function formatBytes(bytes: number): string {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatDate(ts: number): string {
return new Date(ts).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
async function handleRemove(versionId: string) {
removing = versionId;
try {
await removeOffline(versionId);
} finally {
removing = null;
}
}
async function handleRemoveAll() {
if (!confirm('Alle offline-verfügbaren Versionen entfernen?')) return;
for (const v of offlineVersions.value) {
await removeOffline(v.versionId);
}
}
</script>
<TopBar crumbs={[{ label: 'Projekte', href: '/dashboard' }, { label: 'Offline-Tracks' }]}>
{#snippet actions()}
{#if offlineVersions.value.length > 0}
<Button size="sm" variant="ghost" onclick={handleRemoveAll}>Alle entfernen</Button>
{/if}
{/snippet}
</TopBar>
<div class="offline-page">
{#if offlineVersions.value.length === 0}
<EmptyState
title="Keine Offline-Tracks"
description="Öffne einen Track, klicke auf das Cloud-Icon neben einer Version und wähle eine Qualität zum Download."
/>
{:else}
<div class="storage-bar">
<span class="storage-label">Gerätespeicher belegt:</span>
<span class="storage-value">{formatBytes(storageUsed)} / {formatBytes(storageQuota)}</span>
</div>
<div class="version-list">
{#each offlineVersions.value as v (v.versionId)}
<div class="version-row">
<div class="version-info">
<span class="version-title">{v.title}</span>
<span class="version-meta">
V{v.versionNumber}
· {v.quality === 'stream' ? 'Stream (MP3)' : 'Original'}
· {formatBytes(v.sizeBytes)}
· {formatDate(v.downloadedAt)}
</span>
</div>
<button
class="remove-btn"
onclick={() => handleRemove(v.versionId)}
disabled={removing === v.versionId}
aria-label="Offline entfernen"
>
<Icon name="x" size={16} />
</button>
</div>
{/each}
</div>
{/if}
</div>
<style>
.offline-page {
padding: var(--space-6);
max-width: 640px;
}
.storage-bar {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.82rem;
color: var(--color-text-tertiary);
margin-bottom: var(--space-5);
}
.storage-label {
color: var(--color-text-secondary);
}
.version-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.version-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--color-surface-2);
border-radius: 8px;
}
.version-info {
flex: 1;
min-width: 0;
}
.version-title {
display: block;
font-size: 0.9rem;
color: var(--color-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.version-meta {
display: block;
font-size: 0.78rem;
color: var(--color-text-tertiary);
margin-top: 0.15rem;
}
.remove-btn {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-tertiary);
padding: 0.25rem;
border-radius: 4px;
display: flex;
align-items: center;
flex-shrink: 0;
}
.remove-btn:hover {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
}
.remove-btn:disabled {
opacity: 0.4;
cursor: default;
}
</style>

View File

@@ -16,14 +16,26 @@
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 { onDestroy } from 'svelte';
import { onKey } from '$lib/utils/shortcuts.js';
import { snapshotForTrack, continuationFor } from '$lib/stores/player.js';
import { connectTrackSse } from '$lib/stores/sse.js';
import {
offlineVersions,
downloadForOffline,
removeOffline,
getOfflineAudioUrl,
initOfflineStore,
isOffline,
type OfflineQuality,
} from '$lib/stores/offline.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';
import StemList, { type Stem } from './components/StemList.svelte';
import AnalyticsPanel from './components/AnalyticsPanel.svelte';
type Version = {
id: string;
@@ -87,18 +99,25 @@
let branchLabelInput = $state('');
let shareOpen = $state(false);
let stems = $state<Stem[]>([]);
let panelTab = $state<'versions' | 'comments' | 'stems'>('versions');
let panelTab = $state<'versions' | 'comments' | 'stems' | 'analytics'>('versions');
let panelOpen = $state(true);
let editVersionOpen = $state(false);
let editVersionLabel = $state('');
let editVersionNotes = $state('');
let savingVersion = $state(false);
let offlineDropdownOpen = $state(false);
let offlineDownloading = $state(false);
let offlineProgress = $state(0);
let rejectOpen = $state(false);
let rejectReason = $state('');
let rejecting = $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 () => {
await initOfflineStore();
try {
const [projectRes, trackVersions, tracksRes, treeRes, stemsRes] = await Promise.all([
api.get<{ project: { name: string }; role: string }>(`/projects/${projectId}`),
@@ -123,6 +142,19 @@
} finally {
loading = false;
}
const disconnectSse = connectTrackSse(trackId, async ({ type, data }: { type: string; data: any }) => {
if (type === 'version:new') {
await loadVersions();
} else if (type === 'version:status') {
const v = versions.find((v) => v.id === data.versionId);
if (v) { v.status = data.status; versions = [...versions]; }
} else if (type === 'comment:new' && selectedVersion?.id === data.versionId) {
const res = await api.get<{ comments: Comment[] }>(`/comments/version/${data.versionId}`);
comments = res.comments;
}
});
onDestroy(disconnectSse);
});
async function selectVersion(version: Version) {
@@ -135,6 +167,17 @@
nextAutoPlay = cont?.autoPlay ?? false;
selectedVersion = version;
// Use cached audio if offline and version is downloaded
if (!navigator.onLine && isOffline(version.id)) {
const blobUrl = await getOfflineAudioUrl(version.id);
if (blobUrl) {
streamUrl = blobUrl;
comments = [];
return;
}
}
const [streamRes, commentRes] = await Promise.all([
api.get<{ url: string }>(`/versions/${version.id}/stream-url`),
api.get<{ comments: Comment[] }>(`/comments/version/${version.id}`),
@@ -180,11 +223,22 @@
await loadVersions();
}
async function handleReject() {
if (!selectedVersion) return;
await api.post(`/versions/${selectedVersion.id}/reject`);
toastSuccess('Version abgelehnt');
await loadVersions();
function handleReject() {
rejectReason = '';
rejectOpen = true;
}
async function submitReject() {
if (!selectedVersion || !rejectReason.trim()) return;
rejecting = true;
try {
await api.post(`/versions/${selectedVersion.id}/reject`, { reason: rejectReason.trim() });
rejectOpen = false;
toastSuccess('Version abgelehnt');
await loadVersions();
} finally {
rejecting = false;
}
}
async function handleComment(body: string, timestamp: number | null, parentId?: string) {
@@ -312,6 +366,33 @@
},
});
async function handleOfflineDownload(quality: OfflineQuality) {
if (!selectedVersion) return;
offlineDropdownOpen = false;
offlineDownloading = true;
offlineProgress = 0;
try {
await downloadForOffline(
selectedVersion.id,
quality,
{ trackId, projectId, title: trackName, versionNumber: selectedVersion.versionNumber },
(pct) => { offlineProgress = pct; },
);
toastSuccess('Offline verfügbar');
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler';
toastSuccess(`Download fehlgeschlagen: ${msg}`);
} finally {
offlineDownloading = false;
}
}
async function handleOfflineRemove() {
if (!selectedVersion) return;
await removeOffline(selectedVersion.id);
toastSuccess('Offline-Version entfernt');
}
async function deleteVersion() {
if (!selectedVersion) return;
if (!confirm(`Version V${selectedVersion.versionNumber} wirklich löschen? Das kann nicht rückgängig gemacht werden.`)) return;
@@ -331,11 +412,11 @@
{#snippet actions()}
{#if canUpload}
<Button size="sm" variant="ghost" onclick={() => { branchFromId = null; branchLabelInput = ''; showUpload = !showUpload; }}>
<Icon name="upload" size={14} /> Hochladen
<Icon name="upload" size={14} /> <span class="btn-label">Hochladen</span>
</Button>
{/if}
<Button size="sm" variant="ghost" onclick={() => (shareOpen = true)}>
<Icon name="share" size={14} /> Teilen
<Icon name="share" size={14} /> <span class="btn-label">Teilen</span>
</Button>
<button class="panel-toggle" class:open={panelOpen} onclick={() => (panelOpen = !panelOpen)} title="Seitenleiste umschalten" aria-label="Seitenleiste umschalten">
<Icon name="panel" size={16} />
@@ -418,6 +499,31 @@
<Button variant="ghost" size="sm" onclick={handleDownload}>
<Icon name="download" size={14} /> Download Original
</Button>
{#if selectedVersion}
<div class="offline-btn-wrap">
{#if isOffline(selectedVersion.id)}
<Button variant="ghost" size="sm" onclick={handleOfflineRemove}>
<Icon name="cloud-check" size={14} /> Offline
</Button>
{:else if offlineDownloading}
<span class="offline-progress">{offlineProgress}%</span>
{:else}
<Button variant="ghost" size="sm" onclick={() => (offlineDropdownOpen = !offlineDropdownOpen)}>
<Icon name="cloud-download" size={14} /> Offline
</Button>
{/if}
{#if offlineDropdownOpen}
<div class="offline-dropdown" role="menu">
<button onclick={() => handleOfflineDownload('stream')}>
<Icon name="music" size={13} /> Stream (MP3, ~35 MB)
</button>
<button onclick={() => handleOfflineDownload('original')}>
<Icon name="download" size={13} /> Original (WAV/FLAC)
</button>
</div>
{/if}
</div>
{/if}
{#if canUpload}
<Button variant="ghost" size="sm" onclick={openVersionEdit}>
<Icon name="settings" size={14} /> Bearbeiten
@@ -509,6 +615,9 @@
<button class:active={panelTab === 'stems'} onclick={() => (panelTab = 'stems')}>
STEMs <span class="badge">{stems.length}</span>
</button>
<button class:active={panelTab === 'analytics'} onclick={() => (panelTab = 'analytics')}>
Analytik
</button>
</div>
<div class="panel-body">
@@ -534,6 +643,8 @@
currentUserId={$user?.id ?? null}
{role}
/>
{:else if panelTab === 'analytics' && selectedVersion}
<AnalyticsPanel versionId={selectedVersion.id} />
{:else if selectedVersion}
<CommentSection
{comments}
@@ -556,6 +667,26 @@
<ShareModal bind:open={shareOpen} versionId={selectedVersion.id} />
{/if}
<Modal bind:open={rejectOpen} title="Version ablehnen">
<div class="edit-form">
<label>
<span class="lbl">Begründung <span style="color: var(--color-error)">*</span></span>
<textarea
bind:value={rejectReason}
rows="4"
placeholder="Was muss geändert werden? (Pflichtfeld)"
autofocus
></textarea>
</label>
</div>
{#snippet actions()}
<Button variant="ghost" onclick={() => (rejectOpen = false)}>Abbrechen</Button>
<Button onclick={submitReject} loading={rejecting} disabled={!rejectReason.trim()}>
Ablehnen
</Button>
{/snippet}
</Modal>
<Modal bind:open={coverEditOpen} title="Track-Cover ändern">
<div class="cover-modal">
<CoverUpload currentUrl={trackCoverUrl} name={trackName} onUploaded={saveTrackCover} />
@@ -852,9 +983,16 @@
.tabs {
display: flex;
border-bottom: 1px solid var(--color-border);
overflow-x: auto;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
}
.tabs::-webkit-scrollbar {
display: none;
}
.tabs button {
flex: 1;
min-width: max-content;
background: none;
border: none;
color: var(--color-text-secondary);
@@ -869,6 +1007,7 @@
align-items: center;
justify-content: center;
gap: var(--space-2);
white-space: nowrap;
}
.tabs button:hover {
color: var(--color-text-primary);
@@ -900,6 +1039,12 @@
font-size: var(--text-sm);
}
@media (max-width: 480px) {
.btn-label {
display: none;
}
}
.panel-toggle {
background: none;
border: 1px solid var(--color-border);
@@ -936,4 +1081,51 @@
border-top: 1px solid var(--color-border);
}
}
.offline-btn-wrap {
position: relative;
}
.offline-dropdown {
position: absolute;
top: calc(100% + 4px);
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);
white-space: nowrap;
}
.offline-dropdown button {
background: none;
border: none;
padding: 8px 12px;
text-align: left;
cursor: pointer;
border-radius: var(--radius-sm);
color: var(--color-text-secondary);
font-size: var(--text-sm);
font-family: inherit;
display: flex;
align-items: center;
gap: 6px;
}
.offline-dropdown button:hover {
background: var(--color-bg-raised);
color: var(--color-text-primary);
}
.offline-progress {
font-size: var(--text-sm);
color: var(--color-text-tertiary);
padding: 0 var(--space-2);
font-variant-numeric: tabular-nums;
}
</style>

View File

@@ -0,0 +1,164 @@
<script lang="ts">
import { api } from '$lib/api/client.js';
import Icon from '$lib/components/ui/Icon.svelte';
type ListenEvent = {
id: string;
listenerName: string | null;
openedAt: string;
firstPlayAt: string | null;
listenSeconds: number;
completed: boolean;
};
type Analytics = {
totalOpens: number;
totalPlays: number;
uniqueListeners: number;
avgListenSeconds: number;
completionRate: number;
events: ListenEvent[];
};
let { versionId }: { versionId: string } = $props();
let data = $state<Analytics | null>(null);
let loading = $state(true);
let error = $state('');
$effect(() => {
if (versionId) load();
});
async function load() {
loading = true;
error = '';
try {
data = await api.get<Analytics>(`/share/version/${versionId}/analytics`);
} catch {
error = 'Analytics konnten nicht geladen werden.';
} finally {
loading = false;
}
}
function formatDuration(s: number): string {
if (s < 60) return `${s}s`;
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
</script>
<div class="analytics">
{#if loading}
<p class="muted">Lädt…</p>
{:else if error}
<p class="muted">{error}</p>
{:else if data}
{#if data.totalOpens === 0}
<p class="muted">Noch keine Aufrufe. Teile einen Link um Analytics zu sehen.</p>
{:else}
<div class="stats-grid">
<div class="stat">
<span class="stat-value">{data.totalOpens}</span>
<span class="stat-label">Geöffnet</span>
</div>
<div class="stat">
<span class="stat-value">{data.totalPlays}</span>
<span class="stat-label">Abgespielt</span>
</div>
<div class="stat">
<span class="stat-value">{data.uniqueListeners}</span>
<span class="stat-label">Personen</span>
</div>
<div class="stat">
<span class="stat-value">{data.completionRate}%</span>
<span class="stat-label">Abschluss</span>
</div>
</div>
<div class="event-list">
{#each data.events as e (e.id)}
<div class="event-row">
<div class="event-info">
<span class="listener-name">{e.listenerName ?? 'Anonym'}</span>
<span class="event-meta">
{formatDate(e.openedAt)}
{#if e.firstPlayAt}
· {formatDuration(e.listenSeconds)} gehört
{#if e.completed}<span class="completed-badge">✓ Komplett</span>{/if}
{:else}
· nicht abgespielt
{/if}
</span>
</div>
</div>
{/each}
</div>
{/if}
{/if}
</div>
<style>
.analytics { display: flex; flex-direction: column; gap: var(--space-4); }
.muted { color: var(--color-text-tertiary); font-size: var(--text-sm); }
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-2);
}
.stat {
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-3);
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.stat-value {
font-size: var(--text-xl);
font-weight: 600;
color: var(--color-text-primary);
font-variant-numeric: tabular-nums;
}
.stat-label {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.event-list { display: flex; flex-direction: column; gap: 2px; }
.event-row {
padding: var(--space-3) var(--space-3);
background: var(--color-bg-subtle);
border-radius: var(--radius-sm);
}
.event-info { display: flex; flex-direction: column; gap: 2px; }
.listener-name {
font-size: var(--text-sm);
color: var(--color-text-primary);
font-weight: 500;
}
.event-meta {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
display: flex;
align-items: center;
gap: var(--space-1);
flex-wrap: wrap;
}
.completed-badge {
color: #22c55e;
font-weight: 500;
}
</style>

View File

@@ -47,6 +47,18 @@
let submitting = $state(false);
let playerRef = $state<WaveformPlayer>();
// Analytics tracking
let eventId = $state<string | null>(null);
let listenSeconds = $state(0);
let playStartedAt = $state<number | null>(null);
let firstPlayFired = $state(false);
let progressInterval = $state<ReturnType<typeof setInterval> | null>(null);
// Name prompt
let showNamePrompt = $state(false);
let nameInput = $state('');
let nameDismissed = $state(false);
async function load() {
loading = true;
error = '';
@@ -56,11 +68,7 @@
});
if (res.status === 401) {
const j = await res.json();
if (j.passwordRequired) {
passwordRequired = true;
loading = false;
return;
}
if (j.passwordRequired) { passwordRequired = true; loading = false; return; }
}
if (!res.ok) {
error = (await res.json().catch(() => ({}))).error || 'Link nicht verfügbar';
@@ -74,7 +82,93 @@
}
}
onMount(load);
async function createListenEvent() {
try {
const res = await fetch(`/api/v1/share/public/${token}/listen`, { method: 'POST' });
if (res.ok) {
const j = await res.json();
eventId = j.eventId;
}
} catch { /* fire and forget */ }
}
async function patchEvent(patch: Record<string, unknown>) {
if (!eventId) return;
try {
await fetch(`/api/v1/share/public/${token}/listen/${eventId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
} catch { /* fire and forget */ }
}
function onPlay() {
playStartedAt = Date.now();
if (!firstPlayFired) {
firstPlayFired = true;
patchEvent({ firstPlay: true });
// Show name prompt after first play, if name not yet set
const saved = localStorage.getItem('listenName');
if (saved) {
guestName = saved;
patchEvent({ listenerName: saved });
} else if (!nameDismissed) {
showNamePrompt = true;
}
}
progressInterval = setInterval(() => {
if (playStartedAt !== null) {
listenSeconds += 1;
if (listenSeconds % 30 === 0) patchEvent({ listenSeconds });
}
}, 1000);
}
function onPause() {
if (progressInterval) { clearInterval(progressInterval); progressInterval = null; }
patchEvent({ listenSeconds });
}
function onFinish() {
if (progressInterval) { clearInterval(progressInterval); progressInterval = null; }
const duration = data?.version.duration ?? 0;
const pct = duration > 0 ? listenSeconds / duration : 0;
patchEvent({ listenSeconds, completed: pct >= 0.8 });
}
function submitName() {
const name = nameInput.trim();
if (name) {
guestName = name;
localStorage.setItem('listenName', name);
patchEvent({ listenerName: name });
}
showNamePrompt = false;
nameDismissed = true;
}
function dismissName() {
showNamePrompt = false;
nameDismissed = true;
}
onMount(() => {
load().then(() => createListenEvent());
const saved = localStorage.getItem('listenName');
if (saved) guestName = saved;
const handleUnload = () => {
if (!eventId) return;
navigator.sendBeacon(
`/api/v1/share/public/${token}/listen/${eventId}`,
new Blob([JSON.stringify({ listenSeconds })], { type: 'application/json' }),
);
};
window.addEventListener('beforeunload', handleUnload);
return () => window.removeEventListener('beforeunload', handleUnload);
});
async function submitComment(e: Event) {
e.preventDefault();
@@ -87,16 +181,9 @@
'Content-Type': 'application/json',
...(password ? { 'X-Share-Password': password } : {}),
},
body: JSON.stringify({
body,
guestName,
timestampSeconds: commentTimestamp ?? undefined,
}),
body: JSON.stringify({ body, guestName, timestampSeconds: commentTimestamp ?? undefined }),
});
if (!res.ok) {
error = 'Kommentar fehlgeschlagen';
return;
}
if (!res.ok) { error = 'Kommentar fehlgeschlagen'; return; }
body = '';
commentTimestamp = null;
await load();
@@ -142,8 +229,28 @@
userName: c.user?.name ?? c.guestName ?? 'Gast',
}))}
onTimeClick={(t) => (commentTimestamp = Math.round(t * 10) / 10)}
onPlay={onPlay}
onPause={onPause}
onFinish={onFinish}
/>
{#if showNamePrompt}
<div class="name-prompt">
<p>Wie heißt du? So wissen die Künstler, wer zugehört hat.</p>
<div class="name-prompt-row">
<input
type="text"
bind:value={nameInput}
placeholder="Dein Name"
onkeydown={(e) => e.key === 'Enter' && submitName()}
autofocus
/>
<Button size="sm" onclick={submitName} disabled={!nameInput.trim()}>OK</Button>
<button class="skip-btn" onclick={dismissName}>Überspringen</button>
</div>
</div>
{/if}
{#if data.downloadUrl}
<div class="actions">
<a href={data.downloadUrl} target="_blank" rel="noopener">
@@ -210,30 +317,49 @@
flex-direction: column;
gap: var(--space-5);
}
header {
text-align: center;
header { text-align: center; }
.project { color: var(--color-text-tertiary); font-size: var(--text-sm); margin: 0; }
h1 { margin: var(--space-1) 0; font-size: var(--text-2xl); }
.version-label { color: var(--color-text-secondary); font-size: var(--text-sm); margin: 0; }
.muted { color: var(--color-text-tertiary); font-size: var(--text-sm); }
.error { color: var(--color-error, #ef4444); }
.name-prompt {
background: var(--color-bg-raised);
border: 1px solid var(--color-accent);
border-radius: var(--radius-md);
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.project {
.name-prompt p { margin: 0; font-size: var(--text-sm); color: var(--color-text-secondary); }
.name-prompt-row {
display: flex;
gap: var(--space-2);
align-items: center;
}
.name-prompt-row input {
flex: 1;
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);
}
.skip-btn {
background: none;
border: none;
color: var(--color-text-tertiary);
font-size: var(--text-sm);
margin: 0;
}
h1 {
margin: var(--space-1) 0;
font-size: var(--text-2xl);
}
.version-label {
color: var(--color-text-secondary);
font-size: var(--text-sm);
margin: 0;
}
.muted {
color: var(--color-text-tertiary);
font-size: var(--text-sm);
}
.error {
color: var(--color-error, #ef4444);
cursor: pointer;
font-family: inherit;
white-space: nowrap;
}
.skip-btn:hover { color: var(--color-text-secondary); }
.password-gate {
text-align: center;
padding: var(--space-8);
@@ -254,10 +380,7 @@
font-size: var(--text-sm);
width: 100%;
}
.actions {
display: flex;
justify-content: flex-end;
}
.actions { display: flex; justify-content: flex-end; }
.comment-form {
display: flex;
flex-direction: column;
@@ -267,10 +390,7 @@
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.comment-form h2 {
margin: 0 0 var(--space-2);
font-size: var(--text-base);
}
.comment-form h2 { margin: 0 0 var(--space-2); font-size: var(--text-base); }
.ts-badge {
align-self: flex-start;
background: rgba(251, 191, 36, 0.15);
@@ -280,22 +400,9 @@
padding: 0.15rem var(--space-2);
font-size: var(--text-xs);
}
.ts-badge button {
background: none;
border: none;
color: inherit;
cursor: pointer;
margin-left: var(--space-1);
}
.comments {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.comments h2 {
font-size: var(--text-base);
margin: 0 0 var(--space-2);
}
.ts-badge button { background: none; border: none; color: inherit; cursor: pointer; margin-left: var(--space-1); }
.comments { display: flex; flex-direction: column; gap: var(--space-2); }
.comments h2 { font-size: var(--text-base); margin: 0 0 var(--space-2); }
.comment {
padding: var(--space-3);
background: var(--color-bg-raised);
@@ -327,10 +434,7 @@
cursor: pointer;
font-family: inherit;
}
.comment p {
margin: 0;
font-size: var(--text-sm);
}
.comment p { margin: 0; font-size: var(--text-sm); }
footer {
text-align: center;
padding-top: var(--space-4);

View File

@@ -11,6 +11,7 @@ import { build, files, version } from '$service-worker';
const sw = self as unknown as ServiceWorkerGlobalScope;
const CACHE = `musichub-${version}`;
const OFFLINE_CACHE = 'musichub-offline-v1';
const ASSETS = [...build, ...files];
sw.addEventListener('install', (event) => {
@@ -23,19 +24,79 @@ sw.addEventListener('activate', (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then((keys) =>
Promise.all(
// Keep the offline cache across version updates
keys.filter((k) => k !== CACHE && k !== OFFLINE_CACHE).map((k) => caches.delete(k)),
),
)
.then(() => sw.clients.claim()),
);
});
sw.addEventListener('push', (event) => {
if (!event.data) return;
let payload: { title?: string; body?: string; url?: string } = {};
try {
payload = event.data.json();
} catch {
payload = { title: 'Music Hub', body: event.data.text() };
}
event.waitUntil(
sw.registration.showNotification(payload.title ?? 'Music Hub', {
body: payload.body,
icon: '/icons/icon-192.png',
badge: '/icons/badge-72.png',
data: { url: payload.url ?? '/' },
}),
);
});
sw.addEventListener('notificationclick', (event) => {
event.notification.close();
const url = event.notification.data?.url ?? '/';
event.waitUntil(
sw.clients
.matchAll({ type: 'window', includeUncontrolled: true })
.then((clientList) => {
for (const client of clientList) {
if (client.url.includes(sw.location.origin) && 'focus' in client) {
(client as WindowClient).navigate(url);
return client.focus();
}
}
return sw.clients.openWindow(url);
}),
);
});
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;
// Don't intercept S3 or other cross-origin traffic
if (url.hostname !== sw.location.hostname) return;
// Cache-first from offline cache for proxied audio/waveform endpoints
const isOfflineAsset =
/^\/api\/v1\/versions\/[^/]+\/audio/.test(url.pathname) ||
/^\/api\/v1\/versions\/[^/]+\/waveform-data$/.test(url.pathname);
if (isOfflineAsset) {
event.respondWith(
caches.open(OFFLINE_CACHE).then(async (cache) => {
const cached = await cache.match(req);
if (cached) return cached;
return fetch(req);
}),
);
return;
}
// Don't intercept other API traffic
if (url.pathname.startsWith('/api/')) return;
// Cache-first for built assets, network-first for everything else
if (ASSETS.includes(url.pathname)) {

View File

@@ -10,6 +10,20 @@
"theme_color": "#f43f5e",
"lang": "de",
"categories": ["music", "productivity"],
"shortcuts": [
{
"name": "Offline-Tracks",
"short_name": "Offline",
"url": "/offline",
"icons": [{ "src": "/icon-192.png", "sizes": "192x192" }]
},
{
"name": "Dashboard",
"short_name": "Dashboard",
"url": "/dashboard",
"icons": [{ "src": "/icon-192.png", "sizes": "192x192" }]
}
],
"icons": [
{
"src": "/icon-192.png",

View File

@@ -23,6 +23,7 @@
"fflate": "^0.8.2",
"hono": "^4",
"resend": "^6.10.0",
"web-push": "^3.6.7",
},
},
"apps/web": {
@@ -32,6 +33,7 @@
"@fontsource-variable/inter": "^5.2.8",
"@music-hub/shared": "workspace:*",
"@sveltejs/adapter-node": "^5.5.4",
"idb": "^8.0.3",
"wavesurfer.js": "^7.12.5",
},
"devDependencies": {
@@ -433,12 +435,20 @@
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
"asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="],
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"bn.js": ["bn.js@4.12.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="],
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
@@ -451,6 +461,8 @@
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"devalue": ["devalue@5.6.4", "", {}, "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA=="],
@@ -459,6 +471,8 @@
"drizzle-orm": ["drizzle-orm@0.44.7", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ=="],
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
@@ -487,22 +501,40 @@
"hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
"http_ece": ["http_ece@1.2.0", "", {}, "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"idb": ["idb@8.0.3", "", {}, "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
"is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="],
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
@@ -533,6 +565,10 @@
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="],
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
@@ -577,6 +613,8 @@
"wavesurfer.js": ["wavesurfer.js@7.12.5", "", {}, "sha512-MSZcA13R9ZlxgYpzfakaSYf8dz5tCdZKYbjtN1qnKbCi+UoyfaTuhvjlXHrITi/fgeO3qWfsH7U3BP1AKnwRNg=="],
"web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="],
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],

21
docs/templates/adr.md vendored Normal file
View File

@@ -0,0 +1,21 @@
# ADR-[Nummer]: [Titel]
**Datum:** [YYYY-MM-DD]
**Status:** Proposed | Accepted | Deprecated | Superseded by ADR-[N]
## Kontext
Was war die Situation, warum musste eine Entscheidung getroffen werden?
## Entscheidung
Was wurde entschieden?
## Alternativen die verworfen wurden
- **[Alternative A]** — warum nicht
- **[Alternative B]** — warum nicht
## Konsequenzen
Was wird durch diese Entscheidung einfacher, was schwieriger?

29
docs/templates/spec.md vendored Normal file
View File

@@ -0,0 +1,29 @@
# [Feature- oder Sprint-Name]
**Status:** Draft | Active | Done
**Repo:** [repo]
**Erstellt:** [YYYY-MM-DD]
## Ziel
Ein Satz.
## Warum
Warum jetzt, warum das.
## In Scope
-
## Out of Scope
-
## Erfolgskriterien
- [ ]
## Implementierungsnotizen
<!-- Schlüsselentscheidungen, Constraints, offene Fragen -->

View File

@@ -0,0 +1,12 @@
CREATE TABLE "push_subscriptions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"endpoint" text NOT NULL,
"p256dh" text NOT NULL,
"auth" text NOT NULL,
"user_agent" text,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "push_subscriptions_endpoint_unique" UNIQUE("endpoint")
);
--> statement-breakpoint
ALTER TABLE "push_subscriptions" ADD CONSTRAINT "push_subscriptions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1,13 @@
CREATE TABLE "listen_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"share_link_id" uuid NOT NULL,
"listener_name" varchar(255),
"ip_hash" varchar(64),
"user_agent" varchar(500),
"opened_at" timestamp DEFAULT now() NOT NULL,
"first_play_at" timestamp,
"listen_seconds" integer DEFAULT 0 NOT NULL,
"completed" boolean DEFAULT false NOT NULL
);
--> statement-breakpoint
ALTER TABLE "listen_events" ADD CONSTRAINT "listen_events_share_link_id_share_links_id_fk" FOREIGN KEY ("share_link_id") REFERENCES "public"."share_links"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -50,6 +50,20 @@
"when": 1776094119472,
"tag": "0006_brown_lily_hollister",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1745395200000,
"tag": "0007_push_subscriptions",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1745481600000,
"tag": "0008_listen_events",
"breakpoints": true
}
]
}

View File

@@ -4,3 +4,4 @@ export * from './projects.js';
export * from './tracks.js';
export * from './comments.js';
export * from './shareLinks.js';
export * from './pushSubscriptions.js';

View File

@@ -0,0 +1,14 @@
import { pgTable, uuid, text, timestamp } from 'drizzle-orm/pg-core';
import { users } from './users.js';
export const pushSubscriptions = pgTable('push_subscriptions', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
endpoint: text('endpoint').notNull().unique(),
p256dh: text('p256dh').notNull(),
auth: text('auth').notNull(),
userAgent: text('user_agent'),
createdAt: timestamp('created_at').defaultNow().notNull(),
});

View File

@@ -1,4 +1,4 @@
import { pgTable, uuid, varchar, boolean, timestamp } from 'drizzle-orm/pg-core';
import { pgTable, uuid, varchar, boolean, timestamp, integer } from 'drizzle-orm/pg-core';
import { users } from './users.js';
import { versions } from './tracks.js';
@@ -17,3 +17,17 @@ export const shareLinks = pgTable('share_links', {
passwordHash: varchar('password_hash', { length: 255 }),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const listenEvents = pgTable('listen_events', {
id: uuid('id').defaultRandom().primaryKey(),
shareLinkId: uuid('share_link_id')
.references(() => shareLinks.id, { onDelete: 'cascade' })
.notNull(),
listenerName: varchar('listener_name', { length: 255 }),
ipHash: varchar('ip_hash', { length: 64 }),
userAgent: varchar('user_agent', { length: 500 }),
openedAt: timestamp('opened_at').defaultNow().notNull(),
firstPlayAt: timestamp('first_play_at'),
listenSeconds: integer('listen_seconds').default(0).notNull(),
completed: boolean('completed').default(false).notNull(),
});

View File

@@ -2,3 +2,4 @@ export * from './auth.js';
export * from './project.js';
export * from './track.js';
export * from './comment.js';
export * from './push.js';

View File

@@ -0,0 +1,17 @@
import { z } from 'zod';
export const subscribePushSchema = z.object({
endpoint: z.string().url(),
keys: z.object({
p256dh: z.string(),
auth: z.string(),
}),
userAgent: z.string().optional(),
});
export const updateListenEventSchema = z.object({
listenerName: z.string().max(255).optional(),
firstPlay: z.boolean().optional(),
listenSeconds: z.number().int().min(0).optional(),
completed: z.boolean().optional(),
});

View File

@@ -53,6 +53,10 @@ export const updateVersionSchema = z.object({
branchLabel: z.string().max(100).nullable().optional(),
});
export const rejectVersionSchema = z.object({
reason: z.string().min(1, 'Begründung erforderlich').max(2000),
});
export const requestStemUploadUrlSchema = z.object({
fileName: z.string().min(1),
mimeType: z.string().min(1),

54
specs/listen-analytics.md Normal file
View File

@@ -0,0 +1,54 @@
# Spec: Listen Analytics
**Ziel:** Label-Manager und Produzenten sehen wer, wann und wie viel von einem geteilten Track gehört hat.
**Pitch:** "Schick einen Link. Kein Account nötig. Du siehst wer wann gehört hat."
---
## Was gebaut wird
### 1. DB — `listen_events`
| Feld | Typ | Beschreibung |
|---|---|---|
| `id` | uuid PK | — |
| `share_link_id` | uuid FK → share_links | Welcher Link |
| `listener_name` | varchar(255) nullable | Optional eingegeben |
| `ip_hash` | varchar(64) | SHA256(IP) — Dedup ohne PII |
| `user_agent` | text nullable | Browser-Info |
| `opened_at` | timestamp | Seite geladen |
| `first_play_at` | timestamp nullable | Erster Play-Klick |
| `listen_seconds` | integer default 0 | Kumulierte Hörzeit |
| `completed` | boolean default false | >80% des Tracks gehört |
### 2. API
| Route | Auth | Beschreibung |
|---|---|---|
| `POST /share/public/:token/listen` | — | Event anlegen, gibt `eventId` zurück |
| `PATCH /share/public/:token/listen/:eventId` | — | Name/Fortschritt/Abschluss updaten |
| `GET /share/version/:versionId/analytics` | requireAuth | Aggregierte Analytics |
### 3. Listen-Seite (Frontend)
- Soft Name-Prompt bei erstem Laden: "Wie heißt du?" — überspringbar, gespeichert in localStorage
- `onMount` → POST /listen → eventId in State
- `onFirstPlay` → PATCH mit `firstPlayAt`
- Alle 30s während Play → PATCH mit aktuellem `listenSeconds`
- `beforeunload``navigator.sendBeacon` mit finalem `listenSeconds` + `completed`
### 4. Analytics-Panel (Track-Seite)
Neuer "Analytik"-Button in der Track-Toolbar. Öffnet Modal mit:
- Zähler: Geöffnet / Gespielt / Ø Hörzeit / Abschlussrate
- Tabelle: Name (oder "Anonym") · Datum · Hörzeit · Abgeschlossen ✓
---
## Out of scope (jetzt)
- E-Mail-Alerts wenn jemand hört
- Heatmap welcher Teil des Tracks gehört wurde
- Link-spezifische vs. versions-aggregierte Ansicht (immer version-aggregiert)