Full MVP: workspace layout, visual refresh, PWA, production deploy

Major changes since initial commit:

Schema: version branching (parentVersionId, branchLabel), share links,
guest comments, track status enum (sketch/in_progress/final/released),
track sections, cover art for projects and tracks.

API: 29+ endpoints — auth, projects, tracks, versions, comments, share
links (public + management), uploads (cover), activity feed, onboarding
demo seed. Email templates in German with brand styling.

Web: SvelteKit 5 workspace layout with persistent sidebar, breadcrumb
top-bar, collapsible right panel. SoundCloud-style waveform player with
round play button, avatar comment markers, keyboard shortcuts (Space/JKL/C).
Full German UI. Cover art with gradient fallback. Track status pills.
Activity feed dashboard. Welcome modal with demo-seed trigger. Landing
page with 7-section scroll layout. Login on /login. Public /listen/:token
page for guest feedback.

Visual: Inter Variable font, Magenta→Orange gradient accent, warm dark
neutrals, Lucide-style inline SVG icon set, spring animations on modals,
glass-effect toasts, responsive from 360px to 2560px+.

PWA: manifest, service worker, icons, iOS/Android installable.

Production: adapter-node, server-side API proxy hook, docker-compose with
Postgres + MinIO + auto-migration + health checks. Env example included.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Robin Choice
2026-04-10 11:47:48 +02:00
parent 4dc095463f
commit 8bf72c2482
78 changed files with 8216 additions and 1386 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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