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

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

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

View File

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

View File

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

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>