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:
19
apps/api/package.json
Normal file
19
apps/api/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@music-hub/api",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run --hot src/index.ts",
|
||||
"build": "bun build src/index.ts --outdir dist --target bun"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3",
|
||||
"@aws-sdk/s3-request-presigner": "^3",
|
||||
"@hono/zod-validator": "^0.5",
|
||||
"@music-hub/db": "workspace:*",
|
||||
"@music-hub/shared": "workspace:*",
|
||||
"drizzle-orm": "^0.44",
|
||||
"hono": "^4",
|
||||
"resend": "^6.10.0"
|
||||
}
|
||||
}
|
||||
45
apps/api/src/index.ts
Normal file
45
apps/api/src/index.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import { logger } from 'hono/logger';
|
||||
import { createDb } from '@music-hub/db';
|
||||
import { authRoutes } from './routes/auth.js';
|
||||
import { projectRoutes } from './routes/projects.js';
|
||||
import { trackRoutes } from './routes/tracks.js';
|
||||
import { versionRoutes } from './routes/versions.js';
|
||||
import { commentRoutes } from './routes/comments.js';
|
||||
import type { AppEnv } from './types.js';
|
||||
|
||||
const db = createDb(process.env.DATABASE_URL!);
|
||||
|
||||
const app = new Hono<AppEnv>()
|
||||
.use('*', logger())
|
||||
.use(
|
||||
'*',
|
||||
cors({
|
||||
origin: process.env.APP_URL || 'http://localhost:5173',
|
||||
credentials: true,
|
||||
}),
|
||||
)
|
||||
.use('*', async (c, next) => {
|
||||
c.set('db', db);
|
||||
await next();
|
||||
})
|
||||
.onError((err, c) => {
|
||||
console.error('Unhandled error:', err);
|
||||
return c.json({ error: err.message }, 500);
|
||||
})
|
||||
.get('/health', (c) => c.json({ status: 'ok' }))
|
||||
.basePath('/api/v1')
|
||||
.route('/auth', authRoutes)
|
||||
.route('/projects', projectRoutes)
|
||||
.route('/tracks', trackRoutes)
|
||||
.route('/versions', versionRoutes)
|
||||
.route('/comments', commentRoutes);
|
||||
|
||||
const port = parseInt(process.env.PORT || '3000');
|
||||
console.log(`Music Hub API running on port ${port}`);
|
||||
|
||||
export default {
|
||||
port,
|
||||
fetch: app.fetch,
|
||||
};
|
||||
37
apps/api/src/middleware/auth.ts
Normal file
37
apps/api/src/middleware/auth.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { createMiddleware } from 'hono/factory';
|
||||
import { getCookie } from 'hono/cookie';
|
||||
import { eq, gt } from 'drizzle-orm';
|
||||
import { sessions } from '@music-hub/db';
|
||||
import type { AppEnv } from '../types.js';
|
||||
|
||||
export const requireAuth = createMiddleware<AppEnv>(async (c, next) => {
|
||||
const sessionToken = getCookie(c, 'session');
|
||||
if (!sessionToken) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const tokenHash = await hashToken(sessionToken);
|
||||
const db = c.get('db');
|
||||
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.tokenHash, tokenHash))
|
||||
.limit(1);
|
||||
|
||||
if (!session || session.expiresAt < new Date()) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
c.set('userId', session.userId);
|
||||
await next();
|
||||
});
|
||||
|
||||
export async function hashToken(token: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(token);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
return Array.from(new Uint8Array(hashBuffer))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
159
apps/api/src/routes/auth.ts
Normal file
159
apps/api/src/routes/auth.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { setCookie, deleteCookie, getCookie } from 'hono/cookie';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { magicLinkSchema, verifyTokenSchema } from '@music-hub/shared';
|
||||
import { users, magicLinks, sessions } from '@music-hub/db';
|
||||
import { hashToken } from '../middleware/auth.js';
|
||||
import { sendMagicLinkEmail } from '../services/email.js';
|
||||
import type { AppEnv } from '../types.js';
|
||||
|
||||
export const authRoutes = new Hono<AppEnv>()
|
||||
.post('/magic-link', zValidator('json', magicLinkSchema), async (c) => {
|
||||
const { email } = c.req.valid('json');
|
||||
const db = c.get('db');
|
||||
|
||||
const token = generateToken();
|
||||
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 min
|
||||
|
||||
await db.insert(magicLinks).values({
|
||||
email,
|
||||
token,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
await sendMagicLinkEmail(email, token);
|
||||
|
||||
return c.json({ message: 'Magic link sent' });
|
||||
})
|
||||
|
||||
.post('/verify', zValidator('json', verifyTokenSchema), async (c) => {
|
||||
const { token } = c.req.valid('json');
|
||||
const db = c.get('db');
|
||||
|
||||
const [link] = await db
|
||||
.select()
|
||||
.from(magicLinks)
|
||||
.where(eq(magicLinks.token, token))
|
||||
.limit(1);
|
||||
|
||||
if (!link || link.expiresAt < new Date() || link.usedAt) {
|
||||
return c.json({ error: 'Invalid or expired token' }, 400);
|
||||
}
|
||||
|
||||
await db
|
||||
.update(magicLinks)
|
||||
.set({ usedAt: new Date() })
|
||||
.where(eq(magicLinks.id, link.id));
|
||||
|
||||
// Find or create user
|
||||
let [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, link.email))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
const name = link.email.split('@')[0];
|
||||
[user] = await db
|
||||
.insert(users)
|
||||
.values({ email: link.email, name })
|
||||
.returning();
|
||||
}
|
||||
|
||||
// Create session
|
||||
const sessionToken = generateToken();
|
||||
const tokenHash = await hashToken(sessionToken);
|
||||
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
|
||||
|
||||
await db.insert(sessions).values({
|
||||
userId: user.id,
|
||||
tokenHash,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
setCookie(c, 'session', sessionToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'Lax',
|
||||
path: '/',
|
||||
maxAge: 30 * 24 * 60 * 60,
|
||||
});
|
||||
|
||||
return c.json({ user: { id: user.id, email: user.email, name: user.name } });
|
||||
})
|
||||
|
||||
.post('/logout', async (c) => {
|
||||
const sessionToken = getCookie(c, 'session');
|
||||
if (sessionToken) {
|
||||
const db = c.get('db');
|
||||
const tokenHash = await hashToken(sessionToken);
|
||||
await db.delete(sessions).where(eq(sessions.tokenHash, tokenHash));
|
||||
}
|
||||
deleteCookie(c, 'session');
|
||||
return c.json({ message: 'Logged out' });
|
||||
})
|
||||
|
||||
.get('/me', async (c) => {
|
||||
const sessionToken = getCookie(c, 'session');
|
||||
if (!sessionToken) {
|
||||
return c.json({ user: null });
|
||||
}
|
||||
|
||||
const db = c.get('db');
|
||||
const tokenHash = await hashToken(sessionToken);
|
||||
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.tokenHash, tokenHash))
|
||||
.limit(1);
|
||||
|
||||
if (!session || session.expiresAt < new Date()) {
|
||||
return c.json({ user: null });
|
||||
}
|
||||
|
||||
const [user] = await db
|
||||
.select({ id: users.id, email: users.email, name: users.name, avatarUrl: users.avatarUrl })
|
||||
.from(users)
|
||||
.where(eq(users.id, session.userId))
|
||||
.limit(1);
|
||||
|
||||
return c.json({ user: user || null });
|
||||
})
|
||||
|
||||
.patch('/me', async (c) => {
|
||||
const sessionToken = getCookie(c, 'session');
|
||||
if (!sessionToken) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
const db = c.get('db');
|
||||
const tokenHash = await hashToken(sessionToken);
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.tokenHash, tokenHash))
|
||||
.limit(1);
|
||||
|
||||
if (!session || session.expiresAt < new Date()) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const body = await c.req.json<{ name?: string }>();
|
||||
if (!body.name?.trim()) return c.json({ error: 'Name is required' }, 400);
|
||||
|
||||
const [user] = await db
|
||||
.update(users)
|
||||
.set({ name: body.name.trim(), updatedAt: new Date() })
|
||||
.where(eq(users.id, session.userId))
|
||||
.returning({ id: users.id, email: users.email, name: users.name, avatarUrl: users.avatarUrl });
|
||||
|
||||
return c.json({ user });
|
||||
});
|
||||
|
||||
function generateToken(): string {
|
||||
const bytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
171
apps/api/src/routes/comments.ts
Normal file
171
apps/api/src/routes/comments.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
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 type { AppEnv } from '../types.js';
|
||||
|
||||
export const commentRoutes = new Hono<AppEnv>()
|
||||
.use('*', requireAuth)
|
||||
|
||||
// Get comments for a version
|
||||
.get('/version/:versionId', 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: 'Not found' }, 404);
|
||||
|
||||
const versionComments = await db
|
||||
.select({
|
||||
id: comments.id,
|
||||
body: comments.body,
|
||||
timestampSeconds: comments.timestampSeconds,
|
||||
parentId: comments.parentId,
|
||||
resolvedAt: comments.resolvedAt,
|
||||
createdAt: comments.createdAt,
|
||||
user: {
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
avatarUrl: users.avatarUrl,
|
||||
},
|
||||
})
|
||||
.from(comments)
|
||||
.innerJoin(users, eq(users.id, comments.userId))
|
||||
.where(eq(comments.versionId, versionId))
|
||||
.orderBy(asc(comments.createdAt));
|
||||
|
||||
return c.json({ comments: versionComments });
|
||||
})
|
||||
|
||||
// Create comment
|
||||
.post(
|
||||
'/version/:versionId',
|
||||
zValidator('json', createCommentSchema),
|
||||
async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const versionId = c.req.param('versionId');
|
||||
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.canComment) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
const [comment] = await db
|
||||
.insert(comments)
|
||||
.values({
|
||||
versionId,
|
||||
userId,
|
||||
body: input.body,
|
||||
timestampSeconds: input.timestampSeconds,
|
||||
parentId: input.parentId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return c.json({ comment }, 201);
|
||||
},
|
||||
)
|
||||
|
||||
// Update comment
|
||||
.patch('/:id', zValidator('json', updateCommentSchema), async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const commentId = c.req.param('id');
|
||||
const input = c.req.valid('json');
|
||||
|
||||
const [comment] = await db
|
||||
.select()
|
||||
.from(comments)
|
||||
.where(and(eq(comments.id, commentId), eq(comments.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!comment) return c.json({ error: 'Not found' }, 404);
|
||||
|
||||
const [updated] = await db
|
||||
.update(comments)
|
||||
.set({ body: input.body, updatedAt: new Date() })
|
||||
.where(eq(comments.id, commentId))
|
||||
.returning();
|
||||
|
||||
return c.json({ comment: updated });
|
||||
})
|
||||
|
||||
// Delete comment
|
||||
.delete('/:id', 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(and(eq(comments.id, commentId), eq(comments.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!comment) return c.json({ error: 'Not found' }, 404);
|
||||
|
||||
await db.delete(comments).where(eq(comments.id, commentId));
|
||||
return c.json({ message: 'Comment deleted' });
|
||||
})
|
||||
|
||||
// Resolve comment
|
||||
.post('/:id/resolve', async (c) => {
|
||||
const db = c.get('db');
|
||||
const commentId = c.req.param('id');
|
||||
|
||||
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 });
|
||||
});
|
||||
294
apps/api/src/routes/projects.ts
Normal file
294
apps/api/src/routes/projects.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import {
|
||||
createProjectSchema,
|
||||
updateProjectSchema,
|
||||
inviteMemberSchema,
|
||||
updateMemberSchema,
|
||||
} from '@music-hub/shared';
|
||||
import { projects, projectMembers, users } from '@music-hub/db';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import type { AppEnv } from '../types.js';
|
||||
|
||||
export const projectRoutes = new Hono<AppEnv>()
|
||||
.use('*', requireAuth)
|
||||
|
||||
.get('/', async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
|
||||
const memberships = await db
|
||||
.select({
|
||||
project: projects,
|
||||
role: projectMembers.role,
|
||||
})
|
||||
.from(projectMembers)
|
||||
.innerJoin(projects, eq(projects.id, projectMembers.projectId))
|
||||
.where(and(eq(projectMembers.userId, userId), eq(projects.isArchived, false)));
|
||||
|
||||
return c.json({ projects: memberships });
|
||||
})
|
||||
|
||||
.post('/', zValidator('json', createProjectSchema), async (c) => {
|
||||
const input = c.req.valid('json');
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
|
||||
const [project] = await db
|
||||
.insert(projects)
|
||||
.values({ ...input, createdById: userId })
|
||||
.returning();
|
||||
|
||||
await db.insert(projectMembers).values({
|
||||
projectId: project.id,
|
||||
userId,
|
||||
role: 'owner',
|
||||
canUpload: true,
|
||||
canComment: true,
|
||||
canApprove: true,
|
||||
});
|
||||
|
||||
return c.json({ project }, 201);
|
||||
})
|
||||
|
||||
.get('/:id', async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const projectId = c.req.param('id');
|
||||
|
||||
const [membership] = await db
|
||||
.select()
|
||||
.from(projectMembers)
|
||||
.where(and(eq(projectMembers.projectId, projectId), eq(projectMembers.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!membership) {
|
||||
return c.json({ error: 'Not found' }, 404);
|
||||
}
|
||||
|
||||
const [project] = await db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(eq(projects.id, projectId))
|
||||
.limit(1);
|
||||
|
||||
return c.json({ project, role: membership.role });
|
||||
})
|
||||
|
||||
.patch('/:id', zValidator('json', updateProjectSchema), async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const projectId = c.req.param('id');
|
||||
const input = c.req.valid('json');
|
||||
|
||||
const [membership] = await db
|
||||
.select()
|
||||
.from(projectMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(projectMembers.projectId, projectId),
|
||||
eq(projectMembers.userId, userId),
|
||||
eq(projectMembers.role, 'owner'),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!membership) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
const [project] = await db
|
||||
.update(projects)
|
||||
.set({ ...input, updatedAt: new Date() })
|
||||
.where(eq(projects.id, projectId))
|
||||
.returning();
|
||||
|
||||
return c.json({ project });
|
||||
})
|
||||
|
||||
.delete('/:id', async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const projectId = c.req.param('id');
|
||||
|
||||
const [membership] = await db
|
||||
.select()
|
||||
.from(projectMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(projectMembers.projectId, projectId),
|
||||
eq(projectMembers.userId, userId),
|
||||
eq(projectMembers.role, 'owner'),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!membership) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
await db
|
||||
.update(projects)
|
||||
.set({ isArchived: true, updatedAt: new Date() })
|
||||
.where(eq(projects.id, projectId));
|
||||
|
||||
return c.json({ message: 'Project archived' });
|
||||
})
|
||||
|
||||
// Members
|
||||
.get('/:id/members', async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const projectId = c.req.param('id');
|
||||
|
||||
// Check access
|
||||
const [membership] = await db
|
||||
.select()
|
||||
.from(projectMembers)
|
||||
.where(and(eq(projectMembers.projectId, projectId), eq(projectMembers.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!membership) {
|
||||
return c.json({ error: 'Not found' }, 404);
|
||||
}
|
||||
|
||||
const members = await db
|
||||
.select({
|
||||
id: projectMembers.id,
|
||||
role: projectMembers.role,
|
||||
canUpload: projectMembers.canUpload,
|
||||
canComment: projectMembers.canComment,
|
||||
canApprove: projectMembers.canApprove,
|
||||
user: {
|
||||
id: users.id,
|
||||
email: users.email,
|
||||
name: users.name,
|
||||
avatarUrl: users.avatarUrl,
|
||||
},
|
||||
})
|
||||
.from(projectMembers)
|
||||
.innerJoin(users, eq(users.id, projectMembers.userId))
|
||||
.where(eq(projectMembers.projectId, projectId));
|
||||
|
||||
return c.json({ members });
|
||||
})
|
||||
|
||||
.post('/:id/members', zValidator('json', inviteMemberSchema), async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const projectId = c.req.param('id');
|
||||
const { email, role } = c.req.valid('json');
|
||||
|
||||
// Check permission (owner or management)
|
||||
const [membership] = await db
|
||||
.select()
|
||||
.from(projectMembers)
|
||||
.where(and(eq(projectMembers.projectId, projectId), eq(projectMembers.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!membership || (membership.role !== 'owner' && membership.role !== 'management')) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
// Find or create user
|
||||
let [invitedUser] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.limit(1);
|
||||
|
||||
if (!invitedUser) {
|
||||
[invitedUser] = await db
|
||||
.insert(users)
|
||||
.values({ email, name: email.split('@')[0] })
|
||||
.returning();
|
||||
}
|
||||
|
||||
const defaults = getRoleDefaults(role);
|
||||
const [member] = await db
|
||||
.insert(projectMembers)
|
||||
.values({
|
||||
projectId,
|
||||
userId: invitedUser.id,
|
||||
role,
|
||||
...defaults,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
|
||||
if (!member) {
|
||||
return c.json({ error: 'User already a member' }, 409);
|
||||
}
|
||||
|
||||
return c.json({ member }, 201);
|
||||
})
|
||||
|
||||
.patch('/:id/members/:memberId', zValidator('json', updateMemberSchema), async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const projectId = c.req.param('id');
|
||||
const memberId = c.req.param('memberId');
|
||||
const { role: newRole } = c.req.valid('json');
|
||||
|
||||
const [membership] = await db
|
||||
.select()
|
||||
.from(projectMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(projectMembers.projectId, projectId),
|
||||
eq(projectMembers.userId, userId),
|
||||
eq(projectMembers.role, 'owner'),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!membership) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
const defaults = getRoleDefaults(newRole);
|
||||
const [updated] = await db
|
||||
.update(projectMembers)
|
||||
.set({ role: newRole, ...defaults })
|
||||
.where(eq(projectMembers.id, memberId))
|
||||
.returning();
|
||||
|
||||
return c.json({ member: updated });
|
||||
})
|
||||
|
||||
.delete('/:id/members/:memberId', async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const projectId = c.req.param('id');
|
||||
const memberId = c.req.param('memberId');
|
||||
|
||||
const [membership] = await db
|
||||
.select()
|
||||
.from(projectMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(projectMembers.projectId, projectId),
|
||||
eq(projectMembers.userId, userId),
|
||||
eq(projectMembers.role, 'owner'),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!membership) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
await db.delete(projectMembers).where(eq(projectMembers.id, memberId));
|
||||
return c.json({ message: 'Member removed' });
|
||||
});
|
||||
|
||||
function getRoleDefaults(role: string) {
|
||||
const engineerRoles = ['recording_engineer', 'mixing_engineer', 'mastering_engineer'];
|
||||
if (role === 'owner') return { canUpload: true, canComment: true, canApprove: true };
|
||||
if (engineerRoles.includes(role)) return { canUpload: true, canComment: true, canApprove: false };
|
||||
if (role === 'artist') return { canUpload: false, canComment: true, canApprove: true };
|
||||
if (role === 'label') return { canUpload: false, canComment: true, canApprove: true };
|
||||
if (role === 'management') return { canUpload: false, canComment: true, canApprove: true };
|
||||
return { canUpload: false, canComment: false, canApprove: false }; // viewer
|
||||
}
|
||||
123
apps/api/src/routes/tracks.ts
Normal file
123
apps/api/src/routes/tracks.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { eq, and, asc } from 'drizzle-orm';
|
||||
import { createTrackSchema, updateTrackSchema } from '@music-hub/shared';
|
||||
import { tracks, projectMembers } from '@music-hub/db';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import type { AppEnv } from '../types.js';
|
||||
|
||||
export const trackRoutes = new Hono<AppEnv>()
|
||||
.use('*', requireAuth)
|
||||
|
||||
// Get all tracks for a project
|
||||
.get('/project/:projectId', async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const projectId = c.req.param('projectId');
|
||||
|
||||
const [membership] = await db
|
||||
.select()
|
||||
.from(projectMembers)
|
||||
.where(and(eq(projectMembers.projectId, projectId), eq(projectMembers.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!membership) return c.json({ error: 'Not found' }, 404);
|
||||
|
||||
const projectTracks = await db
|
||||
.select()
|
||||
.from(tracks)
|
||||
.where(eq(tracks.projectId, projectId))
|
||||
.orderBy(asc(tracks.sortOrder), asc(tracks.createdAt));
|
||||
|
||||
return c.json({ tracks: projectTracks });
|
||||
})
|
||||
|
||||
.post('/:projectId', zValidator('json', createTrackSchema), async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const projectId = c.req.param('projectId');
|
||||
const input = c.req.valid('json');
|
||||
|
||||
const [membership] = await db
|
||||
.select()
|
||||
.from(projectMembers)
|
||||
.where(and(eq(projectMembers.projectId, projectId), eq(projectMembers.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!membership || !membership.canUpload) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
const [track] = await db
|
||||
.insert(tracks)
|
||||
.values({ ...input, projectId, createdById: userId })
|
||||
.returning();
|
||||
|
||||
return c.json({ track }, 201);
|
||||
})
|
||||
|
||||
.patch('/:id', zValidator('json', updateTrackSchema), async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const trackId = c.req.param('id');
|
||||
const input = c.req.valid('json');
|
||||
|
||||
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 [updated] = await db
|
||||
.update(tracks)
|
||||
.set({ ...input, updatedAt: new Date() })
|
||||
.where(eq(tracks.id, trackId))
|
||||
.returning();
|
||||
|
||||
return c.json({ track: updated });
|
||||
})
|
||||
|
||||
.delete('/:id', async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const trackId = c.req.param('id');
|
||||
|
||||
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),
|
||||
eq(projectMembers.role, 'owner'),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!membership) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
await db.delete(tracks).where(eq(tracks.id, trackId));
|
||||
return c.json({ message: 'Track deleted' });
|
||||
});
|
||||
251
apps/api/src/routes/versions.ts
Normal file
251
apps/api/src/routes/versions.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { eq, and, desc, sql } from 'drizzle-orm';
|
||||
import { requestUploadUrlSchema, createVersionSchema } 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';
|
||||
import { processVersion } from '../services/audio-processor.js';
|
||||
import type { AppEnv } from '../types.js';
|
||||
|
||||
export const versionRoutes = new Hono<AppEnv>()
|
||||
.use('*', requireAuth)
|
||||
|
||||
// Get all versions for a track
|
||||
.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: 'Not found' }, 404);
|
||||
|
||||
const trackVersions = await db
|
||||
.select()
|
||||
.from(versions)
|
||||
.where(eq(versions.trackId, trackId))
|
||||
.orderBy(desc(versions.versionNumber));
|
||||
|
||||
return c.json({ versions: trackVersions });
|
||||
})
|
||||
|
||||
// Request presigned upload URL
|
||||
.post(
|
||||
'/track/:trackId/upload-url',
|
||||
zValidator('json', requestUploadUrlSchema),
|
||||
async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const trackId = c.req.param('trackId');
|
||||
const { fileName, mimeType, fileSize } = c.req.valid('json');
|
||||
|
||||
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 versionId = crypto.randomUUID();
|
||||
const fileKey = `projects/${track.projectId}/tracks/${trackId}/versions/${versionId}/original/${fileName}`;
|
||||
|
||||
const uploadUrl = await createUploadUrl(fileKey, mimeType, fileSize);
|
||||
|
||||
return c.json({ uploadUrl, fileKey, versionId });
|
||||
},
|
||||
)
|
||||
|
||||
// Register version after upload
|
||||
.post('/track/:trackId', zValidator('json', createVersionSchema), async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const trackId = c.req.param('trackId');
|
||||
const input = c.req.valid('json');
|
||||
|
||||
const [track] = await db.select().from(tracks).where(eq(tracks.id, trackId)).limit(1);
|
||||
if (!track) return c.json({ error: 'Not found' }, 404);
|
||||
|
||||
// Get next version number
|
||||
const [latest] = await db
|
||||
.select({ maxVersion: sql<number>`coalesce(max(${versions.versionNumber}), 0)` })
|
||||
.from(versions)
|
||||
.where(eq(versions.trackId, trackId));
|
||||
|
||||
const versionNumber = (latest?.maxVersion ?? 0) + 1;
|
||||
|
||||
const [version] = await db
|
||||
.insert(versions)
|
||||
.values({
|
||||
trackId,
|
||||
versionNumber,
|
||||
label: input.label,
|
||||
notes: input.notes,
|
||||
status: 'uploaded',
|
||||
originalFileName: input.originalFileName,
|
||||
mimeType: input.mimeType,
|
||||
fileSize: input.fileSize,
|
||||
originalFileKey: input.fileKey,
|
||||
createdById: userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Background processing (fire and forget)
|
||||
processVersion(db, version.id).catch((err) =>
|
||||
console.error(`[Worker] Failed: ${err.message}`),
|
||||
);
|
||||
|
||||
return c.json({ version }, 201);
|
||||
})
|
||||
|
||||
// Get stream URL
|
||||
.get('/:id/stream-url', async (c) => {
|
||||
const db = c.get('db');
|
||||
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 key = version.streamFileKey || version.originalFileKey;
|
||||
const url = await createDownloadUrl(key);
|
||||
|
||||
return c.json({ url });
|
||||
})
|
||||
|
||||
// Get download URL
|
||||
.get('/:id/download-url', async (c) => {
|
||||
const db = c.get('db');
|
||||
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 url = await createDownloadUrl(version.originalFileKey);
|
||||
return c.json({ url });
|
||||
})
|
||||
|
||||
// Get waveform data
|
||||
.get('/:id/waveform', async (c) => {
|
||||
const db = c.get('db');
|
||||
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 url = await createDownloadUrl(version.waveformDataKey);
|
||||
return c.json({ url });
|
||||
})
|
||||
|
||||
// Approve version
|
||||
.post('/:id/approve', 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.canApprove) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(versions)
|
||||
.set({ status: 'approved' })
|
||||
.where(eq(versions.id, versionId))
|
||||
.returning();
|
||||
|
||||
return c.json({ version: updated });
|
||||
})
|
||||
|
||||
// Reject version
|
||||
.post('/:id/reject', 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.canApprove) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(versions)
|
||||
.set({ status: 'rejected' })
|
||||
.where(eq(versions.id, versionId))
|
||||
.returning();
|
||||
|
||||
return c.json({ version: updated });
|
||||
});
|
||||
159
apps/api/src/services/audio-processor.ts
Normal file
159
apps/api/src/services/audio-processor.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { versions } from '@music-hub/db';
|
||||
import { createUploadUrl, createDownloadUrl } from '../storage/s3.js';
|
||||
import type { Database } from '@music-hub/db';
|
||||
|
||||
export async function processVersion(db: Database, versionId: string) {
|
||||
console.log(`[Worker] Processing version ${versionId}`);
|
||||
|
||||
// Mark as processing
|
||||
const [version] = await db
|
||||
.update(versions)
|
||||
.set({ status: 'processing' })
|
||||
.where(eq(versions.id, versionId))
|
||||
.returning();
|
||||
|
||||
if (!version) {
|
||||
console.error(`[Worker] Version ${versionId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const originalUrl = await createDownloadUrl(version.originalFileKey, 3600);
|
||||
|
||||
// Extract metadata with ffprobe
|
||||
const metadata = await extractMetadata(originalUrl);
|
||||
|
||||
// Generate waveform peaks
|
||||
const peaks = await generateWaveformPeaks(originalUrl, metadata.duration);
|
||||
const waveformKey = version.originalFileKey.replace(/\/original\/.*$/, '/waveform/peaks.json');
|
||||
|
||||
// Upload waveform data to S3
|
||||
const waveformUploadUrl = await createUploadUrl(waveformKey, 'application/json', 10 * 1024 * 1024);
|
||||
await fetch(waveformUploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(peaks),
|
||||
});
|
||||
|
||||
// Transcode to MP3 for streaming
|
||||
const streamKey = version.originalFileKey.replace(/\/original\/.*$/, '/stream/audio.mp3');
|
||||
await transcodeToMp3(originalUrl, streamKey);
|
||||
|
||||
// Update version with metadata
|
||||
await db
|
||||
.update(versions)
|
||||
.set({
|
||||
status: 'ready',
|
||||
duration: metadata.duration,
|
||||
sampleRate: metadata.sampleRate,
|
||||
bitDepth: metadata.bitDepth,
|
||||
streamFileKey: streamKey,
|
||||
waveformDataKey: waveformKey,
|
||||
})
|
||||
.where(eq(versions.id, versionId));
|
||||
|
||||
console.log(`[Worker] Version ${versionId} ready`);
|
||||
} catch (error) {
|
||||
console.error(`[Worker] Failed to process version ${versionId}:`, error);
|
||||
// Still mark as ready so user can listen to original
|
||||
await db
|
||||
.update(versions)
|
||||
.set({ status: 'ready' })
|
||||
.where(eq(versions.id, versionId));
|
||||
}
|
||||
}
|
||||
|
||||
async function extractMetadata(url: string): Promise<{
|
||||
duration: number;
|
||||
sampleRate: number;
|
||||
bitDepth: number;
|
||||
}> {
|
||||
const proc = Bun.spawn([
|
||||
'ffprobe',
|
||||
'-v', 'quiet',
|
||||
'-print_format', 'json',
|
||||
'-show_format',
|
||||
'-show_streams',
|
||||
url,
|
||||
]);
|
||||
|
||||
const output = await new Response(proc.stdout).text();
|
||||
await proc.exited;
|
||||
|
||||
const data = JSON.parse(output);
|
||||
const audioStream = data.streams?.find((s: any) => s.codec_type === 'audio');
|
||||
|
||||
return {
|
||||
duration: parseFloat(data.format?.duration || '0'),
|
||||
sampleRate: parseInt(audioStream?.sample_rate || '44100'),
|
||||
bitDepth: parseInt(audioStream?.bits_per_raw_sample || audioStream?.bits_per_sample || '16'),
|
||||
};
|
||||
}
|
||||
|
||||
async function generateWaveformPeaks(url: string, duration: number): Promise<number[]> {
|
||||
// Generate raw PCM samples with ffmpeg, then compute peaks
|
||||
const samplesPerPixel = Math.max(1, Math.floor(duration * 44100 / 800)); // ~800 peaks
|
||||
|
||||
const proc = Bun.spawn([
|
||||
'ffmpeg',
|
||||
'-i', url,
|
||||
'-ac', '1', // mono
|
||||
'-ar', '8000', // low sample rate for peaks
|
||||
'-f', 'f32le', // raw 32-bit float
|
||||
'-v', 'quiet',
|
||||
'pipe:1',
|
||||
]);
|
||||
|
||||
const buffer = await new Response(proc.stdout).arrayBuffer();
|
||||
await proc.exited;
|
||||
|
||||
const samples = new Float32Array(buffer);
|
||||
const numPeaks = 800;
|
||||
const blockSize = Math.max(1, Math.floor(samples.length / numPeaks));
|
||||
const peaks: number[] = [];
|
||||
|
||||
for (let i = 0; i < numPeaks && i * blockSize < samples.length; i++) {
|
||||
let max = 0;
|
||||
const start = i * blockSize;
|
||||
const end = Math.min(start + blockSize, samples.length);
|
||||
for (let j = start; j < end; j++) {
|
||||
const abs = Math.abs(samples[j]);
|
||||
if (abs > max) max = abs;
|
||||
}
|
||||
peaks.push(Math.round(max * 1000) / 1000);
|
||||
}
|
||||
|
||||
return peaks;
|
||||
}
|
||||
|
||||
async function transcodeToMp3(inputUrl: string, outputKey: string) {
|
||||
// Transcode to temp file, then upload
|
||||
const tmpFile = `/tmp/musichub-${crypto.randomUUID()}.mp3`;
|
||||
|
||||
const proc = Bun.spawn([
|
||||
'ffmpeg',
|
||||
'-i', inputUrl,
|
||||
'-codec:a', 'libmp3lame',
|
||||
'-b:a', '128k',
|
||||
'-v', 'quiet',
|
||||
'-y',
|
||||
tmpFile,
|
||||
]);
|
||||
|
||||
await proc.exited;
|
||||
|
||||
// Upload to S3
|
||||
const file = Bun.file(tmpFile);
|
||||
const fileSize = file.size;
|
||||
const uploadUrl = await createUploadUrl(outputKey, 'audio/mpeg', fileSize);
|
||||
|
||||
await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'audio/mpeg' },
|
||||
body: file,
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
await Bun.file(tmpFile).exists() && (await Bun.spawn(['rm', tmpFile]).exited);
|
||||
}
|
||||
71
apps/api/src/services/email.ts
Normal file
71
apps/api/src/services/email.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Resend } from 'resend';
|
||||
|
||||
const resend = process.env.RESEND_API_KEY
|
||||
? new Resend(process.env.RESEND_API_KEY)
|
||||
: null;
|
||||
|
||||
const fromEmail = process.env.EMAIL_FROM || 'Music Hub <noreply@musichub.de>';
|
||||
|
||||
export async function sendMagicLinkEmail(email: string, token: string) {
|
||||
const url = `${process.env.APP_URL}/auth/verify?token=${token}`;
|
||||
|
||||
if (!resend) {
|
||||
console.log(`[DEV] Magic link for ${email}: ${url}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await resend.emails.send({
|
||||
from: fromEmail,
|
||||
to: email,
|
||||
subject: 'Your Music Hub Login Link',
|
||||
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>
|
||||
<a href="${url}" style="
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #6366f1;
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
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>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendInviteEmail(email: string, projectName: string, inviterName: string) {
|
||||
const url = `${process.env.APP_URL}`;
|
||||
|
||||
if (!resend) {
|
||||
console.log(`[DEV] Invite ${email} to project "${projectName}" by ${inviterName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await resend.emails.send({
|
||||
from: fromEmail,
|
||||
to: email,
|
||||
subject: `You've been invited to "${projectName}" on 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;">${inviterName} invited you to collaborate on <strong>"${projectName}"</strong>.</p>
|
||||
<a href="${url}" style="
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #6366f1;
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
margin: 1rem 0;
|
||||
">Open Music Hub</a>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
}
|
||||
49
apps/api/src/storage/s3.ts
Normal file
49
apps/api/src/storage/s3.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
GetObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
|
||||
const s3 = new S3Client({
|
||||
region: 'eu-central-1',
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
credentials: {
|
||||
accessKeyId: process.env.S3_ACCESS_KEY!,
|
||||
secretAccessKey: process.env.S3_SECRET_KEY!,
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
|
||||
const bucket = process.env.S3_BUCKET!;
|
||||
|
||||
export async function createUploadUrl(
|
||||
key: string,
|
||||
contentType: string,
|
||||
maxSize: number,
|
||||
): Promise<string> {
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
ContentLength: maxSize,
|
||||
});
|
||||
return getSignedUrl(s3, command, { expiresIn: 900 }); // 15 min
|
||||
}
|
||||
|
||||
export async function createDownloadUrl(key: string, expiresIn = 3600): Promise<string> {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
});
|
||||
return getSignedUrl(s3, command, { expiresIn });
|
||||
}
|
||||
|
||||
export async function deleteObject(key: string): Promise<void> {
|
||||
const command = new DeleteObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
});
|
||||
await s3.send(command);
|
||||
}
|
||||
8
apps/api/src/types.ts
Normal file
8
apps/api/src/types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { Database } from '@music-hub/db';
|
||||
|
||||
export type AppEnv = {
|
||||
Variables: {
|
||||
db: Database;
|
||||
userId: string;
|
||||
};
|
||||
};
|
||||
12
apps/api/tsconfig.json
Normal file
12
apps/api/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
23
apps/web/.gitignore
vendored
Normal file
23
apps/web/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
1
apps/web/.npmrc
Normal file
1
apps/web/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
3
apps/web/.vscode/extensions.json
vendored
Normal file
3
apps/web/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["svelte.svelte-vscode"]
|
||||
}
|
||||
42
apps/web/README.md
Normal file
42
apps/web/README.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# sv
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```sh
|
||||
# create a new project
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
To recreate this project with the same configuration:
|
||||
|
||||
```sh
|
||||
# recreate this project
|
||||
npx sv@0.13.1 create --template minimal --types ts --no-install apps/web
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
26
apps/web/package.json
Normal file
26
apps/web/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@music-hub/web",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@music-hub/shared": "workspace:*",
|
||||
"wavesurfer.js": "^7.12.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"svelte": "^5.54.0",
|
||||
"svelte-check": "^4.4.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
13
apps/web/src/app.d.ts
vendored
Normal file
13
apps/web/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
12
apps/web/src/app.html
Normal file
12
apps/web/src/app.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="text-scale" content="scale" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
38
apps/web/src/lib/api/client.ts
Normal file
38
apps/web/src/lib/api/client.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { toastError } from '$lib/stores/toast.js';
|
||||
|
||||
type FetchOptions = {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
silent?: boolean;
|
||||
};
|
||||
|
||||
async function request<T>(path: string, options: FetchOptions = {}): Promise<T> {
|
||||
const { method = 'GET', body, headers = {}, silent = false } = options;
|
||||
|
||||
const res = await fetch(`/api/v1${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
},
|
||||
credentials: 'include',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ error: res.statusText }));
|
||||
const message = error.error || 'Request failed';
|
||||
if (!silent) toastError(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
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' }),
|
||||
};
|
||||
1
apps/web/src/lib/assets/favicon.svg
Normal file
1
apps/web/src/lib/assets/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
161
apps/web/src/lib/components/audio/ABCompare.svelte
Normal file
161
apps/web/src/lib/components/audio/ABCompare.svelte
Normal file
@@ -0,0 +1,161 @@
|
||||
<script lang="ts">
|
||||
import WaveformPlayer from './WaveformPlayer.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
|
||||
let {
|
||||
versionA,
|
||||
versionB,
|
||||
streamUrlA,
|
||||
streamUrlB,
|
||||
onClose,
|
||||
}: {
|
||||
versionA: { versionNumber: number; label: string | null };
|
||||
versionB: { versionNumber: number; label: string | null };
|
||||
streamUrlA: string;
|
||||
streamUrlB: string;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
let playerA = $state<WaveformPlayer>();
|
||||
let playerB = $state<WaveformPlayer>();
|
||||
let activePlayer = $state<'A' | 'B'>('A');
|
||||
let syncing = false;
|
||||
|
||||
function handleSeekA(time: number) {
|
||||
if (syncing) return;
|
||||
syncing = true;
|
||||
playerB?.seekToTime(time);
|
||||
syncing = false;
|
||||
}
|
||||
|
||||
function handleSeekB(time: number) {
|
||||
if (syncing) return;
|
||||
syncing = true;
|
||||
playerA?.seekToTime(time);
|
||||
syncing = false;
|
||||
}
|
||||
|
||||
function switchTo(player: 'A' | 'B') {
|
||||
activePlayer = player;
|
||||
// Sync position, then play the active one
|
||||
if (player === 'A') {
|
||||
playerB?.pause();
|
||||
const time = playerB?.getCurrentTime() || 0;
|
||||
playerA?.seekToTime(time);
|
||||
playerA?.play();
|
||||
} else {
|
||||
playerA?.pause();
|
||||
const time = playerA?.getCurrentTime() || 0;
|
||||
playerB?.seekToTime(time);
|
||||
playerB?.play();
|
||||
}
|
||||
}
|
||||
|
||||
const labelA = $derived(`V${versionA.versionNumber}${versionA.label ? ' — ' + versionA.label : ''}`);
|
||||
const labelB = $derived(`V${versionB.versionNumber}${versionB.label ? ' — ' + versionB.label : ''}`);
|
||||
</script>
|
||||
|
||||
<div class="ab-compare">
|
||||
<div class="ab-header">
|
||||
<h2>A/B Compare</h2>
|
||||
<div class="ab-toggle">
|
||||
<button class="toggle-btn" class:active={activePlayer === 'A'} onclick={() => switchTo('A')}>A</button>
|
||||
<button class="toggle-btn" class:active={activePlayer === 'B'} onclick={() => switchTo('B')}>B</button>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onclick={onClose}>Close</Button>
|
||||
</div>
|
||||
|
||||
<div class="players">
|
||||
<div class="player-wrapper" class:active={activePlayer === 'A'}>
|
||||
<WaveformPlayer
|
||||
bind:this={playerA}
|
||||
url={streamUrlA}
|
||||
label="A — {labelA}"
|
||||
compact
|
||||
muted={activePlayer !== 'A'}
|
||||
onSeek={handleSeekA}
|
||||
/>
|
||||
</div>
|
||||
<div class="player-wrapper" class:active={activePlayer === 'B'}>
|
||||
<WaveformPlayer
|
||||
bind:this={playerB}
|
||||
url={streamUrlB}
|
||||
label="B — {labelB}"
|
||||
compact
|
||||
muted={activePlayer !== 'B'}
|
||||
onSeek={handleSeekB}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.ab-compare {
|
||||
background: var(--color-bg-overlay);
|
||||
border: 1px solid var(--color-accent);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.ab-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ab-toggle {
|
||||
display: flex;
|
||||
background: var(--color-bg-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: var(--text-sm);
|
||||
font-family: inherit;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.toggle-btn.active {
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.players {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.player-wrapper {
|
||||
opacity: 0.5;
|
||||
transition: opacity var(--transition-base);
|
||||
}
|
||||
|
||||
.player-wrapper.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.players {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.player-wrapper {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
246
apps/web/src/lib/components/audio/UploadDropzone.svelte
Normal file
246
apps/web/src/lib/components/audio/UploadDropzone.svelte
Normal file
@@ -0,0 +1,246 @@
|
||||
<script lang="ts">
|
||||
import { SUPPORTED_EXTENSIONS, MAX_FILE_SIZE } from '@music-hub/shared';
|
||||
import { api } from '$lib/api/client.js';
|
||||
|
||||
let {
|
||||
trackId,
|
||||
onUploaded,
|
||||
}: {
|
||||
trackId: string;
|
||||
onUploaded: () => void;
|
||||
} = $props();
|
||||
|
||||
let dragOver = $state(false);
|
||||
let uploading = $state(false);
|
||||
let progress = $state(0);
|
||||
let error = $state('');
|
||||
let label = $state('');
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragOver = true;
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
dragOver = false;
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragOver = false;
|
||||
const file = e.dataTransfer?.files[0];
|
||||
if (file) uploadFile(file);
|
||||
}
|
||||
|
||||
function handleFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (file) uploadFile(file);
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
async function uploadFile(file: File) {
|
||||
error = '';
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
error = 'File too large (max 500 MB)';
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
|
||||
if (!SUPPORTED_EXTENSIONS.includes(ext as any)) {
|
||||
error = `Unsupported format. Use: ${SUPPORTED_EXTENSIONS.join(', ')}`;
|
||||
return;
|
||||
}
|
||||
|
||||
uploading = true;
|
||||
progress = 0;
|
||||
|
||||
try {
|
||||
// 1. Get presigned upload URL
|
||||
const { uploadUrl, fileKey } = await api.post<{
|
||||
uploadUrl: string;
|
||||
fileKey: string;
|
||||
versionId: string;
|
||||
}>(`/versions/track/${trackId}/upload-url`, {
|
||||
fileName: file.name,
|
||||
mimeType: file.type || 'audio/wav',
|
||||
fileSize: file.size,
|
||||
});
|
||||
|
||||
// 2. Upload directly to S3
|
||||
await uploadWithProgress(uploadUrl, file);
|
||||
|
||||
// 3. Register version
|
||||
await api.post(`/versions/track/${trackId}`, {
|
||||
fileKey,
|
||||
label: label || undefined,
|
||||
originalFileName: file.name,
|
||||
mimeType: file.type || 'audio/wav',
|
||||
fileSize: file.size,
|
||||
});
|
||||
|
||||
label = '';
|
||||
onUploaded();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Upload failed';
|
||||
} finally {
|
||||
uploading = false;
|
||||
progress = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function uploadWithProgress(url: string, file: File): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('PUT', url);
|
||||
xhr.setRequestHeader('Content-Type', file.type || 'audio/wav');
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) {
|
||||
progress = Math.round((e.loaded / e.total) * 100);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve();
|
||||
else reject(new Error(`Upload failed: ${xhr.status}`));
|
||||
};
|
||||
|
||||
xhr.onerror = () => reject(new Error('Upload failed'));
|
||||
xhr.send(file);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="upload-section">
|
||||
<div class="label-input">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={label}
|
||||
placeholder="Version label (e.g. 'Mix V2', 'Master Final')"
|
||||
disabled={uploading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="dropzone"
|
||||
class:dragover={dragOver}
|
||||
class:uploading
|
||||
role="button"
|
||||
tabindex="0"
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={handleDrop}
|
||||
onclick={() => !uploading && document.getElementById(`file-input-${trackId}`)?.click()}
|
||||
onkeydown={(e) => e.key === 'Enter' && !uploading && document.getElementById(`file-input-${trackId}`)?.click()}
|
||||
>
|
||||
<input
|
||||
id="file-input-{trackId}"
|
||||
type="file"
|
||||
accept=".wav,.mp3,.flac,.aiff,.aif"
|
||||
onchange={handleFileSelect}
|
||||
hidden
|
||||
/>
|
||||
|
||||
{#if uploading}
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar" style="width: {progress}%"></div>
|
||||
<span class="progress-text">{progress}%</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="dropzone-content">
|
||||
<span class="dropzone-icon">🎵</span>
|
||||
<p>Drop audio file here or click to browse</p>
|
||||
<span class="formats">WAV, MP3, FLAC, AIFF — max 500 MB</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="error">{error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.upload-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.label-input input {
|
||||
width: 100%;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #333;
|
||||
background: #0a0a0a;
|
||||
color: #e0e0e0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dropzone {
|
||||
border: 2px dashed #333;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
.dropzone:hover,
|
||||
.dropzone.dragover {
|
||||
border-color: #6366f1;
|
||||
background: #1a1a2e;
|
||||
}
|
||||
|
||||
.dropzone.uploading {
|
||||
cursor: default;
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.dropzone-content p {
|
||||
margin: 0.5rem 0 0.25rem;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.dropzone-icon {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.formats {
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
position: relative;
|
||||
height: 40px;
|
||||
background: #222;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: #6366f1;
|
||||
transition: width 0.2s;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ef4444;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
281
apps/web/src/lib/components/audio/WaveformPlayer.svelte
Normal file
281
apps/web/src/lib/components/audio/WaveformPlayer.svelte
Normal file
@@ -0,0 +1,281 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import WaveSurfer from 'wavesurfer.js';
|
||||
import { formatTime } from '$lib/utils/format.js';
|
||||
|
||||
type CommentMarker = {
|
||||
id: string;
|
||||
timestampSeconds: number;
|
||||
body: string;
|
||||
userName: string;
|
||||
};
|
||||
|
||||
let {
|
||||
url,
|
||||
markers = [],
|
||||
muted = false,
|
||||
compact = false,
|
||||
label = '',
|
||||
onTimeClick,
|
||||
onReady,
|
||||
onSeek,
|
||||
}: {
|
||||
url: string;
|
||||
markers?: CommentMarker[];
|
||||
muted?: boolean;
|
||||
compact?: boolean;
|
||||
label?: string;
|
||||
onTimeClick?: (time: number) => void;
|
||||
onReady?: (duration: number) => void;
|
||||
onSeek?: (time: number) => void;
|
||||
} = $props();
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let ws: WaveSurfer | null = null;
|
||||
let isPlaying = $state(false);
|
||||
let currentTime = $state(0);
|
||||
let duration = $state(0);
|
||||
let volume = $state(0.8);
|
||||
|
||||
$effect(() => {
|
||||
if (ws) ws.setVolume(muted ? 0 : volume);
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
ws = WaveSurfer.create({
|
||||
container,
|
||||
waveColor: 'var(--color-bg-subtle, #4a4a5a)',
|
||||
progressColor: 'var(--color-accent, #6366f1)',
|
||||
cursorColor: '#818cf8',
|
||||
cursorWidth: 2,
|
||||
barWidth: 2,
|
||||
barGap: 1,
|
||||
barRadius: 2,
|
||||
height: compact ? 48 : 80,
|
||||
normalize: true,
|
||||
url,
|
||||
});
|
||||
|
||||
ws.on('ready', () => {
|
||||
duration = ws!.getDuration();
|
||||
ws!.setVolume(muted ? 0 : volume);
|
||||
onReady?.(duration);
|
||||
});
|
||||
|
||||
ws.on('timeupdate', (time) => {
|
||||
currentTime = time;
|
||||
onSeek?.(time);
|
||||
});
|
||||
|
||||
ws.on('play', () => (isPlaying = true));
|
||||
ws.on('pause', () => (isPlaying = false));
|
||||
|
||||
ws.on('click', (relativeX) => {
|
||||
if (onTimeClick) {
|
||||
const clickedTime = relativeX * (ws?.getDuration() || 0);
|
||||
onTimeClick(clickedTime);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
ws?.destroy();
|
||||
});
|
||||
|
||||
function togglePlay() {
|
||||
ws?.playPause();
|
||||
}
|
||||
|
||||
function play() { ws?.play(); }
|
||||
function pause() { ws?.pause(); }
|
||||
|
||||
function skip(seconds: number) {
|
||||
if (!ws) return;
|
||||
ws.setTime(Math.max(0, Math.min(ws.getDuration(), ws.getCurrentTime() + seconds)));
|
||||
}
|
||||
|
||||
function setVol(v: number) {
|
||||
volume = v;
|
||||
if (!muted) ws?.setVolume(v);
|
||||
}
|
||||
|
||||
function seekToTime(time: number) {
|
||||
ws?.setTime(time);
|
||||
}
|
||||
|
||||
function getCurrentTime(): number {
|
||||
return ws?.getCurrentTime() || 0;
|
||||
}
|
||||
|
||||
export { seekToTime, play, pause, getCurrentTime };
|
||||
</script>
|
||||
|
||||
<div class="waveform-player" class:compact>
|
||||
{#if label}
|
||||
<span class="player-label">{label}</span>
|
||||
{/if}
|
||||
|
||||
<div class="waveform-container">
|
||||
<div bind:this={container} class="waveform"></div>
|
||||
|
||||
{#if duration > 0 && markers.length > 0}
|
||||
<div class="markers">
|
||||
{#each markers as marker}
|
||||
<button
|
||||
class="marker"
|
||||
style="left: {(marker.timestampSeconds / duration) * 100}%"
|
||||
title="{marker.userName}: {marker.body}"
|
||||
onclick={() => seekToTime(marker.timestampSeconds)}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="controls-left">
|
||||
{#if !compact}
|
||||
<button class="control-btn" onclick={() => skip(-10)} title="Back 10s">⏪</button>
|
||||
{/if}
|
||||
<button class="control-btn play-btn" onclick={togglePlay}>
|
||||
{isPlaying ? '⏸' : '▶'}
|
||||
</button>
|
||||
{#if !compact}
|
||||
<button class="control-btn" onclick={() => skip(10)} title="Forward 10s">⏩</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="time">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</div>
|
||||
|
||||
{#if !compact}
|
||||
<div class="controls-right">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={volume}
|
||||
oninput={(e) => setVol(Number((e.target as HTMLInputElement).value))}
|
||||
class="volume-slider"
|
||||
title="Volume"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.waveform-player {
|
||||
background: var(--color-bg-overlay);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.waveform-player.compact {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.player-label {
|
||||
display: block;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.waveform-container {
|
||||
position: relative;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.waveform {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.markers {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.marker {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 8px;
|
||||
height: 100%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(251, 191, 36, 0.2);
|
||||
border: none;
|
||||
border-left: 2px solid var(--color-warning);
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
padding: 0;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.marker:hover {
|
||||
background: rgba(251, 191, 36, 0.4);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.controls-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
font-size: 1.3rem;
|
||||
padding: var(--space-1) var(--space-3);
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.play-btn:hover {
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.controls-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
width: 80px;
|
||||
accent-color: var(--color-accent);
|
||||
}
|
||||
</style>
|
||||
58
apps/web/src/lib/components/ui/Avatar.svelte
Normal file
58
apps/web/src/lib/components/ui/Avatar.svelte
Normal file
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
src = null,
|
||||
name,
|
||||
size = 'md',
|
||||
}: {
|
||||
src?: string | null;
|
||||
name: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
} = $props();
|
||||
|
||||
const initials = $derived(
|
||||
name
|
||||
.split(' ')
|
||||
.map((w) => w[0])
|
||||
.slice(0, 2)
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
);
|
||||
|
||||
const colors = [
|
||||
'#6366f1', '#8b5cf6', '#ec4899', '#f43f5e',
|
||||
'#f97316', '#eab308', '#22c55e', '#06b6d4',
|
||||
];
|
||||
|
||||
const color = $derived(colors[name.charCodeAt(0) % colors.length]);
|
||||
</script>
|
||||
|
||||
<div class="avatar {size}" style:background={src ? 'none' : color}>
|
||||
{#if src}
|
||||
<img {src} alt={name} />
|
||||
{:else}
|
||||
<span>{initials}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-full);
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sm { width: 24px; height: 24px; font-size: 0.6rem; }
|
||||
.md { width: 32px; height: 32px; font-size: 0.7rem; }
|
||||
.lg { width: 40px; height: 40px; font-size: 0.85rem; }
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
</style>
|
||||
52
apps/web/src/lib/components/ui/Badge.svelte
Normal file
52
apps/web/src/lib/components/ui/Badge.svelte
Normal file
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
variant = 'default',
|
||||
children,
|
||||
}: {
|
||||
variant?: 'default' | 'success' | 'warning' | 'error' | 'accent';
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<span class="badge {variant}">
|
||||
{@render children()}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.default {
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.success {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: rgba(251, 191, 36, 0.15);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.error {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.accent {
|
||||
background: var(--color-accent-subtle);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
</style>
|
||||
129
apps/web/src/lib/components/ui/Button.svelte
Normal file
129
apps/web/src/lib/components/ui/Button.svelte
Normal file
@@ -0,0 +1,129 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
disabled = false,
|
||||
href,
|
||||
type = 'button',
|
||||
onclick,
|
||||
children,
|
||||
}: {
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
href?: string;
|
||||
type?: 'button' | 'submit';
|
||||
onclick?: (e: MouseEvent) => void;
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a {href} class="btn {variant} {size}" class:disabled>
|
||||
{@render children()}
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
{type}
|
||||
class="btn {variant} {size}"
|
||||
disabled={disabled || loading}
|
||||
{onclick}
|
||||
>
|
||||
{#if loading}
|
||||
<span class="spinner"></span>
|
||||
{/if}
|
||||
<span class:hidden={loading}>
|
||||
{@render children()}
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
border-radius: var(--radius-md);
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.btn:disabled, .btn.disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Sizes */
|
||||
.sm { padding: 0.3rem 0.6rem; font-size: var(--text-xs); }
|
||||
.md { padding: 0.5rem 1rem; font-size: var(--text-sm); }
|
||||
.lg { padding: 0.65rem 1.25rem; font-size: var(--text-base); }
|
||||
|
||||
/* Variants */
|
||||
.primary {
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
.primary:hover:not(:disabled) {
|
||||
background: var(--color-accent-hover);
|
||||
border-color: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-secondary);
|
||||
border-color: var(--color-border-hover);
|
||||
}
|
||||
.secondary:hover:not(:disabled) {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.ghost {
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.ghost:hover:not(:disabled) {
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.danger {
|
||||
background: transparent;
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
.danger:hover:not(:disabled) {
|
||||
background: var(--color-error);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
57
apps/web/src/lib/components/ui/EmptyState.svelte
Normal file
57
apps/web/src/lib/components/ui/EmptyState.svelte
Normal file
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
icon = '📁',
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
icon?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="empty-state">
|
||||
<span class="icon">{icon}</span>
|
||||
<h3>{title}</h3>
|
||||
{#if description}
|
||||
<p>{description}</p>
|
||||
{/if}
|
||||
{#if action}
|
||||
<div class="action">
|
||||
{@render action()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: var(--space-12) var(--space-4);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 2.5rem;
|
||||
display: block;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.action {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
</style>
|
||||
80
apps/web/src/lib/components/ui/Input.svelte
Normal file
80
apps/web/src/lib/components/ui/Input.svelte
Normal file
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
type = 'text',
|
||||
value = $bindable(''),
|
||||
placeholder = '',
|
||||
label,
|
||||
error,
|
||||
disabled = false,
|
||||
autofocus = false,
|
||||
}: {
|
||||
type?: 'text' | 'email' | 'password';
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
label?: string;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
autofocus?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="input-group">
|
||||
{#if label}
|
||||
<label class="input-label">{label}</label>
|
||||
{/if}
|
||||
<input
|
||||
{type}
|
||||
bind:value
|
||||
{placeholder}
|
||||
{disabled}
|
||||
{autofocus}
|
||||
class:has-error={!!error}
|
||||
/>
|
||||
{#if error}
|
||||
<span class="input-error">{error}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.input-label {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
input {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border-hover);
|
||||
background: var(--color-bg-base);
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--text-base);
|
||||
font-family: inherit;
|
||||
transition: border-color var(--transition-fast);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-border-focus);
|
||||
}
|
||||
|
||||
input:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
input.has-error {
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.input-error {
|
||||
color: var(--color-error);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
</style>
|
||||
108
apps/web/src/lib/components/ui/Modal.svelte
Normal file
108
apps/web/src/lib/components/ui/Modal.svelte
Normal file
@@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
title,
|
||||
children,
|
||||
actions,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
children: Snippet;
|
||||
actions?: Snippet;
|
||||
} = $props();
|
||||
|
||||
function handleBackdrop(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) open = false;
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={open ? handleKeydown : undefined} />
|
||||
|
||||
{#if open}
|
||||
<div class="backdrop" onclick={handleBackdrop} role="dialog" aria-modal="true">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h2>{title}</h2>
|
||||
<button class="close-btn" onclick={() => open = false}>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{@render children()}
|
||||
</div>
|
||||
{#if actions}
|
||||
<div class="modal-actions">
|
||||
{@render actions()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: var(--z-modal);
|
||||
padding: var(--space-4);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--color-bg-overlay);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-5) var(--space-6);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4) var(--space-6);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
</style>
|
||||
36
apps/web/src/lib/components/ui/Skeleton.svelte
Normal file
36
apps/web/src/lib/components/ui/Skeleton.svelte
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
width = '100%',
|
||||
height = '1rem',
|
||||
variant = 'text',
|
||||
}: {
|
||||
width?: string;
|
||||
height?: string;
|
||||
variant?: 'text' | 'circle' | 'rect';
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="skeleton {variant}"
|
||||
style:width
|
||||
style:height
|
||||
style:border-radius={variant === 'circle' ? '50%' : variant === 'rect' ? 'var(--radius-md)' : 'var(--radius-sm)'}
|
||||
></div>
|
||||
|
||||
<style>
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-bg-subtle) 25%,
|
||||
var(--color-border) 50%,
|
||||
var(--color-bg-subtle) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
</style>
|
||||
104
apps/web/src/lib/components/ui/ToastContainer.svelte
Normal file
104
apps/web/src/lib/components/ui/ToastContainer.svelte
Normal file
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import { toasts, removeToast, type ToastType } from '$lib/stores/toast.js';
|
||||
|
||||
const icons: Record<ToastType, string> = {
|
||||
success: '✓',
|
||||
error: '✕',
|
||||
info: 'i',
|
||||
warning: '!',
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if $toasts.length > 0}
|
||||
<div class="toast-container">
|
||||
{#each $toasts as t (t.id)}
|
||||
<div class="toast {t.type}" role="alert">
|
||||
<span class="toast-icon">{icons[t.type]}</span>
|
||||
<span class="toast-message">{t.message}</span>
|
||||
<button class="toast-close" onclick={() => removeToast(t.id)}>×</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: var(--space-6);
|
||||
right: var(--space-6);
|
||||
z-index: var(--z-toast);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--color-bg-overlay);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
animation: slide-in 0.2s ease;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.success .toast-icon { background: var(--color-success); color: #000; }
|
||||
.error .toast-icon { background: var(--color-error); color: #fff; }
|
||||
.info .toast-icon { background: var(--color-accent); color: #fff; }
|
||||
.warning .toast-icon { background: var(--color-warning); color: #000; }
|
||||
|
||||
.toast-message {
|
||||
flex: 1;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-tertiary);
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.toast-close:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
@keyframes slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.toast-container {
|
||||
left: var(--space-4);
|
||||
right: var(--space-4);
|
||||
bottom: var(--space-4);
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1
apps/web/src/lib/index.ts
Normal file
1
apps/web/src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
38
apps/web/src/lib/stores/auth.ts
Normal file
38
apps/web/src/lib/stores/auth.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { api } from '$lib/api/client.js';
|
||||
|
||||
type User = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
} | null;
|
||||
|
||||
export const user = writable<User>(null);
|
||||
export const authLoading = writable(true);
|
||||
|
||||
export async function checkAuth() {
|
||||
try {
|
||||
const res = await api.get<{ user: User }>('/auth/me', true);
|
||||
user.set(res.user);
|
||||
} catch {
|
||||
user.set(null);
|
||||
} finally {
|
||||
authLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendMagicLink(email: string) {
|
||||
return api.post('/auth/magic-link', { email });
|
||||
}
|
||||
|
||||
export async function verifyToken(token: string) {
|
||||
const res = await api.post<{ user: User }>('/auth/verify', { token });
|
||||
user.set(res.user);
|
||||
return res.user;
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
await api.post('/auth/logout');
|
||||
user.set(null);
|
||||
}
|
||||
24
apps/web/src/lib/stores/toast.ts
Normal file
24
apps/web/src/lib/stores/toast.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'info' | 'warning';
|
||||
|
||||
type Toast = {
|
||||
id: string;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
};
|
||||
|
||||
export const toasts = writable<Toast[]>([]);
|
||||
|
||||
export function toast(message: string, type: ToastType = 'info', duration = 4000) {
|
||||
const id = crypto.randomUUID();
|
||||
toasts.update((t) => [...t, { id, message, type }]);
|
||||
setTimeout(() => removeToast(id), duration);
|
||||
}
|
||||
|
||||
export function removeToast(id: string) {
|
||||
toasts.update((t) => t.filter((x) => x.id !== id));
|
||||
}
|
||||
|
||||
export const toastSuccess = (msg: string) => toast(msg, 'success');
|
||||
export const toastError = (msg: string) => toast(msg, 'error', 6000);
|
||||
29
apps/web/src/lib/utils/format.ts
Normal file
29
apps/web/src/lib/utils/format.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export function formatTime(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function timeAgo(dateStr: string): string {
|
||||
const now = Date.now();
|
||||
const then = new Date(dateStr).getTime();
|
||||
const diff = now - then;
|
||||
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
if (seconds < 60) return 'just now';
|
||||
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days}d ago`;
|
||||
|
||||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: '2-digit',
|
||||
});
|
||||
}
|
||||
144
apps/web/src/routes/+layout.svelte
Normal file
144
apps/web/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,144 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { checkAuth, user, authLoading } from '$lib/stores/auth.js';
|
||||
import ToastContainer from '$lib/components/ui/ToastContainer.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
onMount(() => {
|
||||
checkAuth();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Music Hub</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</svelte:head>
|
||||
|
||||
{#if $authLoading}
|
||||
<div class="loading">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
{:else}
|
||||
{@render children()}
|
||||
{/if}
|
||||
|
||||
<ToastContainer />
|
||||
|
||||
<style>
|
||||
:global(:root) {
|
||||
/* Background */
|
||||
--color-bg-base: #0a0a0a;
|
||||
--color-bg-raised: #111111;
|
||||
--color-bg-overlay: #1a1a1a;
|
||||
--color-bg-subtle: #222222;
|
||||
|
||||
/* Borders */
|
||||
--color-border: #2a2a2a;
|
||||
--color-border-hover: #333333;
|
||||
--color-border-focus: #6366f1;
|
||||
|
||||
/* Text */
|
||||
--color-text-primary: #f0f0f0;
|
||||
--color-text-secondary: #a0a0a0;
|
||||
--color-text-tertiary: #666666;
|
||||
|
||||
/* Accent */
|
||||
--color-accent: #6366f1;
|
||||
--color-accent-hover: #5558e6;
|
||||
--color-accent-subtle: #1a1a2e;
|
||||
|
||||
/* Semantic */
|
||||
--color-success: #22c55e;
|
||||
--color-warning: #fbbf24;
|
||||
--color-error: #ef4444;
|
||||
|
||||
/* Spacing */
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
--space-5: 1.25rem;
|
||||
--space-6: 1.5rem;
|
||||
--space-8: 2rem;
|
||||
--space-10: 2.5rem;
|
||||
--space-12: 3rem;
|
||||
|
||||
/* Radii */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.6);
|
||||
|
||||
/* Typography */
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-mono: 'SF Mono', 'Fira Code', monospace;
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.85rem;
|
||||
--text-base: 0.9rem;
|
||||
--text-lg: 1.1rem;
|
||||
--text-xl: 1.5rem;
|
||||
--text-2xl: 2rem;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-base: 200ms ease;
|
||||
|
||||
/* Z-Index */
|
||||
--z-dropdown: 100;
|
||||
--z-modal: 200;
|
||||
--z-toast: 300;
|
||||
}
|
||||
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
background: var(--color-bg-base);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
:global(*) {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:global(h1, h2, h3) {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
:global(a) {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
:global(a:hover) {
|
||||
color: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid var(--color-border);
|
||||
border-top-color: var(--color-accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
89
apps/web/src/routes/+page.svelte
Normal file
89
apps/web/src/routes/+page.svelte
Normal file
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { user, sendMagicLink } from '$lib/stores/auth.js';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
|
||||
let email = $state('');
|
||||
let sent = $state(false);
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if ($user) goto('/dashboard');
|
||||
});
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
loading = true;
|
||||
try {
|
||||
await sendMagicLink(email);
|
||||
sent = true;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Something went wrong';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="login-page">
|
||||
<div class="login-card">
|
||||
<h1>Music Hub</h1>
|
||||
<p class="subtitle">Collaboration for music production</p>
|
||||
|
||||
{#if sent}
|
||||
<div class="success">
|
||||
<p>Check your email for the login link.</p>
|
||||
<Button variant="secondary" onclick={() => { sent = false; email = ''; }}>Try again</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<form onsubmit={handleSubmit}>
|
||||
<Input type="email" bind:value={email} placeholder="your@email.com" {error} />
|
||||
<Button type="submit" size="lg" {loading}>Send Login Link</Button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.login-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: var(--color-bg-overlay);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-10);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
text-align: center;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 var(--space-1);
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--color-text-tertiary);
|
||||
margin: 0 0 var(--space-8);
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.success p {
|
||||
color: var(--color-success);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
</style>
|
||||
56
apps/web/src/routes/auth/verify/+page.svelte
Normal file
56
apps/web/src/routes/auth/verify/+page.svelte
Normal file
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { verifyToken } from '$lib/stores/auth.js';
|
||||
|
||||
let error = $state('');
|
||||
|
||||
onMount(async () => {
|
||||
const token = $page.url.searchParams.get('token');
|
||||
if (!token) {
|
||||
error = 'No token provided';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await verifyToken(token);
|
||||
goto('/dashboard');
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Verification failed';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="verify-page">
|
||||
{#if error}
|
||||
<div class="error-card">
|
||||
<h2>Login Failed</h2>
|
||||
<p>{error}</p>
|
||||
<a href="/">Try again</a>
|
||||
</div>
|
||||
{:else}
|
||||
<p>Verifying...</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.verify-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.error-card {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-card h2 {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #6366f1;
|
||||
}
|
||||
</style>
|
||||
182
apps/web/src/routes/dashboard/+page.svelte
Normal file
182
apps/web/src/routes/dashboard/+page.svelte
Normal file
@@ -0,0 +1,182 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { user, logout } from '$lib/stores/auth.js';
|
||||
import { api } from '$lib/api/client.js';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import Avatar from '$lib/components/ui/Avatar.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
|
||||
type ProjectMembership = {
|
||||
project: { id: string; name: string; description?: string; createdAt: string };
|
||||
role: string;
|
||||
};
|
||||
|
||||
let projects = $state<ProjectMembership[]>([]);
|
||||
let loading = $state(true);
|
||||
|
||||
$effect(() => {
|
||||
if (!$user) goto('/');
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const res = await api.get<{ projects: ProjectMembership[] }>('/projects');
|
||||
projects = res.projects;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleLogout() {
|
||||
await logout();
|
||||
goto('/');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="dashboard">
|
||||
<header>
|
||||
<h1>Music Hub</h1>
|
||||
<div class="header-right">
|
||||
{#if $user}
|
||||
<Avatar name={$user.name} src={$user.avatarUrl ?? null} size="sm" />
|
||||
<span class="user-name">{$user.name}</span>
|
||||
{/if}
|
||||
<Button variant="ghost" size="sm" onclick={handleLogout}>Logout</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="section-header">
|
||||
<h2>Projects</h2>
|
||||
<Button href="/projects/new">New Project</Button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="project-grid">
|
||||
{#each [1, 2, 3] as _}
|
||||
<div class="project-card skeleton-card">
|
||||
<Skeleton width="60%" height="1.2rem" />
|
||||
<Skeleton width="80%" height="0.9rem" />
|
||||
<Skeleton width="5rem" height="1.2rem" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if projects.length === 0}
|
||||
<EmptyState
|
||||
icon="🎵"
|
||||
title="No projects yet"
|
||||
description="Create your first project to start collaborating."
|
||||
>
|
||||
{#snippet action()}
|
||||
<Button href="/projects/new">Create Project</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else}
|
||||
<div class="project-grid">
|
||||
{#each projects as { project, role }}
|
||||
<a href="/projects/{project.id}" class="project-card">
|
||||
<h3>{project.name}</h3>
|
||||
{#if project.description}
|
||||
<p class="description">{project.description}</p>
|
||||
{/if}
|
||||
<Badge>{role.replaceAll('_', ' ')}</Badge>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dashboard {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-4) 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: var(--text-xl);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.user-name {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.project-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.project-card {
|
||||
background: var(--color-bg-overlay);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-6);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
border: 1px solid var(--color-border);
|
||||
transition: border-color var(--transition-base), box-shadow var(--transition-base);
|
||||
}
|
||||
|
||||
.project-card:hover {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.project-card h3 {
|
||||
margin: 0 0 var(--space-2);
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0 0 var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.project-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
218
apps/web/src/routes/projects/[projectId]/+page.svelte
Normal file
218
apps/web/src/routes/projects/[projectId]/+page.svelte
Normal file
@@ -0,0 +1,218 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { api } from '$lib/api/client.js';
|
||||
import { toastSuccess } from '$lib/stores/toast.js';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
|
||||
type Track = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type Project = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
let project = $state<Project | null>(null);
|
||||
let role = $state('');
|
||||
let tracks = $state<Track[]>([]);
|
||||
let newTrackName = $state('');
|
||||
let showNewTrack = $state(false);
|
||||
let loading = $state(true);
|
||||
let creating = $state(false);
|
||||
|
||||
const projectId = $page.params.projectId;
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const [projectRes, trackRes] = await Promise.all([
|
||||
api.get<{ project: Project; role: string }>(`/projects/${projectId}`),
|
||||
api.get<{ tracks: Track[] }>(`/tracks/project/${projectId}`),
|
||||
]);
|
||||
project = projectRes.project;
|
||||
role = projectRes.role;
|
||||
tracks = trackRes.tracks;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function createTrack() {
|
||||
if (!newTrackName.trim()) return;
|
||||
creating = true;
|
||||
try {
|
||||
const res = await api.post<{ track: Track }>(`/tracks/${projectId}`, {
|
||||
name: newTrackName,
|
||||
});
|
||||
tracks = [...tracks, res.track];
|
||||
newTrackName = '';
|
||||
showNewTrack = false;
|
||||
toastSuccess('Track created');
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
const canUpload = $derived(role === 'owner' || role.includes('engineer'));
|
||||
</script>
|
||||
|
||||
<div class="project-page">
|
||||
<header>
|
||||
<a href="/dashboard" class="back">← Projects</a>
|
||||
{#if loading}
|
||||
<Skeleton width="200px" height="2rem" />
|
||||
{:else if project}
|
||||
<div class="project-header">
|
||||
<h1>{project.name}</h1>
|
||||
{#if role === 'owner' || role === 'management'}
|
||||
<Button variant="ghost" size="sm" href="/projects/{projectId}/settings">Settings</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if project.description}
|
||||
<p class="description">{project.description}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<div class="section-header">
|
||||
<h2>Tracks</h2>
|
||||
{#if canUpload}
|
||||
<Button variant="secondary" onclick={() => showNewTrack = !showNewTrack}>
|
||||
{showNewTrack ? 'Cancel' : 'New Track'}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showNewTrack}
|
||||
<form class="new-track-form" onsubmit={(e) => { e.preventDefault(); createTrack(); }}>
|
||||
<Input bind:value={newTrackName} placeholder="Track name" autofocus />
|
||||
<Button type="submit" loading={creating}>Create</Button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="track-list">
|
||||
{#each [1, 2] as _}
|
||||
<div class="track-item-skeleton">
|
||||
<Skeleton width="40%" height="1rem" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if tracks.length === 0}
|
||||
<EmptyState
|
||||
icon="🎶"
|
||||
title="No tracks yet"
|
||||
description="Create a track and upload your first audio file."
|
||||
/>
|
||||
{:else}
|
||||
<div class="track-list">
|
||||
{#each tracks as track}
|
||||
<a href="/projects/{projectId}/tracks/{track.id}" class="track-item">
|
||||
<span class="track-name">{track.name}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.project-page {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.back {
|
||||
color: var(--color-text-tertiary);
|
||||
text-decoration: none;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.back:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.project-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--color-text-secondary);
|
||||
margin: var(--space-1) 0 0;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.new-track-form {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-6);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.new-track-form :global(.input-group) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.track-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.track-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
background: var(--color-bg-overlay);
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
border: 1px solid var(--color-border);
|
||||
transition: border-color var(--transition-base);
|
||||
}
|
||||
|
||||
.track-item:hover {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.track-name {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.track-item-skeleton {
|
||||
padding: var(--space-4) var(--space-5);
|
||||
background: var(--color-bg-overlay);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
</style>
|
||||
356
apps/web/src/routes/projects/[projectId]/settings/+page.svelte
Normal file
356
apps/web/src/routes/projects/[projectId]/settings/+page.svelte
Normal file
@@ -0,0 +1,356 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { api } from '$lib/api/client.js';
|
||||
import { toastSuccess } from '$lib/stores/toast.js';
|
||||
import { ROLE_LABELS, PROJECT_ROLES } from '@music-hub/shared';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import Avatar from '$lib/components/ui/Avatar.svelte';
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
|
||||
type Member = {
|
||||
id: string;
|
||||
role: string;
|
||||
user: { id: string; email: string; name: string; avatarUrl: string | null };
|
||||
};
|
||||
|
||||
type Project = { id: string; name: string; description: string | null };
|
||||
|
||||
const projectId = $page.params.projectId!;
|
||||
|
||||
let project = $state<Project | null>(null);
|
||||
let members = $state<Member[]>([]);
|
||||
let role = $state('');
|
||||
let loading = $state(true);
|
||||
|
||||
// Edit project
|
||||
let editName = $state('');
|
||||
let editDesc = $state('');
|
||||
let saving = $state(false);
|
||||
|
||||
// Invite
|
||||
let inviteEmail = $state('');
|
||||
let inviteRole = $state('artist');
|
||||
let inviting = $state(false);
|
||||
|
||||
// Archive
|
||||
let showArchiveModal = $state(false);
|
||||
|
||||
const assignableRoles = PROJECT_ROLES.filter((r) => r !== 'owner');
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const [projectRes, membersRes] = await Promise.all([
|
||||
api.get<{ project: Project; role: string }>(`/projects/${projectId}`),
|
||||
api.get<{ members: Member[] }>(`/projects/${projectId}/members`),
|
||||
]);
|
||||
project = projectRes.project;
|
||||
role = projectRes.role;
|
||||
editName = project.name;
|
||||
editDesc = project.description || '';
|
||||
members = membersRes.members;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function saveProject() {
|
||||
saving = true;
|
||||
try {
|
||||
await api.patch(`/projects/${projectId}`, {
|
||||
name: editName,
|
||||
description: editDesc || undefined,
|
||||
});
|
||||
toastSuccess('Project updated');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function inviteMember() {
|
||||
if (!inviteEmail.trim()) return;
|
||||
inviting = true;
|
||||
try {
|
||||
await api.post(`/projects/${projectId}/members`, {
|
||||
email: inviteEmail,
|
||||
role: inviteRole,
|
||||
});
|
||||
inviteEmail = '';
|
||||
toastSuccess('Member invited');
|
||||
const res = await api.get<{ members: Member[] }>(`/projects/${projectId}/members`);
|
||||
members = res.members;
|
||||
} finally {
|
||||
inviting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateRole(memberId: string, newRole: string) {
|
||||
await api.patch(`/projects/${projectId}/members/${memberId}`, { role: newRole });
|
||||
toastSuccess('Role updated');
|
||||
const res = await api.get<{ members: Member[] }>(`/projects/${projectId}/members`);
|
||||
members = res.members;
|
||||
}
|
||||
|
||||
async function removeMember(memberId: string) {
|
||||
await api.delete(`/projects/${projectId}/members/${memberId}`);
|
||||
toastSuccess('Member removed');
|
||||
members = members.filter((m) => m.id !== memberId);
|
||||
}
|
||||
|
||||
async function archiveProject() {
|
||||
await api.delete(`/projects/${projectId}`);
|
||||
toastSuccess('Project archived');
|
||||
goto('/dashboard');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="settings-page">
|
||||
<header>
|
||||
<a href="/projects/{projectId}" class="back">← Back to project</a>
|
||||
<h1>Settings</h1>
|
||||
</header>
|
||||
|
||||
{#if !loading && project}
|
||||
<!-- Project Details -->
|
||||
<section class="section">
|
||||
<h2>Project Details</h2>
|
||||
<form onsubmit={(e) => { e.preventDefault(); saveProject(); }}>
|
||||
<Input label="Name" bind:value={editName} />
|
||||
<div class="textarea-group">
|
||||
<label class="textarea-label">Description</label>
|
||||
<textarea bind:value={editDesc} rows="3" placeholder="Project description..."></textarea>
|
||||
</div>
|
||||
<Button type="submit" loading={saving}>Save</Button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- Members -->
|
||||
<section class="section">
|
||||
<h2>Members</h2>
|
||||
|
||||
<div class="member-list">
|
||||
{#each members as member}
|
||||
<div class="member-row">
|
||||
<Avatar name={member.user.name} src={member.user.avatarUrl} />
|
||||
<div class="member-info">
|
||||
<span class="member-name">{member.user.name}</span>
|
||||
<span class="member-email">{member.user.email}</span>
|
||||
</div>
|
||||
{#if member.role === 'owner'}
|
||||
<Badge variant="accent">Owner</Badge>
|
||||
{:else if role === 'owner'}
|
||||
<select
|
||||
value={member.role}
|
||||
onchange={(e) => updateRole(member.id, (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
{#each assignableRoles as r}
|
||||
<option value={r} selected={r === member.role}>{ROLE_LABELS[r]}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<Button variant="ghost" size="sm" onclick={() => removeMember(member.id)}>
|
||||
<span style="color: var(--color-error)">Remove</span>
|
||||
</Button>
|
||||
{:else}
|
||||
<Badge>{ROLE_LABELS[member.role as keyof typeof ROLE_LABELS] || member.role}</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Invite -->
|
||||
{#if role === 'owner' || role === 'management'}
|
||||
<form class="invite-form" onsubmit={(e) => { e.preventDefault(); inviteMember(); }}>
|
||||
<Input type="email" bind:value={inviteEmail} placeholder="email@example.com" />
|
||||
<select bind:value={inviteRole}>
|
||||
{#each assignableRoles as r}
|
||||
<option value={r}>{ROLE_LABELS[r]}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<Button type="submit" loading={inviting} size="sm">Invite</Button>
|
||||
</form>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Danger Zone -->
|
||||
{#if role === 'owner'}
|
||||
<section class="section danger-zone">
|
||||
<h2>Danger Zone</h2>
|
||||
<div class="danger-content">
|
||||
<div>
|
||||
<strong>Archive this project</strong>
|
||||
<p>The project will be hidden from all members.</p>
|
||||
</div>
|
||||
<Button variant="danger" onclick={() => showArchiveModal = true}>Archive</Button>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Modal bind:open={showArchiveModal} title="Archive Project">
|
||||
<p>Are you sure you want to archive <strong>{project?.name}</strong>? This will hide it from all members.</p>
|
||||
{#snippet actions()}
|
||||
<Button variant="secondary" onclick={() => showArchiveModal = false}>Cancel</Button>
|
||||
<Button variant="danger" onclick={archiveProject}>Archive</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.settings-page {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.back {
|
||||
color: var(--color-text-tertiary);
|
||||
text-decoration: none;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.back:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: var(--space-2) 0 0;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: var(--color-bg-overlay);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-6);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 var(--space-4);
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
form :global(.input-group) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.textarea-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.textarea-label {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border-hover);
|
||||
background: var(--color-bg-base);
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--text-base);
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-border-focus);
|
||||
}
|
||||
|
||||
/* Members */
|
||||
.member-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.member-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--color-bg-raised);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.member-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.member-email {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
select {
|
||||
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-size: var(--text-sm);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.invite-form {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: flex-start;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.invite-form :global(.input-group) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Danger Zone */
|
||||
.danger-zone {
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.danger-zone h2 {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.danger-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.danger-content p {
|
||||
margin: var(--space-1) 0 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,303 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { api } from '$lib/api/client.js';
|
||||
import { toastSuccess } from '$lib/stores/toast.js';
|
||||
import WaveformPlayer from '$lib/components/audio/WaveformPlayer.svelte';
|
||||
import UploadDropzone from '$lib/components/audio/UploadDropzone.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import ABCompare from '$lib/components/audio/ABCompare.svelte';
|
||||
import VersionInfo from './components/VersionInfo.svelte';
|
||||
import VersionHistory from './components/VersionHistory.svelte';
|
||||
import CommentSection from './components/CommentSection.svelte';
|
||||
|
||||
type Version = {
|
||||
id: string;
|
||||
versionNumber: number;
|
||||
label: string | null;
|
||||
notes: string | null;
|
||||
status: string;
|
||||
originalFileName: string;
|
||||
duration: number | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type Comment = {
|
||||
id: string;
|
||||
body: string;
|
||||
timestampSeconds: number | null;
|
||||
parentId: string | null;
|
||||
resolvedAt: string | null;
|
||||
createdAt: string;
|
||||
user: { id: string; name: string; avatarUrl: string | null };
|
||||
};
|
||||
|
||||
const projectId = $page.params.projectId!;
|
||||
const trackId = $page.params.trackId!;
|
||||
|
||||
let trackName = $state('');
|
||||
let versions = $state<Version[]>([]);
|
||||
let selectedVersion = $state<Version | null>(null);
|
||||
let streamUrl = $state('');
|
||||
let comments = $state<Comment[]>([]);
|
||||
let showUpload = $state(false);
|
||||
let role = $state('');
|
||||
let loading = $state(true);
|
||||
let commentTimestamp = $state<number | null>(null);
|
||||
let playerRef = $state<WaveformPlayer>();
|
||||
let compareVersion = $state<Version | null>(null);
|
||||
let compareStreamUrl = $state('');
|
||||
|
||||
const canUpload = $derived(role === 'owner' || role.includes('engineer'));
|
||||
const canApprove = $derived(['owner', 'artist', 'label', 'management'].includes(role));
|
||||
const canComment = $derived(role !== 'viewer');
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const [projectRes, trackVersions, tracksRes] = await Promise.all([
|
||||
api.get<{ project: any; role: string }>(`/projects/${projectId}`),
|
||||
api.get<{ versions: Version[] }>(`/versions/track/${trackId}`),
|
||||
api.get<{ tracks: { id: string; name: string }[] }>(`/tracks/project/${projectId}`),
|
||||
]);
|
||||
|
||||
role = projectRes.role;
|
||||
trackName = tracksRes.tracks.find((t) => t.id === trackId)?.name || '';
|
||||
versions = trackVersions.versions;
|
||||
|
||||
if (versions.length > 0) await selectVersion(versions[0]);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function selectVersion(version: Version) {
|
||||
selectedVersion = version;
|
||||
const [streamRes, commentRes] = await Promise.all([
|
||||
api.get<{ url: string }>(`/versions/${version.id}/stream-url`),
|
||||
api.get<{ comments: Comment[] }>(`/comments/version/${version.id}`),
|
||||
]);
|
||||
streamUrl = streamRes.url;
|
||||
comments = commentRes.comments;
|
||||
}
|
||||
|
||||
async function loadVersions() {
|
||||
const res = await api.get<{ versions: Version[] }>(`/versions/track/${trackId}`);
|
||||
versions = res.versions;
|
||||
if (versions.length > 0) await selectVersion(versions[0]);
|
||||
}
|
||||
|
||||
async function handleApprove() {
|
||||
if (!selectedVersion) return;
|
||||
await api.post(`/versions/${selectedVersion.id}/approve`);
|
||||
toastSuccess('Version approved');
|
||||
await loadVersions();
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
if (!selectedVersion) return;
|
||||
await api.post(`/versions/${selectedVersion.id}/reject`);
|
||||
toastSuccess('Version rejected');
|
||||
await loadVersions();
|
||||
}
|
||||
|
||||
async function handleComment(body: string, timestamp: number | null, parentId?: string) {
|
||||
if (!selectedVersion) return;
|
||||
await api.post(`/comments/version/${selectedVersion.id}`, {
|
||||
body,
|
||||
timestampSeconds: timestamp ?? undefined,
|
||||
parentId,
|
||||
});
|
||||
const res = await api.get<{ comments: Comment[] }>(`/comments/version/${selectedVersion.id}`);
|
||||
comments = res.comments;
|
||||
toastSuccess('Comment added');
|
||||
}
|
||||
|
||||
async function handleResolve(commentId: string) {
|
||||
await api.post(`/comments/${commentId}/resolve`);
|
||||
if (selectedVersion) {
|
||||
const res = await api.get<{ comments: Comment[] }>(`/comments/version/${selectedVersion.id}`);
|
||||
comments = res.comments;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
if (!selectedVersion) return;
|
||||
api.get<{ url: string }>(`/versions/${selectedVersion.id}/download-url`).then((res) => {
|
||||
window.open(res.url, '_blank');
|
||||
});
|
||||
}
|
||||
|
||||
async function startCompare(version: Version) {
|
||||
const res = await api.get<{ url: string }>(`/versions/${version.id}/stream-url`);
|
||||
compareVersion = version;
|
||||
compareStreamUrl = res.url;
|
||||
}
|
||||
|
||||
function closeCompare() {
|
||||
compareVersion = null;
|
||||
compareStreamUrl = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="track-page">
|
||||
<header>
|
||||
<a href="/projects/{projectId}" class="back">← Back to project</a>
|
||||
{#if loading}
|
||||
<Skeleton width="200px" height="2rem" />
|
||||
{:else}
|
||||
<h1>{trackName}</h1>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<!-- Player -->
|
||||
{#if selectedVersion && streamUrl}
|
||||
{#key streamUrl}
|
||||
<WaveformPlayer
|
||||
bind:this={playerRef}
|
||||
url={streamUrl}
|
||||
markers={comments
|
||||
.filter((c) => c.timestampSeconds !== null)
|
||||
.map((c) => ({
|
||||
id: c.id,
|
||||
timestampSeconds: c.timestampSeconds!,
|
||||
body: c.body,
|
||||
userName: c.user.name,
|
||||
}))}
|
||||
onTimeClick={(time) => commentTimestamp = Math.round(time * 10) / 10}
|
||||
/>
|
||||
{/key}
|
||||
|
||||
<VersionInfo
|
||||
version={selectedVersion}
|
||||
{canApprove}
|
||||
onApprove={handleApprove}
|
||||
onReject={handleReject}
|
||||
/>
|
||||
|
||||
<div class="track-actions">
|
||||
{#if canUpload}
|
||||
<Button variant="secondary" size="sm" onclick={() => showUpload = !showUpload}>
|
||||
{showUpload ? 'Cancel' : 'Upload new version'}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button variant="ghost" size="sm" onclick={handleDownload}>
|
||||
↓ Download
|
||||
</Button>
|
||||
{#if versions.length > 1}
|
||||
<select
|
||||
class="compare-select"
|
||||
onchange={(e) => {
|
||||
const id = (e.target as HTMLSelectElement).value;
|
||||
if (id) {
|
||||
const v = versions.find((v) => v.id === id);
|
||||
if (v) startCompare(v);
|
||||
}
|
||||
(e.target as HTMLSelectElement).value = '';
|
||||
}}
|
||||
>
|
||||
<option value="">Compare with...</option>
|
||||
{#each versions.filter((v) => v.id !== selectedVersion?.id) as v}
|
||||
<option value={v.id}>V{v.versionNumber}{v.label ? ` — ${v.label}` : ''}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- A/B Compare -->
|
||||
{#if compareVersion && compareStreamUrl && selectedVersion && streamUrl}
|
||||
<ABCompare
|
||||
versionA={selectedVersion}
|
||||
versionB={compareVersion}
|
||||
streamUrlA={streamUrl}
|
||||
streamUrlB={compareStreamUrl}
|
||||
onClose={closeCompare}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showUpload}
|
||||
<UploadDropzone {trackId} onUploaded={() => { showUpload = false; loadVersions(); toastSuccess('Version uploaded'); }} />
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<Skeleton height="80px" variant="rect" />
|
||||
{:else if versions.length === 0}
|
||||
<EmptyState
|
||||
icon="🎵"
|
||||
title="No versions yet"
|
||||
description="Upload your first audio file to get started."
|
||||
>
|
||||
{#snippet action()}
|
||||
{#if canUpload}
|
||||
<Button onclick={() => showUpload = true}>Upload Audio</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{/if}
|
||||
|
||||
{#if selectedVersion}
|
||||
<CommentSection
|
||||
{comments}
|
||||
{canComment}
|
||||
bind:commentTimestamp
|
||||
onSubmit={handleComment}
|
||||
onResolve={handleResolve}
|
||||
onSeek={(time) => playerRef?.seekToTime(time)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<VersionHistory
|
||||
{versions}
|
||||
selectedId={selectedVersion?.id ?? null}
|
||||
onSelect={selectVersion}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.track-page {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.back {
|
||||
color: var(--color-text-tertiary);
|
||||
text-decoration: none;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.back:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: var(--space-2) 0 0;
|
||||
}
|
||||
|
||||
.track-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.compare-select {
|
||||
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-secondary);
|
||||
font-size: var(--text-sm);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,130 @@
|
||||
<script lang="ts">
|
||||
import Avatar from '$lib/components/ui/Avatar.svelte';
|
||||
import { formatTime, timeAgo } from '$lib/utils/format.js';
|
||||
|
||||
type Comment = {
|
||||
id: string;
|
||||
body: string;
|
||||
timestampSeconds: number | null;
|
||||
resolvedAt: string | null;
|
||||
createdAt: string;
|
||||
user: { id: string; name: string; avatarUrl: string | null };
|
||||
};
|
||||
|
||||
let {
|
||||
comment,
|
||||
onSeek,
|
||||
onResolve,
|
||||
onReply,
|
||||
}: {
|
||||
comment: Comment;
|
||||
onSeek?: (time: number) => void;
|
||||
onResolve: (id: string) => void;
|
||||
onReply?: (id: string) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="comment" class:resolved={comment.resolvedAt}>
|
||||
<div class="comment-header">
|
||||
<Avatar name={comment.user.name} src={comment.user.avatarUrl} size="sm" />
|
||||
<span class="comment-author">{comment.user.name}</span>
|
||||
{#if comment.timestampSeconds !== null}
|
||||
<button
|
||||
class="comment-timestamp"
|
||||
onclick={() => onSeek?.(comment.timestampSeconds!)}
|
||||
>
|
||||
{formatTime(comment.timestampSeconds)}
|
||||
</button>
|
||||
{/if}
|
||||
<span class="comment-date">{timeAgo(comment.createdAt)}</span>
|
||||
<div class="comment-actions">
|
||||
{#if onReply}
|
||||
<button class="action-btn" onclick={() => onReply?.(comment.id)} title="Reply">↩</button>
|
||||
{/if}
|
||||
{#if !comment.resolvedAt}
|
||||
<button class="action-btn resolve" onclick={() => onResolve(comment.id)} title="Resolve">✓</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<p class="comment-body">{comment.body}</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.comment {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--color-bg-raised);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
transition: opacity var(--transition-base);
|
||||
}
|
||||
|
||||
.comment.resolved {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.comment-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.comment-author {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.comment-timestamp {
|
||||
background: rgba(251, 191, 36, 0.15);
|
||||
border: 1px solid rgba(251, 191, 36, 0.3);
|
||||
color: var(--color-warning);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.05rem 0.4rem;
|
||||
font-size: var(--text-xs);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.comment-timestamp:hover {
|
||||
background: rgba(251, 191, 36, 0.25);
|
||||
}
|
||||
|
||||
.comment-date {
|
||||
color: var(--color-text-tertiary);
|
||||
margin-left: auto;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.comment-actions {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
padding: 0 0.3rem;
|
||||
font-size: var(--text-sm);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
color: var(--color-text-primary);
|
||||
border-color: var(--color-border-hover);
|
||||
}
|
||||
|
||||
.action-btn.resolve:hover {
|
||||
color: var(--color-success);
|
||||
border-color: var(--color-success);
|
||||
}
|
||||
|
||||
.comment-body {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,193 @@
|
||||
<script lang="ts">
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import CommentItem from './CommentItem.svelte';
|
||||
import { formatTime } from '$lib/utils/format.js';
|
||||
|
||||
type Comment = {
|
||||
id: string;
|
||||
body: string;
|
||||
timestampSeconds: number | null;
|
||||
parentId: string | null;
|
||||
resolvedAt: string | null;
|
||||
createdAt: string;
|
||||
user: { id: string; name: string; avatarUrl: string | null };
|
||||
};
|
||||
|
||||
let {
|
||||
comments,
|
||||
canComment = false,
|
||||
commentTimestamp = $bindable<number | null>(null),
|
||||
onSubmit,
|
||||
onResolve,
|
||||
onSeek,
|
||||
}: {
|
||||
comments: Comment[];
|
||||
canComment?: boolean;
|
||||
commentTimestamp: number | null;
|
||||
onSubmit: (body: string, timestamp: number | null, parentId?: string) => void;
|
||||
onResolve: (id: string) => void;
|
||||
onSeek?: (time: number) => void;
|
||||
} = $props();
|
||||
|
||||
let body = $state('');
|
||||
let replyingTo = $state<string | null>(null);
|
||||
let submitting = $state(false);
|
||||
|
||||
// Group comments: top-level + replies
|
||||
const topLevel = $derived(comments.filter((c) => !c.parentId));
|
||||
const replies = $derived((parentId: string) => comments.filter((c) => c.parentId === parentId));
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!body.trim()) return;
|
||||
submitting = true;
|
||||
try {
|
||||
await onSubmit(body, replyingTo ? null : commentTimestamp, replyingTo ?? undefined);
|
||||
body = '';
|
||||
commentTimestamp = null;
|
||||
replyingTo = null;
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleReply(id: string) {
|
||||
replyingTo = id;
|
||||
commentTimestamp = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="comments-section">
|
||||
<h2>Comments</h2>
|
||||
|
||||
{#if canComment}
|
||||
<form class="comment-form" onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
|
||||
{#if commentTimestamp !== null}
|
||||
<span class="timestamp-badge">
|
||||
<button type="button" class="ts-seek" onclick={() => onSeek?.(commentTimestamp!)}>
|
||||
{formatTime(commentTimestamp)}
|
||||
</button>
|
||||
<button type="button" class="remove-ts" onclick={() => commentTimestamp = null}>×</button>
|
||||
</span>
|
||||
{/if}
|
||||
{#if replyingTo}
|
||||
<span class="reply-badge">
|
||||
Replying...
|
||||
<button type="button" class="remove-ts" onclick={() => replyingTo = null}>×</button>
|
||||
</span>
|
||||
{/if}
|
||||
<div class="comment-input-row">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={body}
|
||||
placeholder={commentTimestamp !== null
|
||||
? `Comment at ${formatTime(commentTimestamp)}...`
|
||||
: replyingTo
|
||||
? 'Write a reply...'
|
||||
: 'Add a comment... (click waveform for timestamp)'}
|
||||
/>
|
||||
<Button type="submit" size="sm" loading={submitting} disabled={!body.trim()}>Send</Button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<div class="comment-list">
|
||||
{#each topLevel as comment}
|
||||
<CommentItem {comment} {onSeek} {onResolve} onReply={handleReply} />
|
||||
{#each replies(comment.id) as reply}
|
||||
<div class="reply">
|
||||
<CommentItem comment={reply} {onSeek} {onResolve} />
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
|
||||
{#if comments.length === 0}
|
||||
<EmptyState
|
||||
icon="💬"
|
||||
title="No comments yet"
|
||||
description="Click the waveform to leave a timestamped comment."
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.comments-section {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: var(--space-5);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 var(--space-3);
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.comment-form {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.timestamp-badge, .reply-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: 0.15rem var(--space-2) 0.15rem var(--space-2);
|
||||
background: rgba(251, 191, 36, 0.15);
|
||||
border: 1px solid rgba(251, 191, 36, 0.3);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-xs);
|
||||
margin-bottom: var(--space-2);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.reply-badge {
|
||||
background: var(--color-accent-subtle);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.ts-seek, .remove-ts {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.remove-ts {
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.comment-input-row {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.comment-input-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-size: var(--text-sm);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.comment-input-row input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-border-focus);
|
||||
}
|
||||
|
||||
.comment-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.reply {
|
||||
margin-left: var(--space-8);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script lang="ts">
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import { timeAgo } from '$lib/utils/format.js';
|
||||
|
||||
type Version = {
|
||||
id: string;
|
||||
versionNumber: number;
|
||||
label: string | null;
|
||||
notes: string | null;
|
||||
status: string;
|
||||
originalFileName: string;
|
||||
duration: number | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
let {
|
||||
versions,
|
||||
selectedId,
|
||||
onSelect,
|
||||
}: {
|
||||
versions: Version[];
|
||||
selectedId: string | null;
|
||||
onSelect: (version: Version) => void | Promise<void>;
|
||||
} = $props();
|
||||
|
||||
const statusVariant = (s: string) =>
|
||||
({ approved: 'success', rejected: 'error', processing: 'warning', ready: 'accent', uploaded: 'default' } as Record<string, any>)[s] || 'default';
|
||||
</script>
|
||||
|
||||
{#if versions.length > 1}
|
||||
<div class="version-history">
|
||||
<h2>Version History</h2>
|
||||
<div class="version-list">
|
||||
{#each versions as version}
|
||||
<button
|
||||
class="version-item"
|
||||
class:active={selectedId === version.id}
|
||||
onclick={() => onSelect(version)}
|
||||
>
|
||||
<span class="v-number">V{version.versionNumber}</span>
|
||||
<span class="v-label">{version.label || version.originalFileName}</span>
|
||||
<Badge variant={statusVariant(version.status)}>{version.status}</Badge>
|
||||
<span class="v-date">{timeAgo(version.createdAt)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.version-history {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: var(--space-5);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 var(--space-3);
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.version-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.version-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--color-bg-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
font-size: var(--text-sm);
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.version-item:hover {
|
||||
border-color: var(--color-border-hover);
|
||||
}
|
||||
|
||||
.version-item.active {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-accent-subtle);
|
||||
}
|
||||
|
||||
.v-number {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 600;
|
||||
min-width: 2rem;
|
||||
}
|
||||
|
||||
.v-label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.v-date {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
|
||||
type Version = {
|
||||
id: string;
|
||||
versionNumber: number;
|
||||
label: string | null;
|
||||
notes: string | null;
|
||||
status: string;
|
||||
};
|
||||
|
||||
let {
|
||||
version,
|
||||
canApprove = false,
|
||||
onApprove,
|
||||
onReject,
|
||||
}: {
|
||||
version: Version;
|
||||
canApprove?: boolean;
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
} = $props();
|
||||
|
||||
const statusVariant = $derived(
|
||||
({ approved: 'success', rejected: 'error', processing: 'warning', ready: 'accent', uploaded: 'default' } as const)[version.status] || 'default'
|
||||
);
|
||||
|
||||
const showActions = $derived(canApprove && version.status !== 'approved' && version.status !== 'rejected');
|
||||
</script>
|
||||
|
||||
<div class="version-info">
|
||||
<div class="version-meta">
|
||||
<span class="version-label">
|
||||
V{version.versionNumber}
|
||||
{#if version.label} — {version.label}{/if}
|
||||
</span>
|
||||
<Badge variant={statusVariant}>{version.status}</Badge>
|
||||
</div>
|
||||
|
||||
{#if showActions}
|
||||
<div class="version-actions">
|
||||
<Button variant="ghost" size="sm" onclick={onApprove}>
|
||||
<span style="color: var(--color-success)">✓ Approve</span>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onclick={onReject}>
|
||||
<span style="color: var(--color-error)">✕ Reject</span>
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if version.notes}
|
||||
<p class="version-notes">{version.notes}</p>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.version-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.version-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.version-label {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.version-actions {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.version-notes {
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
108
apps/web/src/routes/projects/new/+page.svelte
Normal file
108
apps/web/src/routes/projects/new/+page.svelte
Normal file
@@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api/client.js';
|
||||
import { toastSuccess } from '$lib/stores/toast.js';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
|
||||
let name = $state('');
|
||||
let description = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
loading = true;
|
||||
try {
|
||||
const res = await api.post<{ project: { id: string } }>('/projects', {
|
||||
name,
|
||||
description: description || undefined,
|
||||
});
|
||||
toastSuccess('Project created');
|
||||
goto(`/projects/${res.project.id}`);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<div class="card">
|
||||
<h1>New Project</h1>
|
||||
|
||||
<form onsubmit={handleSubmit}>
|
||||
<Input label="Name" bind:value={name} placeholder="My Album" />
|
||||
|
||||
<div class="textarea-group">
|
||||
<label class="textarea-label">Description (optional)</label>
|
||||
<textarea bind:value={description} placeholder="What's this project about?" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<Button variant="secondary" href="/dashboard">Cancel</Button>
|
||||
<Button type="submit" {loading}>Create</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--space-8) var(--space-4);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--color-bg-overlay);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-8);
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 var(--space-6);
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.textarea-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.textarea-label {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
textarea {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border-hover);
|
||||
background: var(--color-bg-base);
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--text-base);
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-border-focus);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
</style>
|
||||
3
apps/web/static/robots.txt
Normal file
3
apps/web/static/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
24
apps/web/svelte.config.js
Normal file
24
apps/web/svelte.config.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import adapter from '@sveltejs/adapter-auto';
|
||||
import { relative, sep } from 'node:path';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
compilerOptions: {
|
||||
// defaults to rune mode for the project, except for `node_modules`. Can be removed in svelte 6.
|
||||
runes: ({ filename }) => {
|
||||
const relativePath = relative(import.meta.dirname, filename);
|
||||
const pathSegments = relativePath.toLowerCase().split(sep);
|
||||
const isExternalLibrary = pathSegments.includes('node_modules');
|
||||
|
||||
return isExternalLibrary ? undefined : true;
|
||||
}
|
||||
},
|
||||
kit: {
|
||||
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
||||
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
||||
adapter: adapter()
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
20
apps/web/tsconfig.json
Normal file
20
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||
}
|
||||
14
apps/web/vite.config.ts
Normal file
14
apps/web/vite.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user