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/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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
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 });
|
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,
|
||||||
|
|||||||
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'
|
| '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>
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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/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=="],
|
||||||
|
|||||||
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,
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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(),
|
||||||
|
});
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
Reference in New Issue
Block a user