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:
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');
|
||||
|
||||
Reference in New Issue
Block a user