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:
Robin Choice
2026-04-13 18:13:01 +02:00
parent df54fde710
commit 9530add1ff
15 changed files with 1812 additions and 4 deletions

45
CLAUDE.md Normal file
View 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.

View File

@@ -14,6 +14,7 @@
"@music-hub/db": "workspace:*", "@music-hub/db": "workspace:*",
"@music-hub/shared": "workspace:*", "@music-hub/shared": "workspace:*",
"drizzle-orm": "^0.44", "drizzle-orm": "^0.44",
"fflate": "^0.8.2",
"hono": "^4", "hono": "^4",
"resend": "^6.10.0" "resend": "^6.10.0"
} }

View File

@@ -11,6 +11,7 @@ import { shareRoutes } from './routes/share.js';
import { uploadRoutes } from './routes/uploads.js'; import { uploadRoutes } from './routes/uploads.js';
import { activityRoutes } from './routes/activity.js'; 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 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!);
@@ -104,7 +105,8 @@ const app = new Hono<AppEnv>()
.route('/share', shareRoutes) .route('/share', shareRoutes)
.route('/uploads', uploadRoutes) .route('/uploads', uploadRoutes)
.route('/activity', activityRoutes) .route('/activity', activityRoutes)
.route('/onboarding', onboardingRoutes); .route('/onboarding', onboardingRoutes)
.route('/stems', stemRoutes);
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}`);

View 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),
},
});
});

View File

@@ -40,6 +40,12 @@ export async function createDownloadUrl(key: string, expiresIn = 3600): Promise<
return getSignedUrl(s3, command, { expiresIn }); 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> { export async function deleteObject(key: string): Promise<void> {
const command = new DeleteObjectCommand({ const command = new DeleteObjectCommand({
Bucket: bucket, Bucket: bucket,

View 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>

View File

@@ -7,7 +7,7 @@
| 'download' | 'upload' | 'share' | 'plus' | 'check' | 'x' | 'close' | 'download' | 'upload' | 'share' | 'plus' | 'check' | 'x' | 'close'
| 'chevron-down' | 'chevron-right' | 'more' | 'home' | 'panel' | 'panel-off' | 'chevron-down' | 'chevron-right' | 'more' | 'home' | 'panel' | 'panel-off'
| 'git-branch' | 'arrow-up' | 'compare' | 'comment' | 'lock' | 'link' | 'git-branch' | 'arrow-up' | 'compare' | 'comment' | 'lock' | 'link'
| 'settings' | 'logout' | 'list' | 'graph' | 'menu' | 'search'; | 'settings' | 'logout' | 'list' | 'graph' | 'menu' | 'search' | 'music';
let { let {
name, name,
@@ -134,6 +134,10 @@
{:else if name === 'search'} {:else if name === 'search'}
<circle cx="11" cy="11" r="8" /> <circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" /> <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} {/if}
</svg> </svg>

View File

@@ -23,6 +23,7 @@
import VersionGraph from './components/VersionGraph.svelte'; import VersionGraph from './components/VersionGraph.svelte';
import ShareModal from './components/ShareModal.svelte'; import ShareModal from './components/ShareModal.svelte';
import CommentSection from './components/CommentSection.svelte'; import CommentSection from './components/CommentSection.svelte';
import StemList from './components/StemList.svelte';
type Version = { type Version = {
id: string; id: string;
@@ -85,7 +86,9 @@
let branchFromId = $state<string | null>(null); let branchFromId = $state<string | null>(null);
let branchLabelInput = $state(''); let branchLabelInput = $state('');
let shareOpen = $state(false); 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 panelOpen = $state(true);
let editVersionOpen = $state(false); let editVersionOpen = $state(false);
let editVersionLabel = $state(''); let editVersionLabel = $state('');
@@ -98,11 +101,12 @@
onMount(async () => { onMount(async () => {
try { 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<{ project: { name: string }; role: string }>(`/projects/${projectId}`),
api.get<{ versions: Version[] }>(`/versions/track/${trackId}`), 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<{ 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<{ nodes: GraphNode[] }>(`/versions/track/${trackId}/tree`),
api.get<{ stems: Stem[] }>(`/stems/track/${trackId}`),
]); ]);
projectName = projectRes.project.name; projectName = projectRes.project.name;
@@ -114,6 +118,7 @@
trackSection = t?.section ?? null; trackSection = t?.section ?? null;
versions = trackVersions.versions; versions = trackVersions.versions;
graphNodes = treeRes.nodes; graphNodes = treeRes.nodes;
stems = stemsRes.stems;
if (versions.length > 0) await selectVersion(versions[0]); if (versions.length > 0) await selectVersion(versions[0]);
} finally { } finally {
@@ -502,6 +507,9 @@
<button class:active={panelTab === 'comments'} onclick={() => (panelTab = 'comments')}> <button class:active={panelTab === 'comments'} onclick={() => (panelTab = 'comments')}>
Kommentare <span class="badge">{comments.length}</span> Kommentare <span class="badge">{comments.length}</span>
</button> </button>
<button class:active={panelTab === 'stems'} onclick={() => (panelTab = 'stems')}>
STEMs <span class="badge">{stems.length}</span>
</button>
</div> </div>
<div class="panel-body"> <div class="panel-body">
@@ -519,6 +527,14 @@
onBranch={canUpload ? startBranch : undefined} onBranch={canUpload ? startBranch : undefined}
/> />
{/if} {/if}
{:else if panelTab === 'stems'}
<StemList
{trackId}
bind:stems
{canUpload}
currentUserId={$user?.id ?? null}
{role}
/>
{:else if selectedVersion} {:else if selectedVersion}
<CommentSection <CommentSection
{comments} {comments}

View File

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

View File

@@ -20,6 +20,7 @@
"@music-hub/db": "workspace:*", "@music-hub/db": "workspace:*",
"@music-hub/shared": "workspace:*", "@music-hub/shared": "workspace:*",
"drizzle-orm": "^0.44", "drizzle-orm": "^0.44",
"fflate": "^0.8.2",
"hono": "^4", "hono": "^4",
"resend": "^6.10.0", "resend": "^6.10.0",
}, },
@@ -474,6 +475,8 @@
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "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=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],

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

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,13 @@
"when": 1776012912970, "when": 1776012912970,
"tag": "0005_rare_triathlon", "tag": "0005_rare_triathlon",
"breakpoints": true "breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1776094119472,
"tag": "0006_brown_lily_hollister",
"breakpoints": true
} }
] ]
} }

View File

@@ -77,3 +77,20 @@ export const versions = pgTable('versions', {
.notNull(), .notNull(),
createdAt: timestamp('created_at').defaultNow().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(),
});

View File

@@ -53,6 +53,20 @@ export const updateVersionSchema = z.object({
branchLabel: z.string().max(100).nullable().optional(), 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({ export const createShareLinkSchema = z.object({
expiresAt: z.string().datetime().optional(), expiresAt: z.string().datetime().optional(),
allowComments: z.boolean().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 CreateShareLinkInput = z.infer<typeof createShareLinkSchema>;
export type CoverUploadInput = z.infer<typeof coverUploadSchema>; export type CoverUploadInput = z.infer<typeof coverUploadSchema>;
export type GuestCommentInput = z.infer<typeof guestCommentSchema>; export type GuestCommentInput = z.infer<typeof guestCommentSchema>;
export type RequestStemUploadUrlInput = z.infer<typeof requestStemUploadUrlSchema>;
export type CreateStemInput = z.infer<typeof createStemSchema>;