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:
@@ -13,6 +13,7 @@ import { activityRoutes } from './routes/activity.js';
|
|||||||
import { onboardingRoutes } from './routes/onboarding.js';
|
import { onboardingRoutes } from './routes/onboarding.js';
|
||||||
import { stemRoutes } from './routes/stems.js';
|
import { stemRoutes } from './routes/stems.js';
|
||||||
import { pushRoutes } from './routes/push.js';
|
import { pushRoutes } from './routes/push.js';
|
||||||
|
import { sseRoutes } from './routes/sse.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!);
|
||||||
@@ -80,7 +81,8 @@ const app = new Hono<AppEnv>()
|
|||||||
.route('/activity', activityRoutes)
|
.route('/activity', activityRoutes)
|
||||||
.route('/onboarding', onboardingRoutes)
|
.route('/onboarding', onboardingRoutes)
|
||||||
.route('/stems', stemRoutes)
|
.route('/stems', stemRoutes)
|
||||||
.route('/push', pushRoutes);
|
.route('/push', pushRoutes)
|
||||||
|
.route('/sse', sseRoutes);
|
||||||
|
|
||||||
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}`);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { eq, and, asc } from 'drizzle-orm';
|
|||||||
import { createCommentSchema, updateCommentSchema } from '@music-hub/shared';
|
import { createCommentSchema, updateCommentSchema } from '@music-hub/shared';
|
||||||
import { comments, versions, tracks, projectMembers, users } from '@music-hub/db';
|
import { comments, versions, tracks, projectMembers, users } from '@music-hub/db';
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
import { publish } from '../services/sse.js';
|
||||||
import type { AppEnv } from '../types.js';
|
import type { AppEnv } from '../types.js';
|
||||||
|
|
||||||
export const commentRoutes = new Hono<AppEnv>()
|
export const commentRoutes = new Hono<AppEnv>()
|
||||||
@@ -109,6 +110,8 @@ export const commentRoutes = new Hono<AppEnv>()
|
|||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
publish(track!.id, { type: 'comment:new', data: { versionId, commentId: comment.id } });
|
||||||
|
|
||||||
return c.json({ comment }, 201);
|
return c.json({ comment }, 201);
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from '@music-hub/db';
|
} from '@music-hub/db';
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
import { createDownloadUrl } from '../storage/s3.js';
|
import { createDownloadUrl } from '../storage/s3.js';
|
||||||
|
import { sendListenAlertEmail } from '../services/email.js';
|
||||||
|
|
||||||
async function hashIp(ip: string): Promise<string> {
|
async function hashIp(ip: string): Promise<string> {
|
||||||
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(ip + 'musichub-salt'));
|
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(ip + 'musichub-salt'));
|
||||||
@@ -315,16 +316,48 @@ export const shareRoutes = new Hono<AppEnv>()
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
if (!event) return c.json({ error: 'Not found' }, 404);
|
if (!event) return c.json({ error: 'Not found' }, 404);
|
||||||
|
|
||||||
|
const isFirstPlay = input.firstPlay && !event.firstPlayAt;
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(listenEvents)
|
.update(listenEvents)
|
||||||
.set({
|
.set({
|
||||||
...(input.listenerName !== undefined ? { listenerName: input.listenerName } : {}),
|
...(input.listenerName !== undefined ? { listenerName: input.listenerName } : {}),
|
||||||
...(input.firstPlay && !event.firstPlayAt ? { firstPlayAt: new Date() } : {}),
|
...(isFirstPlay ? { firstPlayAt: new Date() } : {}),
|
||||||
...(input.listenSeconds !== undefined ? { listenSeconds: input.listenSeconds } : {}),
|
...(input.listenSeconds !== undefined ? { listenSeconds: input.listenSeconds } : {}),
|
||||||
...(input.completed !== undefined ? { completed: input.completed } : {}),
|
...(input.completed !== undefined ? { completed: input.completed } : {}),
|
||||||
})
|
})
|
||||||
.where(eq(listenEvents.id, eventId));
|
.where(eq(listenEvents.id, eventId));
|
||||||
|
|
||||||
|
if (isFirstPlay) {
|
||||||
|
// Fire-and-forget: alert link creator by email
|
||||||
|
Promise.resolve().then(async () => {
|
||||||
|
try {
|
||||||
|
const [fullLink] = await db
|
||||||
|
.select({ createdById: shareLinks.createdById, versionId: shareLinks.versionId })
|
||||||
|
.from(shareLinks)
|
||||||
|
.where(eq(shareLinks.id, link.id))
|
||||||
|
.limit(1);
|
||||||
|
if (!fullLink) return;
|
||||||
|
|
||||||
|
const [creator] = await db
|
||||||
|
.select({ email: users.email, name: users.name })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, fullLink.createdById))
|
||||||
|
.limit(1);
|
||||||
|
if (!creator) return;
|
||||||
|
|
||||||
|
const [version] = await db.select().from(versions).where(eq(versions.id, fullLink.versionId)).limit(1);
|
||||||
|
const [track] = version ? await db.select().from(tracks).where(eq(tracks.id, version.trackId)).limit(1) : [null];
|
||||||
|
const [project] = track ? await db.select().from(projects).where(eq(projects.id, track.projectId)).limit(1) : [null];
|
||||||
|
if (!track || !project) return;
|
||||||
|
|
||||||
|
const listenerName = input.listenerName ?? event.listenerName;
|
||||||
|
const trackUrl = `${process.env.APP_URL}/projects/${track.projectId}/tracks/${track.id}`;
|
||||||
|
await sendListenAlertEmail(creator.email, listenerName ?? null, track.name, project.name, trackUrl);
|
||||||
|
} catch { /* non-critical */ }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
49
apps/api/src/routes/sse.ts
Normal file
49
apps/api/src/routes/sse.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { tracks, projectMembers } from '@music-hub/db';
|
||||||
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
import { subscribe } from '../services/sse.js';
|
||||||
|
import type { AppEnv } from '../types.js';
|
||||||
|
|
||||||
|
export const sseRoutes = new Hono<AppEnv>()
|
||||||
|
.use('*', requireAuth)
|
||||||
|
|
||||||
|
.get('/track/:trackId', 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: 'Forbidden' }, 403);
|
||||||
|
|
||||||
|
const { readable, writable } = new TransformStream();
|
||||||
|
const writer = writable.getWriter();
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
const send = (data: string) => writer.write(encoder.encode(data)).catch(() => {});
|
||||||
|
const unsubscribe = subscribe(trackId, send);
|
||||||
|
|
||||||
|
// Initial ping
|
||||||
|
send(': connected\n\n');
|
||||||
|
|
||||||
|
c.req.raw.signal.addEventListener('abort', () => {
|
||||||
|
unsubscribe();
|
||||||
|
writer.close().catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(readable, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
import { eq, and, desc, asc, sql } from 'drizzle-orm';
|
import { eq, and, desc, asc, sql } from 'drizzle-orm';
|
||||||
import { requestUploadUrlSchema, createVersionSchema, updateVersionSchema } from '@music-hub/shared';
|
import { requestUploadUrlSchema, createVersionSchema, updateVersionSchema, rejectVersionSchema } from '@music-hub/shared';
|
||||||
import { tracks, versions, projectMembers } from '@music-hub/db';
|
import { tracks, versions, projectMembers, comments } from '@music-hub/db';
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
import { createUploadUrl, createDownloadUrl, getObjectBuffer } from '../storage/s3.js';
|
import { createUploadUrl, createDownloadUrl, getObjectBuffer } from '../storage/s3.js';
|
||||||
import { processVersion } from '../services/audio-processor.js';
|
import { processVersion } from '../services/audio-processor.js';
|
||||||
import { notifyProjectMembers, notifyUser } from '../services/push.js';
|
import { notifyProjectMembers, notifyUser } from '../services/push.js';
|
||||||
|
import { publish } from '../services/sse.js';
|
||||||
import type { AppEnv } from '../types.js';
|
import type { AppEnv } from '../types.js';
|
||||||
|
|
||||||
export const versionRoutes = new Hono<AppEnv>()
|
export const versionRoutes = new Hono<AppEnv>()
|
||||||
@@ -130,13 +131,14 @@ export const versionRoutes = new Hono<AppEnv>()
|
|||||||
console.error(`[Worker] Failed: ${err.message}`),
|
console.error(`[Worker] Failed: ${err.message}`),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Push: notify other project members
|
|
||||||
notifyProjectMembers(db, track.projectId, userId, {
|
notifyProjectMembers(db, track.projectId, userId, {
|
||||||
title: 'Neue Version',
|
title: 'Neue Version',
|
||||||
body: `${track.name} — V${versionNumber} hochgeladen`,
|
body: `${track.name} — V${versionNumber} hochgeladen`,
|
||||||
url: `/projects/${track.projectId}/tracks/${trackId}`,
|
url: `/projects/${track.projectId}/tracks/${trackId}`,
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
|
publish(trackId, { type: 'version:new', data: { versionId: version.id, versionNumber, trackId } });
|
||||||
|
|
||||||
return c.json({ version }, 201);
|
return c.json({ version }, 201);
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -400,6 +402,8 @@ export const versionRoutes = new Hono<AppEnv>()
|
|||||||
url: `/projects/${track!.projectId}/tracks/${version.trackId}`,
|
url: `/projects/${track!.projectId}/tracks/${version.trackId}`,
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
|
publish(version.trackId, { type: 'version:status', data: { versionId, status: 'approved' } });
|
||||||
|
|
||||||
return c.json({ version: updated });
|
return c.json({ version: updated });
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -464,11 +468,12 @@ export const versionRoutes = new Hono<AppEnv>()
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|
||||||
// Reject version
|
// Reject version (requires reason — posted as a comment)
|
||||||
.post('/:id/reject', async (c) => {
|
.post('/:id/reject', zValidator('json', rejectVersionSchema), async (c) => {
|
||||||
const db = c.get('db');
|
const db = c.get('db');
|
||||||
const userId = c.get('userId');
|
const userId = c.get('userId');
|
||||||
const versionId = c.req.param('id');
|
const versionId = c.req.param('id');
|
||||||
|
const { reason } = c.req.valid('json');
|
||||||
|
|
||||||
const [version] = await db
|
const [version] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -502,11 +507,21 @@ export const versionRoutes = new Hono<AppEnv>()
|
|||||||
.where(eq(versions.id, versionId))
|
.where(eq(versions.id, versionId))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
await db.insert(comments).values({
|
||||||
|
versionId,
|
||||||
|
userId,
|
||||||
|
body: `❌ Abgelehnt: ${reason}`,
|
||||||
|
timestampSeconds: null,
|
||||||
|
parentId: null,
|
||||||
|
});
|
||||||
|
|
||||||
notifyUser(db, version.createdById, {
|
notifyUser(db, version.createdById, {
|
||||||
title: 'Version abgelehnt',
|
title: 'Version abgelehnt',
|
||||||
body: `${track!.name} V${version.versionNumber} wurde abgelehnt`,
|
body: `${track!.name} V${version.versionNumber}: ${reason.slice(0, 80)}`,
|
||||||
url: `/projects/${track!.projectId}/tracks/${version.trackId}`,
|
url: `/projects/${track!.projectId}/tracks/${version.trackId}`,
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
|
publish(version.trackId, { type: 'version:status', data: { versionId, status: 'rejected' } });
|
||||||
|
|
||||||
return c.json({ version: updated });
|
return c.json({ version: updated });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,6 +39,46 @@ export async function sendMagicLinkEmail(email: string, token: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sendListenAlertEmail(
|
||||||
|
to: string,
|
||||||
|
listenerName: string | null,
|
||||||
|
trackName: string,
|
||||||
|
projectName: string,
|
||||||
|
trackUrl: string,
|
||||||
|
) {
|
||||||
|
const who = listenerName ?? 'Jemand';
|
||||||
|
|
||||||
|
if (!resend) {
|
||||||
|
console.log(`[DEV] Listen alert: ${who} hat "${trackName}" gehört — ${to}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await resend.emails.send({
|
||||||
|
from: fromEmail,
|
||||||
|
to,
|
||||||
|
subject: `${who} hat "${trackName}" gehört`,
|
||||||
|
html: `
|
||||||
|
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 460px; margin: 0 auto; padding: 2.5rem 2rem; color: #f4f0ec; background: #0a0910;">
|
||||||
|
<h1 style="font-size: 1.6rem; margin: 0 0 1rem; background: linear-gradient(135deg, #f43f5e, #fb923c); -webkit-background-clip: text; background-clip: text; color: transparent; display: inline-block;">Music Hub</h1>
|
||||||
|
<p style="color: #9b96a8; line-height: 1.55; margin: 0 0 0.5rem;">
|
||||||
|
<strong style="color: #f4f0ec;">${who}</strong> hat deinen Track gehört:
|
||||||
|
</p>
|
||||||
|
<p style="color: #f4f0ec; font-size: 1.1rem; font-weight: 600; margin: 0 0 0.25rem;">${trackName}</p>
|
||||||
|
<p style="color: #5e596b; font-size: 0.85rem; margin: 0 0 1.5rem;">${projectName}</p>
|
||||||
|
<a href="${trackUrl}" style="
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.8rem 1.6rem;
|
||||||
|
background: linear-gradient(135deg, #f43f5e, #fb923c);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
">Analytics ansehen</a>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendInviteEmail(email: string, projectName: string, inviterName: string) {
|
export async function sendInviteEmail(email: string, projectName: string, inviterName: string) {
|
||||||
const url = `${process.env.APP_URL}`;
|
const url = `${process.env.APP_URL}`;
|
||||||
|
|
||||||
|
|||||||
21
apps/api/src/services/sse.ts
Normal file
21
apps/api/src/services/sse.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
type SseClient = (data: string) => void;
|
||||||
|
|
||||||
|
const channels = new Map<string, Set<SseClient>>();
|
||||||
|
|
||||||
|
export function subscribe(trackId: string, send: SseClient): () => void {
|
||||||
|
if (!channels.has(trackId)) channels.set(trackId, new Set());
|
||||||
|
channels.get(trackId)!.add(send);
|
||||||
|
return () => {
|
||||||
|
channels.get(trackId)?.delete(send);
|
||||||
|
if (channels.get(trackId)?.size === 0) channels.delete(trackId);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publish(trackId: string, event: { type: string; data: unknown }) {
|
||||||
|
const clients = channels.get(trackId);
|
||||||
|
if (!clients || clients.size === 0) return;
|
||||||
|
const msg = `event: ${event.type}\ndata: ${JSON.stringify(event.data)}\n\n`;
|
||||||
|
for (const send of clients) {
|
||||||
|
try { send(msg); } catch { /* client gone */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
40
apps/web/src/lib/stores/sse.ts
Normal file
40
apps/web/src/lib/stores/sse.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -16,8 +16,10 @@
|
|||||||
import CoverImage from '$lib/components/ui/CoverImage.svelte';
|
import CoverImage from '$lib/components/ui/CoverImage.svelte';
|
||||||
import CoverUpload from '$lib/components/ui/CoverUpload.svelte';
|
import CoverUpload from '$lib/components/ui/CoverUpload.svelte';
|
||||||
import TrackStatusPill from '$lib/components/ui/TrackStatusPill.svelte';
|
import TrackStatusPill from '$lib/components/ui/TrackStatusPill.svelte';
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
import { onKey } from '$lib/utils/shortcuts.js';
|
import { onKey } from '$lib/utils/shortcuts.js';
|
||||||
import { snapshotForTrack, continuationFor } from '$lib/stores/player.js';
|
import { snapshotForTrack, continuationFor } from '$lib/stores/player.js';
|
||||||
|
import { connectTrackSse } from '$lib/stores/sse.js';
|
||||||
import {
|
import {
|
||||||
offlineVersions,
|
offlineVersions,
|
||||||
downloadForOffline,
|
downloadForOffline,
|
||||||
@@ -106,6 +108,9 @@
|
|||||||
let offlineDropdownOpen = $state(false);
|
let offlineDropdownOpen = $state(false);
|
||||||
let offlineDownloading = $state(false);
|
let offlineDownloading = $state(false);
|
||||||
let offlineProgress = $state(0);
|
let offlineProgress = $state(0);
|
||||||
|
let rejectOpen = $state(false);
|
||||||
|
let rejectReason = $state('');
|
||||||
|
let rejecting = $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));
|
||||||
@@ -137,6 +142,19 @@
|
|||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
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) {
|
async function selectVersion(version: Version) {
|
||||||
@@ -205,11 +223,22 @@
|
|||||||
await loadVersions();
|
await loadVersions();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleReject() {
|
function handleReject() {
|
||||||
if (!selectedVersion) return;
|
rejectReason = '';
|
||||||
await api.post(`/versions/${selectedVersion.id}/reject`);
|
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');
|
toastSuccess('Version abgelehnt');
|
||||||
await loadVersions();
|
await loadVersions();
|
||||||
|
} finally {
|
||||||
|
rejecting = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleComment(body: string, timestamp: number | null, parentId?: string) {
|
async function handleComment(body: string, timestamp: number | null, parentId?: string) {
|
||||||
@@ -638,6 +667,26 @@
|
|||||||
<ShareModal bind:open={shareOpen} versionId={selectedVersion.id} />
|
<ShareModal bind:open={shareOpen} versionId={selectedVersion.id} />
|
||||||
{/if}
|
{/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">
|
<Modal bind:open={coverEditOpen} title="Track-Cover ändern">
|
||||||
<div class="cover-modal">
|
<div class="cover-modal">
|
||||||
<CoverUpload currentUrl={trackCoverUrl} name={trackName} onUploaded={saveTrackCover} />
|
<CoverUpload currentUrl={trackCoverUrl} name={trackName} onUploaded={saveTrackCover} />
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ export const updateVersionSchema = z.object({
|
|||||||
branchLabel: z.string().max(100).nullable().optional(),
|
branchLabel: z.string().max(100).nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const rejectVersionSchema = z.object({
|
||||||
|
reason: z.string().min(1, 'Begründung erforderlich').max(2000),
|
||||||
|
});
|
||||||
|
|
||||||
export const requestStemUploadUrlSchema = z.object({
|
export const requestStemUploadUrlSchema = z.object({
|
||||||
fileName: z.string().min(1),
|
fileName: z.string().min(1),
|
||||||
mimeType: z.string().min(1),
|
mimeType: z.string().min(1),
|
||||||
|
|||||||
Reference in New Issue
Block a user