Compare commits
10 Commits
ccd7ed3a93
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea3a63211d | ||
|
|
69d41d0b70 | ||
|
|
06f0a43532 | ||
|
|
df571df567 | ||
|
|
e5d0b00761 | ||
|
|
9bad5c704a | ||
|
|
e58a7c250e | ||
|
|
e642e63fdc | ||
|
|
c949d6b829 | ||
|
|
afcb818dd4 |
@@ -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
|
||||
|
||||
@@ -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
49
AGENTS.md
Normal 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.
|
||||
45
CLAUDE.md
45
CLAUDE.md
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
|
||||
|
||||
49
apps/api/src/routes/push.ts
Normal file
49
apps/api/src/routes/push.ts
Normal 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 });
|
||||
});
|
||||
@@ -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,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
49
apps/api/src/routes/sse.ts
Normal file
49
apps/api/src/routes/sse.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
|
||||
100
apps/api/src/services/push.ts
Normal file
100
apps/api/src/services/push.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
21
apps/api/src/services/sse.ts
Normal file
21
apps/api/src/services/sse.ts
Normal 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 */ }
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
567
apps/web/src/lib/components/dashboard/OnboardingFlow.svelte
Normal file
567
apps/web/src/lib/components/dashboard/OnboardingFlow.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
115
apps/web/src/lib/components/workspace/BottomNav.svelte
Normal file
115
apps/web/src/lib/components/workspace/BottomNav.svelte
Normal 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>
|
||||
60
apps/web/src/lib/offline/db.ts
Normal file
60
apps/web/src/lib/offline/db.ts
Normal 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);
|
||||
}
|
||||
133
apps/web/src/lib/stores/offline.ts
Normal file
133
apps/web/src/lib/stores/offline.ts
Normal 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);
|
||||
}
|
||||
70
apps/web/src/lib/stores/push.ts
Normal file
70
apps/web/src/lib/stores/push.ts
Normal 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;
|
||||
}
|
||||
40
apps/web/src/lib/stores/sse.ts
Normal file
40
apps/web/src/lib/stores/sse.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
1
apps/web/src/routes/(app)/+layout.ts
Normal file
1
apps/web/src/routes/(app)/+layout.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const ssr = false;
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
170
apps/web/src/routes/(app)/offline/+page.svelte
Normal file
170
apps/web/src/routes/(app)/offline/+page.svelte
Normal 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>
|
||||
@@ -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, ~3–5 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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
38
bun.lock
38
bun.lock
@@ -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
21
docs/templates/adr.md
vendored
Normal 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
29
docs/templates/spec.md
vendored
Normal 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 -->
|
||||
12
packages/db/src/migrations/0007_push_subscriptions.sql
Normal file
12
packages/db/src/migrations/0007_push_subscriptions.sql
Normal 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;
|
||||
13
packages/db/src/migrations/0008_listen_events.sql
Normal file
13
packages/db/src/migrations/0008_listen_events.sql
Normal 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;
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4,3 +4,4 @@ export * from './projects.js';
|
||||
export * from './tracks.js';
|
||||
export * from './comments.js';
|
||||
export * from './shareLinks.js';
|
||||
export * from './pushSubscriptions.js';
|
||||
|
||||
14
packages/db/src/schema/pushSubscriptions.ts
Normal file
14
packages/db/src/schema/pushSubscriptions.ts
Normal 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(),
|
||||
});
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from './auth.js';
|
||||
export * from './project.js';
|
||||
export * from './track.js';
|
||||
export * from './comment.js';
|
||||
export * from './push.js';
|
||||
|
||||
17
packages/shared/src/validation/push.ts
Normal file
17
packages/shared/src/validation/push.ts
Normal 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(),
|
||||
});
|
||||
@@ -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
54
specs/listen-analytics.md
Normal 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)
|
||||
Reference in New Issue
Block a user