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:
Robin Choice
2026-04-07 16:31:52 +02:00
parent e420ed198b
commit 4dc095463f
19 changed files with 2136 additions and 50 deletions

View File

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

View File

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

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

View File

@@ -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');

View File

@@ -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 = '';

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View 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": {}
}
}

View File

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

View File

@@ -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'),

View File

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

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

View File

@@ -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(),
});

View File

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