Initial commit: Music Hub collaboration platform
Full-stack music production collaboration tool with: - SvelteKit frontend with Design System (CSS vars, 8 shared components) - Hono API with auth, projects, tracks, versions, comments - PostgreSQL + Drizzle ORM (8 tables, roles, permissions) - S3-compatible storage with presigned upload URLs - wavesurfer.js audio player with waveform visualization - A/B version comparison with synchronized playback - Timestamped comments with threading and resolve workflow - Magic Link authentication with Resend email integration - Background audio processing (ffmpeg transcode + waveform peaks) - Role-based access control (Owner, Engineers, Artist, Label, Management, Viewer) - Toast notifications, skeleton loading, responsive layout - Docker deployment setup (API + Web + Postgres) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
11
packages/shared/package.json
Normal file
11
packages/shared/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@music-hub/shared",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.24"
|
||||
}
|
||||
}
|
||||
24
packages/shared/src/constants/audio.ts
Normal file
24
packages/shared/src/constants/audio.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export const SUPPORTED_AUDIO_FORMATS = [
|
||||
'audio/wav',
|
||||
'audio/x-wav',
|
||||
'audio/mp3',
|
||||
'audio/mpeg',
|
||||
'audio/flac',
|
||||
'audio/x-flac',
|
||||
'audio/aiff',
|
||||
'audio/x-aiff',
|
||||
] as const;
|
||||
|
||||
export const SUPPORTED_EXTENSIONS = ['.wav', '.mp3', '.flac', '.aiff', '.aif'] as const;
|
||||
|
||||
export const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB
|
||||
|
||||
export const VERSION_STATUSES = [
|
||||
'uploaded',
|
||||
'processing',
|
||||
'ready',
|
||||
'approved',
|
||||
'rejected',
|
||||
] as const;
|
||||
|
||||
export type VersionStatus = (typeof VERSION_STATUSES)[number];
|
||||
3
packages/shared/src/constants/index.ts
Normal file
3
packages/shared/src/constants/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './roles.js';
|
||||
export * from './permissions.js';
|
||||
export * from './audio.js';
|
||||
69
packages/shared/src/constants/permissions.ts
Normal file
69
packages/shared/src/constants/permissions.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { ProjectRole } from './roles.js';
|
||||
|
||||
export type Permission =
|
||||
| 'project.edit'
|
||||
| 'project.invite'
|
||||
| 'track.upload'
|
||||
| 'track.listen'
|
||||
| 'track.download'
|
||||
| 'version.comment'
|
||||
| 'version.approve';
|
||||
|
||||
const PERMISSIONS: Record<ProjectRole, Permission[]> = {
|
||||
owner: [
|
||||
'project.edit',
|
||||
'project.invite',
|
||||
'track.upload',
|
||||
'track.listen',
|
||||
'track.download',
|
||||
'version.comment',
|
||||
'version.approve',
|
||||
],
|
||||
recording_engineer: [
|
||||
'track.upload',
|
||||
'track.listen',
|
||||
'track.download',
|
||||
'version.comment',
|
||||
],
|
||||
mixing_engineer: [
|
||||
'track.upload',
|
||||
'track.listen',
|
||||
'track.download',
|
||||
'version.comment',
|
||||
],
|
||||
mastering_engineer: [
|
||||
'track.upload',
|
||||
'track.listen',
|
||||
'track.download',
|
||||
'version.comment',
|
||||
],
|
||||
artist: [
|
||||
'track.listen',
|
||||
'track.download',
|
||||
'version.comment',
|
||||
'version.approve',
|
||||
],
|
||||
label: [
|
||||
'track.listen',
|
||||
'version.comment',
|
||||
'version.approve',
|
||||
],
|
||||
management: [
|
||||
'project.invite',
|
||||
'track.listen',
|
||||
'track.download',
|
||||
'version.comment',
|
||||
'version.approve',
|
||||
],
|
||||
viewer: [
|
||||
'track.listen',
|
||||
],
|
||||
};
|
||||
|
||||
export function hasPermission(role: ProjectRole, permission: Permission): boolean {
|
||||
return PERMISSIONS[role].includes(permission);
|
||||
}
|
||||
|
||||
export function getPermissions(role: ProjectRole): Permission[] {
|
||||
return PERMISSIONS[role];
|
||||
}
|
||||
29
packages/shared/src/constants/roles.ts
Normal file
29
packages/shared/src/constants/roles.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export const PROJECT_ROLES = [
|
||||
'owner',
|
||||
'recording_engineer',
|
||||
'mixing_engineer',
|
||||
'mastering_engineer',
|
||||
'artist',
|
||||
'label',
|
||||
'management',
|
||||
'viewer',
|
||||
] as const;
|
||||
|
||||
export type ProjectRole = (typeof PROJECT_ROLES)[number];
|
||||
|
||||
export const ROLE_LABELS: Record<ProjectRole, string> = {
|
||||
owner: 'Owner',
|
||||
recording_engineer: 'Recording Engineer',
|
||||
mixing_engineer: 'Mixing Engineer',
|
||||
mastering_engineer: 'Mastering Engineer',
|
||||
artist: 'Artist',
|
||||
label: 'Label',
|
||||
management: 'Management',
|
||||
viewer: 'Viewer',
|
||||
};
|
||||
|
||||
export const ENGINEER_ROLES: ProjectRole[] = [
|
||||
'recording_engineer',
|
||||
'mixing_engineer',
|
||||
'mastering_engineer',
|
||||
];
|
||||
2
packages/shared/src/index.ts
Normal file
2
packages/shared/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './constants/index.js';
|
||||
export * from './validation/index.js';
|
||||
12
packages/shared/src/validation/auth.ts
Normal file
12
packages/shared/src/validation/auth.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const magicLinkSchema = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
export const verifyTokenSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
});
|
||||
|
||||
export type MagicLinkInput = z.infer<typeof magicLinkSchema>;
|
||||
export type VerifyTokenInput = z.infer<typeof verifyTokenSchema>;
|
||||
14
packages/shared/src/validation/comment.ts
Normal file
14
packages/shared/src/validation/comment.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const createCommentSchema = z.object({
|
||||
body: z.string().min(1).max(5000),
|
||||
timestampSeconds: z.number().nonnegative().optional(),
|
||||
parentId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
export const updateCommentSchema = z.object({
|
||||
body: z.string().min(1).max(5000),
|
||||
});
|
||||
|
||||
export type CreateCommentInput = z.infer<typeof createCommentSchema>;
|
||||
export type UpdateCommentInput = z.infer<typeof updateCommentSchema>;
|
||||
4
packages/shared/src/validation/index.ts
Normal file
4
packages/shared/src/validation/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './auth.js';
|
||||
export * from './project.js';
|
||||
export * from './track.js';
|
||||
export * from './comment.js';
|
||||
30
packages/shared/src/validation/project.ts
Normal file
30
packages/shared/src/validation/project.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { z } from 'zod';
|
||||
import { PROJECT_ROLES } from '../constants/roles.js';
|
||||
|
||||
export const createProjectSchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
description: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
export const updateProjectSchema = z.object({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
description: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
export const inviteMemberSchema = z.object({
|
||||
email: z.string().email(),
|
||||
role: z.enum(PROJECT_ROLES).refine((r) => r !== 'owner', {
|
||||
message: 'Cannot invite as owner',
|
||||
}),
|
||||
});
|
||||
|
||||
export const updateMemberSchema = z.object({
|
||||
role: z.enum(PROJECT_ROLES).refine((r) => r !== 'owner', {
|
||||
message: 'Cannot change role to owner',
|
||||
}),
|
||||
});
|
||||
|
||||
export type CreateProjectInput = z.infer<typeof createProjectSchema>;
|
||||
export type UpdateProjectInput = z.infer<typeof updateProjectSchema>;
|
||||
export type InviteMemberInput = z.infer<typeof inviteMemberSchema>;
|
||||
export type UpdateMemberInput = z.infer<typeof updateMemberSchema>;
|
||||
32
packages/shared/src/validation/track.ts
Normal file
32
packages/shared/src/validation/track.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { z } from 'zod';
|
||||
import { SUPPORTED_AUDIO_FORMATS, MAX_FILE_SIZE } from '../constants/audio.js';
|
||||
|
||||
export const createTrackSchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
description: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
export const updateTrackSchema = z.object({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
description: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
export const requestUploadUrlSchema = z.object({
|
||||
fileName: z.string().min(1),
|
||||
mimeType: z.enum(SUPPORTED_AUDIO_FORMATS),
|
||||
fileSize: z.number().int().positive().max(MAX_FILE_SIZE),
|
||||
});
|
||||
|
||||
export const createVersionSchema = z.object({
|
||||
fileKey: z.string().min(1),
|
||||
label: z.string().max(100).optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
originalFileName: z.string().min(1),
|
||||
mimeType: z.string().min(1),
|
||||
fileSize: z.number().int().positive(),
|
||||
});
|
||||
|
||||
export type CreateTrackInput = z.infer<typeof createTrackSchema>;
|
||||
export type UpdateTrackInput = z.infer<typeof updateTrackSchema>;
|
||||
export type RequestUploadUrlInput = z.infer<typeof requestUploadUrlSchema>;
|
||||
export type CreateVersionInput = z.infer<typeof createVersionSchema>;
|
||||
13
packages/shared/tsconfig.json
Normal file
13
packages/shared/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user