feat: reject with feedback, email alerts, SSE real-time updates

Reject modal now requires a reason — stored as an auto-comment on the
version so context stays in the thread. Email alert fires on first play
of a shared link (fire-and-forget, no-op without RESEND_API_KEY). SSE
endpoint per track broadcasts version:new, version:status and
comment:new events; track page subscribes and reloads data live.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Robin Choice
2026-04-23 10:28:58 +02:00
parent df571df567
commit 06f0a43532
10 changed files with 269 additions and 13 deletions

View File

@@ -0,0 +1,40 @@
type SseHandler = (event: { type: string; data: unknown }) => void;
let es: EventSource | null = null;
let currentTrackId: string | null = null;
let handler: SseHandler | null = null;
export function connectTrackSse(trackId: string, onEvent: SseHandler): () => void {
if (currentTrackId === trackId && es?.readyState === EventSource.OPEN) {
handler = onEvent;
return () => disconnect();
}
disconnect();
currentTrackId = trackId;
handler = onEvent;
es = new EventSource(`/api/v1/sse/track/${trackId}`, { withCredentials: true });
const types = ['version:new', 'version:status', 'comment:new'];
for (const type of types) {
es.addEventListener(type, (e: MessageEvent) => {
try {
handler?.({ type, data: JSON.parse(e.data) });
} catch { /* ignore malformed */ }
});
}
es.onerror = () => {
// Browser auto-reconnects EventSource — nothing to do
};
return () => disconnect();
}
function disconnect() {
es?.close();
es = null;
currentTrackId = null;
handler = null;
}

View File

@@ -16,8 +16,10 @@
import CoverImage from '$lib/components/ui/CoverImage.svelte';
import CoverUpload from '$lib/components/ui/CoverUpload.svelte';
import TrackStatusPill from '$lib/components/ui/TrackStatusPill.svelte';
import { onDestroy } from 'svelte';
import { onKey } from '$lib/utils/shortcuts.js';
import { snapshotForTrack, continuationFor } from '$lib/stores/player.js';
import { connectTrackSse } from '$lib/stores/sse.js';
import {
offlineVersions,
downloadForOffline,
@@ -106,6 +108,9 @@
let offlineDropdownOpen = $state(false);
let offlineDownloading = $state(false);
let offlineProgress = $state(0);
let rejectOpen = $state(false);
let rejectReason = $state('');
let rejecting = $state(false);
const canUpload = $derived(role === 'owner' || role.includes('engineer'));
const canApprove = $derived(['owner', 'artist', 'label', 'management'].includes(role));
@@ -137,6 +142,19 @@
} finally {
loading = false;
}
const disconnectSse = connectTrackSse(trackId, async ({ type, data }: { type: string; data: any }) => {
if (type === 'version:new') {
await loadVersions();
} else if (type === 'version:status') {
const v = versions.find((v) => v.id === data.versionId);
if (v) { v.status = data.status; versions = [...versions]; }
} else if (type === 'comment:new' && selectedVersion?.id === data.versionId) {
const res = await api.get<{ comments: Comment[] }>(`/comments/version/${data.versionId}`);
comments = res.comments;
}
});
onDestroy(disconnectSse);
});
async function selectVersion(version: Version) {
@@ -205,11 +223,22 @@
await loadVersions();
}
async function handleReject() {
if (!selectedVersion) return;
await api.post(`/versions/${selectedVersion.id}/reject`);
toastSuccess('Version abgelehnt');
await loadVersions();
function handleReject() {
rejectReason = '';
rejectOpen = true;
}
async function submitReject() {
if (!selectedVersion || !rejectReason.trim()) return;
rejecting = true;
try {
await api.post(`/versions/${selectedVersion.id}/reject`, { reason: rejectReason.trim() });
rejectOpen = false;
toastSuccess('Version abgelehnt');
await loadVersions();
} finally {
rejecting = false;
}
}
async function handleComment(body: string, timestamp: number | null, parentId?: string) {
@@ -638,6 +667,26 @@
<ShareModal bind:open={shareOpen} versionId={selectedVersion.id} />
{/if}
<Modal bind:open={rejectOpen} title="Version ablehnen">
<div class="edit-form">
<label>
<span class="lbl">Begründung <span style="color: var(--color-error)">*</span></span>
<textarea
bind:value={rejectReason}
rows="4"
placeholder="Was muss geändert werden? (Pflichtfeld)"
autofocus
></textarea>
</label>
</div>
{#snippet actions()}
<Button variant="ghost" onclick={() => (rejectOpen = false)}>Abbrechen</Button>
<Button onclick={submitReject} loading={rejecting} disabled={!rejectReason.trim()}>
Ablehnen
</Button>
{/snippet}
</Modal>
<Modal bind:open={coverEditOpen} title="Track-Cover ändern">
<div class="cover-modal">
<CoverUpload currentUrl={trackCoverUrl} name={trackName} onUploaded={saveTrackCover} />