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:
Robin Choice
2026-04-02 13:23:10 +02:00
commit e420ed198b
88 changed files with 7306 additions and 0 deletions

8
.env.example Normal file
View File

@@ -0,0 +1,8 @@
DATABASE_URL=postgresql://musichub:musichub@localhost:5433/musichub
S3_ENDPOINT=http://localhost:9000
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
S3_BUCKET=music-hub
APP_URL=http://localhost:5173
API_URL=http://localhost:3000
MAGIC_LINK_SECRET=change-me-in-production

41
.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# dependencies (bun install)
node_modules
# output
out
dist
build
*.tgz
# turbo
.turbo
# svelte
.svelte-kit
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

23
Dockerfile.api Normal file
View File

@@ -0,0 +1,23 @@
FROM oven/bun:1 AS base
WORKDIR /app
FROM base AS install
COPY package.json bun.lock ./
COPY packages/shared/package.json ./packages/shared/
COPY packages/db/package.json ./packages/db/
COPY apps/api/package.json ./apps/api/
RUN bun install --frozen-lockfile --production
FROM base AS build
COPY --from=install /app/node_modules ./node_modules
COPY --from=install /app/packages/shared/node_modules ./packages/shared/node_modules
COPY --from=install /app/packages/db/node_modules ./packages/db/node_modules
COPY --from=install /app/apps/api/node_modules ./apps/api/node_modules
COPY . .
FROM base AS production
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/*
COPY --from=build /app .
EXPOSE 3000
ENV NODE_ENV=production
CMD ["bun", "run", "apps/api/src/index.ts"]

23
Dockerfile.web Normal file
View File

@@ -0,0 +1,23 @@
FROM oven/bun:1 AS base
WORKDIR /app
FROM base AS install
COPY package.json bun.lock ./
COPY packages/shared/package.json ./packages/shared/
COPY apps/web/package.json ./apps/web/
RUN bun install --frozen-lockfile
FROM base AS build
COPY --from=install /app/node_modules ./node_modules
COPY --from=install /app/packages/shared/node_modules ./packages/shared/node_modules
COPY --from=install /app/apps/web/node_modules ./apps/web/node_modules
COPY . .
ENV PUBLIC_API_URL=/api
RUN cd apps/web && bun run build
FROM base AS production
COPY --from=build /app/apps/web/build ./build
COPY --from=build /app/apps/web/package.json .
EXPOSE 3000
ENV NODE_ENV=production
CMD ["bun", "./build/index.js"]

19
apps/api/package.json Normal file
View 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
View 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,
};

View 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
View 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('');
}

View 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 });
});

View 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
}

View 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' });
});

View 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 });
});

View 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);
}

View 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>
`,
});
}

View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
engine-strict=true

3
apps/web/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}

42
apps/web/README.md Normal file
View 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
View 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
View 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
View 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>

View 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' }),
};

View 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

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View 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);
}

View 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);

View 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',
});
}

View 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>

View 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>

View 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>

View 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>

View 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">&larr; 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>

View 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">&larr; 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>

View File

@@ -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">&larr; 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

24
apps/web/svelte.config.js Normal file
View 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
View 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
View 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,
},
},
},
});

719
bun.lock Normal file
View File

@@ -0,0 +1,719 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "music-hub",
"devDependencies": {
"@types/bun": "latest",
"turbo": "latest",
"typescript": "^5",
},
},
"apps/api": {
"name": "@music-hub/api",
"version": "0.0.1",
"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",
},
},
"apps/web": {
"name": "@music-hub/web",
"version": "0.0.1",
"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",
},
},
"packages/db": {
"name": "@music-hub/db",
"version": "0.0.1",
"dependencies": {
"@music-hub/shared": "workspace:*",
"drizzle-orm": "^0.44",
"postgres": "^3.4",
},
"devDependencies": {
"drizzle-kit": "^0.31",
},
},
"packages/shared": {
"name": "@music-hub/shared",
"version": "0.0.1",
"dependencies": {
"zod": "^3.24",
},
},
},
"packages": {
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
"@aws-crypto/crc32c": ["@aws-crypto/crc32c@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag=="],
"@aws-crypto/sha1-browser": ["@aws-crypto/sha1-browser@5.2.0", "", { "dependencies": { "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg=="],
"@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="],
"@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="],
"@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="],
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
"@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1022.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.26", "@aws-sdk/credential-provider-node": "^3.972.29", "@aws-sdk/middleware-bucket-endpoint": "^3.972.8", "@aws-sdk/middleware-expect-continue": "^3.972.8", "@aws-sdk/middleware-flexible-checksums": "^3.974.6", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-location-constraint": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.9", "@aws-sdk/middleware-sdk-s3": "^3.972.27", "@aws-sdk/middleware-ssec": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.28", "@aws-sdk/region-config-resolver": "^3.972.10", "@aws-sdk/signature-v4-multi-region": "^3.996.15", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.14", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.13", "@smithy/eventstream-serde-browser": "^4.2.12", "@smithy/eventstream-serde-config-resolver": "^4.3.12", "@smithy/eventstream-serde-node": "^4.2.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-blob-browser": "^4.2.13", "@smithy/hash-node": "^4.2.12", "@smithy/hash-stream-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/md5-js": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.28", "@smithy/middleware-retry": "^4.4.46", "@smithy/middleware-serde": "^4.2.16", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.1", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.8", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.44", "@smithy/util-defaults-mode-node": "^4.2.48", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.13", "@smithy/util-stream": "^4.5.21", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.14", "tslib": "^2.6.2" } }, "sha512-PhdIW0LxjzcMlBiCldRefnyZk84wtYGnEV0sNGOD55DZTvZsibG2XHvQiL1aFliKugfAhuIpNmFkctI2n2I3Dg=="],
"@aws-sdk/core": ["@aws-sdk/core@3.973.26", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/xml-builder": "^3.972.16", "@smithy/core": "^3.23.13", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/smithy-client": "^4.12.8", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-A/E6n2W42ruU+sfWk+mMUOyVXbsSgGrY3MJ9/0Az5qUdG67y8I6HYzzoAa+e/lzxxl1uCYmEL6BTMi9ZiZnplQ=="],
"@aws-sdk/crc64-nvme": ["@aws-sdk/crc64-nvme@3.972.5", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-2VbTstbjKdT+yKi8m7b3a9CiVac+pL/IY2PHJwsaGkkHmuuqkJZIErPck1h6P3T9ghQMLSdMPyW6Qp7Di5swFg=="],
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.24", "", { "dependencies": { "@aws-sdk/core": "^3.973.26", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-FWg8uFmT6vQM7VuzELzwVo5bzExGaKHdubn0StjgrcU5FvuLExUe+k06kn/40uKv59rYzhez8eFNM4yYE/Yb/w=="],
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.26", "", { "dependencies": { "@aws-sdk/core": "^3.973.26", "@aws-sdk/types": "^3.973.6", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.5.1", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.8", "@smithy/types": "^4.13.1", "@smithy/util-stream": "^4.5.21", "tslib": "^2.6.2" } }, "sha512-CY4ppZ+qHYqcXqBVi//sdHST1QK3KzOEiLtpLsc9W2k2vfZPKExGaQIsOwcyvjpjUEolotitmd3mUNY56IwDEA=="],
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.28", "", { "dependencies": { "@aws-sdk/core": "^3.973.26", "@aws-sdk/credential-provider-env": "^3.972.24", "@aws-sdk/credential-provider-http": "^3.972.26", "@aws-sdk/credential-provider-login": "^3.972.28", "@aws-sdk/credential-provider-process": "^3.972.24", "@aws-sdk/credential-provider-sso": "^3.972.28", "@aws-sdk/credential-provider-web-identity": "^3.972.28", "@aws-sdk/nested-clients": "^3.996.18", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wXYvq3+uQcZV7k+bE4yDXCTBdzWTU9x/nMiKBfzInmv6yYK1veMK0AKvRfRBd72nGWYKcL6AxwiPg9z/pYlgpw=="],
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.28", "", { "dependencies": { "@aws-sdk/core": "^3.973.26", "@aws-sdk/nested-clients": "^3.996.18", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-ZSTfO6jqUTCysbdBPtEX5OUR//3rbD0lN7jO3sQeS2Gjr/Y+DT6SbIJ0oT2cemNw3UzKu97sNONd1CwNMthuZQ=="],
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.29", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.24", "@aws-sdk/credential-provider-http": "^3.972.26", "@aws-sdk/credential-provider-ini": "^3.972.28", "@aws-sdk/credential-provider-process": "^3.972.24", "@aws-sdk/credential-provider-sso": "^3.972.28", "@aws-sdk/credential-provider-web-identity": "^3.972.28", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-clSzDcvndpFJAggLDnDb36sPdlZYyEs5Zm6zgZjjUhwsJgSWiWKwFIXUVBcbruidNyBdbpOv2tNDL9sX8y3/0g=="],
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.24", "", { "dependencies": { "@aws-sdk/core": "^3.973.26", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Q2k/XLrFXhEztPHqj4SLCNID3hEPdlhh1CDLBpNnM+1L8fq7P+yON9/9M1IGN/dA5W45v44ylERfXtDAlmMNmw=="],
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.28", "", { "dependencies": { "@aws-sdk/core": "^3.973.26", "@aws-sdk/nested-clients": "^3.996.18", "@aws-sdk/token-providers": "3.1021.0", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-IoUlmKMLEITFn1SiCTjPfR6KrE799FBo5baWyk/5Ppar2yXZoUdaRqZzJzK6TcJxx450M8m8DbpddRVYlp5R/A=="],
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.28", "", { "dependencies": { "@aws-sdk/core": "^3.973.26", "@aws-sdk/nested-clients": "^3.996.18", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-d+6h0SD8GGERzKe27v5rOzNGKOl0D+l0bWJdqrxH8WSQzHzjsQFIAPgIeOTUwBHVsKKwtSxc91K/SWax6XgswQ=="],
"@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-WR525Rr2QJSETa9a050isktyWi/4yIGcmY3BQ1kpHqb0LqUglQHCS8R27dTJxxWNZvQ0RVGtEZjTCbZJpyF3Aw=="],
"@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-5DTBTiotEES1e2jOHAq//zyzCjeMB78lEHd35u15qnrid4Nxm7diqIf9fQQ3Ov0ChH1V3Vvt13thOnrACmfGVQ=="],
"@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.974.6", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.973.26", "@aws-sdk/crc64-nvme": "^3.972.5", "@aws-sdk/types": "^3.973.6", "@smithy/is-array-buffer": "^4.2.2", "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.21", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-YckB8k1ejbyCg/g36gUMFLNzE4W5cERIa4MtsdO+wpTmJEP0+TB7okWIt7d8TDOvnb7SwvxJ21E4TGOBxFpSWQ=="],
"@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ=="],
"@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-KaUoFuoFPziIa98DSQsTPeke1gvGXlc5ZGMhy+b+nLxZ4A7jmJgLzjEF95l8aOQN2T/qlPP3MrAyELm8ExXucw=="],
"@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-CWl5UCM57WUFaFi5kB7IBY1UmOeLvNZAZ2/OZ5l20ldiJ3TiIz1pC65gYj8X0BCPWkeR1E32mpsCk1L1I4n+lA=="],
"@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-/Wt5+CT8dpTFQxEJ9iGy/UGrXr7p2wlIOEHvIr/YcHYByzoLjrqkYqXdJjd9UIgWjv7eqV2HnFJen93UTuwfTQ=="],
"@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.27", "", { "dependencies": { "@aws-sdk/core": "^3.973.26", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/core": "^3.23.13", "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/smithy-client": "^4.12.8", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.21", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-gomO6DZwx+1D/9mbCpcqO5tPBqYBK7DtdgjTIjZ4yvfh/S7ETwAPS0XbJgP2JD8Ycr5CwVrEkV1sFtu3ShXeOw=="],
"@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wqlK0yO/TxEC2UsY9wIlqeeutF6jjLe0f96Pbm40XscTo57nImUk9lBcw0dPgsm0sppFtAkSlDrfpK+pC30Wqw=="],
"@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.28", "", { "dependencies": { "@aws-sdk/core": "^3.973.26", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@smithy/core": "^3.23.13", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-retry": "^4.2.13", "tslib": "^2.6.2" } }, "sha512-cfWZFlVh7Va9lRay4PN2A9ARFzaBYcA097InT5M2CdRS05ECF5yaz86jET8Wsl2WcyKYEvVr/QNmKtYtafUHtQ=="],
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.18", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.26", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.9", "@aws-sdk/middleware-user-agent": "^3.972.28", "@aws-sdk/region-config-resolver": "^3.972.10", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.14", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.13", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.28", "@smithy/middleware-retry": "^4.4.46", "@smithy/middleware-serde": "^4.2.16", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.1", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.8", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.44", "@smithy/util-defaults-mode-node": "^4.2.48", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.13", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-c7ZSIXrESxHKx2Mcopgd8AlzZgoXMr20fkx5ViPWPOLBvmyhw9VwJx/Govg8Ef/IhEon5R9l53Z8fdYSEmp6VA=="],
"@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/config-resolver": "^4.4.13", "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-1dq9ToC6e070QvnVhhbAs3bb5r6cQ10gTVc6cyRV5uvQe7P138TV2uG2i6+Yok4bAkVAcx5AqkTEBUvWEtBlsQ=="],
"@aws-sdk/s3-request-presigner": ["@aws-sdk/s3-request-presigner@3.1022.0", "", { "dependencies": { "@aws-sdk/signature-v4-multi-region": "^3.996.15", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-format-url": "^3.972.8", "@smithy/middleware-endpoint": "^4.4.28", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.8", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-2arKiJswYGEOScAhEeOuy/1A1wScfgbfmU/6NAn0UK0/LDxqsOTc4/bCEuUK+/LtB+Lxu3rODqbY8V5gTGPLaQ=="],
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.15", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.27", "@aws-sdk/types": "^3.973.6", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Ukw2RpqvaL96CjfH/FgfBmy/ZosHBqoHBCFsN61qGg99F33vpntIVii8aNeh65XuOja73arSduskoa4OJea9RQ=="],
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1021.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.26", "@aws-sdk/nested-clients": "^3.996.18", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-TKY6h9spUk3OLs5v1oAgW9mAeBE3LAGNBwJokLy96wwmd4W2v/tYlXseProyed9ValDj2u1jK/4Rg1T+1NXyJA=="],
"@aws-sdk/types": ["@aws-sdk/types@3.973.6", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw=="],
"@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.972.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA=="],
"@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.5", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-endpoints": "^3.3.3", "tslib": "^2.6.2" } }, "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw=="],
"@aws-sdk/util-format-url": ["@aws-sdk/util-format-url@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/querystring-builder": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-J6DS9oocrgxM8xlUTTmQOuwRF6rnAGEujAN9SAzllcrQmwn5iJ58ogxy3SEhD0Q7JZvlA5jvIXBkpQRqEqlE9A=="],
"@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.5", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ=="],
"@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA=="],
"@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.14", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.28", "@aws-sdk/types": "^3.973.6", "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-vNSB/DYaPOyujVZBg/zUznH9QC142MaTHVmaFlF7uzzfg3CgT9f/l4C0Yi+vU/tbBhxVcXVB90Oohk5+o+ZbWw=="],
"@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.16", "", { "dependencies": { "@smithy/types": "^4.13.1", "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" } }, "sha512-iu2pyvaqmeatIJLURLqx9D+4jKAdTH20ntzB6BFwjyN7V960r4jK32mx0Zf7YbtOYAbmbtQfDNuL60ONinyw7A=="],
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
"@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@hono/zod-validator": ["@hono/zod-validator@0.5.0", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-ds5bW6DCgAnNHP33E3ieSbaZFd5dkV52ZjyaXtGoR06APFrCtzAsKZxTHwOrJNBdXsi0e5wNwo5L4nVEVnJUdg=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@music-hub/api": ["@music-hub/api@workspace:apps/api"],
"@music-hub/db": ["@music-hub/db@workspace:packages/db"],
"@music-hub/shared": ["@music-hub/shared@workspace:packages/shared"],
"@music-hub/web": ["@music-hub/web@workspace:apps/web"],
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.1", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="],
"@smithy/chunked-blob-reader": ["@smithy/chunked-blob-reader@5.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw=="],
"@smithy/chunked-blob-reader-native": ["@smithy/chunked-blob-reader-native@4.2.3", "", { "dependencies": { "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw=="],
"@smithy/config-resolver": ["@smithy/config-resolver@4.4.13", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-iIzMC5NmOUP6WL6o8iPBjFhUhBZ9pPjpUpQYWMUFQqKyXXzOftbfK8zcQCz/jFV1Psmf05BK5ypx4K2r4Tnwdg=="],
"@smithy/core": ["@smithy/core@3.23.13", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.21", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-J+2TT9D6oGsUVXVEMvz8h2EmdVnkBiy2auCie4aSJMvKlzUtO5hqjEzXhoCUkIMo7gAYjbQcN0g/MMSXEhDs1Q=="],
"@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.12", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg=="],
"@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="],
"@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.2.12", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-XUSuMxlTxV5pp4VpqZf6Sa3vT/Q75FVkLSpSSE3KkWBvAQWeuWt1msTv8fJfgA4/jcJhrbrbMzN1AC/hvPmm5A=="],
"@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.3.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-7epsAZ3QvfHkngz6RXQYseyZYHlmWXSTPOfPmXkiS+zA6TBNo1awUaMFL9vxyXlGdoELmCZyZe1nQE+imbmV+Q=="],
"@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.2.12", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-D1pFuExo31854eAvg89KMn9Oab/wEeJR6Buy32B49A9Ogdtx5fwZPqBHUlDzaCDpycTFk2+fSQgX689Qsk7UGA=="],
"@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.12", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ=="],
"@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.15", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/querystring-builder": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A=="],
"@smithy/hash-blob-browser": ["@smithy/hash-blob-browser@4.2.13", "", { "dependencies": { "@smithy/chunked-blob-reader": "^5.2.2", "@smithy/chunked-blob-reader-native": "^4.2.3", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-YrF4zWKh+ghLuquldj6e/RzE3xZYL8wIPfkt0MqCRphVICjyyjH8OwKD7LLlKpVEbk4FLizFfC1+gwK6XQdR3g=="],
"@smithy/hash-node": ["@smithy/hash-node@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w=="],
"@smithy/hash-stream-node": ["@smithy/hash-stream-node@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-O3YbmGExeafuM/kP7Y8r6+1y0hIh3/zn6GROx0uNlB54K9oihAL75Qtc+jFfLNliTi6pxOAYZrRKD9A7iA6UFw=="],
"@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g=="],
"@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow=="],
"@smithy/md5-js": ["@smithy/md5-js@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-W/oIpHCpWU2+iAkfZYyGWE+qkpuf3vEXHLxQQDx9FPNZTTdnul0dZ2d/gUFrtQ5je1G2kp4cjG0/24YueG2LbQ=="],
"@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.12", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA=="],
"@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.28", "", { "dependencies": { "@smithy/core": "^3.23.13", "@smithy/middleware-serde": "^4.2.16", "@smithy/node-config-provider": "^4.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-p1gfYpi91CHcs5cBq982UlGlDrxoYUX6XdHSo91cQ2KFuz6QloHosO7Jc60pJiVmkWrKOV8kFYlGFFbQ2WUKKQ=="],
"@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.46", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/service-error-classification": "^4.2.12", "@smithy/smithy-client": "^4.12.8", "@smithy/types": "^4.13.1", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.13", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-SpvWNNOPOrKQGUqZbEPO+es+FRXMWvIyzUKUOYdDgdlA6BdZj/R58p4umoQ76c2oJC44PiM7mKizyyex1IJzow=="],
"@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.16", "", { "dependencies": { "@smithy/core": "^3.23.13", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-beqfV+RZ9RSv+sQqor3xroUUYgRFCGRw6niGstPG8zO9LgTl0B0MCucxjmrH/2WwksQN7UUgI7KNANoZv+KALA=="],
"@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw=="],
"@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.12", "", { "dependencies": { "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw=="],
"@smithy/node-http-handler": ["@smithy/node-http-handler@4.5.1", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/querystring-builder": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-ejjxdAXjkPIs9lyYyVutOGNOraqUE9v/NjGMKwwFrfOM354wfSD8lmlj8hVwUzQmlLLF4+udhfCX9Exnbmvfzw=="],
"@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
"@smithy/protocol-http": ["@smithy/protocol-http@5.3.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw=="],
"@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg=="],
"@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw=="],
"@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1" } }, "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ=="],
"@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.7", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw=="],
"@smithy/signature-v4": ["@smithy/signature-v4@5.3.12", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw=="],
"@smithy/smithy-client": ["@smithy/smithy-client@4.12.8", "", { "dependencies": { "@smithy/core": "^3.23.13", "@smithy/middleware-endpoint": "^4.4.28", "@smithy/middleware-stack": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-stream": "^4.5.21", "tslib": "^2.6.2" } }, "sha512-aJaAX7vHe5i66smoSSID7t4rKY08PbD8EBU7DOloixvhOozfYWdcSYE4l6/tjkZ0vBZhGjheWzB2mh31sLgCMA=="],
"@smithy/types": ["@smithy/types@4.13.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g=="],
"@smithy/url-parser": ["@smithy/url-parser@4.2.12", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA=="],
"@smithy/util-base64": ["@smithy/util-base64@4.3.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ=="],
"@smithy/util-body-length-browser": ["@smithy/util-body-length-browser@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ=="],
"@smithy/util-body-length-node": ["@smithy/util-body-length-node@4.2.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g=="],
"@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.2", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q=="],
"@smithy/util-config-provider": ["@smithy/util-config-provider@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ=="],
"@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.44", "", { "dependencies": { "@smithy/property-provider": "^4.2.12", "@smithy/smithy-client": "^4.12.8", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-eZg6XzaCbVr2S5cAErU5eGBDaOVTuTo1I65i4tQcHENRcZ8rMWhQy1DaIYUSLyZjsfXvmCqZrstSMYyGFocvHA=="],
"@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.48", "", { "dependencies": { "@smithy/config-resolver": "^4.4.13", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/smithy-client": "^4.12.8", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-FqOKTlqSaoV3nzO55pMs5NBnZX8EhoI0DGmn9kbYeXWppgHD6dchyuj2HLqp4INJDJbSrj6OFYJkAh/WhSzZPg=="],
"@smithy/util-endpoints": ["@smithy/util-endpoints@3.3.3", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig=="],
"@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg=="],
"@smithy/util-middleware": ["@smithy/util-middleware@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ=="],
"@smithy/util-retry": ["@smithy/util-retry@4.2.13", "", { "dependencies": { "@smithy/service-error-classification": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-qQQsIvL0MGIbUjeSrg0/VlQ3jGNKyM3/2iU3FPNgy01z+Sp4OvcaxbgIoFOTvB61ZoohtutuOvOcgmhbD0katQ=="],
"@smithy/util-stream": ["@smithy/util-stream@4.5.21", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.5.1", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-KzSg+7KKywLnkoKejRtIBXDmwBfjGvg1U1i/etkC7XSWUyFCoLno1IohV2c74IzQqdhX5y3uE44r/8/wuK+A7Q=="],
"@smithy/util-uri-escape": ["@smithy/util-uri-escape@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw=="],
"@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
"@smithy/util-waiter": ["@smithy/util-waiter@4.2.14", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-2zqq5o/oizvMaFUlNiTyZ7dbgYv1a893aGut2uaxtbzTx/VYYnRxWzDHuD/ftgcw94ffenua+ZNLrbqwUYE+Bg=="],
"@smithy/uuid": ["@smithy/uuid@1.1.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g=="],
"@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.9", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA=="],
"@sveltejs/adapter-auto": ["@sveltejs/adapter-auto@7.0.1", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ=="],
"@sveltejs/kit": ["@sveltejs/kit@2.55.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.6.4", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA=="],
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.4", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA=="],
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.2", "", { "dependencies": { "obug": "^2.1.0" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig=="],
"@turbo/darwin-64": ["@turbo/darwin-64@2.9.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-P8foouaP+y/p+hhEGBoZpzMbpVvUMwPjDpcy6wN7EYfvvyISD1USuV27qWkczecihwuPJzQ1lDBuL8ERcavTyg=="],
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SIzEkvtNdzdI50FJDaIQ6kQGqgSSdFPcdn0wqmmONN6iGKjy6hsT+EH99GP65FsfV7DLZTh2NmtTIRl2kdoz5Q=="],
"@turbo/linux-64": ["@turbo/linux-64@2.9.3", "", { "os": "linux", "cpu": "x64" }, "sha512-pLRwFmcHHNBvsCySLS6OFabr/07kDT2pxEt/k6eBf/3asiVQZKJ7Rk88AafQx2aYA641qek4RsXvYO3JYpiBug=="],
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-gy6ApUroC2Nzv+qjGtE/uPNkhHAFU4c8God+zd5Aiv9L9uBgHlxVJpHT3XWl5xwlJZ2KWuMrlHTaS5kmNB+q1Q=="],
"@turbo/windows-64": ["@turbo/windows-64@2.9.3", "", { "os": "win32", "cpu": "x64" }, "sha512-d0YelTX6hAsB7kIEtGB3PzIzSfAg3yDoUlHwuwJc3adBXUsyUIs0YLG+1NNtuhcDOUGnWQeKUoJ2pGWvbpRj7w=="],
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-/08CwpKJl3oRY8nOlh2YgilZVJDHsr60XTNxRhuDeuFXONpUZ5X+Nv65izbG/xBew9qxcJFbDX9/sAmAX+ITcQ=="],
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.58.0", "", {}, "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"devalue": ["devalue@5.6.4", "", {}, "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA=="],
"drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="],
"drizzle-orm": ["drizzle-orm@0.44.7", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
"esrap": ["esrap@2.2.4", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15", "@typescript-eslint/types": "^8.2.0" } }, "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg=="],
"fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="],
"fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="],
"fast-xml-parser": ["fast-xml-parser@5.5.8", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"get-tsconfig": ["get-tsconfig@4.13.7", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q=="],
"hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
"path-expression-matcher": ["path-expression-matcher@1.2.0", "", {}, "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"postal-mime": ["postal-mime@2.7.4", "", {}, "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g=="],
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
"postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"resend": ["resend@6.10.0", "", { "dependencies": { "postal-mime": "2.7.4", "svix": "1.88.0" }, "peerDependencies": { "@react-email/render": "*" }, "optionalPeers": ["@react-email/render"] }, "sha512-i7CwZpYj4Oho1RxsTpLcCUkO08+HiL4NXrm6jLJ2WzJ89UGI8eROSieLONJA3hnUrf1OYnCyfq5F6POnHUMv1Q=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="],
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
"set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="],
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
"standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="],
"strnum": ["strnum@2.2.2", "", {}, "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA=="],
"svelte": ["svelte@5.55.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.4", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-QjvU7EFemf6mRzdMGlAFttMWtAAVXrax61SZYHdkD6yoVGQ89VeyKfZD4H1JrV1WLmJBxWhFch9H6ig/87VGjw=="],
"svelte-check": ["svelte-check@4.4.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-kP1zG81EWaFe9ZyTv4ZXv44Csi6Pkdpb7S3oj6m+K2ec/IcDg/a8LsFsnVLqm2nxtkSwsd5xPj/qFkTBgXHXjg=="],
"svix": ["svix@1.88.0", "", { "dependencies": { "standardwebhooks": "1.0.0", "uuid": "^10.0.0" } }, "sha512-vm/JrrUd3bVyBE+3L33TIyVSs8gS5fYx7lrISvKlDJXTYX1ACH4REX8P1tHxsSKoZi/rvifM1t0XRc5Vc45THw=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
"turbo": ["turbo@2.9.3", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.3", "@turbo/darwin-arm64": "2.9.3", "@turbo/linux-64": "2.9.3", "@turbo/linux-arm64": "2.9.3", "@turbo/windows-64": "2.9.3", "@turbo/windows-arm64": "2.9.3" }, "bin": { "turbo": "bin/turbo" } }, "sha512-J/VUvsGRykPb9R8Kh8dHVBOqioDexLk9BhLCU/ZybRR+HN9UR3cURdazFvNgMDt9zPP8TF6K73Z+tplfmi0PqQ=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="],
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
"wavesurfer.js": ["wavesurfer.js@7.12.5", "", {}, "sha512-MSZcA13R9ZlxgYpzfakaSYf8dz5tCdZKYbjtN1qnKbCi+UoyfaTuhvjlXHrITi/fgeO3qWfsH7U3BP1AKnwRNg=="],
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"tsx/esbuild": ["esbuild@0.27.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.5", "@esbuild/android-arm": "0.27.5", "@esbuild/android-arm64": "0.27.5", "@esbuild/android-x64": "0.27.5", "@esbuild/darwin-arm64": "0.27.5", "@esbuild/darwin-x64": "0.27.5", "@esbuild/freebsd-arm64": "0.27.5", "@esbuild/freebsd-x64": "0.27.5", "@esbuild/linux-arm": "0.27.5", "@esbuild/linux-arm64": "0.27.5", "@esbuild/linux-ia32": "0.27.5", "@esbuild/linux-loong64": "0.27.5", "@esbuild/linux-mips64el": "0.27.5", "@esbuild/linux-ppc64": "0.27.5", "@esbuild/linux-riscv64": "0.27.5", "@esbuild/linux-s390x": "0.27.5", "@esbuild/linux-x64": "0.27.5", "@esbuild/netbsd-arm64": "0.27.5", "@esbuild/netbsd-x64": "0.27.5", "@esbuild/openbsd-arm64": "0.27.5", "@esbuild/openbsd-x64": "0.27.5", "@esbuild/openharmony-arm64": "0.27.5", "@esbuild/sunos-x64": "0.27.5", "@esbuild/win32-arm64": "0.27.5", "@esbuild/win32-ia32": "0.27.5", "@esbuild/win32-x64": "0.27.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-zdQoHBjuDqKsvV5OPaWansOwfSQ0Js+Uj9J85TBvj3bFW1JjWTSULMRwdQAc8qMeIScbClxeMK0jlrtB9linhA=="],
"vite/esbuild": ["esbuild@0.27.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.5", "@esbuild/android-arm": "0.27.5", "@esbuild/android-arm64": "0.27.5", "@esbuild/android-x64": "0.27.5", "@esbuild/darwin-arm64": "0.27.5", "@esbuild/darwin-x64": "0.27.5", "@esbuild/freebsd-arm64": "0.27.5", "@esbuild/freebsd-x64": "0.27.5", "@esbuild/linux-arm": "0.27.5", "@esbuild/linux-arm64": "0.27.5", "@esbuild/linux-ia32": "0.27.5", "@esbuild/linux-loong64": "0.27.5", "@esbuild/linux-mips64el": "0.27.5", "@esbuild/linux-ppc64": "0.27.5", "@esbuild/linux-riscv64": "0.27.5", "@esbuild/linux-s390x": "0.27.5", "@esbuild/linux-x64": "0.27.5", "@esbuild/netbsd-arm64": "0.27.5", "@esbuild/netbsd-x64": "0.27.5", "@esbuild/openbsd-arm64": "0.27.5", "@esbuild/openbsd-x64": "0.27.5", "@esbuild/openharmony-arm64": "0.27.5", "@esbuild/sunos-x64": "0.27.5", "@esbuild/win32-arm64": "0.27.5", "@esbuild/win32-ia32": "0.27.5", "@esbuild/win32-x64": "0.27.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-zdQoHBjuDqKsvV5OPaWansOwfSQ0Js+Uj9J85TBvj3bFW1JjWTSULMRwdQAc8qMeIScbClxeMK0jlrtB9linhA=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-nGsF/4C7uzUj+Nj/4J+Zt0bYQ6bz33Phz8Lb2N80Mti1HjGclTJdXZ+9APC4kLvONbjxN1zfvYNd8FEcbBK/MQ=="],
"tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.5", "", { "os": "android", "cpu": "arm" }, "sha512-Cv781jd0Rfj/paoNrul1/r4G0HLvuFKYh7C9uHZ2Pl8YXstzvCyyeWENTFR9qFnRzNMCjXmsulZuvosDg10Mog=="],
"tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.5", "", { "os": "android", "cpu": "arm64" }, "sha512-Oeghq+XFgh1pUGd1YKs4DDoxzxkoUkvko+T/IVKwlghKLvvjbGFB3ek8VEDBmNvqhwuL0CQS3cExdzpmUyIrgA=="],
"tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.5", "", { "os": "android", "cpu": "x64" }, "sha512-nQD7lspbzerlmtNOxYMFAGmhxgzn8Z7m9jgFkh6kpkjsAhZee1w8tJW3ZlW+N9iRePz0oPUDrYrXidCPSImD0Q=="],
"tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-I+Ya/MgC6rr8oRWGRDF3BXDfP8K1BVUggHqN6VI2lUZLdDi1IM1v2cy0e3lCPbP+pVcK3Tv8cgUhHse1kaNZZw=="],
"tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-MCjQUtC8wWJn/pIPM7vQaO69BFgwPD1jriEdqwTCKzWjGgkMbcg+M5HzrOhPhuYe1AJjXlHmD142KQf+jnYj8A=="],
"tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-X6xVS+goSH0UelYXnuf4GHLwpOdc8rgK/zai+dKzBMnncw7BTQIwquOodE7EKvY2UVUetSqyAfyZC1D+oqLQtg=="],
"tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-233X1FGo3a8x1ekLB6XT69LfZ83vqz+9z3TSEQCTYfMNY880A97nr81KbPcAMl9rmOFp11wO0dP+eB18KU/Ucg=="],
"tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.5", "", { "os": "linux", "cpu": "arm" }, "sha512-0wkVrYHG4sdCCN/bcwQ7yYMXACkaHc3UFeaEOwSVW6e5RycMageYAFv+JS2bKLwHyeKVUvtoVH+5/RHq0fgeFw=="],
"tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-euKkilsNOv7x/M1NKsx5znyprbpsRFIzTV6lWziqJch7yWYayfLtZzDxDTl+LSQDJYAjd9TVb/Kt5UKIrj2e4A=="],
"tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-hVRQX4+P3MS36NxOy24v/Cdsimy/5HYePw+tmPqnNN1fxV0bPrFWR6TMqwXPwoTM2VzbkA+4lbHWUKDd5ZDA/w=="],
"tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.5", "", { "os": "linux", "cpu": "none" }, "sha512-mKqqRuOPALI8nDzhOBmIS0INvZOOFGGg5n1osGIXAx8oersceEbKd4t1ACNTHM3sJBXGFAlEgqM+svzjPot+ZQ=="],
"tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.5", "", { "os": "linux", "cpu": "none" }, "sha512-EE/QXH9IyaAj1qeuIV5+/GZkBTipgGO782Ff7Um3vPS9cvLhJJeATy4Ggxikz2inZ46KByamMn6GqtqyVjhenA=="],
"tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-0V2iF1RGxBf1b7/BjurA5jfkl7PtySjom1r6xOK2q9KWw/XCpAdtB6KNMO+9xx69yYfSCRR9FE0TyKfHA2eQMw=="],
"tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.5", "", { "os": "linux", "cpu": "none" }, "sha512-rYxThBx6G9HN6tFNuvB/vykeLi4VDsm5hE5pVwzqbAjZEARQrWu3noZSfbEnPZ/CRXP3271GyFk/49up2W190g=="],
"tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-uEP2q/4qgd8goEUc4QIdU/1P2NmEtZ/zX5u3OpLlCGhJIuBIv0s0wr7TB2nBrd3/A5XIdEkkS5ZLF0ULuvaaYQ=="],
"tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.5", "", { "os": "linux", "cpu": "x64" }, "sha512-+Gq47Wqq6PLOOZuBzVSII2//9yyHNKZLuwfzCemqexqOQCSz0zy0O26kIzyp9EMNMK+nZ0tFHBZrCeVUuMs/ew=="],
"tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.5", "", { "os": "none", "cpu": "arm64" }, "sha512-3F/5EG8VHfN/I+W5cO1/SV2H9Q/5r7vcHabMnBqhHK2lTWOh3F8vixNzo8lqxrlmBtZVFpW8pmITHnq54+Tq4g=="],
"tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.5", "", { "os": "none", "cpu": "x64" }, "sha512-28t+Sj3CPN8vkMOlZotOmDgilQwVvxWZl7b8rxpn73Tt/gCnvrHxQUMng4uu3itdFvrtba/1nHejvxqz8xgEMA=="],
"tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.5", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Doz/hKtiuVAi9hMsBMpwBANhIZc8l238U2Onko3t2xUp8xtM0ZKdDYHMnm/qPFVthY8KtxkXaocwmMh6VolzMA=="],
"tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-WfGVaa1oz5A7+ZFPkERIbIhKT4olvGl1tyzTRaB5yoZRLqC0KwaO95FeZtOdQj/oKkjW57KcVF944m62/0GYtA=="],
"tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.5", "", { "os": "none", "cpu": "arm64" }, "sha512-Xh+VRuh6OMh3uJ0JkCjI57l+DVe7VRGBYymen8rFPnTVgATBwA6nmToxM2OwTlSvrnWpPKkrQUj93+K9huYC6A=="],
"tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-aC1gpJkkaUADHuAdQfuVTnqVUTLqqUNhAvEwHwVWcnVVZvNlDPGA0UveZsfXJJ9T6k9Po4eHi3c02gbdwO3g6w=="],
"tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-0UNx2aavV0fk6UpZcwXFLztA2r/k9jTUa7OW7SAea1VYUhkug99MW1uZeXEnPn5+cHOd0n8myQay6TlFnBR07w=="],
"tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-5nlJ3AeJWCTSzR7AEqVjT/faWyqKU86kCi1lLmxVqmNR+j4HrYdns+eTGjS/vmrzCIe8inGQckUadvS0+JkKdQ=="],
"tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.5", "", { "os": "win32", "cpu": "x64" }, "sha512-PWypQR+d4FLfkhBIV+/kHsUELAnMpx1bRvvsn3p+/sAERbnCzFrtDRG2Xw5n+2zPxBK2+iaP+vetsRl4Ti7WgA=="],
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-nGsF/4C7uzUj+Nj/4J+Zt0bYQ6bz33Phz8Lb2N80Mti1HjGclTJdXZ+9APC4kLvONbjxN1zfvYNd8FEcbBK/MQ=="],
"vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.5", "", { "os": "android", "cpu": "arm" }, "sha512-Cv781jd0Rfj/paoNrul1/r4G0HLvuFKYh7C9uHZ2Pl8YXstzvCyyeWENTFR9qFnRzNMCjXmsulZuvosDg10Mog=="],
"vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.5", "", { "os": "android", "cpu": "arm64" }, "sha512-Oeghq+XFgh1pUGd1YKs4DDoxzxkoUkvko+T/IVKwlghKLvvjbGFB3ek8VEDBmNvqhwuL0CQS3cExdzpmUyIrgA=="],
"vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.5", "", { "os": "android", "cpu": "x64" }, "sha512-nQD7lspbzerlmtNOxYMFAGmhxgzn8Z7m9jgFkh6kpkjsAhZee1w8tJW3ZlW+N9iRePz0oPUDrYrXidCPSImD0Q=="],
"vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-I+Ya/MgC6rr8oRWGRDF3BXDfP8K1BVUggHqN6VI2lUZLdDi1IM1v2cy0e3lCPbP+pVcK3Tv8cgUhHse1kaNZZw=="],
"vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-MCjQUtC8wWJn/pIPM7vQaO69BFgwPD1jriEdqwTCKzWjGgkMbcg+M5HzrOhPhuYe1AJjXlHmD142KQf+jnYj8A=="],
"vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-X6xVS+goSH0UelYXnuf4GHLwpOdc8rgK/zai+dKzBMnncw7BTQIwquOodE7EKvY2UVUetSqyAfyZC1D+oqLQtg=="],
"vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-233X1FGo3a8x1ekLB6XT69LfZ83vqz+9z3TSEQCTYfMNY880A97nr81KbPcAMl9rmOFp11wO0dP+eB18KU/Ucg=="],
"vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.5", "", { "os": "linux", "cpu": "arm" }, "sha512-0wkVrYHG4sdCCN/bcwQ7yYMXACkaHc3UFeaEOwSVW6e5RycMageYAFv+JS2bKLwHyeKVUvtoVH+5/RHq0fgeFw=="],
"vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-euKkilsNOv7x/M1NKsx5znyprbpsRFIzTV6lWziqJch7yWYayfLtZzDxDTl+LSQDJYAjd9TVb/Kt5UKIrj2e4A=="],
"vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-hVRQX4+P3MS36NxOy24v/Cdsimy/5HYePw+tmPqnNN1fxV0bPrFWR6TMqwXPwoTM2VzbkA+4lbHWUKDd5ZDA/w=="],
"vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.5", "", { "os": "linux", "cpu": "none" }, "sha512-mKqqRuOPALI8nDzhOBmIS0INvZOOFGGg5n1osGIXAx8oersceEbKd4t1ACNTHM3sJBXGFAlEgqM+svzjPot+ZQ=="],
"vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.5", "", { "os": "linux", "cpu": "none" }, "sha512-EE/QXH9IyaAj1qeuIV5+/GZkBTipgGO782Ff7Um3vPS9cvLhJJeATy4Ggxikz2inZ46KByamMn6GqtqyVjhenA=="],
"vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-0V2iF1RGxBf1b7/BjurA5jfkl7PtySjom1r6xOK2q9KWw/XCpAdtB6KNMO+9xx69yYfSCRR9FE0TyKfHA2eQMw=="],
"vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.5", "", { "os": "linux", "cpu": "none" }, "sha512-rYxThBx6G9HN6tFNuvB/vykeLi4VDsm5hE5pVwzqbAjZEARQrWu3noZSfbEnPZ/CRXP3271GyFk/49up2W190g=="],
"vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-uEP2q/4qgd8goEUc4QIdU/1P2NmEtZ/zX5u3OpLlCGhJIuBIv0s0wr7TB2nBrd3/A5XIdEkkS5ZLF0ULuvaaYQ=="],
"vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.5", "", { "os": "linux", "cpu": "x64" }, "sha512-+Gq47Wqq6PLOOZuBzVSII2//9yyHNKZLuwfzCemqexqOQCSz0zy0O26kIzyp9EMNMK+nZ0tFHBZrCeVUuMs/ew=="],
"vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.5", "", { "os": "none", "cpu": "arm64" }, "sha512-3F/5EG8VHfN/I+W5cO1/SV2H9Q/5r7vcHabMnBqhHK2lTWOh3F8vixNzo8lqxrlmBtZVFpW8pmITHnq54+Tq4g=="],
"vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.5", "", { "os": "none", "cpu": "x64" }, "sha512-28t+Sj3CPN8vkMOlZotOmDgilQwVvxWZl7b8rxpn73Tt/gCnvrHxQUMng4uu3itdFvrtba/1nHejvxqz8xgEMA=="],
"vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.5", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Doz/hKtiuVAi9hMsBMpwBANhIZc8l238U2Onko3t2xUp8xtM0ZKdDYHMnm/qPFVthY8KtxkXaocwmMh6VolzMA=="],
"vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-WfGVaa1oz5A7+ZFPkERIbIhKT4olvGl1tyzTRaB5yoZRLqC0KwaO95FeZtOdQj/oKkjW57KcVF944m62/0GYtA=="],
"vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.5", "", { "os": "none", "cpu": "arm64" }, "sha512-Xh+VRuh6OMh3uJ0JkCjI57l+DVe7VRGBYymen8rFPnTVgATBwA6nmToxM2OwTlSvrnWpPKkrQUj93+K9huYC6A=="],
"vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-aC1gpJkkaUADHuAdQfuVTnqVUTLqqUNhAvEwHwVWcnVVZvNlDPGA0UveZsfXJJ9T6k9Po4eHi3c02gbdwO3g6w=="],
"vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-0UNx2aavV0fk6UpZcwXFLztA2r/k9jTUa7OW7SAea1VYUhkug99MW1uZeXEnPn5+cHOd0n8myQay6TlFnBR07w=="],
"vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-5nlJ3AeJWCTSzR7AEqVjT/faWyqKU86kCi1lLmxVqmNR+j4HrYdns+eTGjS/vmrzCIe8inGQckUadvS0+JkKdQ=="],
"vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.5", "", { "os": "win32", "cpu": "x64" }, "sha512-PWypQR+d4FLfkhBIV+/kHsUELAnMpx1bRvvsn3p+/sAERbnCzFrtDRG2Xw5n+2zPxBK2+iaP+vetsRl4Ti7WgA=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
}
}

44
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,44 @@
services:
api:
build:
context: .
dockerfile: Dockerfile.api
ports:
- "3000:3000"
environment:
- DATABASE_URL=${DATABASE_URL}
- S3_ENDPOINT=${S3_ENDPOINT}
- S3_ACCESS_KEY=${S3_ACCESS_KEY}
- S3_SECRET_KEY=${S3_SECRET_KEY}
- S3_BUCKET=${S3_BUCKET}
- APP_URL=${APP_URL}
- MAGIC_LINK_SECRET=${MAGIC_LINK_SECRET}
- RESEND_API_KEY=${RESEND_API_KEY}
- EMAIL_FROM=${EMAIL_FROM}
- NODE_ENV=production
depends_on:
- postgres
restart: unless-stopped
web:
build:
context: .
dockerfile: Dockerfile.web
ports:
- "5173:3000"
environment:
- NODE_ENV=production
restart: unless-stopped
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: musichub
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: musichub
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped
volumes:
pgdata:

27
docker-compose.yml Normal file
View File

@@ -0,0 +1,27 @@
services:
postgres:
image: postgres:16-alpine
ports:
- "5433:5432"
environment:
POSTGRES_USER: musichub
POSTGRES_PASSWORD: musichub
POSTGRES_DB: musichub
volumes:
- pgdata:/var/lib/postgresql/data
minio:
image: minio/minio
command: server /data --console-address ":9001"
ports:
- "9000:9000"
- "9001:9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
volumes:
- miniodata:/data
volumes:
pgdata:
miniodata:

19
package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "music-hub",
"private": true,
"packageManager": "bun@1.3.11",
"type": "module",
"workspaces": ["packages/*", "apps/*"],
"scripts": {
"dev": "turbo dev",
"build": "turbo build",
"db:generate": "turbo db:generate --filter=@music-hub/db",
"db:migrate": "turbo db:migrate --filter=@music-hub/db",
"db:seed": "turbo db:seed --filter=@music-hub/db"
},
"devDependencies": {
"@types/bun": "latest",
"turbo": "latest",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/schema/index.ts',
out: './src/migrations',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});

21
packages/db/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "@music-hub/db",
"version": "0.0.1",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:seed": "bun run seed.ts"
},
"dependencies": {
"@music-hub/shared": "workspace:*",
"drizzle-orm": "^0.44",
"postgres": "^3.4"
},
"devDependencies": {
"drizzle-kit": "^0.31"
}
}

12
packages/db/src/index.ts Normal file
View File

@@ -0,0 +1,12 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema/index.js';
export function createDb(connectionString: string) {
const client = postgres(connectionString);
return drizzle(client, { schema });
}
export type Database = ReturnType<typeof createDb>;
export * from './schema/index.js';

View File

@@ -0,0 +1,108 @@
CREATE TYPE "public"."project_role" AS ENUM('owner', 'recording_engineer', 'mixing_engineer', 'mastering_engineer', 'artist', 'label', 'management', 'viewer');--> statement-breakpoint
CREATE TYPE "public"."version_status" AS ENUM('uploaded', 'processing', 'ready', 'approved', 'rejected');--> statement-breakpoint
CREATE TABLE "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"email" varchar(255) NOT NULL,
"name" varchar(255) NOT NULL,
"avatar_url" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "users_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "magic_links" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"email" varchar(255) NOT NULL,
"token" varchar(64) NOT NULL,
"expires_at" timestamp NOT NULL,
"used_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "magic_links_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "sessions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"token_hash" varchar(128) NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "sessions_token_hash_unique" UNIQUE("token_hash")
);
--> statement-breakpoint
CREATE TABLE "project_members" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"project_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"role" "project_role" NOT NULL,
"can_upload" boolean DEFAULT false NOT NULL,
"can_comment" boolean DEFAULT true NOT NULL,
"can_approve" boolean DEFAULT false NOT NULL,
"invited_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "project_members_project_id_user_id_unique" UNIQUE("project_id","user_id")
);
--> statement-breakpoint
CREATE TABLE "projects" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" varchar(255) NOT NULL,
"description" text,
"cover_image_url" text,
"created_by_id" uuid NOT NULL,
"is_archived" boolean DEFAULT false NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "tracks" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"project_id" uuid NOT NULL,
"name" varchar(255) NOT NULL,
"description" text,
"sort_order" integer DEFAULT 0 NOT NULL,
"created_by_id" uuid NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "versions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"track_id" uuid NOT NULL,
"version_number" integer NOT NULL,
"label" varchar(100),
"notes" text,
"status" "version_status" DEFAULT 'uploaded' NOT NULL,
"original_file_name" varchar(500) NOT NULL,
"mime_type" varchar(100) NOT NULL,
"file_size" bigint NOT NULL,
"duration" real,
"sample_rate" integer,
"bit_depth" integer,
"original_file_key" text NOT NULL,
"stream_file_key" text,
"waveform_data_key" text,
"created_by_id" uuid NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "versions_track_id_version_number_unique" UNIQUE("track_id","version_number")
);
--> statement-breakpoint
CREATE TABLE "comments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"version_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"body" text NOT NULL,
"timestamp_seconds" real,
"parent_id" uuid,
"resolved_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "project_members" ADD CONSTRAINT "project_members_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "project_members" ADD CONSTRAINT "project_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "projects" ADD CONSTRAINT "projects_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tracks" ADD CONSTRAINT "tracks_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tracks" ADD CONSTRAINT "tracks_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "versions" ADD CONSTRAINT "versions_track_id_tracks_id_fk" FOREIGN KEY ("track_id") REFERENCES "public"."tracks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "versions" ADD CONSTRAINT "versions_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "comments" ADD CONSTRAINT "comments_version_id_versions_id_fk" FOREIGN KEY ("version_id") REFERENCES "public"."versions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "comments" ADD CONSTRAINT "comments_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1,757 @@
{
"id": "4e5be5fd-2fae-43d2-8273-5a372d714cc5",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"avatar_url": {
"name": "avatar_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.magic_links": {
"name": "magic_links",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"used_at": {
"name": "used_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"magic_links_token_unique": {
"name": "magic_links_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sessions": {
"name": "sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"token_hash": {
"name": "token_hash",
"type": "varchar(128)",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"sessions_token_hash_unique": {
"name": "sessions_token_hash_unique",
"nullsNotDistinct": false,
"columns": [
"token_hash"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.project_members": {
"name": "project_members",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"project_id": {
"name": "project_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "project_role",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"can_upload": {
"name": "can_upload",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"can_comment": {
"name": "can_comment",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"can_approve": {
"name": "can_approve",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"invited_at": {
"name": "invited_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"project_members_project_id_projects_id_fk": {
"name": "project_members_project_id_projects_id_fk",
"tableFrom": "project_members",
"tableTo": "projects",
"columnsFrom": [
"project_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"project_members_user_id_users_id_fk": {
"name": "project_members_user_id_users_id_fk",
"tableFrom": "project_members",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"project_members_project_id_user_id_unique": {
"name": "project_members_project_id_user_id_unique",
"nullsNotDistinct": false,
"columns": [
"project_id",
"user_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.projects": {
"name": "projects",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"cover_image_url": {
"name": "cover_image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_by_id": {
"name": "created_by_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"is_archived": {
"name": "is_archived",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"projects_created_by_id_users_id_fk": {
"name": "projects_created_by_id_users_id_fk",
"tableFrom": "projects",
"tableTo": "users",
"columnsFrom": [
"created_by_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tracks": {
"name": "tracks",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"project_id": {
"name": "project_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_by_id": {
"name": "created_by_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"tracks_project_id_projects_id_fk": {
"name": "tracks_project_id_projects_id_fk",
"tableFrom": "tracks",
"tableTo": "projects",
"columnsFrom": [
"project_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"tracks_created_by_id_users_id_fk": {
"name": "tracks_created_by_id_users_id_fk",
"tableFrom": "tracks",
"tableTo": "users",
"columnsFrom": [
"created_by_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.versions": {
"name": "versions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"track_id": {
"name": "track_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"version_number": {
"name": "version_number",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"label": {
"name": "label",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "version_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'uploaded'"
},
"original_file_name": {
"name": "original_file_name",
"type": "varchar(500)",
"primaryKey": false,
"notNull": true
},
"mime_type": {
"name": "mime_type",
"type": "varchar(100)",
"primaryKey": false,
"notNull": true
},
"file_size": {
"name": "file_size",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"duration": {
"name": "duration",
"type": "real",
"primaryKey": false,
"notNull": false
},
"sample_rate": {
"name": "sample_rate",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"bit_depth": {
"name": "bit_depth",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"original_file_key": {
"name": "original_file_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"stream_file_key": {
"name": "stream_file_key",
"type": "text",
"primaryKey": false,
"notNull": false
},
"waveform_data_key": {
"name": "waveform_data_key",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_by_id": {
"name": "created_by_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"versions_track_id_tracks_id_fk": {
"name": "versions_track_id_tracks_id_fk",
"tableFrom": "versions",
"tableTo": "tracks",
"columnsFrom": [
"track_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"versions_created_by_id_users_id_fk": {
"name": "versions_created_by_id_users_id_fk",
"tableFrom": "versions",
"tableTo": "users",
"columnsFrom": [
"created_by_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"versions_track_id_version_number_unique": {
"name": "versions_track_id_version_number_unique",
"nullsNotDistinct": false,
"columns": [
"track_id",
"version_number"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.comments": {
"name": "comments",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"version_id": {
"name": "version_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": true
},
"timestamp_seconds": {
"name": "timestamp_seconds",
"type": "real",
"primaryKey": false,
"notNull": false
},
"parent_id": {
"name": "parent_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"resolved_at": {
"name": "resolved_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"comments_version_id_versions_id_fk": {
"name": "comments_version_id_versions_id_fk",
"tableFrom": "comments",
"tableTo": "versions",
"columnsFrom": [
"version_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"comments_user_id_users_id_fk": {
"name": "comments_user_id_users_id_fk",
"tableFrom": "comments",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.project_role": {
"name": "project_role",
"schema": "public",
"values": [
"owner",
"recording_engineer",
"mixing_engineer",
"mastering_engineer",
"artist",
"label",
"management",
"viewer"
]
},
"public.version_status": {
"name": "version_status",
"schema": "public",
"values": [
"uploaded",
"processing",
"ready",
"approved",
"rejected"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1775122377765,
"tag": "0000_magenta_apocalypse",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,21 @@
import { pgTable, uuid, varchar, timestamp } from 'drizzle-orm/pg-core';
import { users } from './users.js';
export const magicLinks = pgTable('magic_links', {
id: uuid('id').defaultRandom().primaryKey(),
email: varchar('email', { length: 255 }).notNull(),
token: varchar('token', { length: 64 }).notNull().unique(),
expiresAt: timestamp('expires_at').notNull(),
usedAt: timestamp('used_at'),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const sessions = pgTable('sessions', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
tokenHash: varchar('token_hash', { length: 128 }).notNull().unique(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});

View File

@@ -0,0 +1,19 @@
import { pgTable, uuid, text, real, timestamp } from 'drizzle-orm/pg-core';
import { users } from './users.js';
import { versions } from './tracks.js';
export const comments = pgTable('comments', {
id: uuid('id').defaultRandom().primaryKey(),
versionId: uuid('version_id')
.references(() => versions.id, { onDelete: 'cascade' })
.notNull(),
userId: uuid('user_id')
.references(() => users.id)
.notNull(),
body: text('body').notNull(),
timestampSeconds: real('timestamp_seconds'),
parentId: uuid('parent_id'),
resolvedAt: timestamp('resolved_at'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});

View File

@@ -0,0 +1,5 @@
export * from './users.js';
export * from './auth.js';
export * from './projects.js';
export * from './tracks.js';
export * from './comments.js';

View File

@@ -0,0 +1,54 @@
import {
pgTable,
pgEnum,
uuid,
varchar,
text,
boolean,
timestamp,
unique,
} from 'drizzle-orm/pg-core';
import { users } from './users.js';
export const projectRoleEnum = pgEnum('project_role', [
'owner',
'recording_engineer',
'mixing_engineer',
'mastering_engineer',
'artist',
'label',
'management',
'viewer',
]);
export const projects = pgTable('projects', {
id: uuid('id').defaultRandom().primaryKey(),
name: varchar('name', { length: 255 }).notNull(),
description: text('description'),
coverImageUrl: text('cover_image_url'),
createdById: uuid('created_by_id')
.references(() => users.id)
.notNull(),
isArchived: boolean('is_archived').default(false).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
export const projectMembers = pgTable(
'project_members',
{
id: uuid('id').defaultRandom().primaryKey(),
projectId: uuid('project_id')
.references(() => projects.id, { onDelete: 'cascade' })
.notNull(),
userId: uuid('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
role: projectRoleEnum('role').notNull(),
canUpload: boolean('can_upload').default(false).notNull(),
canComment: boolean('can_comment').default(true).notNull(),
canApprove: boolean('can_approve').default(false).notNull(),
invitedAt: timestamp('invited_at').defaultNow().notNull(),
},
(table) => [unique().on(table.projectId, table.userId)],
);

View File

@@ -0,0 +1,68 @@
import {
pgTable,
pgEnum,
uuid,
varchar,
text,
integer,
bigint,
real,
timestamp,
unique,
} from 'drizzle-orm/pg-core';
import { users } from './users.js';
import { projects } from './projects.js';
export const versionStatusEnum = pgEnum('version_status', [
'uploaded',
'processing',
'ready',
'approved',
'rejected',
]);
export const tracks = pgTable('tracks', {
id: uuid('id').defaultRandom().primaryKey(),
projectId: uuid('project_id')
.references(() => projects.id, { onDelete: 'cascade' })
.notNull(),
name: varchar('name', { length: 255 }).notNull(),
description: text('description'),
sortOrder: integer('sort_order').default(0).notNull(),
createdById: uuid('created_by_id')
.references(() => users.id)
.notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
export const versions = pgTable(
'versions',
{
id: uuid('id').defaultRandom().primaryKey(),
trackId: uuid('track_id')
.references(() => tracks.id, { onDelete: 'cascade' })
.notNull(),
versionNumber: integer('version_number').notNull(),
label: varchar('label', { length: 100 }),
notes: text('notes'),
status: versionStatusEnum('status').default('uploaded').notNull(),
originalFileName: varchar('original_file_name', { length: 500 }).notNull(),
mimeType: varchar('mime_type', { length: 100 }).notNull(),
fileSize: bigint('file_size', { mode: 'number' }).notNull(),
duration: real('duration'),
sampleRate: integer('sample_rate'),
bitDepth: integer('bit_depth'),
originalFileKey: text('original_file_key').notNull(),
streamFileKey: text('stream_file_key'),
waveformDataKey: text('waveform_data_key'),
createdById: uuid('created_by_id')
.references(() => users.id)
.notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
},
(table) => [unique().on(table.trackId, table.versionNumber)],
);

View File

@@ -0,0 +1,10 @@
import { pgTable, uuid, varchar, text, timestamp } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: uuid('id').defaultRandom().primaryKey(),
email: varchar('email', { length: 255 }).notNull().unique(),
name: varchar('name', { length: 255 }).notNull(),
avatarUrl: text('avatar_url'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});

13
packages/db/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"outDir": "dist"
},
"include": ["src"]
}

View File

@@ -0,0 +1,11 @@
{
"name": "@music-hub/shared",
"version": "0.0.1",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"zod": "^3.24"
}
}

View File

@@ -0,0 +1,24 @@
export const SUPPORTED_AUDIO_FORMATS = [
'audio/wav',
'audio/x-wav',
'audio/mp3',
'audio/mpeg',
'audio/flac',
'audio/x-flac',
'audio/aiff',
'audio/x-aiff',
] as const;
export const SUPPORTED_EXTENSIONS = ['.wav', '.mp3', '.flac', '.aiff', '.aif'] as const;
export const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB
export const VERSION_STATUSES = [
'uploaded',
'processing',
'ready',
'approved',
'rejected',
] as const;
export type VersionStatus = (typeof VERSION_STATUSES)[number];

View File

@@ -0,0 +1,3 @@
export * from './roles.js';
export * from './permissions.js';
export * from './audio.js';

View File

@@ -0,0 +1,69 @@
import type { ProjectRole } from './roles.js';
export type Permission =
| 'project.edit'
| 'project.invite'
| 'track.upload'
| 'track.listen'
| 'track.download'
| 'version.comment'
| 'version.approve';
const PERMISSIONS: Record<ProjectRole, Permission[]> = {
owner: [
'project.edit',
'project.invite',
'track.upload',
'track.listen',
'track.download',
'version.comment',
'version.approve',
],
recording_engineer: [
'track.upload',
'track.listen',
'track.download',
'version.comment',
],
mixing_engineer: [
'track.upload',
'track.listen',
'track.download',
'version.comment',
],
mastering_engineer: [
'track.upload',
'track.listen',
'track.download',
'version.comment',
],
artist: [
'track.listen',
'track.download',
'version.comment',
'version.approve',
],
label: [
'track.listen',
'version.comment',
'version.approve',
],
management: [
'project.invite',
'track.listen',
'track.download',
'version.comment',
'version.approve',
],
viewer: [
'track.listen',
],
};
export function hasPermission(role: ProjectRole, permission: Permission): boolean {
return PERMISSIONS[role].includes(permission);
}
export function getPermissions(role: ProjectRole): Permission[] {
return PERMISSIONS[role];
}

View File

@@ -0,0 +1,29 @@
export const PROJECT_ROLES = [
'owner',
'recording_engineer',
'mixing_engineer',
'mastering_engineer',
'artist',
'label',
'management',
'viewer',
] as const;
export type ProjectRole = (typeof PROJECT_ROLES)[number];
export const ROLE_LABELS: Record<ProjectRole, string> = {
owner: 'Owner',
recording_engineer: 'Recording Engineer',
mixing_engineer: 'Mixing Engineer',
mastering_engineer: 'Mastering Engineer',
artist: 'Artist',
label: 'Label',
management: 'Management',
viewer: 'Viewer',
};
export const ENGINEER_ROLES: ProjectRole[] = [
'recording_engineer',
'mixing_engineer',
'mastering_engineer',
];

View File

@@ -0,0 +1,2 @@
export * from './constants/index.js';
export * from './validation/index.js';

View File

@@ -0,0 +1,12 @@
import { z } from 'zod';
export const magicLinkSchema = z.object({
email: z.string().email(),
});
export const verifyTokenSchema = z.object({
token: z.string().min(1),
});
export type MagicLinkInput = z.infer<typeof magicLinkSchema>;
export type VerifyTokenInput = z.infer<typeof verifyTokenSchema>;

View File

@@ -0,0 +1,14 @@
import { z } from 'zod';
export const createCommentSchema = z.object({
body: z.string().min(1).max(5000),
timestampSeconds: z.number().nonnegative().optional(),
parentId: z.string().uuid().optional(),
});
export const updateCommentSchema = z.object({
body: z.string().min(1).max(5000),
});
export type CreateCommentInput = z.infer<typeof createCommentSchema>;
export type UpdateCommentInput = z.infer<typeof updateCommentSchema>;

View File

@@ -0,0 +1,4 @@
export * from './auth.js';
export * from './project.js';
export * from './track.js';
export * from './comment.js';

View File

@@ -0,0 +1,30 @@
import { z } from 'zod';
import { PROJECT_ROLES } from '../constants/roles.js';
export const createProjectSchema = z.object({
name: z.string().min(1).max(255),
description: z.string().max(2000).optional(),
});
export const updateProjectSchema = z.object({
name: z.string().min(1).max(255).optional(),
description: z.string().max(2000).optional(),
});
export const inviteMemberSchema = z.object({
email: z.string().email(),
role: z.enum(PROJECT_ROLES).refine((r) => r !== 'owner', {
message: 'Cannot invite as owner',
}),
});
export const updateMemberSchema = z.object({
role: z.enum(PROJECT_ROLES).refine((r) => r !== 'owner', {
message: 'Cannot change role to owner',
}),
});
export type CreateProjectInput = z.infer<typeof createProjectSchema>;
export type UpdateProjectInput = z.infer<typeof updateProjectSchema>;
export type InviteMemberInput = z.infer<typeof inviteMemberSchema>;
export type UpdateMemberInput = z.infer<typeof updateMemberSchema>;

View File

@@ -0,0 +1,32 @@
import { z } from 'zod';
import { SUPPORTED_AUDIO_FORMATS, MAX_FILE_SIZE } from '../constants/audio.js';
export const createTrackSchema = z.object({
name: z.string().min(1).max(255),
description: z.string().max(2000).optional(),
});
export const updateTrackSchema = z.object({
name: z.string().min(1).max(255).optional(),
description: z.string().max(2000).optional(),
});
export const requestUploadUrlSchema = z.object({
fileName: z.string().min(1),
mimeType: z.enum(SUPPORTED_AUDIO_FORMATS),
fileSize: z.number().int().positive().max(MAX_FILE_SIZE),
});
export const createVersionSchema = z.object({
fileKey: z.string().min(1),
label: z.string().max(100).optional(),
notes: z.string().max(2000).optional(),
originalFileName: z.string().min(1),
mimeType: z.string().min(1),
fileSize: z.number().int().positive(),
});
export type CreateTrackInput = z.infer<typeof createTrackSchema>;
export type UpdateTrackInput = z.infer<typeof updateTrackSchema>;
export type RequestUploadUrlInput = z.infer<typeof requestUploadUrlSchema>;
export type CreateVersionInput = z.infer<typeof createVersionSchema>;

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"outDir": "dist"
},
"include": ["src"]
}

29
tsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

22
turbo.json Normal file
View File

@@ -0,0 +1,22 @@
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"db:generate": {
"cache": false
},
"db:migrate": {
"cache": false
},
"db:seed": {
"cache": false
}
}
}