Full MVP: workspace layout, visual refresh, PWA, production deploy
Major changes since initial commit: Schema: version branching (parentVersionId, branchLabel), share links, guest comments, track status enum (sketch/in_progress/final/released), track sections, cover art for projects and tracks. API: 29+ endpoints — auth, projects, tracks, versions, comments, share links (public + management), uploads (cover), activity feed, onboarding demo seed. Email templates in German with brand styling. Web: SvelteKit 5 workspace layout with persistent sidebar, breadcrumb top-bar, collapsible right panel. SoundCloud-style waveform player with round play button, avatar comment markers, keyboard shortcuts (Space/JKL/C). Full German UI. Cover art with gradient fallback. Track status pills. Activity feed dashboard. Welcome modal with demo-seed trigger. Landing page with 7-section scroll layout. Login on /login. Public /listen/:token page for guest feedback. Visual: Inter Variable font, Magenta→Orange gradient accent, warm dark neutrals, Lucide-style inline SVG icon set, spring animations on modals, glass-effect toasts, responsive from 360px to 2560px+. PWA: manifest, service worker, icons, iOS/Android installable. Production: adapter-node, server-side API proxy hook, docker-compose with Postgres + MinIO + auto-migration + health checks. Env example included. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,9 @@ import { trackRoutes } from './routes/tracks.js';
|
||||
import { versionRoutes } from './routes/versions.js';
|
||||
import { commentRoutes } from './routes/comments.js';
|
||||
import { shareRoutes } from './routes/share.js';
|
||||
import { uploadRoutes } from './routes/uploads.js';
|
||||
import { activityRoutes } from './routes/activity.js';
|
||||
import { onboardingRoutes } from './routes/onboarding.js';
|
||||
import type { AppEnv } from './types.js';
|
||||
|
||||
const db = createDb(process.env.DATABASE_URL!);
|
||||
@@ -36,7 +39,10 @@ const app = new Hono<AppEnv>()
|
||||
.route('/tracks', trackRoutes)
|
||||
.route('/versions', versionRoutes)
|
||||
.route('/comments', commentRoutes)
|
||||
.route('/share', shareRoutes);
|
||||
.route('/share', shareRoutes)
|
||||
.route('/uploads', uploadRoutes)
|
||||
.route('/activity', activityRoutes)
|
||||
.route('/onboarding', onboardingRoutes);
|
||||
|
||||
const port = parseInt(process.env.PORT || '3000');
|
||||
console.log(`Music Hub API running on port ${port}`);
|
||||
|
||||
159
apps/api/src/lib/demo-seed.ts
Normal file
159
apps/api/src/lib/demo-seed.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Lightweight demo seed used by the onboarding endpoint.
|
||||
* Creates one project with one track and three versions, plus a comment and a share link.
|
||||
*
|
||||
* Audio source: a synthetic WAV is generated on demand via FFmpeg if no asset is found,
|
||||
* so this works regardless of whether the rich seed assets exist on disk.
|
||||
*/
|
||||
import {
|
||||
projects,
|
||||
projectMembers,
|
||||
tracks,
|
||||
versions,
|
||||
comments,
|
||||
shareLinks,
|
||||
type Database,
|
||||
} from '@music-hub/db';
|
||||
import { createUploadUrl } from '../storage/s3.js';
|
||||
import { processVersion } from '../services/audio-processor.js';
|
||||
|
||||
const ASSET_DIR = '/tmp/musichub-seed';
|
||||
|
||||
async function getDemoAudio(name: string, frequency: number, duration: number): Promise<ArrayBuffer> {
|
||||
const f = Bun.file(`${ASSET_DIR}/${name}`);
|
||||
if (await f.exists()) return await f.arrayBuffer();
|
||||
|
||||
// Synthesize on the fly with ffmpeg
|
||||
const tmp = `/tmp/musichub-onb-${crypto.randomUUID()}.wav`;
|
||||
const proc = Bun.spawn([
|
||||
'ffmpeg',
|
||||
'-f', 'lavfi',
|
||||
'-i', `sine=frequency=${frequency}:duration=${duration}`,
|
||||
'-ac', '2', '-ar', '44100',
|
||||
'-v', 'quiet',
|
||||
'-y',
|
||||
tmp,
|
||||
]);
|
||||
await proc.exited;
|
||||
const bytes = await Bun.file(tmp).arrayBuffer();
|
||||
await Bun.spawn(['rm', tmp]).exited;
|
||||
return bytes;
|
||||
}
|
||||
|
||||
async function uploadAudio(
|
||||
bytes: ArrayBuffer,
|
||||
projectId: string,
|
||||
trackId: string,
|
||||
versionId: string,
|
||||
filename: string,
|
||||
): Promise<string> {
|
||||
const fileKey = `projects/${projectId}/tracks/${trackId}/versions/${versionId}/original/${filename}`;
|
||||
const uploadUrl = await createUploadUrl(fileKey, 'audio/wav', bytes.byteLength);
|
||||
const res = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'audio/wav' },
|
||||
body: bytes,
|
||||
});
|
||||
if (!res.ok) throw new Error(`Audio upload failed: ${res.status}`);
|
||||
return fileKey;
|
||||
}
|
||||
|
||||
export async function createDemoProject(db: Database, userId: string): Promise<string> {
|
||||
// Project
|
||||
const [project] = await db
|
||||
.insert(projects)
|
||||
.values({
|
||||
name: 'Mein erstes Projekt',
|
||||
description: 'Ein kleines Demo-Projekt zum Ausprobieren. Du kannst es jederzeit löschen.',
|
||||
createdById: userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await db.insert(projectMembers).values({
|
||||
projectId: project.id,
|
||||
userId,
|
||||
role: 'owner',
|
||||
canUpload: true,
|
||||
canComment: true,
|
||||
canApprove: true,
|
||||
});
|
||||
|
||||
// Track
|
||||
const [track] = await db
|
||||
.insert(tracks)
|
||||
.values({
|
||||
projectId: project.id,
|
||||
name: 'Demo-Track',
|
||||
description: 'Drei Versionen mit Comments und einer Variante.',
|
||||
status: 'in_progress',
|
||||
createdById: userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Versions
|
||||
const versionDefs = [
|
||||
{ label: 'Erster Wurf', frequency: 220, duration: 8, file: 'demo-v1.wav' },
|
||||
{ label: 'Mehr Bass', frequency: 110, duration: 8, file: 'demo-v2.wav' },
|
||||
{ label: 'Final Mix', frequency: 330, duration: 8, file: 'demo-v3.wav' },
|
||||
];
|
||||
|
||||
const createdVersionIds: string[] = [];
|
||||
|
||||
for (let i = 0; i < versionDefs.length; i++) {
|
||||
const v = versionDefs[i];
|
||||
const versionId = crypto.randomUUID();
|
||||
const bytes = await getDemoAudio(v.file, v.frequency, v.duration);
|
||||
const fileKey = await uploadAudio(bytes, project.id, track.id, versionId, v.file);
|
||||
|
||||
const [version] = await db
|
||||
.insert(versions)
|
||||
.values({
|
||||
id: versionId,
|
||||
trackId: track.id,
|
||||
versionNumber: i + 1,
|
||||
label: v.label,
|
||||
status: 'uploaded',
|
||||
originalFileName: v.file,
|
||||
mimeType: 'audio/wav',
|
||||
fileSize: bytes.byteLength,
|
||||
originalFileKey: fileKey,
|
||||
createdById: userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
createdVersionIds.push(version.id);
|
||||
// Process synchronously so the player has a waveform when the user lands.
|
||||
await processVersion(db, version.id);
|
||||
}
|
||||
|
||||
// Comments on V2
|
||||
await db.insert(comments).values([
|
||||
{
|
||||
versionId: createdVersionIds[1],
|
||||
userId,
|
||||
body: 'Klick auf die Welle bei einer beliebigen Stelle, um einen Kommentar mit Timestamp zu schreiben.',
|
||||
timestampSeconds: 2.5,
|
||||
},
|
||||
{
|
||||
versionId: createdVersionIds[1],
|
||||
userId: null,
|
||||
guestName: 'Demo-Listener',
|
||||
body: 'So sieht es aus, wenn jemand ohne Account über einen geteilten Link kommentiert.',
|
||||
timestampSeconds: 5.0,
|
||||
},
|
||||
]);
|
||||
|
||||
// Share link on V3
|
||||
const token = Array.from(crypto.getRandomValues(new Uint8Array(32)), (b) =>
|
||||
b.toString(16).padStart(2, '0'),
|
||||
).join('');
|
||||
await db.insert(shareLinks).values({
|
||||
versionId: createdVersionIds[2],
|
||||
token,
|
||||
createdById: userId,
|
||||
allowComments: true,
|
||||
allowDownload: false,
|
||||
});
|
||||
|
||||
return project.id;
|
||||
}
|
||||
116
apps/api/src/routes/activity.ts
Normal file
116
apps/api/src/routes/activity.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { Hono } from 'hono';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import type { AppEnv } from '../types.js';
|
||||
|
||||
type ActivityEvent = {
|
||||
type: 'comment' | 'version' | 'approval';
|
||||
id: string;
|
||||
createdAt: string;
|
||||
user: { id: string | null; name: string; avatarUrl: string | null } | null;
|
||||
guestName: string | null;
|
||||
project: { id: string; name: string };
|
||||
track: { id: string; name: string };
|
||||
version?: { id: string; versionNumber: number; label: string | null };
|
||||
body?: string;
|
||||
status?: string;
|
||||
timestampSeconds?: number | null;
|
||||
};
|
||||
|
||||
export const activityRoutes = new Hono<AppEnv>()
|
||||
.use('*', requireAuth)
|
||||
|
||||
.get('/', async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const limit = Math.min(parseInt(c.req.query('limit') ?? '40'), 100);
|
||||
const projectFilter = c.req.query('projectId');
|
||||
|
||||
// Helper: only events from projects the user is a member of.
|
||||
// We use raw sql for the UNION since drizzle's union builder is per-table-shape.
|
||||
const projectClause = projectFilter ? sql`AND p.id = ${projectFilter}` : sql``;
|
||||
|
||||
const rows = await db.execute(sql`
|
||||
WITH membership AS (
|
||||
SELECT project_id FROM project_members WHERE user_id = ${userId}
|
||||
)
|
||||
SELECT * FROM (
|
||||
-- New versions
|
||||
SELECT
|
||||
'version' as type,
|
||||
v.id as event_id,
|
||||
v.created_at as created_at,
|
||||
u.id as user_id,
|
||||
u.name as user_name,
|
||||
u.avatar_url as user_avatar,
|
||||
NULL::text as guest_name,
|
||||
p.id as project_id,
|
||||
p.name as project_name,
|
||||
t.id as track_id,
|
||||
t.name as track_name,
|
||||
v.id as version_id,
|
||||
v.version_number as version_number,
|
||||
v.label as version_label,
|
||||
NULL::text as body,
|
||||
v.status::text as status,
|
||||
NULL::real as timestamp_seconds
|
||||
FROM versions v
|
||||
JOIN tracks t ON t.id = v.track_id
|
||||
JOIN projects p ON p.id = t.project_id
|
||||
JOIN membership m ON m.project_id = p.id
|
||||
LEFT JOIN users u ON u.id = v.created_by_id
|
||||
WHERE p.is_archived = false ${projectClause}
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- New comments
|
||||
SELECT
|
||||
'comment' as type,
|
||||
c.id as event_id,
|
||||
c.created_at as created_at,
|
||||
u.id as user_id,
|
||||
u.name as user_name,
|
||||
u.avatar_url as user_avatar,
|
||||
c.guest_name as guest_name,
|
||||
p.id as project_id,
|
||||
p.name as project_name,
|
||||
t.id as track_id,
|
||||
t.name as track_name,
|
||||
v.id as version_id,
|
||||
v.version_number as version_number,
|
||||
v.label as version_label,
|
||||
c.body as body,
|
||||
NULL::text as status,
|
||||
c.timestamp_seconds as timestamp_seconds
|
||||
FROM comments c
|
||||
JOIN versions v ON v.id = c.version_id
|
||||
JOIN tracks t ON t.id = v.track_id
|
||||
JOIN projects p ON p.id = t.project_id
|
||||
JOIN membership m ON m.project_id = p.id
|
||||
LEFT JOIN users u ON u.id = c.user_id
|
||||
WHERE p.is_archived = false ${projectClause}
|
||||
) events
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ${limit}
|
||||
`);
|
||||
|
||||
const events: ActivityEvent[] = (rows as unknown as any[]).map((r) => ({
|
||||
type: r.type as ActivityEvent['type'],
|
||||
id: r.event_id,
|
||||
createdAt: typeof r.created_at === 'string' ? r.created_at : new Date(r.created_at).toISOString(),
|
||||
user: r.user_id
|
||||
? { id: r.user_id, name: r.user_name, avatarUrl: r.user_avatar }
|
||||
: null,
|
||||
guestName: r.guest_name ?? null,
|
||||
project: { id: r.project_id, name: r.project_name },
|
||||
track: { id: r.track_id, name: r.track_name },
|
||||
version: r.version_id
|
||||
? { id: r.version_id, versionNumber: r.version_number, label: r.version_label }
|
||||
: undefined,
|
||||
body: r.body ?? undefined,
|
||||
status: r.status ?? undefined,
|
||||
timestampSeconds: r.timestamp_seconds ?? null,
|
||||
}));
|
||||
|
||||
return c.json({ events });
|
||||
});
|
||||
29
apps/api/src/routes/onboarding.ts
Normal file
29
apps/api/src/routes/onboarding.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Hono } from 'hono';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { projectMembers } from '@music-hub/db';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { createDemoProject } from '../lib/demo-seed.js';
|
||||
import type { AppEnv } from '../types.js';
|
||||
|
||||
export const onboardingRoutes = new Hono<AppEnv>()
|
||||
.use('*', requireAuth)
|
||||
|
||||
.post('/seed-demo', async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
|
||||
// Refuse to spam users with multiple demos
|
||||
const existing = await db
|
||||
.select({ id: projectMembers.id })
|
||||
.from(projectMembers)
|
||||
.where(eq(projectMembers.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
// User already has projects — they shouldn't see the welcome modal anyway,
|
||||
// but be defensive: still create a fresh demo so the action is meaningful.
|
||||
}
|
||||
|
||||
const projectId = await createDemoProject(db, userId);
|
||||
return c.json({ projectId }, 201);
|
||||
});
|
||||
@@ -1,16 +1,24 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { eq, and, sql } from 'drizzle-orm';
|
||||
import {
|
||||
createProjectSchema,
|
||||
updateProjectSchema,
|
||||
inviteMemberSchema,
|
||||
updateMemberSchema,
|
||||
} from '@music-hub/shared';
|
||||
import { projects, projectMembers, users } from '@music-hub/db';
|
||||
import { projects, projectMembers, users, tracks } from '@music-hub/db';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { createDownloadUrl } from '../storage/s3.js';
|
||||
import type { AppEnv } from '../types.js';
|
||||
|
||||
async function withCoverUrl<T extends { coverImageUrl?: string | null }>(
|
||||
obj: T,
|
||||
): Promise<T & { coverUrl: string | null }> {
|
||||
const coverUrl = obj.coverImageUrl ? await createDownloadUrl(obj.coverImageUrl) : null;
|
||||
return { ...obj, coverUrl };
|
||||
}
|
||||
|
||||
export const projectRoutes = new Hono<AppEnv>()
|
||||
.use('*', requireAuth)
|
||||
|
||||
@@ -22,12 +30,19 @@ export const projectRoutes = new Hono<AppEnv>()
|
||||
.select({
|
||||
project: projects,
|
||||
role: projectMembers.role,
|
||||
trackCount: sql<number>`(select count(*)::int from ${tracks} where ${tracks.projectId} = ${projects.id})`,
|
||||
})
|
||||
.from(projectMembers)
|
||||
.innerJoin(projects, eq(projects.id, projectMembers.projectId))
|
||||
.where(and(eq(projectMembers.userId, userId), eq(projects.isArchived, false)));
|
||||
|
||||
return c.json({ projects: memberships });
|
||||
const enriched = await Promise.all(
|
||||
memberships.map(async (m) => ({
|
||||
...m,
|
||||
project: await withCoverUrl(m.project),
|
||||
})),
|
||||
);
|
||||
return c.json({ projects: enriched });
|
||||
})
|
||||
|
||||
.post('/', zValidator('json', createProjectSchema), async (c) => {
|
||||
@@ -73,7 +88,7 @@ export const projectRoutes = new Hono<AppEnv>()
|
||||
.where(eq(projects.id, projectId))
|
||||
.limit(1);
|
||||
|
||||
return c.json({ project, role: membership.role });
|
||||
return c.json({ project: await withCoverUrl(project), role: membership.role });
|
||||
})
|
||||
|
||||
.patch('/:id', zValidator('json', updateProjectSchema), async (c) => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { eq, and, asc } from 'drizzle-orm';
|
||||
import { eq, and, asc, sql } from 'drizzle-orm';
|
||||
import { createTrackSchema, updateTrackSchema } from '@music-hub/shared';
|
||||
import { tracks, projectMembers } from '@music-hub/db';
|
||||
import { tracks, projectMembers, versions } from '@music-hub/db';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { createDownloadUrl } from '../storage/s3.js';
|
||||
import type { AppEnv } from '../types.js';
|
||||
|
||||
export const trackRoutes = new Hono<AppEnv>()
|
||||
@@ -24,12 +25,32 @@ export const trackRoutes = new Hono<AppEnv>()
|
||||
if (!membership) return c.json({ error: 'Not found' }, 404);
|
||||
|
||||
const projectTracks = await db
|
||||
.select()
|
||||
.select({
|
||||
id: tracks.id,
|
||||
projectId: tracks.projectId,
|
||||
name: tracks.name,
|
||||
description: tracks.description,
|
||||
coverImageUrl: tracks.coverImageUrl,
|
||||
status: tracks.status,
|
||||
section: tracks.section,
|
||||
sortOrder: tracks.sortOrder,
|
||||
createdById: tracks.createdById,
|
||||
createdAt: tracks.createdAt,
|
||||
updatedAt: tracks.updatedAt,
|
||||
versionCount: sql<number>`(select count(*)::int from ${versions} where ${versions.trackId} = ${tracks.id})`,
|
||||
branchCount: sql<number>`(select count(distinct ${versions.branchLabel})::int from ${versions} where ${versions.trackId} = ${tracks.id} and ${versions.branchLabel} is not null)`,
|
||||
})
|
||||
.from(tracks)
|
||||
.where(eq(tracks.projectId, projectId))
|
||||
.orderBy(asc(tracks.sortOrder), asc(tracks.createdAt));
|
||||
|
||||
return c.json({ tracks: projectTracks });
|
||||
const enriched = await Promise.all(
|
||||
projectTracks.map(async (t) => ({
|
||||
...t,
|
||||
coverUrl: t.coverImageUrl ? await createDownloadUrl(t.coverImageUrl) : null,
|
||||
})),
|
||||
);
|
||||
return c.json({ tracks: enriched });
|
||||
})
|
||||
|
||||
.post('/:projectId', zValidator('json', createTrackSchema), async (c) => {
|
||||
|
||||
17
apps/api/src/routes/uploads.ts
Normal file
17
apps/api/src/routes/uploads.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { coverUploadSchema } from '@music-hub/shared';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { createUploadUrl } from '../storage/s3.js';
|
||||
import type { AppEnv } from '../types.js';
|
||||
|
||||
export const uploadRoutes = new Hono<AppEnv>()
|
||||
.use('*', requireAuth)
|
||||
|
||||
.post('/cover', zValidator('json', coverUploadSchema), async (c) => {
|
||||
const { fileName, mimeType, fileSize } = c.req.valid('json');
|
||||
const ext = fileName.split('.').pop()?.toLowerCase() || 'jpg';
|
||||
const key = `covers/${crypto.randomUUID()}.${ext}`;
|
||||
const uploadUrl = await createUploadUrl(key, mimeType, fileSize);
|
||||
return c.json({ uploadUrl, key });
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { eq, and, desc, asc, sql } from 'drizzle-orm';
|
||||
import { requestUploadUrlSchema, createVersionSchema } from '@music-hub/shared';
|
||||
import { requestUploadUrlSchema, createVersionSchema, updateVersionSchema } from '@music-hub/shared';
|
||||
import { tracks, versions, projectMembers } from '@music-hub/db';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { createUploadUrl, createDownloadUrl } from '../storage/s3.js';
|
||||
@@ -115,6 +115,82 @@ export const versionRoutes = new Hono<AppEnv>()
|
||||
return c.json({ version }, 201);
|
||||
})
|
||||
|
||||
// Update label/notes/branchLabel
|
||||
.patch('/:id', zValidator('json', updateVersionSchema), async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const versionId = c.req.param('id');
|
||||
const input = c.req.valid('json');
|
||||
|
||||
const [version] = await db
|
||||
.select()
|
||||
.from(versions)
|
||||
.where(eq(versions.id, versionId))
|
||||
.limit(1);
|
||||
if (!version) return c.json({ error: 'Not found' }, 404);
|
||||
|
||||
const [track] = await db
|
||||
.select()
|
||||
.from(tracks)
|
||||
.where(eq(tracks.id, version.trackId))
|
||||
.limit(1);
|
||||
|
||||
const [membership] = await db
|
||||
.select()
|
||||
.from(projectMembers)
|
||||
.where(
|
||||
and(eq(projectMembers.projectId, track!.projectId), eq(projectMembers.userId, userId)),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!membership || !membership.canUpload) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(versions)
|
||||
.set(input)
|
||||
.where(eq(versions.id, versionId))
|
||||
.returning();
|
||||
|
||||
return c.json({ version: updated });
|
||||
})
|
||||
|
||||
// Delete version
|
||||
.delete('/:id', async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const versionId = c.req.param('id');
|
||||
|
||||
const [version] = await db
|
||||
.select()
|
||||
.from(versions)
|
||||
.where(eq(versions.id, versionId))
|
||||
.limit(1);
|
||||
if (!version) return c.json({ error: 'Not found' }, 404);
|
||||
|
||||
const [track] = await db
|
||||
.select()
|
||||
.from(tracks)
|
||||
.where(eq(tracks.id, version.trackId))
|
||||
.limit(1);
|
||||
|
||||
const [membership] = await db
|
||||
.select()
|
||||
.from(projectMembers)
|
||||
.where(
|
||||
and(eq(projectMembers.projectId, track!.projectId), eq(projectMembers.userId, userId)),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!membership || membership.role !== 'owner') {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
await db.delete(versions).where(eq(versions.id, versionId));
|
||||
return c.json({ message: 'Version deleted' });
|
||||
})
|
||||
|
||||
// Get version tree (graph) for a track
|
||||
.get('/track/:trackId/tree', async (c) => {
|
||||
const db = c.get('db');
|
||||
|
||||
391
apps/api/src/scripts/seed-rich.ts
Normal file
391
apps/api/src/scripts/seed-rich.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* Reicher Demo-Seed: 4 Projekte mit verschiedenen Charakteren, mehreren Tracks,
|
||||
* Versionen, Mitgliedern, Comments und Share-Links.
|
||||
*
|
||||
* Voraussetzung: Audio-Dateien und Cover-PNGs liegen in /tmp/musichub-seed/
|
||||
* bass.wav drums.wav hi.wav lead.wav mix.wav pad.wav vox.wav warm.wav
|
||||
* sunset.png lichtjahre.png rooftop.png wave.png
|
||||
*
|
||||
* Usage:
|
||||
* bun run apps/api/src/scripts/seed-rich.ts <email>
|
||||
*/
|
||||
import { eq } from 'drizzle-orm';
|
||||
import {
|
||||
createDb,
|
||||
users,
|
||||
projects,
|
||||
projectMembers,
|
||||
tracks,
|
||||
versions,
|
||||
comments,
|
||||
shareLinks,
|
||||
} from '@music-hub/db';
|
||||
import { createUploadUrl } from '../storage/s3.js';
|
||||
import { processVersion } from '../services/audio-processor.js';
|
||||
|
||||
const email = process.argv[2];
|
||||
if (!email) {
|
||||
console.error('Usage: bun run seed-rich.ts <email>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const ASSET_DIR = '/tmp/musichub-seed';
|
||||
|
||||
async function readBytes(name: string): Promise<ArrayBuffer> {
|
||||
const f = Bun.file(`${ASSET_DIR}/${name}`);
|
||||
if (!(await f.exists())) throw new Error(`Missing asset: ${name}`);
|
||||
return await f.arrayBuffer();
|
||||
}
|
||||
|
||||
const db = createDb(process.env.DATABASE_URL!);
|
||||
|
||||
// === USER SETUP ===
|
||||
console.log(`→ User ${email}`);
|
||||
let [me] = await db.select().from(users).where(eq(users.email, email)).limit(1);
|
||||
if (!me) {
|
||||
[me] = await db.insert(users).values({ email, name: email.split('@')[0] }).returning();
|
||||
console.log(` created ${me.id}`);
|
||||
} else {
|
||||
console.log(` exists ${me.id}`);
|
||||
}
|
||||
|
||||
// Co-Mitglieder
|
||||
const memberDefs = [
|
||||
{ email: 'anna@example.com', name: 'Anna Berger', role: 'artist' as const },
|
||||
{ email: 'jonas@example.com', name: 'Jonas Klein', role: 'mixing_engineer' as const },
|
||||
{ email: 'lina@example.com', name: 'Lina Roth', role: 'mastering_engineer' as const },
|
||||
{ email: 'felix@example.com', name: 'Felix Lang', role: 'label' as const },
|
||||
];
|
||||
|
||||
const collaborators: Record<string, typeof users.$inferSelect> = {};
|
||||
for (const m of memberDefs) {
|
||||
let [u] = await db.select().from(users).where(eq(users.email, m.email)).limit(1);
|
||||
if (!u) [u] = await db.insert(users).values({ email: m.email, name: m.name }).returning();
|
||||
collaborators[m.email] = u;
|
||||
}
|
||||
console.log(`→ ${memberDefs.length} Mitwirkende vorbereitet`);
|
||||
|
||||
// === COVER UPLOAD HELPER ===
|
||||
async function uploadCover(filename: string): Promise<string> {
|
||||
const bytes = await readBytes(filename);
|
||||
const key = `covers/${crypto.randomUUID()}.png`;
|
||||
const url = await createUploadUrl(key, 'image/png', bytes.byteLength);
|
||||
const res = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'image/png' },
|
||||
body: bytes,
|
||||
});
|
||||
if (!res.ok) throw new Error(`Cover upload failed: ${res.status}`);
|
||||
return key;
|
||||
}
|
||||
|
||||
// === PROJECT FACTORY ===
|
||||
type TrackSpec = {
|
||||
name: string;
|
||||
description?: string;
|
||||
versions: VersionSpec[];
|
||||
comments?: CommentSpec[];
|
||||
};
|
||||
type VersionSpec = {
|
||||
label: string;
|
||||
audio: string;
|
||||
status?: 'ready' | 'approved' | 'rejected';
|
||||
parentLabel?: string;
|
||||
branchLabel?: string;
|
||||
notes?: string;
|
||||
createdBy?: string;
|
||||
};
|
||||
type CommentSpec = {
|
||||
versionLabel: string;
|
||||
body: string;
|
||||
at?: number;
|
||||
by?: string; // email or 'guest:Name'
|
||||
};
|
||||
type ProjectSpec = {
|
||||
name: string;
|
||||
description: string;
|
||||
cover: string;
|
||||
members: { email: string; role: any }[];
|
||||
tracks: TrackSpec[];
|
||||
shareVersionLabel?: { trackName: string; versionLabel: string };
|
||||
};
|
||||
|
||||
async function createProject(spec: ProjectSpec) {
|
||||
console.log(`\n📀 ${spec.name}`);
|
||||
const coverKey = await uploadCover(spec.cover);
|
||||
const [project] = await db
|
||||
.insert(projects)
|
||||
.values({
|
||||
name: spec.name,
|
||||
description: spec.description,
|
||||
coverImageUrl: coverKey,
|
||||
createdById: me.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await db.insert(projectMembers).values({
|
||||
projectId: project.id,
|
||||
userId: me.id,
|
||||
role: 'owner',
|
||||
canUpload: true,
|
||||
canComment: true,
|
||||
canApprove: true,
|
||||
});
|
||||
|
||||
for (const m of spec.members) {
|
||||
const u = collaborators[m.email];
|
||||
if (!u) continue;
|
||||
await db.insert(projectMembers).values({
|
||||
projectId: project.id,
|
||||
userId: u.id,
|
||||
role: m.role,
|
||||
canUpload: m.role.includes('engineer') || m.role === 'owner',
|
||||
canComment: true,
|
||||
canApprove: m.role === 'artist' || m.role === 'label' || m.role === 'management',
|
||||
});
|
||||
}
|
||||
|
||||
let shareToken: string | null = null;
|
||||
|
||||
for (const trackSpec of spec.tracks) {
|
||||
const [track] = await db
|
||||
.insert(tracks)
|
||||
.values({
|
||||
projectId: project.id,
|
||||
name: trackSpec.name,
|
||||
description: trackSpec.description,
|
||||
createdById: me.id,
|
||||
})
|
||||
.returning();
|
||||
console.log(` 🎵 ${trackSpec.name}`);
|
||||
|
||||
// Versionen in Reihenfolge anlegen, parent-Lookup über Label
|
||||
const versionMap = new Map<string, string>();
|
||||
|
||||
for (let i = 0; i < trackSpec.versions.length; i++) {
|
||||
const v = trackSpec.versions[i];
|
||||
const audioBytes = await readBytes(v.audio);
|
||||
const versionId = crypto.randomUUID();
|
||||
const fileName = `${v.audio}`;
|
||||
const fileKey = `projects/${project.id}/tracks/${track.id}/versions/${versionId}/original/${fileName}`;
|
||||
const uploadUrl = await createUploadUrl(fileKey, 'audio/wav', audioBytes.byteLength);
|
||||
const res = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'audio/wav' },
|
||||
body: audioBytes,
|
||||
});
|
||||
if (!res.ok) throw new Error(`Audio upload failed: ${res.status}`);
|
||||
|
||||
const parentVersionId = v.parentLabel ? versionMap.get(v.parentLabel) ?? null : null;
|
||||
const createdById = v.createdBy
|
||||
? collaborators[v.createdBy]?.id ?? me.id
|
||||
: me.id;
|
||||
|
||||
const [version] = await db
|
||||
.insert(versions)
|
||||
.values({
|
||||
id: versionId,
|
||||
trackId: track.id,
|
||||
versionNumber: i + 1,
|
||||
label: v.label,
|
||||
notes: v.notes,
|
||||
status: 'uploaded',
|
||||
parentVersionId,
|
||||
branchLabel: v.branchLabel ?? null,
|
||||
originalFileName: fileName,
|
||||
mimeType: 'audio/wav',
|
||||
fileSize: audioBytes.byteLength,
|
||||
originalFileKey: fileKey,
|
||||
createdById,
|
||||
})
|
||||
.returning();
|
||||
|
||||
versionMap.set(v.label, version.id);
|
||||
console.log(` V${i + 1} ${v.label}${v.branchLabel ? ` (Variante: ${v.branchLabel})` : ''}`);
|
||||
await processVersion(db, version.id);
|
||||
|
||||
if (v.status && v.status !== 'ready') {
|
||||
await db
|
||||
.update(versions)
|
||||
.set({ status: v.status })
|
||||
.where(eq(versions.id, version.id));
|
||||
}
|
||||
}
|
||||
|
||||
// Comments
|
||||
for (const c of trackSpec.comments ?? []) {
|
||||
const versionId = versionMap.get(c.versionLabel);
|
||||
if (!versionId) continue;
|
||||
const isGuest = c.by?.startsWith('guest:');
|
||||
const userId = isGuest
|
||||
? null
|
||||
: c.by
|
||||
? collaborators[c.by]?.id ?? me.id
|
||||
: me.id;
|
||||
const guestName = isGuest ? c.by!.slice(6) : null;
|
||||
await db.insert(comments).values({
|
||||
versionId,
|
||||
userId,
|
||||
guestName,
|
||||
body: c.body,
|
||||
timestampSeconds: c.at ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// Share-Link auf eine bestimmte Version
|
||||
if (
|
||||
spec.shareVersionLabel &&
|
||||
spec.shareVersionLabel.trackName === trackSpec.name
|
||||
) {
|
||||
const versionId = versionMap.get(spec.shareVersionLabel.versionLabel);
|
||||
if (versionId) {
|
||||
const token = Array.from(crypto.getRandomValues(new Uint8Array(32)), (b) =>
|
||||
b.toString(16).padStart(2, '0'),
|
||||
).join('');
|
||||
await db.insert(shareLinks).values({
|
||||
versionId,
|
||||
token,
|
||||
createdById: me.id,
|
||||
allowComments: true,
|
||||
allowDownload: false,
|
||||
});
|
||||
shareToken = token;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shareToken) {
|
||||
console.log(` 🔗 Share: http://localhost:5173/listen/${shareToken}`);
|
||||
}
|
||||
}
|
||||
|
||||
// === PROJEKT-KATALOG ===
|
||||
await createProject({
|
||||
name: 'Sunset Drive',
|
||||
description: 'Synthwave-EP. Mainline ist V3, V4 ist eine Variante mit anderem Bass.',
|
||||
cover: 'sunset.png',
|
||||
members: [
|
||||
{ email: 'anna@example.com', role: 'artist' },
|
||||
{ email: 'jonas@example.com', role: 'mixing_engineer' },
|
||||
{ email: 'lina@example.com', role: 'mastering_engineer' },
|
||||
],
|
||||
tracks: [
|
||||
{
|
||||
name: 'Hauptmix',
|
||||
description: 'Der zentrale Track der EP. Drei Iterationen plus eine Vocal-Variante.',
|
||||
versions: [
|
||||
{ label: 'Erster Wurf', audio: 'pad.wav' },
|
||||
{ label: 'Mehr Bass', audio: 'bass.wav', notes: 'Sub etwas runter, Punch nach vorne' },
|
||||
{ label: 'Final Mix', audio: 'mix.wav', status: 'approved', notes: 'Freigegeben von Lina nach Mastering' },
|
||||
{ label: 'Andere Vocals', audio: 'vox.wav', parentLabel: 'Mehr Bass', branchLabel: 'vocals-alt', notes: 'Variante mit dem zweiten Vocal-Take', createdBy: 'jonas@example.com' },
|
||||
],
|
||||
comments: [
|
||||
{ versionLabel: 'Mehr Bass', at: 3.2, by: 'anna@example.com', body: 'Hier ist es nice — aber bei 0:05 fehlt mir noch was im Mid-Range.' },
|
||||
{ versionLabel: 'Mehr Bass', at: 5.5, body: 'Verstanden, ziehe ich nach. EQ-Boost bei 800Hz.' },
|
||||
{ versionLabel: 'Final Mix', at: 1.8, by: 'lina@example.com', body: 'Master sitzt. -1 LUFS, dynamic range bleibt erhalten.' },
|
||||
{ versionLabel: 'Andere Vocals', at: 2.0, by: 'anna@example.com', body: 'Yes! Das ist die richtige Energie für den Refrain.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Drums',
|
||||
description: 'Drum-Bus für den Hauptmix.',
|
||||
versions: [
|
||||
{ label: 'Punchy', audio: 'drums.wav', createdBy: 'jonas@example.com' },
|
||||
{ label: 'Mehr Air', audio: 'hi.wav', notes: 'Top-Hi-Hats lauter, Reverb-Tail dazu' },
|
||||
],
|
||||
comments: [
|
||||
{ versionLabel: 'Punchy', at: 1.5, by: 'anna@example.com', body: 'Snare ist perfekt hier.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Lead Synth',
|
||||
description: 'Hauptmotiv. Sitzt zwischen Vox und Pad.',
|
||||
versions: [
|
||||
{ label: 'V1', audio: 'lead.wav' },
|
||||
],
|
||||
},
|
||||
],
|
||||
shareVersionLabel: { trackName: 'Hauptmix', versionLabel: 'Final Mix' },
|
||||
});
|
||||
|
||||
await createProject({
|
||||
name: 'Lichtjahre',
|
||||
description: 'Indie-Single. Acoustic-Demo, dann Studio-Take, dann Mix.',
|
||||
cover: 'lichtjahre.png',
|
||||
members: [
|
||||
{ email: 'anna@example.com', role: 'artist' },
|
||||
{ email: 'felix@example.com', role: 'label' },
|
||||
],
|
||||
tracks: [
|
||||
{
|
||||
name: 'Lichtjahre',
|
||||
description: 'Hauptsong der Single.',
|
||||
versions: [
|
||||
{ label: 'Acoustic Demo', audio: 'warm.wav', notes: 'Aufgenommen am Küchentisch' },
|
||||
{ label: 'Studio Take', audio: 'pad.wav', notes: 'Voller Mix mit Band' },
|
||||
{ label: 'Mix V1', audio: 'mix.wav', createdBy: 'jonas@example.com' },
|
||||
],
|
||||
comments: [
|
||||
{ versionLabel: 'Studio Take', at: 4.0, by: 'felix@example.com', body: 'Geht in die Richtung. Bei 0:08 könnten wir noch ein Break einbauen.' },
|
||||
{ versionLabel: 'Studio Take', at: 7.2, body: 'Notiert.' },
|
||||
{ versionLabel: 'Mix V1', at: 2.5, by: 'guest:Sarah (A&R)', body: 'Ich bin begeistert. Bei wann ist das fertig?' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'B-Side',
|
||||
description: 'Instrumental-Version für Sync-Lizenz.',
|
||||
versions: [
|
||||
{ label: 'Instrumental', audio: 'pad.wav' },
|
||||
],
|
||||
},
|
||||
],
|
||||
shareVersionLabel: { trackName: 'Lichtjahre', versionLabel: 'Mix V1' },
|
||||
});
|
||||
|
||||
await createProject({
|
||||
name: 'Rooftop Session',
|
||||
description: 'Live-Aufnahme vom letzten Sommer-Gig. Soundcheck + ein Take pro Song.',
|
||||
cover: 'rooftop.png',
|
||||
members: [
|
||||
{ email: 'jonas@example.com', role: 'recording_engineer' },
|
||||
],
|
||||
tracks: [
|
||||
{
|
||||
name: 'Soundcheck',
|
||||
versions: [{ label: 'Take 1', audio: 'drums.wav', createdBy: 'jonas@example.com' }],
|
||||
},
|
||||
{
|
||||
name: 'Opener',
|
||||
versions: [
|
||||
{ label: 'Live Take', audio: 'bass.wav' },
|
||||
{ label: 'Roh-Mix', audio: 'mix.wav', notes: 'Schneller Mix für die Band-Doku' },
|
||||
],
|
||||
comments: [
|
||||
{ versionLabel: 'Live Take', at: 6.0, by: 'jonas@example.com', body: 'Crowd-Geräusche bewusst drin gelassen.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Encore',
|
||||
versions: [{ label: 'Live Take', audio: 'lead.wav' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await createProject({
|
||||
name: 'Wave Goodbye',
|
||||
description: 'Closing-Track für die EP. Noch früh in der Entwicklung.',
|
||||
cover: 'wave.png',
|
||||
members: [
|
||||
{ email: 'anna@example.com', role: 'artist' },
|
||||
],
|
||||
tracks: [
|
||||
{
|
||||
name: 'Skizze',
|
||||
versions: [{ label: 'V1', audio: 'pad.wav', notes: 'Erste Idee, sehr roh' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
console.log('\n✅ Fertig.');
|
||||
console.log(` Login als ${email} via Magic Link.`);
|
||||
process.exit(0);
|
||||
159
apps/api/src/scripts/seed.ts
Normal file
159
apps/api/src/scripts/seed.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Demo seed script.
|
||||
*
|
||||
* Usage:
|
||||
* ffmpeg -f lavfi -i "sine=frequency=440:duration=8" -ac 2 /tmp/musichub-demo.wav
|
||||
* bun run apps/api/src/scripts/seed.ts demo@example.com [/tmp/musichub-demo.wav]
|
||||
*
|
||||
* Creates user (if missing), a demo project with 1 track and 3 versions
|
||||
* (V2 mainline, V3 as branch off V2), 2 sample comments and 1 share link.
|
||||
*/
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { createDb, users, projects, projectMembers, tracks, versions, comments, shareLinks } from '@music-hub/db';
|
||||
import { createUploadUrl } from '../storage/s3.js';
|
||||
import { processVersion } from '../services/audio-processor.js';
|
||||
|
||||
const email = process.argv[2];
|
||||
const audioPath = process.argv[3] || '/tmp/musichub-demo.wav';
|
||||
|
||||
if (!email) {
|
||||
console.error('Usage: bun run seed.ts <email> [audioPath]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const audioFile = Bun.file(audioPath);
|
||||
if (!(await audioFile.exists())) {
|
||||
console.error(`Audio file missing: ${audioPath}`);
|
||||
console.error('Generate one first:');
|
||||
console.error(` ffmpeg -f lavfi -i "sine=frequency=440:duration=8" -ac 2 ${audioPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const audioBytes = await audioFile.arrayBuffer();
|
||||
const audioSize = audioBytes.byteLength;
|
||||
|
||||
const db = createDb(process.env.DATABASE_URL!);
|
||||
|
||||
console.log(`→ User ${email}`);
|
||||
let [user] = await db.select().from(users).where(eq(users.email, email)).limit(1);
|
||||
if (!user) {
|
||||
[user] = await db.insert(users).values({ email, name: email.split('@')[0] }).returning();
|
||||
console.log(` created ${user.id}`);
|
||||
} else {
|
||||
console.log(` exists ${user.id}`);
|
||||
}
|
||||
|
||||
console.log('→ Projekt "Demo: Sunset Drive"');
|
||||
const [project] = await db
|
||||
.insert(projects)
|
||||
.values({
|
||||
name: 'Demo: Sunset Drive',
|
||||
description: 'Synthwave-Track in Arbeit. Probier den Graph, lade eine neue Variante hoch oder teile den Link.',
|
||||
createdById: user.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await db.insert(projectMembers).values({
|
||||
projectId: project.id,
|
||||
userId: user.id,
|
||||
role: 'owner',
|
||||
canUpload: true,
|
||||
canComment: true,
|
||||
canApprove: true,
|
||||
});
|
||||
|
||||
console.log('→ Track "Hauptmix"');
|
||||
const [track] = await db
|
||||
.insert(tracks)
|
||||
.values({
|
||||
projectId: project.id,
|
||||
name: 'Hauptmix',
|
||||
description: 'Der Hauptmix mit zwei Mainline-Versionen und einer Vocals-Variante.',
|
||||
createdById: user.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
async function uploadVersion(opts: {
|
||||
versionNumber: number;
|
||||
label: string;
|
||||
parentVersionId?: string | null;
|
||||
branchLabel?: string | null;
|
||||
}) {
|
||||
const versionId = crypto.randomUUID();
|
||||
const fileName = `demo-v${opts.versionNumber}.wav`;
|
||||
const fileKey = `projects/${project.id}/tracks/${track.id}/versions/${versionId}/original/${fileName}`;
|
||||
|
||||
const uploadUrl = await createUploadUrl(fileKey, 'audio/wav', audioSize);
|
||||
const res = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'audio/wav' },
|
||||
body: audioBytes,
|
||||
});
|
||||
if (!res.ok) throw new Error(`S3 upload failed: ${res.status} ${await res.text()}`);
|
||||
|
||||
const [version] = await db
|
||||
.insert(versions)
|
||||
.values({
|
||||
id: versionId,
|
||||
trackId: track.id,
|
||||
versionNumber: opts.versionNumber,
|
||||
label: opts.label,
|
||||
status: 'uploaded',
|
||||
parentVersionId: opts.parentVersionId ?? null,
|
||||
branchLabel: opts.branchLabel ?? null,
|
||||
originalFileName: fileName,
|
||||
mimeType: 'audio/wav',
|
||||
fileSize: audioSize,
|
||||
originalFileKey: fileKey,
|
||||
createdById: user.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
console.log(` V${opts.versionNumber} (${opts.branchLabel ?? 'main'}) → processing`);
|
||||
await processVersion(db, version.id);
|
||||
return version;
|
||||
}
|
||||
|
||||
const v1 = await uploadVersion({ versionNumber: 1, label: 'Erster Wurf' });
|
||||
const v2 = await uploadVersion({ versionNumber: 2, label: 'Mehr Bass' });
|
||||
const v3 = await uploadVersion({
|
||||
versionNumber: 3,
|
||||
label: 'Vocals neu',
|
||||
parentVersionId: v2.id,
|
||||
branchLabel: 'vocals-neu',
|
||||
});
|
||||
|
||||
console.log('→ Comments');
|
||||
await db.insert(comments).values([
|
||||
{
|
||||
versionId: v2.id,
|
||||
userId: user.id,
|
||||
body: 'Bei 0:03 sitzt der Drop, finde ich richtig stark.',
|
||||
timestampSeconds: 3.0,
|
||||
},
|
||||
{
|
||||
versionId: v2.id,
|
||||
userId: null,
|
||||
guestName: 'Anna (Artist)',
|
||||
body: 'Können wir die Vocals etwas weiter nach vorne ziehen?',
|
||||
timestampSeconds: 5.5,
|
||||
},
|
||||
]);
|
||||
|
||||
console.log('→ Share-Link');
|
||||
const token = Array.from(crypto.getRandomValues(new Uint8Array(32)), (b) =>
|
||||
b.toString(16).padStart(2, '0'),
|
||||
).join('');
|
||||
await db.insert(shareLinks).values({
|
||||
versionId: v2.id,
|
||||
token,
|
||||
createdById: user.id,
|
||||
allowComments: true,
|
||||
allowDownload: false,
|
||||
});
|
||||
|
||||
console.log('\n✅ Done.');
|
||||
console.log(` Login als ${email} via Magic Link.`);
|
||||
console.log(` Share-Link: http://localhost:5173/listen/${token}`);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
process.exit(0);
|
||||
@@ -17,23 +17,23 @@ export async function sendMagicLinkEmail(email: string, token: string) {
|
||||
await resend.emails.send({
|
||||
from: fromEmail,
|
||||
to: email,
|
||||
subject: 'Your Music Hub Login Link',
|
||||
subject: 'Dein Login-Link für Music Hub',
|
||||
html: `
|
||||
<div style="font-family: -apple-system, sans-serif; max-width: 400px; margin: 0 auto; padding: 2rem;">
|
||||
<h1 style="font-size: 1.5rem; color: #f0f0f0;">Music Hub</h1>
|
||||
<p style="color: #a0a0a0;">Click the button below to log in:</p>
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 460px; margin: 0 auto; padding: 2.5rem 2rem; color: #f4f0ec; background: #0a0910;">
|
||||
<h1 style="font-size: 1.6rem; margin: 0 0 1rem; background: linear-gradient(135deg, #f43f5e, #fb923c); -webkit-background-clip: text; background-clip: text; color: transparent; display: inline-block;">Music Hub</h1>
|
||||
<p style="color: #9b96a8; line-height: 1.55; margin: 0 0 1.5rem;">Klick auf den Button um dich einzuloggen:</p>
|
||||
<a href="${url}" style="
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #6366f1;
|
||||
padding: 0.8rem 1.6rem;
|
||||
background: linear-gradient(135deg, #f43f5e, #fb923c);
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
margin: 1rem 0;
|
||||
">Log in to Music Hub</a>
|
||||
<p style="color: #666; font-size: 0.85rem;">This link expires in 15 minutes.</p>
|
||||
<p style="color: #666; font-size: 0.85rem;">If you didn't request this, ignore this email.</p>
|
||||
font-weight: 600;
|
||||
margin: 0 0 1.5rem;
|
||||
">Einloggen</a>
|
||||
<p style="color: #5e596b; font-size: 0.85rem; margin: 0 0 0.5rem;">Der Link läuft in 15 Minuten ab.</p>
|
||||
<p style="color: #5e596b; font-size: 0.85rem; margin: 0;">Wenn du das nicht angefordert hast, ignorier diese Mail einfach.</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
@@ -50,21 +50,20 @@ export async function sendInviteEmail(email: string, projectName: string, invite
|
||||
await resend.emails.send({
|
||||
from: fromEmail,
|
||||
to: email,
|
||||
subject: `You've been invited to "${projectName}" on Music Hub`,
|
||||
subject: `${inviterName} hat dich zu "${projectName}" eingeladen`,
|
||||
html: `
|
||||
<div style="font-family: -apple-system, sans-serif; max-width: 400px; margin: 0 auto; padding: 2rem;">
|
||||
<h1 style="font-size: 1.5rem; color: #f0f0f0;">Music Hub</h1>
|
||||
<p style="color: #a0a0a0;">${inviterName} invited you to collaborate on <strong>"${projectName}"</strong>.</p>
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 460px; margin: 0 auto; padding: 2.5rem 2rem; color: #f4f0ec; background: #0a0910;">
|
||||
<h1 style="font-size: 1.6rem; margin: 0 0 1rem; background: linear-gradient(135deg, #f43f5e, #fb923c); -webkit-background-clip: text; background-clip: text; color: transparent; display: inline-block;">Music Hub</h1>
|
||||
<p style="color: #9b96a8; line-height: 1.55; margin: 0 0 1.5rem;"><strong style="color: #f4f0ec;">${inviterName}</strong> hat dich eingeladen, am Projekt <strong style="color: #f4f0ec;">"${projectName}"</strong> mitzuarbeiten.</p>
|
||||
<a href="${url}" style="
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #6366f1;
|
||||
padding: 0.8rem 1.6rem;
|
||||
background: linear-gradient(135deg, #f43f5e, #fb923c);
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
margin: 1rem 0;
|
||||
">Open Music Hub</a>
|
||||
font-weight: 600;
|
||||
">Projekt öffnen</a>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user