Phase 1: version branching + public share links
Add parentVersionId/branchLabel to versions, enabling git-style branching. New /tree and /promote endpoints; VersionGraph (SVG) component as toggle next to the existing list view. Upload dropzone accepts a parent for branch uploads. Add public share links: new share_links table, /api/v1/share router with authenticated CRUD and a public /public/:token endpoint serving signed stream/waveform URLs. Comments now allow guests (nullable userId, guestName) so artists can leave timestamped feedback without an account. New /listen/:token standalone page with password gate, optional download, and guest comment form. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import { projectRoutes } from './routes/projects.js';
|
|||||||
import { trackRoutes } from './routes/tracks.js';
|
import { trackRoutes } from './routes/tracks.js';
|
||||||
import { versionRoutes } from './routes/versions.js';
|
import { versionRoutes } from './routes/versions.js';
|
||||||
import { commentRoutes } from './routes/comments.js';
|
import { commentRoutes } from './routes/comments.js';
|
||||||
|
import { shareRoutes } from './routes/share.js';
|
||||||
import type { AppEnv } from './types.js';
|
import type { AppEnv } from './types.js';
|
||||||
|
|
||||||
const db = createDb(process.env.DATABASE_URL!);
|
const db = createDb(process.env.DATABASE_URL!);
|
||||||
@@ -34,7 +35,8 @@ const app = new Hono<AppEnv>()
|
|||||||
.route('/projects', projectRoutes)
|
.route('/projects', projectRoutes)
|
||||||
.route('/tracks', trackRoutes)
|
.route('/tracks', trackRoutes)
|
||||||
.route('/versions', versionRoutes)
|
.route('/versions', versionRoutes)
|
||||||
.route('/comments', commentRoutes);
|
.route('/comments', commentRoutes)
|
||||||
|
.route('/share', shareRoutes);
|
||||||
|
|
||||||
const port = parseInt(process.env.PORT || '3000');
|
const port = parseInt(process.env.PORT || '3000');
|
||||||
console.log(`Music Hub API running on port ${port}`);
|
console.log(`Music Hub API running on port ${port}`);
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export const commentRoutes = new Hono<AppEnv>()
|
|||||||
parentId: comments.parentId,
|
parentId: comments.parentId,
|
||||||
resolvedAt: comments.resolvedAt,
|
resolvedAt: comments.resolvedAt,
|
||||||
createdAt: comments.createdAt,
|
createdAt: comments.createdAt,
|
||||||
|
guestName: comments.guestName,
|
||||||
user: {
|
user: {
|
||||||
id: users.id,
|
id: users.id,
|
||||||
name: users.name,
|
name: users.name,
|
||||||
@@ -54,7 +55,7 @@ export const commentRoutes = new Hono<AppEnv>()
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
.from(comments)
|
.from(comments)
|
||||||
.innerJoin(users, eq(users.id, comments.userId))
|
.leftJoin(users, eq(users.id, comments.userId))
|
||||||
.where(eq(comments.versionId, versionId))
|
.where(eq(comments.versionId, versionId))
|
||||||
.orderBy(asc(comments.createdAt));
|
.orderBy(asc(comments.createdAt));
|
||||||
|
|
||||||
|
|||||||
265
apps/api/src/routes/share.ts
Normal file
265
apps/api/src/routes/share.ts
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import { eq, and, asc } from 'drizzle-orm';
|
||||||
|
import { createShareLinkSchema, guestCommentSchema } from '@music-hub/shared';
|
||||||
|
import {
|
||||||
|
shareLinks,
|
||||||
|
versions,
|
||||||
|
tracks,
|
||||||
|
projects,
|
||||||
|
comments,
|
||||||
|
users,
|
||||||
|
projectMembers,
|
||||||
|
} from '@music-hub/db';
|
||||||
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
import { createDownloadUrl } from '../storage/s3.js';
|
||||||
|
import type { AppEnv } from '../types.js';
|
||||||
|
|
||||||
|
function generateToken(): string {
|
||||||
|
const bytes = new Uint8Array(32);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const shareRoutes = new Hono<AppEnv>()
|
||||||
|
// --- Authenticated: create / list / revoke ---
|
||||||
|
.post(
|
||||||
|
'/version/:versionId',
|
||||||
|
requireAuth,
|
||||||
|
zValidator('json', createShareLinkSchema),
|
||||||
|
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) return c.json({ error: 'Forbidden' }, 403);
|
||||||
|
|
||||||
|
const token = generateToken();
|
||||||
|
const passwordHash = input.password
|
||||||
|
? await Bun.password.hash(input.password)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const [link] = await db
|
||||||
|
.insert(shareLinks)
|
||||||
|
.values({
|
||||||
|
versionId,
|
||||||
|
token,
|
||||||
|
createdById: userId,
|
||||||
|
expiresAt: input.expiresAt ? new Date(input.expiresAt) : null,
|
||||||
|
allowComments: input.allowComments ?? true,
|
||||||
|
allowDownload: input.allowDownload ?? false,
|
||||||
|
passwordHash,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return c.json({ link: { ...link, passwordHash: undefined } }, 201);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
.get('/version/:versionId', requireAuth, 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: 'Forbidden' }, 403);
|
||||||
|
|
||||||
|
const links = await db
|
||||||
|
.select({
|
||||||
|
id: shareLinks.id,
|
||||||
|
token: shareLinks.token,
|
||||||
|
expiresAt: shareLinks.expiresAt,
|
||||||
|
allowComments: shareLinks.allowComments,
|
||||||
|
allowDownload: shareLinks.allowDownload,
|
||||||
|
hasPassword: shareLinks.passwordHash,
|
||||||
|
createdAt: shareLinks.createdAt,
|
||||||
|
})
|
||||||
|
.from(shareLinks)
|
||||||
|
.where(eq(shareLinks.versionId, versionId))
|
||||||
|
.orderBy(asc(shareLinks.createdAt));
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
links: links.map((l) => ({ ...l, hasPassword: l.hasPassword !== null })),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
.delete('/:linkId', requireAuth, async (c) => {
|
||||||
|
const db = c.get('db');
|
||||||
|
const userId = c.get('userId');
|
||||||
|
const linkId = c.req.param('linkId');
|
||||||
|
|
||||||
|
const [link] = await db
|
||||||
|
.select()
|
||||||
|
.from(shareLinks)
|
||||||
|
.where(eq(shareLinks.id, linkId))
|
||||||
|
.limit(1);
|
||||||
|
if (!link) return c.json({ error: 'Not found' }, 404);
|
||||||
|
if (link.createdById !== userId) return c.json({ error: 'Forbidden' }, 403);
|
||||||
|
|
||||||
|
await db.delete(shareLinks).where(eq(shareLinks.id, linkId));
|
||||||
|
return c.json({ message: 'Revoked' });
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Public: resolve token, fetch, comment ---
|
||||||
|
.get('/public/:token', async (c) => {
|
||||||
|
const db = c.get('db');
|
||||||
|
const token = c.req.param('token');
|
||||||
|
const password = c.req.header('x-share-password');
|
||||||
|
|
||||||
|
const [link] = await db
|
||||||
|
.select()
|
||||||
|
.from(shareLinks)
|
||||||
|
.where(eq(shareLinks.token, token))
|
||||||
|
.limit(1);
|
||||||
|
if (!link) return c.json({ error: 'Not found' }, 404);
|
||||||
|
if (link.expiresAt && link.expiresAt < new Date()) {
|
||||||
|
return c.json({ error: 'Expired' }, 410);
|
||||||
|
}
|
||||||
|
if (link.passwordHash) {
|
||||||
|
if (!password || !(await Bun.password.verify(password, link.passwordHash))) {
|
||||||
|
return c.json({ error: 'Password required', passwordRequired: true }, 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [version] = await db
|
||||||
|
.select()
|
||||||
|
.from(versions)
|
||||||
|
.where(eq(versions.id, link.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 [project] = await db
|
||||||
|
.select()
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, track!.projectId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const streamKey = version.streamFileKey || version.originalFileKey;
|
||||||
|
const streamUrl = await createDownloadUrl(streamKey);
|
||||||
|
const waveformUrl = version.waveformDataKey
|
||||||
|
? await createDownloadUrl(version.waveformDataKey)
|
||||||
|
: null;
|
||||||
|
const downloadUrl = link.allowDownload
|
||||||
|
? await createDownloadUrl(version.originalFileKey)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const versionComments = await db
|
||||||
|
.select({
|
||||||
|
id: comments.id,
|
||||||
|
body: comments.body,
|
||||||
|
timestampSeconds: comments.timestampSeconds,
|
||||||
|
parentId: comments.parentId,
|
||||||
|
resolvedAt: comments.resolvedAt,
|
||||||
|
createdAt: comments.createdAt,
|
||||||
|
guestName: comments.guestName,
|
||||||
|
user: {
|
||||||
|
id: users.id,
|
||||||
|
name: users.name,
|
||||||
|
avatarUrl: users.avatarUrl,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.from(comments)
|
||||||
|
.leftJoin(users, eq(users.id, comments.userId))
|
||||||
|
.where(eq(comments.versionId, version.id))
|
||||||
|
.orderBy(asc(comments.createdAt));
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
project: { name: project!.name },
|
||||||
|
track: { id: track!.id, name: track!.name },
|
||||||
|
version: {
|
||||||
|
id: version.id,
|
||||||
|
label: version.label,
|
||||||
|
notes: version.notes,
|
||||||
|
duration: version.duration,
|
||||||
|
status: version.status,
|
||||||
|
originalFileName: version.originalFileName,
|
||||||
|
},
|
||||||
|
streamUrl,
|
||||||
|
waveformUrl,
|
||||||
|
downloadUrl,
|
||||||
|
allowComments: link.allowComments,
|
||||||
|
comments: versionComments,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
.post('/public/:token/comments', zValidator('json', guestCommentSchema), async (c) => {
|
||||||
|
const db = c.get('db');
|
||||||
|
const token = c.req.param('token');
|
||||||
|
const password = c.req.header('x-share-password');
|
||||||
|
const input = c.req.valid('json');
|
||||||
|
|
||||||
|
const [link] = await db
|
||||||
|
.select()
|
||||||
|
.from(shareLinks)
|
||||||
|
.where(eq(shareLinks.token, token))
|
||||||
|
.limit(1);
|
||||||
|
if (!link) return c.json({ error: 'Not found' }, 404);
|
||||||
|
if (link.expiresAt && link.expiresAt < new Date()) {
|
||||||
|
return c.json({ error: 'Expired' }, 410);
|
||||||
|
}
|
||||||
|
if (!link.allowComments) return c.json({ error: 'Comments disabled' }, 403);
|
||||||
|
if (link.passwordHash) {
|
||||||
|
if (!password || !(await Bun.password.verify(password, link.passwordHash))) {
|
||||||
|
return c.json({ error: 'Password required' }, 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [comment] = await db
|
||||||
|
.insert(comments)
|
||||||
|
.values({
|
||||||
|
versionId: link.versionId,
|
||||||
|
userId: null,
|
||||||
|
guestName: input.guestName,
|
||||||
|
body: input.body,
|
||||||
|
timestampSeconds: input.timestampSeconds,
|
||||||
|
parentId: input.parentId,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return c.json({ comment }, 201);
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
import { eq, and, desc, sql } from 'drizzle-orm';
|
import { eq, and, desc, asc, sql } from 'drizzle-orm';
|
||||||
import { requestUploadUrlSchema, createVersionSchema } from '@music-hub/shared';
|
import { requestUploadUrlSchema, createVersionSchema } from '@music-hub/shared';
|
||||||
import { tracks, versions, projectMembers } from '@music-hub/db';
|
import { tracks, versions, projectMembers } from '@music-hub/db';
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
@@ -97,6 +97,8 @@ export const versionRoutes = new Hono<AppEnv>()
|
|||||||
label: input.label,
|
label: input.label,
|
||||||
notes: input.notes,
|
notes: input.notes,
|
||||||
status: 'uploaded',
|
status: 'uploaded',
|
||||||
|
parentVersionId: input.parentVersionId,
|
||||||
|
branchLabel: input.branchLabel,
|
||||||
originalFileName: input.originalFileName,
|
originalFileName: input.originalFileName,
|
||||||
mimeType: input.mimeType,
|
mimeType: input.mimeType,
|
||||||
fileSize: input.fileSize,
|
fileSize: input.fileSize,
|
||||||
@@ -113,6 +115,81 @@ export const versionRoutes = new Hono<AppEnv>()
|
|||||||
return c.json({ version }, 201);
|
return c.json({ version }, 201);
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Get version tree (graph) for a track
|
||||||
|
.get('/track/:trackId/tree', 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 nodes = await db
|
||||||
|
.select({
|
||||||
|
id: versions.id,
|
||||||
|
parentVersionId: versions.parentVersionId,
|
||||||
|
branchLabel: versions.branchLabel,
|
||||||
|
versionNumber: versions.versionNumber,
|
||||||
|
label: versions.label,
|
||||||
|
status: versions.status,
|
||||||
|
createdById: versions.createdById,
|
||||||
|
createdAt: versions.createdAt,
|
||||||
|
})
|
||||||
|
.from(versions)
|
||||||
|
.where(eq(versions.trackId, trackId))
|
||||||
|
.orderBy(asc(versions.createdAt));
|
||||||
|
|
||||||
|
return c.json({ nodes });
|
||||||
|
})
|
||||||
|
|
||||||
|
// Promote a version to mainline (clears branchLabel)
|
||||||
|
.post('/:id/promote', 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({ branchLabel: null })
|
||||||
|
.where(eq(versions.id, versionId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return c.json({ version: updated });
|
||||||
|
})
|
||||||
|
|
||||||
// Get stream URL
|
// Get stream URL
|
||||||
.get('/:id/stream-url', async (c) => {
|
.get('/:id/stream-url', async (c) => {
|
||||||
const db = c.get('db');
|
const db = c.get('db');
|
||||||
|
|||||||
@@ -4,9 +4,13 @@
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
trackId,
|
trackId,
|
||||||
|
parentVersionId = null,
|
||||||
|
branchLabel = null,
|
||||||
onUploaded,
|
onUploaded,
|
||||||
}: {
|
}: {
|
||||||
trackId: string;
|
trackId: string;
|
||||||
|
parentVersionId?: string | null;
|
||||||
|
branchLabel?: string | null;
|
||||||
onUploaded: () => void;
|
onUploaded: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
@@ -78,6 +82,8 @@
|
|||||||
originalFileName: file.name,
|
originalFileName: file.name,
|
||||||
mimeType: file.type || 'audio/wav',
|
mimeType: file.type || 'audio/wav',
|
||||||
fileSize: file.size,
|
fileSize: file.size,
|
||||||
|
parentVersionId: parentVersionId ?? undefined,
|
||||||
|
branchLabel: branchLabel ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
label = '';
|
label = '';
|
||||||
|
|||||||
339
apps/web/src/routes/listen/[token]/+page.svelte
Normal file
339
apps/web/src/routes/listen/[token]/+page.svelte
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import WaveformPlayer from '$lib/components/audio/WaveformPlayer.svelte';
|
||||||
|
import Button from '$lib/components/ui/Button.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;
|
||||||
|
guestName: string | null;
|
||||||
|
user: { id: string; name: string; avatarUrl: string | null } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ShareData = {
|
||||||
|
project: { name: string };
|
||||||
|
track: { id: string; name: string };
|
||||||
|
version: {
|
||||||
|
id: string;
|
||||||
|
label: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
duration: number | null;
|
||||||
|
status: string;
|
||||||
|
originalFileName: string;
|
||||||
|
};
|
||||||
|
streamUrl: string;
|
||||||
|
waveformUrl: string | null;
|
||||||
|
downloadUrl: string | null;
|
||||||
|
allowComments: boolean;
|
||||||
|
comments: Comment[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const token = ($page.params as Record<string, string>).token;
|
||||||
|
let data = $state<ShareData | null>(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state('');
|
||||||
|
let passwordRequired = $state(false);
|
||||||
|
let password = $state('');
|
||||||
|
|
||||||
|
let guestName = $state('');
|
||||||
|
let body = $state('');
|
||||||
|
let commentTimestamp = $state<number | null>(null);
|
||||||
|
let submitting = $state(false);
|
||||||
|
let playerRef = $state<WaveformPlayer>();
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/share/public/${token}`, {
|
||||||
|
headers: password ? { 'X-Share-Password': password } : {},
|
||||||
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
const j = await res.json();
|
||||||
|
if (j.passwordRequired) {
|
||||||
|
passwordRequired = true;
|
||||||
|
loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
error = (await res.json().catch(() => ({}))).error || 'Link nicht verfügbar';
|
||||||
|
loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
data = await res.json();
|
||||||
|
passwordRequired = false;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(load);
|
||||||
|
|
||||||
|
async function submitComment(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!body.trim() || !guestName.trim() || !data) return;
|
||||||
|
submitting = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/share/public/${token}/comments`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(password ? { 'X-Share-Password': password } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
body,
|
||||||
|
guestName,
|
||||||
|
timestampSeconds: commentTimestamp ?? undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
error = 'Kommentar fehlgeschlagen';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body = '';
|
||||||
|
commentTimestamp = null;
|
||||||
|
await load();
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{data ? `${data.track.name} — ${data.project.name}` : 'Music Hub'}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="listen-page">
|
||||||
|
{#if loading}
|
||||||
|
<p class="muted">Lädt…</p>
|
||||||
|
{:else if passwordRequired}
|
||||||
|
<div class="password-gate">
|
||||||
|
<h1>🔒 Geschützter Link</h1>
|
||||||
|
<p>Bitte Passwort eingeben:</p>
|
||||||
|
<input type="password" bind:value={password} placeholder="Passwort" />
|
||||||
|
<Button onclick={load}>Öffnen</Button>
|
||||||
|
{#if error}<p class="error">{error}</p>{/if}
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<p class="error">{error}</p>
|
||||||
|
{:else if data}
|
||||||
|
<header>
|
||||||
|
<p class="project">{data.project.name}</p>
|
||||||
|
<h1>{data.track.name}</h1>
|
||||||
|
{#if data.version.label}<p class="version-label">{data.version.label}</p>{/if}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<WaveformPlayer
|
||||||
|
bind:this={playerRef}
|
||||||
|
url={data.streamUrl}
|
||||||
|
markers={data.comments
|
||||||
|
.filter((c) => c.timestampSeconds !== null)
|
||||||
|
.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
timestampSeconds: c.timestampSeconds!,
|
||||||
|
body: c.body,
|
||||||
|
userName: c.user?.name ?? c.guestName ?? 'Gast',
|
||||||
|
}))}
|
||||||
|
onTimeClick={(t) => (commentTimestamp = Math.round(t * 10) / 10)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if data.downloadUrl}
|
||||||
|
<div class="actions">
|
||||||
|
<a href={data.downloadUrl} target="_blank" rel="noopener">
|
||||||
|
<Button variant="ghost" size="sm">↓ Original herunterladen</Button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if data.allowComments}
|
||||||
|
<form class="comment-form" onsubmit={submitComment}>
|
||||||
|
<h2>Feedback hinterlassen</h2>
|
||||||
|
<input type="text" bind:value={guestName} placeholder="Dein Name" required />
|
||||||
|
{#if commentTimestamp !== null}
|
||||||
|
<span class="ts-badge">
|
||||||
|
bei {formatTime(commentTimestamp)}
|
||||||
|
<button type="button" onclick={() => (commentTimestamp = null)}>×</button>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<textarea
|
||||||
|
bind:value={body}
|
||||||
|
placeholder="Was denkst du? (Klick auf die Wellenform für Timestamp)"
|
||||||
|
rows="3"
|
||||||
|
required
|
||||||
|
></textarea>
|
||||||
|
<Button type="submit" loading={submitting} disabled={!body.trim() || !guestName.trim()}>
|
||||||
|
Senden
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section class="comments">
|
||||||
|
<h2>Kommentare ({data.comments.length})</h2>
|
||||||
|
{#each data.comments.filter((c) => !c.parentId) as c}
|
||||||
|
<div class="comment">
|
||||||
|
<div class="comment-head">
|
||||||
|
<strong>{c.user?.name ?? c.guestName ?? 'Gast'}</strong>
|
||||||
|
{#if !c.user}<span class="guest">Gast</span>{/if}
|
||||||
|
{#if c.timestampSeconds !== null}
|
||||||
|
<button class="ts" onclick={() => playerRef?.seekToTime(c.timestampSeconds!)}>
|
||||||
|
{formatTime(c.timestampSeconds)}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p>{c.body}</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if data.comments.length === 0}
|
||||||
|
<p class="muted">Noch keine Kommentare.</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p class="muted">Geteilt über Music Hub</p>
|
||||||
|
</footer>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.listen-page {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-6) var(--space-4);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-5);
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.project {
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: var(--space-1) 0;
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
}
|
||||||
|
.version-label {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: var(--color-error, #ef4444);
|
||||||
|
}
|
||||||
|
.password-gate {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-8);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.password-gate input,
|
||||||
|
.comment-form input,
|
||||||
|
.comment-form textarea {
|
||||||
|
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-family: inherit;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.comment-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-4);
|
||||||
|
background: var(--color-bg-raised);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
.comment-form h2 {
|
||||||
|
margin: 0 0 var(--space-2);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
}
|
||||||
|
.ts-badge {
|
||||||
|
align-self: flex-start;
|
||||||
|
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.15rem var(--space-2);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
.ts-badge button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: var(--space-1);
|
||||||
|
}
|
||||||
|
.comments {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.comments h2 {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
margin: 0 0 var(--space-2);
|
||||||
|
}
|
||||||
|
.comment {
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: var(--color-bg-raised);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
.comment-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
.guest {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
background: var(--color-bg-base);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
padding: 0 0.3rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.ts {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
.comment p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
footer {
|
||||||
|
text-align: center;
|
||||||
|
padding-top: var(--space-4);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -11,6 +11,8 @@
|
|||||||
import ABCompare from '$lib/components/audio/ABCompare.svelte';
|
import ABCompare from '$lib/components/audio/ABCompare.svelte';
|
||||||
import VersionInfo from './components/VersionInfo.svelte';
|
import VersionInfo from './components/VersionInfo.svelte';
|
||||||
import VersionHistory from './components/VersionHistory.svelte';
|
import VersionHistory from './components/VersionHistory.svelte';
|
||||||
|
import VersionGraph from './components/VersionGraph.svelte';
|
||||||
|
import ShareModal from './components/ShareModal.svelte';
|
||||||
import CommentSection from './components/CommentSection.svelte';
|
import CommentSection from './components/CommentSection.svelte';
|
||||||
|
|
||||||
type Version = {
|
type Version = {
|
||||||
@@ -22,6 +24,18 @@
|
|||||||
originalFileName: string;
|
originalFileName: string;
|
||||||
duration: number | null;
|
duration: number | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
parentVersionId?: string | null;
|
||||||
|
branchLabel?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GraphNode = {
|
||||||
|
id: string;
|
||||||
|
parentVersionId: string | null;
|
||||||
|
branchLabel: string | null;
|
||||||
|
versionNumber: number;
|
||||||
|
label: string | null;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Comment = {
|
type Comment = {
|
||||||
@@ -31,7 +45,8 @@
|
|||||||
parentId: string | null;
|
parentId: string | null;
|
||||||
resolvedAt: string | null;
|
resolvedAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
user: { id: string; name: string; avatarUrl: string | null };
|
guestName?: string | null;
|
||||||
|
user: { id: string; name: string; avatarUrl: string | null } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const projectId = $page.params.projectId!;
|
const projectId = $page.params.projectId!;
|
||||||
@@ -49,6 +64,11 @@
|
|||||||
let playerRef = $state<WaveformPlayer>();
|
let playerRef = $state<WaveformPlayer>();
|
||||||
let compareVersion = $state<Version | null>(null);
|
let compareVersion = $state<Version | null>(null);
|
||||||
let compareStreamUrl = $state('');
|
let compareStreamUrl = $state('');
|
||||||
|
let graphNodes = $state<GraphNode[]>([]);
|
||||||
|
let viewMode = $state<'list' | 'graph'>('list');
|
||||||
|
let branchFromId = $state<string | null>(null);
|
||||||
|
let branchLabelInput = $state('');
|
||||||
|
let shareOpen = $state(false);
|
||||||
|
|
||||||
const canUpload = $derived(role === 'owner' || role.includes('engineer'));
|
const canUpload = $derived(role === 'owner' || role.includes('engineer'));
|
||||||
const canApprove = $derived(['owner', 'artist', 'label', 'management'].includes(role));
|
const canApprove = $derived(['owner', 'artist', 'label', 'management'].includes(role));
|
||||||
@@ -56,15 +76,17 @@
|
|||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
const [projectRes, trackVersions, tracksRes] = await Promise.all([
|
const [projectRes, trackVersions, tracksRes, treeRes] = await Promise.all([
|
||||||
api.get<{ project: any; role: string }>(`/projects/${projectId}`),
|
api.get<{ project: any; role: string }>(`/projects/${projectId}`),
|
||||||
api.get<{ versions: Version[] }>(`/versions/track/${trackId}`),
|
api.get<{ versions: Version[] }>(`/versions/track/${trackId}`),
|
||||||
api.get<{ tracks: { id: string; name: string }[] }>(`/tracks/project/${projectId}`),
|
api.get<{ tracks: { id: string; name: string }[] }>(`/tracks/project/${projectId}`),
|
||||||
|
api.get<{ nodes: GraphNode[] }>(`/versions/track/${trackId}/tree`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
role = projectRes.role;
|
role = projectRes.role;
|
||||||
trackName = tracksRes.tracks.find((t) => t.id === trackId)?.name || '';
|
trackName = tracksRes.tracks.find((t) => t.id === trackId)?.name || '';
|
||||||
versions = trackVersions.versions;
|
versions = trackVersions.versions;
|
||||||
|
graphNodes = treeRes.nodes;
|
||||||
|
|
||||||
if (versions.length > 0) await selectVersion(versions[0]);
|
if (versions.length > 0) await selectVersion(versions[0]);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -83,11 +105,28 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadVersions() {
|
async function loadVersions() {
|
||||||
const res = await api.get<{ versions: Version[] }>(`/versions/track/${trackId}`);
|
const [res, treeRes] = await Promise.all([
|
||||||
|
api.get<{ versions: Version[] }>(`/versions/track/${trackId}`),
|
||||||
|
api.get<{ nodes: GraphNode[] }>(`/versions/track/${trackId}/tree`),
|
||||||
|
]);
|
||||||
versions = res.versions;
|
versions = res.versions;
|
||||||
|
graphNodes = treeRes.nodes;
|
||||||
if (versions.length > 0) await selectVersion(versions[0]);
|
if (versions.length > 0) await selectVersion(versions[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handlePromote() {
|
||||||
|
if (!selectedVersion) return;
|
||||||
|
await api.post(`/versions/${selectedVersion.id}/promote`);
|
||||||
|
toastSuccess('Version übernommen');
|
||||||
|
await loadVersions();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startBranch(id: string) {
|
||||||
|
branchFromId = id;
|
||||||
|
branchLabelInput = '';
|
||||||
|
showUpload = true;
|
||||||
|
}
|
||||||
|
|
||||||
async function handleApprove() {
|
async function handleApprove() {
|
||||||
if (!selectedVersion) return;
|
if (!selectedVersion) return;
|
||||||
await api.post(`/versions/${selectedVersion.id}/approve`);
|
await api.post(`/versions/${selectedVersion.id}/approve`);
|
||||||
@@ -163,7 +202,7 @@
|
|||||||
id: c.id,
|
id: c.id,
|
||||||
timestampSeconds: c.timestampSeconds!,
|
timestampSeconds: c.timestampSeconds!,
|
||||||
body: c.body,
|
body: c.body,
|
||||||
userName: c.user.name,
|
userName: c.user?.name ?? c.guestName ?? 'Gast',
|
||||||
}))}
|
}))}
|
||||||
onTimeClick={(time) => commentTimestamp = Math.round(time * 10) / 10}
|
onTimeClick={(time) => commentTimestamp = Math.round(time * 10) / 10}
|
||||||
/>
|
/>
|
||||||
@@ -178,13 +217,21 @@
|
|||||||
|
|
||||||
<div class="track-actions">
|
<div class="track-actions">
|
||||||
{#if canUpload}
|
{#if canUpload}
|
||||||
<Button variant="secondary" size="sm" onclick={() => showUpload = !showUpload}>
|
<Button variant="secondary" size="sm" onclick={() => { branchFromId = null; branchLabelInput = ''; showUpload = !showUpload; }}>
|
||||||
{showUpload ? 'Cancel' : 'Upload new version'}
|
{showUpload ? 'Cancel' : 'Upload new version'}
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
<Button variant="ghost" size="sm" onclick={handleDownload}>
|
<Button variant="ghost" size="sm" onclick={handleDownload}>
|
||||||
↓ Download
|
↓ Download
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onclick={() => (shareOpen = true)}>
|
||||||
|
↗ Share
|
||||||
|
</Button>
|
||||||
|
{#if canApprove && selectedVersion.branchLabel}
|
||||||
|
<Button variant="ghost" size="sm" onclick={handlePromote}>
|
||||||
|
⤴ Übernehmen (Mainline)
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
{#if versions.length > 1}
|
{#if versions.length > 1}
|
||||||
<select
|
<select
|
||||||
class="compare-select"
|
class="compare-select"
|
||||||
@@ -218,7 +265,23 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showUpload}
|
{#if showUpload}
|
||||||
<UploadDropzone {trackId} onUploaded={() => { showUpload = false; loadVersions(); toastSuccess('Version uploaded'); }} />
|
{#if branchFromId}
|
||||||
|
<div class="branch-banner">
|
||||||
|
<span>Neue Variante von <strong>V{graphNodes.find((n) => n.id === branchFromId)?.versionNumber}</strong></span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={branchLabelInput}
|
||||||
|
placeholder="Branch-Name (z.B. 'vocals-neu')"
|
||||||
|
/>
|
||||||
|
<button class="cancel-branch" onclick={() => (branchFromId = null)}>×</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<UploadDropzone
|
||||||
|
{trackId}
|
||||||
|
parentVersionId={branchFromId}
|
||||||
|
branchLabel={branchFromId ? branchLabelInput || 'branch' : null}
|
||||||
|
onUploaded={() => { showUpload = false; branchFromId = null; loadVersions(); toastSuccess('Version uploaded'); }}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
@@ -248,11 +311,34 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<VersionHistory
|
{#if versions.length > 1}
|
||||||
{versions}
|
<div class="view-toggle">
|
||||||
selectedId={selectedVersion?.id ?? null}
|
<button class:active={viewMode === 'list'} onclick={() => (viewMode = 'list')}>Liste</button>
|
||||||
onSelect={selectVersion}
|
<button class:active={viewMode === 'graph'} onclick={() => (viewMode = 'graph')}>Graph</button>
|
||||||
/>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if viewMode === 'graph'}
|
||||||
|
<VersionGraph
|
||||||
|
nodes={graphNodes}
|
||||||
|
selectedId={selectedVersion?.id ?? null}
|
||||||
|
onSelect={(id) => {
|
||||||
|
const v = versions.find((v) => v.id === id);
|
||||||
|
if (v) selectVersion(v);
|
||||||
|
}}
|
||||||
|
onBranch={canUpload ? startBranch : undefined}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<VersionHistory
|
||||||
|
{versions}
|
||||||
|
selectedId={selectedVersion?.id ?? null}
|
||||||
|
onSelect={selectVersion}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if selectedVersion}
|
||||||
|
<ShareModal bind:open={shareOpen} versionId={selectedVersion.id} />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -290,6 +376,53 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.view-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
.view-toggle button {
|
||||||
|
background: var(--color-bg-raised);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.view-toggle button.active {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
.branch-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
background: var(--color-accent-subtle);
|
||||||
|
border: 1px solid var(--color-accent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
.branch-banner input {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
.cancel-branch {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
.compare-select {
|
.compare-select {
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
timestampSeconds: number | null;
|
timestampSeconds: number | null;
|
||||||
resolvedAt: string | null;
|
resolvedAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
user: { id: string; name: string; avatarUrl: string | null };
|
guestName?: string | null;
|
||||||
|
user: { id: string; name: string; avatarUrl: string | null } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -22,12 +23,15 @@
|
|||||||
onResolve: (id: string) => void;
|
onResolve: (id: string) => void;
|
||||||
onReply?: (id: string) => void;
|
onReply?: (id: string) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
const displayName = $derived(comment.user?.name ?? comment.guestName ?? 'Gast');
|
||||||
|
const isGuest = $derived(!comment.user);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="comment" class:resolved={comment.resolvedAt}>
|
<div class="comment" class:resolved={comment.resolvedAt}>
|
||||||
<div class="comment-header">
|
<div class="comment-header">
|
||||||
<Avatar name={comment.user.name} src={comment.user.avatarUrl} size="sm" />
|
<Avatar name={displayName} src={comment.user?.avatarUrl ?? null} size="sm" />
|
||||||
<span class="comment-author">{comment.user.name}</span>
|
<span class="comment-author">{displayName}{#if isGuest} <span class="guest-tag">Gast</span>{/if}</span>
|
||||||
{#if comment.timestampSeconds !== null}
|
{#if comment.timestampSeconds !== null}
|
||||||
<button
|
<button
|
||||||
class="comment-timestamp"
|
class="comment-timestamp"
|
||||||
@@ -123,6 +127,16 @@
|
|||||||
border-color: var(--color-success);
|
border-color: var(--color-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.guest-tag {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
background: var(--color-bg-base);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
padding: 0 0.3rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
margin-left: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
.comment-body {
|
.comment-body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
parentId: string | null;
|
parentId: string | null;
|
||||||
resolvedAt: string | null;
|
resolvedAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
user: { id: string; name: string; avatarUrl: string | null };
|
guestName?: string | null;
|
||||||
|
user: { id: string; name: string; avatarUrl: string | null } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Modal from '$lib/components/ui/Modal.svelte';
|
||||||
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
|
import { api } from '$lib/api/client.js';
|
||||||
|
import { toastSuccess } from '$lib/stores/toast.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
open = $bindable(false),
|
||||||
|
versionId,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
versionId: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let allowComments = $state(true);
|
||||||
|
let allowDownload = $state(false);
|
||||||
|
let password = $state('');
|
||||||
|
let creating = $state(false);
|
||||||
|
let createdUrl = $state('');
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
creating = true;
|
||||||
|
try {
|
||||||
|
const res = await api.post<{ link: { token: string } }>(
|
||||||
|
`/share/version/${versionId}`,
|
||||||
|
{
|
||||||
|
allowComments,
|
||||||
|
allowDownload,
|
||||||
|
password: password || undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
||||||
|
createdUrl = `${origin}/listen/${res.link.token}`;
|
||||||
|
} finally {
|
||||||
|
creating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copy() {
|
||||||
|
await navigator.clipboard.writeText(createdUrl);
|
||||||
|
toastSuccess('Link kopiert');
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
createdUrl = '';
|
||||||
|
password = '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal bind:open title="Link teilen">
|
||||||
|
{#if !createdUrl}
|
||||||
|
<div class="form">
|
||||||
|
<label class="row">
|
||||||
|
<input type="checkbox" bind:checked={allowComments} />
|
||||||
|
<span>Kommentare erlauben (auch ohne Account)</span>
|
||||||
|
</label>
|
||||||
|
<label class="row">
|
||||||
|
<input type="checkbox" bind:checked={allowDownload} />
|
||||||
|
<span>Download des Originals erlauben</span>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Passwort (optional)</span>
|
||||||
|
<input type="text" bind:value={password} placeholder="leer = kein Passwort" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="result">
|
||||||
|
<p>Link erstellt:</p>
|
||||||
|
<input type="text" readonly value={createdUrl} onclick={(e) => (e.target as HTMLInputElement).select()} />
|
||||||
|
<Button size="sm" onclick={copy}>Kopieren</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#snippet actions()}
|
||||||
|
{#if !createdUrl}
|
||||||
|
<Button variant="ghost" onclick={() => (open = false)}>Abbrechen</Button>
|
||||||
|
<Button loading={creating} onclick={create}>Link erzeugen</Button>
|
||||||
|
{:else}
|
||||||
|
<Button variant="ghost" onclick={reset}>Weiteren erzeugen</Button>
|
||||||
|
<Button onclick={() => { open = false; reset(); }}>Fertig</Button>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
.field input,
|
||||||
|
.result input {
|
||||||
|
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-family: inherit;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.result {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.result p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
type Node = {
|
||||||
|
id: string;
|
||||||
|
parentVersionId: string | null;
|
||||||
|
branchLabel: string | null;
|
||||||
|
versionNumber: number;
|
||||||
|
label: string | null;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
nodes,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
onBranch,
|
||||||
|
}: {
|
||||||
|
nodes: Node[];
|
||||||
|
selectedId: string | null;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
onBranch?: (id: string) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// Assign each node a column based on branchLabel.
|
||||||
|
// Mainline (branchLabel === null) → col 0; each distinct branchLabel → next col.
|
||||||
|
const layout = $derived.by(() => {
|
||||||
|
const cols = new Map<string, number>();
|
||||||
|
cols.set('__main__', 0);
|
||||||
|
let next = 1;
|
||||||
|
for (const n of nodes) {
|
||||||
|
const key = n.branchLabel ?? '__main__';
|
||||||
|
if (!cols.has(key)) cols.set(key, next++);
|
||||||
|
}
|
||||||
|
const sorted = [...nodes].sort(
|
||||||
|
(a, b) => +new Date(a.createdAt) - +new Date(b.createdAt),
|
||||||
|
);
|
||||||
|
const rowOf = new Map<string, number>();
|
||||||
|
sorted.forEach((n, i) => rowOf.set(n.id, i));
|
||||||
|
const positions = sorted.map((n) => ({
|
||||||
|
node: n,
|
||||||
|
col: cols.get(n.branchLabel ?? '__main__')!,
|
||||||
|
row: rowOf.get(n.id)!,
|
||||||
|
}));
|
||||||
|
const colCount = cols.size;
|
||||||
|
return { positions, colCount, rowCount: sorted.length };
|
||||||
|
});
|
||||||
|
|
||||||
|
const COL_W = 60;
|
||||||
|
const ROW_H = 50;
|
||||||
|
const PAD = 20;
|
||||||
|
const R = 12;
|
||||||
|
|
||||||
|
const width = $derived(PAD * 2 + (layout.colCount - 1) * COL_W + 200);
|
||||||
|
const height = $derived(PAD * 2 + Math.max(1, layout.rowCount - 1) * ROW_H);
|
||||||
|
|
||||||
|
function pos(col: number, row: number) {
|
||||||
|
return { x: PAD + col * COL_W, y: PAD + row * ROW_H };
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorOf = (s: string) =>
|
||||||
|
({
|
||||||
|
approved: '#22c55e',
|
||||||
|
rejected: '#ef4444',
|
||||||
|
processing: '#fbbf24',
|
||||||
|
ready: '#6366f1',
|
||||||
|
uploaded: '#666',
|
||||||
|
}[s] || '#888');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="graph">
|
||||||
|
<h2>Version Graph</h2>
|
||||||
|
{#if nodes.length === 0}
|
||||||
|
<p class="empty">Noch keine Versionen.</p>
|
||||||
|
{:else}
|
||||||
|
<svg {width} {height} role="img" aria-label="Version graph">
|
||||||
|
<!-- edges -->
|
||||||
|
{#each layout.positions as p}
|
||||||
|
{#if p.node.parentVersionId}
|
||||||
|
{@const parent = layout.positions.find((q) => q.node.id === p.node.parentVersionId)}
|
||||||
|
{#if parent}
|
||||||
|
{@const a = pos(parent.col, parent.row)}
|
||||||
|
{@const b = pos(p.col, p.row)}
|
||||||
|
<path
|
||||||
|
d={`M ${a.x} ${a.y} C ${a.x} ${(a.y + b.y) / 2}, ${b.x} ${(a.y + b.y) / 2}, ${b.x} ${b.y}`}
|
||||||
|
stroke="#444"
|
||||||
|
stroke-width="2"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- nodes -->
|
||||||
|
{#each layout.positions as p}
|
||||||
|
{@const c = pos(p.col, p.row)}
|
||||||
|
<g
|
||||||
|
class="node"
|
||||||
|
class:selected={selectedId === p.node.id}
|
||||||
|
onclick={() => onSelect(p.node.id)}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx={c.x}
|
||||||
|
cy={c.y}
|
||||||
|
r={R}
|
||||||
|
fill={colorOf(p.node.status)}
|
||||||
|
stroke={selectedId === p.node.id ? '#fff' : '#222'}
|
||||||
|
stroke-width="2"
|
||||||
|
/>
|
||||||
|
<text x={c.x} y={c.y + 4} text-anchor="middle" font-size="11" fill="#fff">
|
||||||
|
{p.node.versionNumber}
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
x={PAD + (layout.colCount - 1) * COL_W + 30}
|
||||||
|
y={c.y + 4}
|
||||||
|
font-size="12"
|
||||||
|
fill="#ccc"
|
||||||
|
>
|
||||||
|
{p.node.label || p.node.branchLabel || (p.col === 0 ? 'main' : 'branch')}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
{/each}
|
||||||
|
</svg>
|
||||||
|
{#if onBranch && selectedId}
|
||||||
|
<button class="branch-btn" onclick={() => onBranch?.(selectedId!)}>
|
||||||
|
⑂ Neue Variante von dieser Version
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.graph {
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
padding-top: var(--space-5);
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 var(--space-3);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
svg {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.node {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.node:hover circle {
|
||||||
|
stroke: #fff;
|
||||||
|
}
|
||||||
|
.branch-btn {
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
background: var(--color-bg-raised);
|
||||||
|
border: 1px solid var(--color-border-hover);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.branch-btn:hover {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
21
packages/db/src/migrations/0001_many_sir_ram.sql
Normal file
21
packages/db/src/migrations/0001_many_sir_ram.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
CREATE TABLE "share_links" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"version_id" uuid NOT NULL,
|
||||||
|
"token" varchar(64) NOT NULL,
|
||||||
|
"created_by_id" uuid NOT NULL,
|
||||||
|
"expires_at" timestamp,
|
||||||
|
"allow_comments" boolean DEFAULT true NOT NULL,
|
||||||
|
"allow_download" boolean DEFAULT false NOT NULL,
|
||||||
|
"password_hash" varchar(255),
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "share_links_token_unique" UNIQUE("token")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "versions" DROP CONSTRAINT "versions_track_id_version_number_unique";--> statement-breakpoint
|
||||||
|
ALTER TABLE "comments" ALTER COLUMN "user_id" DROP NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "versions" ADD COLUMN "parent_version_id" uuid;--> statement-breakpoint
|
||||||
|
ALTER TABLE "versions" ADD COLUMN "branch_label" varchar(100);--> statement-breakpoint
|
||||||
|
ALTER TABLE "comments" ADD COLUMN "guest_name" varchar(100);--> statement-breakpoint
|
||||||
|
ALTER TABLE "share_links" ADD CONSTRAINT "share_links_version_id_versions_id_fk" FOREIGN KEY ("version_id") REFERENCES "public"."versions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "share_links" ADD CONSTRAINT "share_links_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_parent_version_id_versions_id_fk" FOREIGN KEY ("parent_version_id") REFERENCES "public"."versions"("id") ON DELETE set null ON UPDATE no action;
|
||||||
885
packages/db/src/migrations/meta/0001_snapshot.json
Normal file
885
packages/db/src/migrations/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,885 @@
|
|||||||
|
{
|
||||||
|
"id": "7e1d45fa-02c2-43ae-96f3-d5c8367c15ed",
|
||||||
|
"prevId": "4e5be5fd-2fae-43d2-8273-5a372d714cc5",
|
||||||
|
"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'"
|
||||||
|
},
|
||||||
|
"parent_version_id": {
|
||||||
|
"name": "parent_version_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"branch_label": {
|
||||||
|
"name": "branch_label",
|
||||||
|
"type": "varchar(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"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_parent_version_id_versions_id_fk": {
|
||||||
|
"name": "versions_parent_version_id_versions_id_fk",
|
||||||
|
"tableFrom": "versions",
|
||||||
|
"tableTo": "versions",
|
||||||
|
"columnsFrom": [
|
||||||
|
"parent_version_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"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": {},
|
||||||
|
"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": false
|
||||||
|
},
|
||||||
|
"guest_name": {
|
||||||
|
"name": "guest_name",
|
||||||
|
"type": "varchar(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"public.share_links": {
|
||||||
|
"name": "share_links",
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "varchar(64)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_by_id": {
|
||||||
|
"name": "created_by_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"allow_comments": {
|
||||||
|
"name": "allow_comments",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"allow_download": {
|
||||||
|
"name": "allow_download",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"share_links_version_id_versions_id_fk": {
|
||||||
|
"name": "share_links_version_id_versions_id_fk",
|
||||||
|
"tableFrom": "share_links",
|
||||||
|
"tableTo": "versions",
|
||||||
|
"columnsFrom": [
|
||||||
|
"version_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"share_links_created_by_id_users_id_fk": {
|
||||||
|
"name": "share_links_created_by_id_users_id_fk",
|
||||||
|
"tableFrom": "share_links",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"created_by_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"share_links_token_unique": {
|
||||||
|
"name": "share_links_token_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"token"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,13 @@
|
|||||||
"when": 1775122377765,
|
"when": 1775122377765,
|
||||||
"tag": "0000_magenta_apocalypse",
|
"tag": "0000_magenta_apocalypse",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775571497577,
|
||||||
|
"tag": "0001_many_sir_ram",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { pgTable, uuid, text, real, timestamp } from 'drizzle-orm/pg-core';
|
import { pgTable, uuid, varchar, text, real, timestamp } from 'drizzle-orm/pg-core';
|
||||||
import { users } from './users.js';
|
import { users } from './users.js';
|
||||||
import { versions } from './tracks.js';
|
import { versions } from './tracks.js';
|
||||||
|
|
||||||
@@ -7,9 +7,8 @@ export const comments = pgTable('comments', {
|
|||||||
versionId: uuid('version_id')
|
versionId: uuid('version_id')
|
||||||
.references(() => versions.id, { onDelete: 'cascade' })
|
.references(() => versions.id, { onDelete: 'cascade' })
|
||||||
.notNull(),
|
.notNull(),
|
||||||
userId: uuid('user_id')
|
userId: uuid('user_id').references(() => users.id),
|
||||||
.references(() => users.id)
|
guestName: varchar('guest_name', { length: 100 }),
|
||||||
.notNull(),
|
|
||||||
body: text('body').notNull(),
|
body: text('body').notNull(),
|
||||||
timestampSeconds: real('timestamp_seconds'),
|
timestampSeconds: real('timestamp_seconds'),
|
||||||
parentId: uuid('parent_id'),
|
parentId: uuid('parent_id'),
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ export * from './auth.js';
|
|||||||
export * from './projects.js';
|
export * from './projects.js';
|
||||||
export * from './tracks.js';
|
export * from './tracks.js';
|
||||||
export * from './comments.js';
|
export * from './comments.js';
|
||||||
|
export * from './shareLinks.js';
|
||||||
|
|||||||
19
packages/db/src/schema/shareLinks.ts
Normal file
19
packages/db/src/schema/shareLinks.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { pgTable, uuid, varchar, boolean, timestamp } from 'drizzle-orm/pg-core';
|
||||||
|
import { users } from './users.js';
|
||||||
|
import { versions } from './tracks.js';
|
||||||
|
|
||||||
|
export const shareLinks = pgTable('share_links', {
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
versionId: uuid('version_id')
|
||||||
|
.references(() => versions.id, { onDelete: 'cascade' })
|
||||||
|
.notNull(),
|
||||||
|
token: varchar('token', { length: 64 }).notNull().unique(),
|
||||||
|
createdById: uuid('created_by_id')
|
||||||
|
.references(() => users.id)
|
||||||
|
.notNull(),
|
||||||
|
expiresAt: timestamp('expires_at'),
|
||||||
|
allowComments: boolean('allow_comments').default(true).notNull(),
|
||||||
|
allowDownload: boolean('allow_download').default(false).notNull(),
|
||||||
|
passwordHash: varchar('password_hash', { length: 255 }),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
@@ -8,8 +8,8 @@ import {
|
|||||||
bigint,
|
bigint,
|
||||||
real,
|
real,
|
||||||
timestamp,
|
timestamp,
|
||||||
unique,
|
|
||||||
} from 'drizzle-orm/pg-core';
|
} from 'drizzle-orm/pg-core';
|
||||||
|
import type { AnyPgColumn } from 'drizzle-orm/pg-core';
|
||||||
import { users } from './users.js';
|
import { users } from './users.js';
|
||||||
import { projects } from './projects.js';
|
import { projects } from './projects.js';
|
||||||
|
|
||||||
@@ -36,33 +36,34 @@ export const tracks = pgTable('tracks', {
|
|||||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const versions = pgTable(
|
export const versions = pgTable('versions', {
|
||||||
'versions',
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
{
|
trackId: uuid('track_id')
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
.references(() => tracks.id, { onDelete: 'cascade' })
|
||||||
trackId: uuid('track_id')
|
.notNull(),
|
||||||
.references(() => tracks.id, { onDelete: 'cascade' })
|
versionNumber: integer('version_number').notNull(),
|
||||||
.notNull(),
|
label: varchar('label', { length: 100 }),
|
||||||
versionNumber: integer('version_number').notNull(),
|
notes: text('notes'),
|
||||||
label: varchar('label', { length: 100 }),
|
status: versionStatusEnum('status').default('uploaded').notNull(),
|
||||||
notes: text('notes'),
|
|
||||||
status: versionStatusEnum('status').default('uploaded').notNull(),
|
|
||||||
|
|
||||||
originalFileName: varchar('original_file_name', { length: 500 }).notNull(),
|
parentVersionId: uuid('parent_version_id').references((): AnyPgColumn => versions.id, {
|
||||||
mimeType: varchar('mime_type', { length: 100 }).notNull(),
|
onDelete: 'set null',
|
||||||
fileSize: bigint('file_size', { mode: 'number' }).notNull(),
|
}),
|
||||||
duration: real('duration'),
|
branchLabel: varchar('branch_label', { length: 100 }),
|
||||||
sampleRate: integer('sample_rate'),
|
|
||||||
bitDepth: integer('bit_depth'),
|
|
||||||
|
|
||||||
originalFileKey: text('original_file_key').notNull(),
|
originalFileName: varchar('original_file_name', { length: 500 }).notNull(),
|
||||||
streamFileKey: text('stream_file_key'),
|
mimeType: varchar('mime_type', { length: 100 }).notNull(),
|
||||||
waveformDataKey: text('waveform_data_key'),
|
fileSize: bigint('file_size', { mode: 'number' }).notNull(),
|
||||||
|
duration: real('duration'),
|
||||||
|
sampleRate: integer('sample_rate'),
|
||||||
|
bitDepth: integer('bit_depth'),
|
||||||
|
|
||||||
createdById: uuid('created_by_id')
|
originalFileKey: text('original_file_key').notNull(),
|
||||||
.references(() => users.id)
|
streamFileKey: text('stream_file_key'),
|
||||||
.notNull(),
|
waveformDataKey: text('waveform_data_key'),
|
||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
||||||
},
|
createdById: uuid('created_by_id')
|
||||||
(table) => [unique().on(table.trackId, table.versionNumber)],
|
.references(() => users.id)
|
||||||
);
|
.notNull(),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|||||||
@@ -24,9 +24,27 @@ export const createVersionSchema = z.object({
|
|||||||
originalFileName: z.string().min(1),
|
originalFileName: z.string().min(1),
|
||||||
mimeType: z.string().min(1),
|
mimeType: z.string().min(1),
|
||||||
fileSize: z.number().int().positive(),
|
fileSize: z.number().int().positive(),
|
||||||
|
parentVersionId: z.string().uuid().optional(),
|
||||||
|
branchLabel: z.string().max(100).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createShareLinkSchema = z.object({
|
||||||
|
expiresAt: z.string().datetime().optional(),
|
||||||
|
allowComments: z.boolean().optional(),
|
||||||
|
allowDownload: z.boolean().optional(),
|
||||||
|
password: z.string().min(1).max(255).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const guestCommentSchema = z.object({
|
||||||
|
body: z.string().min(1).max(5000),
|
||||||
|
timestampSeconds: z.number().nonnegative().optional(),
|
||||||
|
parentId: z.string().uuid().optional(),
|
||||||
|
guestName: z.string().min(1).max(100),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CreateTrackInput = z.infer<typeof createTrackSchema>;
|
export type CreateTrackInput = z.infer<typeof createTrackSchema>;
|
||||||
export type UpdateTrackInput = z.infer<typeof updateTrackSchema>;
|
export type UpdateTrackInput = z.infer<typeof updateTrackSchema>;
|
||||||
export type RequestUploadUrlInput = z.infer<typeof requestUploadUrlSchema>;
|
export type RequestUploadUrlInput = z.infer<typeof requestUploadUrlSchema>;
|
||||||
export type CreateVersionInput = z.infer<typeof createVersionSchema>;
|
export type CreateVersionInput = z.infer<typeof createVersionSchema>;
|
||||||
|
export type CreateShareLinkInput = z.infer<typeof createShareLinkSchema>;
|
||||||
|
export type GuestCommentInput = z.infer<typeof guestCommentSchema>;
|
||||||
|
|||||||
Reference in New Issue
Block a user