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 { versionRoutes } from './routes/versions.js';
|
||||
import { commentRoutes } from './routes/comments.js';
|
||||
import { shareRoutes } from './routes/share.js';
|
||||
import type { AppEnv } from './types.js';
|
||||
|
||||
const db = createDb(process.env.DATABASE_URL!);
|
||||
@@ -34,7 +35,8 @@ const app = new Hono<AppEnv>()
|
||||
.route('/projects', projectRoutes)
|
||||
.route('/tracks', trackRoutes)
|
||||
.route('/versions', versionRoutes)
|
||||
.route('/comments', commentRoutes);
|
||||
.route('/comments', commentRoutes)
|
||||
.route('/share', shareRoutes);
|
||||
|
||||
const port = parseInt(process.env.PORT || '3000');
|
||||
console.log(`Music Hub API running on port ${port}`);
|
||||
|
||||
@@ -47,6 +47,7 @@ export const commentRoutes = new Hono<AppEnv>()
|
||||
parentId: comments.parentId,
|
||||
resolvedAt: comments.resolvedAt,
|
||||
createdAt: comments.createdAt,
|
||||
guestName: comments.guestName,
|
||||
user: {
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
@@ -54,7 +55,7 @@ export const commentRoutes = new Hono<AppEnv>()
|
||||
},
|
||||
})
|
||||
.from(comments)
|
||||
.innerJoin(users, eq(users.id, comments.userId))
|
||||
.leftJoin(users, eq(users.id, comments.userId))
|
||||
.where(eq(comments.versionId, versionId))
|
||||
.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 { 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 { tracks, versions, projectMembers } from '@music-hub/db';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
@@ -97,6 +97,8 @@ export const versionRoutes = new Hono<AppEnv>()
|
||||
label: input.label,
|
||||
notes: input.notes,
|
||||
status: 'uploaded',
|
||||
parentVersionId: input.parentVersionId,
|
||||
branchLabel: input.branchLabel,
|
||||
originalFileName: input.originalFileName,
|
||||
mimeType: input.mimeType,
|
||||
fileSize: input.fileSize,
|
||||
@@ -113,6 +115,81 @@ export const versionRoutes = new Hono<AppEnv>()
|
||||
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('/:id/stream-url', async (c) => {
|
||||
const db = c.get('db');
|
||||
|
||||
@@ -4,9 +4,13 @@
|
||||
|
||||
let {
|
||||
trackId,
|
||||
parentVersionId = null,
|
||||
branchLabel = null,
|
||||
onUploaded,
|
||||
}: {
|
||||
trackId: string;
|
||||
parentVersionId?: string | null;
|
||||
branchLabel?: string | null;
|
||||
onUploaded: () => void;
|
||||
} = $props();
|
||||
|
||||
@@ -78,6 +82,8 @@
|
||||
originalFileName: file.name,
|
||||
mimeType: file.type || 'audio/wav',
|
||||
fileSize: file.size,
|
||||
parentVersionId: parentVersionId ?? undefined,
|
||||
branchLabel: branchLabel ?? undefined,
|
||||
});
|
||||
|
||||
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 VersionInfo from './components/VersionInfo.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';
|
||||
|
||||
type Version = {
|
||||
@@ -22,6 +24,18 @@
|
||||
originalFileName: string;
|
||||
duration: number | null;
|
||||
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 = {
|
||||
@@ -31,7 +45,8 @@
|
||||
parentId: string | null;
|
||||
resolvedAt: string | null;
|
||||
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!;
|
||||
@@ -49,6 +64,11 @@
|
||||
let playerRef = $state<WaveformPlayer>();
|
||||
let compareVersion = $state<Version | null>(null);
|
||||
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 canApprove = $derived(['owner', 'artist', 'label', 'management'].includes(role));
|
||||
@@ -56,15 +76,17 @@
|
||||
|
||||
onMount(async () => {
|
||||
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<{ versions: Version[] }>(`/versions/track/${trackId}`),
|
||||
api.get<{ tracks: { id: string; name: string }[] }>(`/tracks/project/${projectId}`),
|
||||
api.get<{ nodes: GraphNode[] }>(`/versions/track/${trackId}/tree`),
|
||||
]);
|
||||
|
||||
role = projectRes.role;
|
||||
trackName = tracksRes.tracks.find((t) => t.id === trackId)?.name || '';
|
||||
versions = trackVersions.versions;
|
||||
graphNodes = treeRes.nodes;
|
||||
|
||||
if (versions.length > 0) await selectVersion(versions[0]);
|
||||
} finally {
|
||||
@@ -83,11 +105,28 @@
|
||||
}
|
||||
|
||||
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;
|
||||
graphNodes = treeRes.nodes;
|
||||
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() {
|
||||
if (!selectedVersion) return;
|
||||
await api.post(`/versions/${selectedVersion.id}/approve`);
|
||||
@@ -163,7 +202,7 @@
|
||||
id: c.id,
|
||||
timestampSeconds: c.timestampSeconds!,
|
||||
body: c.body,
|
||||
userName: c.user.name,
|
||||
userName: c.user?.name ?? c.guestName ?? 'Gast',
|
||||
}))}
|
||||
onTimeClick={(time) => commentTimestamp = Math.round(time * 10) / 10}
|
||||
/>
|
||||
@@ -178,13 +217,21 @@
|
||||
|
||||
<div class="track-actions">
|
||||
{#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'}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button variant="ghost" size="sm" onclick={handleDownload}>
|
||||
↓ Download
|
||||
</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}
|
||||
<select
|
||||
class="compare-select"
|
||||
@@ -218,7 +265,23 @@
|
||||
{/if}
|
||||
|
||||
{#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 loading}
|
||||
@@ -248,11 +311,34 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<VersionHistory
|
||||
{versions}
|
||||
selectedId={selectedVersion?.id ?? null}
|
||||
onSelect={selectVersion}
|
||||
/>
|
||||
{#if versions.length > 1}
|
||||
<div class="view-toggle">
|
||||
<button class:active={viewMode === 'list'} onclick={() => (viewMode = 'list')}>Liste</button>
|
||||
<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>
|
||||
|
||||
<style>
|
||||
@@ -290,6 +376,53 @@
|
||||
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 {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
timestampSeconds: number | null;
|
||||
resolvedAt: string | null;
|
||||
createdAt: string;
|
||||
user: { id: string; name: string; avatarUrl: string | null };
|
||||
guestName?: string | null;
|
||||
user: { id: string; name: string; avatarUrl: string | null } | null;
|
||||
};
|
||||
|
||||
let {
|
||||
@@ -22,12 +23,15 @@
|
||||
onResolve: (id: string) => void;
|
||||
onReply?: (id: string) => void;
|
||||
} = $props();
|
||||
|
||||
const displayName = $derived(comment.user?.name ?? comment.guestName ?? 'Gast');
|
||||
const isGuest = $derived(!comment.user);
|
||||
</script>
|
||||
|
||||
<div class="comment" class:resolved={comment.resolvedAt}>
|
||||
<div class="comment-header">
|
||||
<Avatar name={comment.user.name} src={comment.user.avatarUrl} size="sm" />
|
||||
<span class="comment-author">{comment.user.name}</span>
|
||||
<Avatar name={displayName} src={comment.user?.avatarUrl ?? null} size="sm" />
|
||||
<span class="comment-author">{displayName}{#if isGuest} <span class="guest-tag">Gast</span>{/if}</span>
|
||||
{#if comment.timestampSeconds !== null}
|
||||
<button
|
||||
class="comment-timestamp"
|
||||
@@ -123,6 +127,16 @@
|
||||
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 {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
parentId: string | null;
|
||||
resolvedAt: string | null;
|
||||
createdAt: string;
|
||||
user: { id: string; name: string; avatarUrl: string | null };
|
||||
guestName?: string | null;
|
||||
user: { id: string; name: string; avatarUrl: string | null } | null;
|
||||
};
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user