feat: add STEM file support per track
- DB: stems table with trackId FK, fileKey, sortOrder, createdById - API: GET/POST/DELETE stems, presigned upload URL, ZIP download via fflate - Web: StemUploadDropzone (multi-file, batch upload, progress bars) - Web: StemList with download-all-ZIP and per-stem delete - Web: STEMs tab in track detail view - Icon: add 'music' icon to inline set - Auto-migration runs stems table creation on boot Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
45
CLAUDE.md
Normal file
45
CLAUDE.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Music Hub
|
||||
|
||||
Webapp für Label-Kollaboration. Stack: SvelteKit + Hono + Postgres.
|
||||
|
||||
## Aktueller Stand
|
||||
|
||||
<!-- Zuletzt aktualisiert: 2026-04-13 via /save -->
|
||||
|
||||
**Sprint / Phase:** Deploy + erster Klienten-Test
|
||||
|
||||
**Zuletzt implementiert:**
|
||||
- App live auf hub.mydrugismusic.com (Registrierung, Login funktionieren)
|
||||
- Coolify-Deploy via Webhook-Script (kein UI nötig, im Memory dokumentiert)
|
||||
- DATABASE_URL auf public port umgestellt (interner Coolify-Hostname war nicht erreichbar)
|
||||
- README geschrieben und gepusht
|
||||
|
||||
**Als nächstes:**
|
||||
- RESEND_API_KEY setzen → echter E-Mail-Versand
|
||||
- App-Bugs fixen (User: „man kann quasi nichts machen außer Profil/Karte")
|
||||
- DB `is_public` nach Tests wieder deaktivieren
|
||||
|
||||
**Offene Punkte:**
|
||||
- Interner Coolify-Netzwerkfehler (API→DB via UUID-Hostname) ungeklärt
|
||||
|
||||
## Decisions
|
||||
|
||||
`docs/decisions/` — Architecture Decision Records für nicht-offensichtliche Entscheidungen.
|
||||
Template: `~/.claude/templates/adr.md`
|
||||
Anlegen wenn: Alternative verworfen, Constraint akzeptiert, Richtungsentscheidung getroffen.
|
||||
|
||||
## Specs
|
||||
|
||||
`specs/` — ein File pro Sprint oder Feature, bevor Code geschrieben wird.
|
||||
Template: `~/.claude/templates/spec.md`
|
||||
|
||||
Konvention:
|
||||
- Neues Sprint/Feature → erst `specs/sprint-N.md` oder `specs/feature-name.md` anlegen
|
||||
- Kanban-Task verlinkt auf die Spec-Datei
|
||||
- Aktive Spec steht im `## Aktueller Stand`
|
||||
|
||||
## Kanban
|
||||
|
||||
Board-ID: `cfddb658-6f5b-4d36-b311-369307a5fc51`
|
||||
|
||||
Konvention: Bei Session-Start `get-board-info` aufrufen und offene Tasks zeigen. Aktive Tasks nach In Progress ziehen, erledigte nach Done.
|
||||
@@ -14,6 +14,7 @@
|
||||
"@music-hub/db": "workspace:*",
|
||||
"@music-hub/shared": "workspace:*",
|
||||
"drizzle-orm": "^0.44",
|
||||
"fflate": "^0.8.2",
|
||||
"hono": "^4",
|
||||
"resend": "^6.10.0"
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { shareRoutes } from './routes/share.js';
|
||||
import { uploadRoutes } from './routes/uploads.js';
|
||||
import { activityRoutes } from './routes/activity.js';
|
||||
import { onboardingRoutes } from './routes/onboarding.js';
|
||||
import { stemRoutes } from './routes/stems.js';
|
||||
import type { AppEnv } from './types.js';
|
||||
|
||||
const db = createDb(process.env.DATABASE_URL!);
|
||||
@@ -104,7 +105,8 @@ const app = new Hono<AppEnv>()
|
||||
.route('/share', shareRoutes)
|
||||
.route('/uploads', uploadRoutes)
|
||||
.route('/activity', activityRoutes)
|
||||
.route('/onboarding', onboardingRoutes);
|
||||
.route('/onboarding', onboardingRoutes)
|
||||
.route('/stems', stemRoutes);
|
||||
|
||||
const port = parseInt(process.env.PORT || '3000');
|
||||
console.log(`Music Hub API running on port ${port}`);
|
||||
|
||||
164
apps/api/src/routes/stems.ts
Normal file
164
apps/api/src/routes/stems.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { eq, and, asc } from 'drizzle-orm';
|
||||
import { requestStemUploadUrlSchema, createStemSchema } from '@music-hub/shared';
|
||||
import { tracks, stems, projectMembers } from '@music-hub/db';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { createUploadUrl, getObjectBuffer, deleteObject } from '../storage/s3.js';
|
||||
import { zipSync } from 'fflate';
|
||||
import type { AppEnv } from '../types.js';
|
||||
|
||||
export const stemRoutes = new Hono<AppEnv>()
|
||||
.use('*', requireAuth)
|
||||
|
||||
// List stems for a track
|
||||
.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: 'Not found' }, 404);
|
||||
|
||||
const trackStems = await db
|
||||
.select()
|
||||
.from(stems)
|
||||
.where(eq(stems.trackId, trackId))
|
||||
.orderBy(asc(stems.sortOrder), asc(stems.createdAt));
|
||||
|
||||
return c.json({ stems: trackStems });
|
||||
})
|
||||
|
||||
// Request presigned upload URL
|
||||
.post('/track/:trackId/upload-url', zValidator('json', requestStemUploadUrlSchema), async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const trackId = c.req.param('trackId');
|
||||
const { fileName, mimeType, fileSize } = c.req.valid('json');
|
||||
|
||||
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 || !membership.canUpload) return c.json({ error: 'Forbidden' }, 403);
|
||||
|
||||
const stemId = crypto.randomUUID();
|
||||
const fileKey = `projects/${track.projectId}/tracks/${trackId}/stems/${stemId}/${fileName}`;
|
||||
const uploadUrl = await createUploadUrl(fileKey, mimeType, fileSize);
|
||||
|
||||
return c.json({ uploadUrl, fileKey, stemId });
|
||||
})
|
||||
|
||||
// Register stem after upload
|
||||
.post('/track/:trackId', zValidator('json', createStemSchema), async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const trackId = c.req.param('trackId');
|
||||
const input = c.req.valid('json');
|
||||
|
||||
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 || !membership.canUpload) return c.json({ error: 'Forbidden' }, 403);
|
||||
|
||||
const [stem] = await db
|
||||
.insert(stems)
|
||||
.values({
|
||||
trackId,
|
||||
name: input.name,
|
||||
originalFileName: input.originalFileName,
|
||||
mimeType: input.mimeType,
|
||||
fileSize: input.fileSize,
|
||||
fileKey: input.fileKey,
|
||||
createdById: userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return c.json({ stem }, 201);
|
||||
})
|
||||
|
||||
// Delete stem
|
||||
.delete('/:id', async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const stemId = c.req.param('id');
|
||||
|
||||
const [stem] = await db.select().from(stems).where(eq(stems.id, stemId)).limit(1);
|
||||
if (!stem) return c.json({ error: 'Not found' }, 404);
|
||||
|
||||
const [track] = await db.select().from(tracks).where(eq(tracks.id, stem.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.role !== 'owner' && stem.createdById !== userId)) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
await deleteObject(stem.fileKey);
|
||||
await db.delete(stems).where(eq(stems.id, stemId));
|
||||
return c.json({ message: 'Stem deleted' });
|
||||
})
|
||||
|
||||
// Download all stems as ZIP
|
||||
.get('/track/:trackId/download-zip', 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 trackStems = await db
|
||||
.select()
|
||||
.from(stems)
|
||||
.where(eq(stems.trackId, trackId))
|
||||
.orderBy(asc(stems.sortOrder), asc(stems.createdAt));
|
||||
|
||||
if (trackStems.length === 0) return c.json({ error: 'No stems found' }, 404);
|
||||
|
||||
// Download all files and build ZIP
|
||||
const files: Record<string, Uint8Array> = {};
|
||||
await Promise.all(
|
||||
trackStems.map(async (stem) => {
|
||||
const buf = await getObjectBuffer(stem.fileKey);
|
||||
files[stem.originalFileName] = buf;
|
||||
}),
|
||||
);
|
||||
|
||||
const zipped = zipSync(files);
|
||||
const zipName = `${track.name.replace(/[^a-z0-9]/gi, '-').toLowerCase()}-stems.zip`;
|
||||
|
||||
return new Response(zipped, {
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': `attachment; filename="${zipName}"`,
|
||||
'Content-Length': String(zipped.length),
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,12 @@ export async function createDownloadUrl(key: string, expiresIn = 3600): Promise<
|
||||
return getSignedUrl(s3, command, { expiresIn });
|
||||
}
|
||||
|
||||
export async function getObjectBuffer(key: string): Promise<Uint8Array> {
|
||||
const command = new GetObjectCommand({ Bucket: bucket, Key: key });
|
||||
const response = await s3.send(command);
|
||||
return response.Body!.transformToByteArray();
|
||||
}
|
||||
|
||||
export async function deleteObject(key: string): Promise<void> {
|
||||
const command = new DeleteObjectCommand({
|
||||
Bucket: bucket,
|
||||
|
||||
256
apps/web/src/lib/components/audio/StemUploadDropzone.svelte
Normal file
256
apps/web/src/lib/components/audio/StemUploadDropzone.svelte
Normal file
@@ -0,0 +1,256 @@
|
||||
<script lang="ts">
|
||||
import { MAX_FILE_SIZE } from '@music-hub/shared';
|
||||
import { api } from '$lib/api/client.js';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
|
||||
let { trackId, onUploaded }: { trackId: string; onUploaded: () => void } = $props();
|
||||
|
||||
let dragOver = $state(false);
|
||||
let files = $state<{ name: string; progress: number; error: string }[]>([]);
|
||||
let uploading = $state(false);
|
||||
let globalError = $state('');
|
||||
|
||||
function stemNameFromFile(fileName: string) {
|
||||
return fileName.replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragOver = true;
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
dragOver = false;
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragOver = false;
|
||||
const dropped = e.dataTransfer?.files;
|
||||
if (dropped && dropped.length > 0) uploadFiles(Array.from(dropped));
|
||||
}
|
||||
|
||||
function handleFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) uploadFiles(Array.from(input.files));
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
async function uploadFiles(selected: File[]) {
|
||||
globalError = '';
|
||||
const tooBig = selected.filter((f) => f.size > MAX_FILE_SIZE);
|
||||
if (tooBig.length > 0) {
|
||||
globalError = `${tooBig.map((f) => f.name).join(', ')} zu groß (max 500 MB)`;
|
||||
return;
|
||||
}
|
||||
|
||||
uploading = true;
|
||||
files = selected.map((f) => ({ name: f.name, progress: 0, error: '' }));
|
||||
|
||||
// Upload in batches of 3
|
||||
for (let i = 0; i < selected.length; i += 3) {
|
||||
const batch = selected.slice(i, i + 3);
|
||||
await Promise.all(batch.map((file, j) => uploadOne(file, i + j)));
|
||||
}
|
||||
|
||||
uploading = false;
|
||||
const anyError = files.some((f) => f.error);
|
||||
if (!anyError) {
|
||||
files = [];
|
||||
onUploaded();
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadOne(file: File, idx: number) {
|
||||
try {
|
||||
const { uploadUrl, fileKey } = await api.post<{ uploadUrl: string; fileKey: string }>(
|
||||
`/stems/track/${trackId}/upload-url`,
|
||||
{ fileName: file.name, mimeType: file.type || 'audio/wav', fileSize: file.size },
|
||||
);
|
||||
|
||||
await uploadWithProgress(uploadUrl, file, (p) => {
|
||||
files[idx] = { ...files[idx], progress: p };
|
||||
});
|
||||
|
||||
await api.post(`/stems/track/${trackId}`, {
|
||||
fileKey,
|
||||
name: stemNameFromFile(file.name),
|
||||
originalFileName: file.name,
|
||||
mimeType: file.type || 'audio/wav',
|
||||
fileSize: file.size,
|
||||
});
|
||||
|
||||
files[idx] = { ...files[idx], progress: 100 };
|
||||
} catch (err) {
|
||||
files[idx] = { ...files[idx], error: err instanceof Error ? err.message : 'Fehler' };
|
||||
}
|
||||
}
|
||||
|
||||
function uploadWithProgress(url: string, file: File, onProgress: (p: number) => void): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('PUT', url);
|
||||
xhr.setRequestHeader('Content-Type', file.type || 'audio/wav');
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
|
||||
};
|
||||
xhr.onload = () => (xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new Error(`HTTP ${xhr.status}`)));
|
||||
xhr.onerror = () => reject(new Error('Upload fehlgeschlagen'));
|
||||
xhr.send(file);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="stem-upload">
|
||||
<div
|
||||
class="dropzone"
|
||||
class:dragover={dragOver}
|
||||
class:uploading
|
||||
role="button"
|
||||
tabindex="0"
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={handleDrop}
|
||||
onclick={() => !uploading && document.getElementById(`stem-input-${trackId}`)?.click()}
|
||||
onkeydown={(e) => e.key === 'Enter' && !uploading && document.getElementById(`stem-input-${trackId}`)?.click()}
|
||||
>
|
||||
<input
|
||||
id="stem-input-{trackId}"
|
||||
type="file"
|
||||
accept=".wav,.mp3,.flac,.aiff,.aif"
|
||||
multiple
|
||||
onchange={handleFileSelect}
|
||||
hidden
|
||||
/>
|
||||
<div class="dropzone-content">
|
||||
<span class="icon"><Icon name="upload" size={24} /></span>
|
||||
<p>STEMs hier ablegen oder klicken</p>
|
||||
<span class="hint">Mehrere Dateien gleichzeitig möglich · WAV, FLAC, AIFF · max 500 MB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if files.length > 0}
|
||||
<div class="file-list">
|
||||
{#each files as f}
|
||||
<div class="file-row" class:done={f.progress === 100} class:error={!!f.error}>
|
||||
<span class="file-name">{f.name}</span>
|
||||
{#if f.error}
|
||||
<span class="file-error">{f.error}</span>
|
||||
{:else}
|
||||
<div class="file-progress">
|
||||
<div class="file-bar" style="width: {f.progress}%"></div>
|
||||
</div>
|
||||
<span class="file-pct">{f.progress}%</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if globalError}
|
||||
<p class="error">{globalError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.stem-upload {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.dropzone {
|
||||
border: 2px dashed #333;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
.dropzone:hover,
|
||||
.dropzone.dragover {
|
||||
border-color: #6366f1;
|
||||
background: #1a1a2e;
|
||||
}
|
||||
|
||||
.dropzone.uploading {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dropzone-content p {
|
||||
margin: 0.4rem 0 0.2rem;
|
||||
color: #ccc;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--color-text-tertiary);
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.78rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.file-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.file-progress {
|
||||
width: 80px;
|
||||
height: 4px;
|
||||
background: #222;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-bar {
|
||||
height: 100%;
|
||||
background: #6366f1;
|
||||
transition: width 0.2s;
|
||||
}
|
||||
|
||||
.file-row.done .file-bar {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.file-pct {
|
||||
width: 30px;
|
||||
text-align: right;
|
||||
color: var(--color-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.file-error {
|
||||
color: #ef4444;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ef4444;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
@@ -7,7 +7,7 @@
|
||||
| 'download' | 'upload' | 'share' | 'plus' | 'check' | 'x' | 'close'
|
||||
| 'chevron-down' | 'chevron-right' | 'more' | 'home' | 'panel' | 'panel-off'
|
||||
| 'git-branch' | 'arrow-up' | 'compare' | 'comment' | 'lock' | 'link'
|
||||
| 'settings' | 'logout' | 'list' | 'graph' | 'menu' | 'search';
|
||||
| 'settings' | 'logout' | 'list' | 'graph' | 'menu' | 'search' | 'music';
|
||||
|
||||
let {
|
||||
name,
|
||||
@@ -134,6 +134,10 @@
|
||||
{:else if name === 'search'}
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
{:else if name === 'music'}
|
||||
<path d="M9 18V5l12-2v13" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="18" cy="16" r="3" />
|
||||
{/if}
|
||||
</svg>
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
import VersionGraph from './components/VersionGraph.svelte';
|
||||
import ShareModal from './components/ShareModal.svelte';
|
||||
import CommentSection from './components/CommentSection.svelte';
|
||||
import StemList from './components/StemList.svelte';
|
||||
|
||||
type Version = {
|
||||
id: string;
|
||||
@@ -85,7 +86,9 @@
|
||||
let branchFromId = $state<string | null>(null);
|
||||
let branchLabelInput = $state('');
|
||||
let shareOpen = $state(false);
|
||||
let panelTab = $state<'versions' | 'comments'>('versions');
|
||||
type Stem = { id: string; name: string; originalFileName: string; mimeType: string; fileSize: number; createdAt: string; createdById: string };
|
||||
let stems = $state<Stem[]>([]);
|
||||
let panelTab = $state<'versions' | 'comments' | 'stems'>('versions');
|
||||
let panelOpen = $state(true);
|
||||
let editVersionOpen = $state(false);
|
||||
let editVersionLabel = $state('');
|
||||
@@ -98,11 +101,12 @@
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const [projectRes, trackVersions, tracksRes, treeRes] = await Promise.all([
|
||||
const [projectRes, trackVersions, tracksRes, treeRes, stemsRes] = await Promise.all([
|
||||
api.get<{ project: { name: string }; role: string }>(`/projects/${projectId}`),
|
||||
api.get<{ versions: Version[] }>(`/versions/track/${trackId}`),
|
||||
api.get<{ tracks: { id: string; name: string; coverUrl: string | null; status: TrackStatus; section: string | null }[] }>(`/tracks/project/${projectId}`),
|
||||
api.get<{ nodes: GraphNode[] }>(`/versions/track/${trackId}/tree`),
|
||||
api.get<{ stems: Stem[] }>(`/stems/track/${trackId}`),
|
||||
]);
|
||||
|
||||
projectName = projectRes.project.name;
|
||||
@@ -114,6 +118,7 @@
|
||||
trackSection = t?.section ?? null;
|
||||
versions = trackVersions.versions;
|
||||
graphNodes = treeRes.nodes;
|
||||
stems = stemsRes.stems;
|
||||
|
||||
if (versions.length > 0) await selectVersion(versions[0]);
|
||||
} finally {
|
||||
@@ -502,6 +507,9 @@
|
||||
<button class:active={panelTab === 'comments'} onclick={() => (panelTab = 'comments')}>
|
||||
Kommentare <span class="badge">{comments.length}</span>
|
||||
</button>
|
||||
<button class:active={panelTab === 'stems'} onclick={() => (panelTab = 'stems')}>
|
||||
STEMs <span class="badge">{stems.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
@@ -519,6 +527,14 @@
|
||||
onBranch={canUpload ? startBranch : undefined}
|
||||
/>
|
||||
{/if}
|
||||
{:else if panelTab === 'stems'}
|
||||
<StemList
|
||||
{trackId}
|
||||
bind:stems
|
||||
{canUpload}
|
||||
currentUserId={$user?.id ?? null}
|
||||
{role}
|
||||
/>
|
||||
{:else if selectedVersion}
|
||||
<CommentSection
|
||||
{comments}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api/client.js';
|
||||
import { toastSuccess } from '$lib/stores/toast.js';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import StemUploadDropzone from '$lib/components/audio/StemUploadDropzone.svelte';
|
||||
|
||||
type Stem = {
|
||||
id: string;
|
||||
name: string;
|
||||
originalFileName: string;
|
||||
mimeType: string;
|
||||
fileSize: number;
|
||||
createdAt: string;
|
||||
createdById: string;
|
||||
};
|
||||
|
||||
let {
|
||||
trackId,
|
||||
stems = $bindable<Stem[]>([]),
|
||||
canUpload,
|
||||
currentUserId,
|
||||
role,
|
||||
}: {
|
||||
trackId: string;
|
||||
stems: Stem[];
|
||||
canUpload: boolean;
|
||||
currentUserId: string | null;
|
||||
role: string;
|
||||
} = $props();
|
||||
|
||||
let showUpload = $state(false);
|
||||
let deleting = $state<string | null>(null);
|
||||
|
||||
function formatSize(bytes: number) {
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
async function loadStems() {
|
||||
const res = await api.get<{ stems: Stem[] }>(`/stems/track/${trackId}`);
|
||||
stems = res.stems;
|
||||
}
|
||||
|
||||
async function downloadZip() {
|
||||
const res = await fetch(`/api/v1/stems/track/${trackId}/download-zip`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = res.headers.get('content-disposition')?.match(/filename="(.+?)"/)?.[1] ?? 'stems.zip';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function deleteStem(id: string, name: string) {
|
||||
if (!confirm(`Stem "${name}" wirklich löschen?`)) return;
|
||||
deleting = id;
|
||||
try {
|
||||
await api.delete(`/stems/${id}`);
|
||||
stems = stems.filter((s) => s.id !== id);
|
||||
toastSuccess('Stem gelöscht');
|
||||
} finally {
|
||||
deleting = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="stems">
|
||||
<div class="stems-header">
|
||||
{#if stems.length > 0}
|
||||
<Button variant="ghost" size="sm" onclick={downloadZip}>
|
||||
<Icon name="download" size={14} /> Alle als ZIP
|
||||
</Button>
|
||||
{/if}
|
||||
{#if canUpload}
|
||||
<Button variant="ghost" size="sm" onclick={() => (showUpload = !showUpload)}>
|
||||
<Icon name="upload" size={14} /> {showUpload ? 'Schließen' : 'STEMs hochladen'}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showUpload}
|
||||
<div class="upload-box">
|
||||
<StemUploadDropzone
|
||||
{trackId}
|
||||
onUploaded={async () => {
|
||||
await loadStems();
|
||||
toastSuccess('STEMs hochgeladen');
|
||||
showUpload = false;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if stems.length === 0 && !showUpload}
|
||||
<p class="empty">Noch keine STEMs hochgeladen.</p>
|
||||
{:else}
|
||||
<ul class="stem-list">
|
||||
{#each stems as stem (stem.id)}
|
||||
<li class="stem-item">
|
||||
<span class="stem-icon"><Icon name="music" size={14} /></span>
|
||||
<div class="stem-info">
|
||||
<span class="stem-name">{stem.name}</span>
|
||||
<span class="stem-meta">{stem.originalFileName} · {formatSize(stem.fileSize)}</span>
|
||||
</div>
|
||||
{#if role === 'owner' || stem.createdById === currentUserId}
|
||||
<button
|
||||
class="delete-btn"
|
||||
onclick={() => deleteStem(stem.id, stem.name)}
|
||||
disabled={deleting === stem.id}
|
||||
title="Stem löschen"
|
||||
>
|
||||
<Icon name="x" size={12} />
|
||||
</button>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.stems {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.stems-header {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.upload-box {
|
||||
background: var(--color-bg-base);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.stem-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.stem-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-base);
|
||||
border: 1px solid var(--color-border);
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.stem-item:hover {
|
||||
border-color: var(--color-border-hover);
|
||||
}
|
||||
|
||||
.stem-icon {
|
||||
color: var(--color-text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stem-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.stem-name {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.stem-meta {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-tertiary);
|
||||
cursor: pointer;
|
||||
padding: var(--space-1);
|
||||
border-radius: var(--radius-sm);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.delete-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
3
bun.lock
3
bun.lock
@@ -20,6 +20,7 @@
|
||||
"@music-hub/db": "workspace:*",
|
||||
"@music-hub/shared": "workspace:*",
|
||||
"drizzle-orm": "^0.44",
|
||||
"fflate": "^0.8.2",
|
||||
"hono": "^4",
|
||||
"resend": "^6.10.0",
|
||||
},
|
||||
@@ -474,6 +475,8 @@
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
15
packages/db/src/migrations/0006_brown_lily_hollister.sql
Normal file
15
packages/db/src/migrations/0006_brown_lily_hollister.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE "stems" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"track_id" uuid NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"original_file_name" varchar(500) NOT NULL,
|
||||
"mime_type" varchar(100) NOT NULL,
|
||||
"file_size" bigint NOT NULL,
|
||||
"file_key" text NOT NULL,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"created_by_id" uuid NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "stems" ADD CONSTRAINT "stems_track_id_tracks_id_fk" FOREIGN KEY ("track_id") REFERENCES "public"."tracks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "stems" ADD CONSTRAINT "stems_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
|
||||
1030
packages/db/src/migrations/meta/0006_snapshot.json
Normal file
1030
packages/db/src/migrations/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,13 @@
|
||||
"when": 1776012912970,
|
||||
"tag": "0005_rare_triathlon",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1776094119472,
|
||||
"tag": "0006_brown_lily_hollister",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -77,3 +77,20 @@ export const versions = pgTable('versions', {
|
||||
.notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const stems = pgTable('stems', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
trackId: uuid('track_id')
|
||||
.references(() => tracks.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
originalFileName: varchar('original_file_name', { length: 500 }).notNull(),
|
||||
mimeType: varchar('mime_type', { length: 100 }).notNull(),
|
||||
fileSize: bigint('file_size', { mode: 'number' }).notNull(),
|
||||
fileKey: text('file_key').notNull(),
|
||||
sortOrder: integer('sort_order').default(0).notNull(),
|
||||
createdById: uuid('created_by_id')
|
||||
.references(() => users.id)
|
||||
.notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
@@ -53,6 +53,20 @@ export const updateVersionSchema = z.object({
|
||||
branchLabel: z.string().max(100).nullable().optional(),
|
||||
});
|
||||
|
||||
export const requestStemUploadUrlSchema = z.object({
|
||||
fileName: z.string().min(1),
|
||||
mimeType: z.string().min(1),
|
||||
fileSize: z.number().int().positive().max(MAX_FILE_SIZE),
|
||||
});
|
||||
|
||||
export const createStemSchema = z.object({
|
||||
fileKey: z.string().min(1),
|
||||
name: z.string().min(1).max(255),
|
||||
originalFileName: z.string().min(1),
|
||||
mimeType: z.string().min(1),
|
||||
fileSize: z.number().int().positive(),
|
||||
});
|
||||
|
||||
export const createShareLinkSchema = z.object({
|
||||
expiresAt: z.string().datetime().optional(),
|
||||
allowComments: z.boolean().optional(),
|
||||
@@ -75,3 +89,5 @@ export type UpdateVersionInput = z.infer<typeof updateVersionSchema>;
|
||||
export type CreateShareLinkInput = z.infer<typeof createShareLinkSchema>;
|
||||
export type CoverUploadInput = z.infer<typeof coverUploadSchema>;
|
||||
export type GuestCommentInput = z.infer<typeof guestCommentSchema>;
|
||||
export type RequestStemUploadUrlInput = z.infer<typeof requestStemUploadUrlSchema>;
|
||||
export type CreateStemInput = z.infer<typeof createStemSchema>;
|
||||
|
||||
Reference in New Issue
Block a user