Full MVP: workspace layout, visual refresh, PWA, production deploy
Major changes since initial commit: Schema: version branching (parentVersionId, branchLabel), share links, guest comments, track status enum (sketch/in_progress/final/released), track sections, cover art for projects and tracks. API: 29+ endpoints — auth, projects, tracks, versions, comments, share links (public + management), uploads (cover), activity feed, onboarding demo seed. Email templates in German with brand styling. Web: SvelteKit 5 workspace layout with persistent sidebar, breadcrumb top-bar, collapsible right panel. SoundCloud-style waveform player with round play button, avatar comment markers, keyboard shortcuts (Space/JKL/C). Full German UI. Cover art with gradient fallback. Track status pills. Activity feed dashboard. Welcome modal with demo-seed trigger. Landing page with 7-section scroll layout. Login on /login. Public /listen/:token page for guest feedback. Visual: Inter Variable font, Magenta→Orange gradient accent, warm dark neutrals, Lucide-style inline SVG icon set, spring animations on modals, glass-effect toasts, responsive from 360px to 2560px+. PWA: manifest, service worker, icons, iOS/Android installable. Production: adapter-node, server-side API proxy hook, docker-compose with Postgres + MinIO + auto-migration + health checks. Env example included. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
27
.env.production.example
Normal file
27
.env.production.example
Normal file
@@ -0,0 +1,27 @@
|
||||
# === PFLICHT ===
|
||||
|
||||
# Öffentliche URL der App (ohne Trailing-Slash)
|
||||
APP_URL=https://hub.mydrugismusic.com
|
||||
|
||||
# Postgres-Passwort — lang und zufällig, z.B.: openssl rand -hex 24
|
||||
POSTGRES_PASSWORD=CHANGE_ME
|
||||
|
||||
# Magic-Link-Secret — lang und zufällig, z.B.: openssl rand -hex 32
|
||||
MAGIC_LINK_SECRET=CHANGE_ME
|
||||
|
||||
# === EMAIL (ohne = Magic Links nur in API-Log) ===
|
||||
|
||||
# Resend API-Key (kostenlos bis 3000 Mails/Mo): https://resend.com
|
||||
RESEND_API_KEY=re_xxxxxxxxxxxx
|
||||
EMAIL_FROM=Music Hub <noreply@mydrugismusic.com>
|
||||
|
||||
# === S3 / MINIO (Defaults passen für eingebautes MinIO) ===
|
||||
|
||||
S3_ACCESS_KEY=minioadmin
|
||||
S3_SECRET_KEY=minioadmin
|
||||
S3_BUCKET=music-hub
|
||||
|
||||
# === OPTIONAL ===
|
||||
|
||||
# Externer Port (Coolify mappt das auf die Domain)
|
||||
PORT=3000
|
||||
@@ -12,12 +12,13 @@ COPY --from=install /app/node_modules ./node_modules
|
||||
COPY --from=install /app/packages/shared/node_modules ./packages/shared/node_modules
|
||||
COPY --from=install /app/apps/web/node_modules ./apps/web/node_modules
|
||||
COPY . .
|
||||
ENV PUBLIC_API_URL=/api
|
||||
RUN cd apps/web && bun run build
|
||||
|
||||
FROM base AS production
|
||||
COPY --from=build /app/apps/web/build ./build
|
||||
COPY --from=build /app/apps/web/package.json .
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
EXPOSE 3000
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
CMD ["bun", "./build/index.js"]
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run --hot src/index.ts",
|
||||
"build": "bun build src/index.ts --outdir dist --target bun"
|
||||
"build": "bun build src/index.ts --outdir dist --target bun",
|
||||
"seed": "bun run src/scripts/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3",
|
||||
|
||||
@@ -8,6 +8,9 @@ import { trackRoutes } from './routes/tracks.js';
|
||||
import { versionRoutes } from './routes/versions.js';
|
||||
import { commentRoutes } from './routes/comments.js';
|
||||
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 type { AppEnv } from './types.js';
|
||||
|
||||
const db = createDb(process.env.DATABASE_URL!);
|
||||
@@ -36,7 +39,10 @@ const app = new Hono<AppEnv>()
|
||||
.route('/tracks', trackRoutes)
|
||||
.route('/versions', versionRoutes)
|
||||
.route('/comments', commentRoutes)
|
||||
.route('/share', shareRoutes);
|
||||
.route('/share', shareRoutes)
|
||||
.route('/uploads', uploadRoutes)
|
||||
.route('/activity', activityRoutes)
|
||||
.route('/onboarding', onboardingRoutes);
|
||||
|
||||
const port = parseInt(process.env.PORT || '3000');
|
||||
console.log(`Music Hub API running on port ${port}`);
|
||||
|
||||
159
apps/api/src/lib/demo-seed.ts
Normal file
159
apps/api/src/lib/demo-seed.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Lightweight demo seed used by the onboarding endpoint.
|
||||
* Creates one project with one track and three versions, plus a comment and a share link.
|
||||
*
|
||||
* Audio source: a synthetic WAV is generated on demand via FFmpeg if no asset is found,
|
||||
* so this works regardless of whether the rich seed assets exist on disk.
|
||||
*/
|
||||
import {
|
||||
projects,
|
||||
projectMembers,
|
||||
tracks,
|
||||
versions,
|
||||
comments,
|
||||
shareLinks,
|
||||
type Database,
|
||||
} from '@music-hub/db';
|
||||
import { createUploadUrl } from '../storage/s3.js';
|
||||
import { processVersion } from '../services/audio-processor.js';
|
||||
|
||||
const ASSET_DIR = '/tmp/musichub-seed';
|
||||
|
||||
async function getDemoAudio(name: string, frequency: number, duration: number): Promise<ArrayBuffer> {
|
||||
const f = Bun.file(`${ASSET_DIR}/${name}`);
|
||||
if (await f.exists()) return await f.arrayBuffer();
|
||||
|
||||
// Synthesize on the fly with ffmpeg
|
||||
const tmp = `/tmp/musichub-onb-${crypto.randomUUID()}.wav`;
|
||||
const proc = Bun.spawn([
|
||||
'ffmpeg',
|
||||
'-f', 'lavfi',
|
||||
'-i', `sine=frequency=${frequency}:duration=${duration}`,
|
||||
'-ac', '2', '-ar', '44100',
|
||||
'-v', 'quiet',
|
||||
'-y',
|
||||
tmp,
|
||||
]);
|
||||
await proc.exited;
|
||||
const bytes = await Bun.file(tmp).arrayBuffer();
|
||||
await Bun.spawn(['rm', tmp]).exited;
|
||||
return bytes;
|
||||
}
|
||||
|
||||
async function uploadAudio(
|
||||
bytes: ArrayBuffer,
|
||||
projectId: string,
|
||||
trackId: string,
|
||||
versionId: string,
|
||||
filename: string,
|
||||
): Promise<string> {
|
||||
const fileKey = `projects/${projectId}/tracks/${trackId}/versions/${versionId}/original/${filename}`;
|
||||
const uploadUrl = await createUploadUrl(fileKey, 'audio/wav', bytes.byteLength);
|
||||
const res = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'audio/wav' },
|
||||
body: bytes,
|
||||
});
|
||||
if (!res.ok) throw new Error(`Audio upload failed: ${res.status}`);
|
||||
return fileKey;
|
||||
}
|
||||
|
||||
export async function createDemoProject(db: Database, userId: string): Promise<string> {
|
||||
// Project
|
||||
const [project] = await db
|
||||
.insert(projects)
|
||||
.values({
|
||||
name: 'Mein erstes Projekt',
|
||||
description: 'Ein kleines Demo-Projekt zum Ausprobieren. Du kannst es jederzeit löschen.',
|
||||
createdById: userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await db.insert(projectMembers).values({
|
||||
projectId: project.id,
|
||||
userId,
|
||||
role: 'owner',
|
||||
canUpload: true,
|
||||
canComment: true,
|
||||
canApprove: true,
|
||||
});
|
||||
|
||||
// Track
|
||||
const [track] = await db
|
||||
.insert(tracks)
|
||||
.values({
|
||||
projectId: project.id,
|
||||
name: 'Demo-Track',
|
||||
description: 'Drei Versionen mit Comments und einer Variante.',
|
||||
status: 'in_progress',
|
||||
createdById: userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Versions
|
||||
const versionDefs = [
|
||||
{ label: 'Erster Wurf', frequency: 220, duration: 8, file: 'demo-v1.wav' },
|
||||
{ label: 'Mehr Bass', frequency: 110, duration: 8, file: 'demo-v2.wav' },
|
||||
{ label: 'Final Mix', frequency: 330, duration: 8, file: 'demo-v3.wav' },
|
||||
];
|
||||
|
||||
const createdVersionIds: string[] = [];
|
||||
|
||||
for (let i = 0; i < versionDefs.length; i++) {
|
||||
const v = versionDefs[i];
|
||||
const versionId = crypto.randomUUID();
|
||||
const bytes = await getDemoAudio(v.file, v.frequency, v.duration);
|
||||
const fileKey = await uploadAudio(bytes, project.id, track.id, versionId, v.file);
|
||||
|
||||
const [version] = await db
|
||||
.insert(versions)
|
||||
.values({
|
||||
id: versionId,
|
||||
trackId: track.id,
|
||||
versionNumber: i + 1,
|
||||
label: v.label,
|
||||
status: 'uploaded',
|
||||
originalFileName: v.file,
|
||||
mimeType: 'audio/wav',
|
||||
fileSize: bytes.byteLength,
|
||||
originalFileKey: fileKey,
|
||||
createdById: userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
createdVersionIds.push(version.id);
|
||||
// Process synchronously so the player has a waveform when the user lands.
|
||||
await processVersion(db, version.id);
|
||||
}
|
||||
|
||||
// Comments on V2
|
||||
await db.insert(comments).values([
|
||||
{
|
||||
versionId: createdVersionIds[1],
|
||||
userId,
|
||||
body: 'Klick auf die Welle bei einer beliebigen Stelle, um einen Kommentar mit Timestamp zu schreiben.',
|
||||
timestampSeconds: 2.5,
|
||||
},
|
||||
{
|
||||
versionId: createdVersionIds[1],
|
||||
userId: null,
|
||||
guestName: 'Demo-Listener',
|
||||
body: 'So sieht es aus, wenn jemand ohne Account über einen geteilten Link kommentiert.',
|
||||
timestampSeconds: 5.0,
|
||||
},
|
||||
]);
|
||||
|
||||
// Share link on V3
|
||||
const token = Array.from(crypto.getRandomValues(new Uint8Array(32)), (b) =>
|
||||
b.toString(16).padStart(2, '0'),
|
||||
).join('');
|
||||
await db.insert(shareLinks).values({
|
||||
versionId: createdVersionIds[2],
|
||||
token,
|
||||
createdById: userId,
|
||||
allowComments: true,
|
||||
allowDownload: false,
|
||||
});
|
||||
|
||||
return project.id;
|
||||
}
|
||||
116
apps/api/src/routes/activity.ts
Normal file
116
apps/api/src/routes/activity.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { Hono } from 'hono';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import type { AppEnv } from '../types.js';
|
||||
|
||||
type ActivityEvent = {
|
||||
type: 'comment' | 'version' | 'approval';
|
||||
id: string;
|
||||
createdAt: string;
|
||||
user: { id: string | null; name: string; avatarUrl: string | null } | null;
|
||||
guestName: string | null;
|
||||
project: { id: string; name: string };
|
||||
track: { id: string; name: string };
|
||||
version?: { id: string; versionNumber: number; label: string | null };
|
||||
body?: string;
|
||||
status?: string;
|
||||
timestampSeconds?: number | null;
|
||||
};
|
||||
|
||||
export const activityRoutes = new Hono<AppEnv>()
|
||||
.use('*', requireAuth)
|
||||
|
||||
.get('/', async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const limit = Math.min(parseInt(c.req.query('limit') ?? '40'), 100);
|
||||
const projectFilter = c.req.query('projectId');
|
||||
|
||||
// Helper: only events from projects the user is a member of.
|
||||
// We use raw sql for the UNION since drizzle's union builder is per-table-shape.
|
||||
const projectClause = projectFilter ? sql`AND p.id = ${projectFilter}` : sql``;
|
||||
|
||||
const rows = await db.execute(sql`
|
||||
WITH membership AS (
|
||||
SELECT project_id FROM project_members WHERE user_id = ${userId}
|
||||
)
|
||||
SELECT * FROM (
|
||||
-- New versions
|
||||
SELECT
|
||||
'version' as type,
|
||||
v.id as event_id,
|
||||
v.created_at as created_at,
|
||||
u.id as user_id,
|
||||
u.name as user_name,
|
||||
u.avatar_url as user_avatar,
|
||||
NULL::text as guest_name,
|
||||
p.id as project_id,
|
||||
p.name as project_name,
|
||||
t.id as track_id,
|
||||
t.name as track_name,
|
||||
v.id as version_id,
|
||||
v.version_number as version_number,
|
||||
v.label as version_label,
|
||||
NULL::text as body,
|
||||
v.status::text as status,
|
||||
NULL::real as timestamp_seconds
|
||||
FROM versions v
|
||||
JOIN tracks t ON t.id = v.track_id
|
||||
JOIN projects p ON p.id = t.project_id
|
||||
JOIN membership m ON m.project_id = p.id
|
||||
LEFT JOIN users u ON u.id = v.created_by_id
|
||||
WHERE p.is_archived = false ${projectClause}
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- New comments
|
||||
SELECT
|
||||
'comment' as type,
|
||||
c.id as event_id,
|
||||
c.created_at as created_at,
|
||||
u.id as user_id,
|
||||
u.name as user_name,
|
||||
u.avatar_url as user_avatar,
|
||||
c.guest_name as guest_name,
|
||||
p.id as project_id,
|
||||
p.name as project_name,
|
||||
t.id as track_id,
|
||||
t.name as track_name,
|
||||
v.id as version_id,
|
||||
v.version_number as version_number,
|
||||
v.label as version_label,
|
||||
c.body as body,
|
||||
NULL::text as status,
|
||||
c.timestamp_seconds as timestamp_seconds
|
||||
FROM comments c
|
||||
JOIN versions v ON v.id = c.version_id
|
||||
JOIN tracks t ON t.id = v.track_id
|
||||
JOIN projects p ON p.id = t.project_id
|
||||
JOIN membership m ON m.project_id = p.id
|
||||
LEFT JOIN users u ON u.id = c.user_id
|
||||
WHERE p.is_archived = false ${projectClause}
|
||||
) events
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ${limit}
|
||||
`);
|
||||
|
||||
const events: ActivityEvent[] = (rows as unknown as any[]).map((r) => ({
|
||||
type: r.type as ActivityEvent['type'],
|
||||
id: r.event_id,
|
||||
createdAt: typeof r.created_at === 'string' ? r.created_at : new Date(r.created_at).toISOString(),
|
||||
user: r.user_id
|
||||
? { id: r.user_id, name: r.user_name, avatarUrl: r.user_avatar }
|
||||
: null,
|
||||
guestName: r.guest_name ?? null,
|
||||
project: { id: r.project_id, name: r.project_name },
|
||||
track: { id: r.track_id, name: r.track_name },
|
||||
version: r.version_id
|
||||
? { id: r.version_id, versionNumber: r.version_number, label: r.version_label }
|
||||
: undefined,
|
||||
body: r.body ?? undefined,
|
||||
status: r.status ?? undefined,
|
||||
timestampSeconds: r.timestamp_seconds ?? null,
|
||||
}));
|
||||
|
||||
return c.json({ events });
|
||||
});
|
||||
29
apps/api/src/routes/onboarding.ts
Normal file
29
apps/api/src/routes/onboarding.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Hono } from 'hono';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { projectMembers } from '@music-hub/db';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { createDemoProject } from '../lib/demo-seed.js';
|
||||
import type { AppEnv } from '../types.js';
|
||||
|
||||
export const onboardingRoutes = new Hono<AppEnv>()
|
||||
.use('*', requireAuth)
|
||||
|
||||
.post('/seed-demo', async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
|
||||
// Refuse to spam users with multiple demos
|
||||
const existing = await db
|
||||
.select({ id: projectMembers.id })
|
||||
.from(projectMembers)
|
||||
.where(eq(projectMembers.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
// User already has projects — they shouldn't see the welcome modal anyway,
|
||||
// but be defensive: still create a fresh demo so the action is meaningful.
|
||||
}
|
||||
|
||||
const projectId = await createDemoProject(db, userId);
|
||||
return c.json({ projectId }, 201);
|
||||
});
|
||||
@@ -1,16 +1,24 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { eq, and, sql } from 'drizzle-orm';
|
||||
import {
|
||||
createProjectSchema,
|
||||
updateProjectSchema,
|
||||
inviteMemberSchema,
|
||||
updateMemberSchema,
|
||||
} from '@music-hub/shared';
|
||||
import { projects, projectMembers, users } from '@music-hub/db';
|
||||
import { projects, projectMembers, users, tracks } from '@music-hub/db';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { createDownloadUrl } from '../storage/s3.js';
|
||||
import type { AppEnv } from '../types.js';
|
||||
|
||||
async function withCoverUrl<T extends { coverImageUrl?: string | null }>(
|
||||
obj: T,
|
||||
): Promise<T & { coverUrl: string | null }> {
|
||||
const coverUrl = obj.coverImageUrl ? await createDownloadUrl(obj.coverImageUrl) : null;
|
||||
return { ...obj, coverUrl };
|
||||
}
|
||||
|
||||
export const projectRoutes = new Hono<AppEnv>()
|
||||
.use('*', requireAuth)
|
||||
|
||||
@@ -22,12 +30,19 @@ export const projectRoutes = new Hono<AppEnv>()
|
||||
.select({
|
||||
project: projects,
|
||||
role: projectMembers.role,
|
||||
trackCount: sql<number>`(select count(*)::int from ${tracks} where ${tracks.projectId} = ${projects.id})`,
|
||||
})
|
||||
.from(projectMembers)
|
||||
.innerJoin(projects, eq(projects.id, projectMembers.projectId))
|
||||
.where(and(eq(projectMembers.userId, userId), eq(projects.isArchived, false)));
|
||||
|
||||
return c.json({ projects: memberships });
|
||||
const enriched = await Promise.all(
|
||||
memberships.map(async (m) => ({
|
||||
...m,
|
||||
project: await withCoverUrl(m.project),
|
||||
})),
|
||||
);
|
||||
return c.json({ projects: enriched });
|
||||
})
|
||||
|
||||
.post('/', zValidator('json', createProjectSchema), async (c) => {
|
||||
@@ -73,7 +88,7 @@ export const projectRoutes = new Hono<AppEnv>()
|
||||
.where(eq(projects.id, projectId))
|
||||
.limit(1);
|
||||
|
||||
return c.json({ project, role: membership.role });
|
||||
return c.json({ project: await withCoverUrl(project), role: membership.role });
|
||||
})
|
||||
|
||||
.patch('/:id', zValidator('json', updateProjectSchema), async (c) => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { eq, and, asc } from 'drizzle-orm';
|
||||
import { eq, and, asc, sql } from 'drizzle-orm';
|
||||
import { createTrackSchema, updateTrackSchema } from '@music-hub/shared';
|
||||
import { tracks, projectMembers } from '@music-hub/db';
|
||||
import { tracks, projectMembers, versions } from '@music-hub/db';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { createDownloadUrl } from '../storage/s3.js';
|
||||
import type { AppEnv } from '../types.js';
|
||||
|
||||
export const trackRoutes = new Hono<AppEnv>()
|
||||
@@ -24,12 +25,32 @@ export const trackRoutes = new Hono<AppEnv>()
|
||||
if (!membership) return c.json({ error: 'Not found' }, 404);
|
||||
|
||||
const projectTracks = await db
|
||||
.select()
|
||||
.select({
|
||||
id: tracks.id,
|
||||
projectId: tracks.projectId,
|
||||
name: tracks.name,
|
||||
description: tracks.description,
|
||||
coverImageUrl: tracks.coverImageUrl,
|
||||
status: tracks.status,
|
||||
section: tracks.section,
|
||||
sortOrder: tracks.sortOrder,
|
||||
createdById: tracks.createdById,
|
||||
createdAt: tracks.createdAt,
|
||||
updatedAt: tracks.updatedAt,
|
||||
versionCount: sql<number>`(select count(*)::int from ${versions} where ${versions.trackId} = ${tracks.id})`,
|
||||
branchCount: sql<number>`(select count(distinct ${versions.branchLabel})::int from ${versions} where ${versions.trackId} = ${tracks.id} and ${versions.branchLabel} is not null)`,
|
||||
})
|
||||
.from(tracks)
|
||||
.where(eq(tracks.projectId, projectId))
|
||||
.orderBy(asc(tracks.sortOrder), asc(tracks.createdAt));
|
||||
|
||||
return c.json({ tracks: projectTracks });
|
||||
const enriched = await Promise.all(
|
||||
projectTracks.map(async (t) => ({
|
||||
...t,
|
||||
coverUrl: t.coverImageUrl ? await createDownloadUrl(t.coverImageUrl) : null,
|
||||
})),
|
||||
);
|
||||
return c.json({ tracks: enriched });
|
||||
})
|
||||
|
||||
.post('/:projectId', zValidator('json', createTrackSchema), async (c) => {
|
||||
|
||||
17
apps/api/src/routes/uploads.ts
Normal file
17
apps/api/src/routes/uploads.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { coverUploadSchema } from '@music-hub/shared';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { createUploadUrl } from '../storage/s3.js';
|
||||
import type { AppEnv } from '../types.js';
|
||||
|
||||
export const uploadRoutes = new Hono<AppEnv>()
|
||||
.use('*', requireAuth)
|
||||
|
||||
.post('/cover', zValidator('json', coverUploadSchema), async (c) => {
|
||||
const { fileName, mimeType, fileSize } = c.req.valid('json');
|
||||
const ext = fileName.split('.').pop()?.toLowerCase() || 'jpg';
|
||||
const key = `covers/${crypto.randomUUID()}.${ext}`;
|
||||
const uploadUrl = await createUploadUrl(key, mimeType, fileSize);
|
||||
return c.json({ uploadUrl, key });
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { eq, and, desc, asc, sql } from 'drizzle-orm';
|
||||
import { requestUploadUrlSchema, createVersionSchema } from '@music-hub/shared';
|
||||
import { requestUploadUrlSchema, createVersionSchema, updateVersionSchema } from '@music-hub/shared';
|
||||
import { tracks, versions, projectMembers } from '@music-hub/db';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { createUploadUrl, createDownloadUrl } from '../storage/s3.js';
|
||||
@@ -115,6 +115,82 @@ export const versionRoutes = new Hono<AppEnv>()
|
||||
return c.json({ version }, 201);
|
||||
})
|
||||
|
||||
// Update label/notes/branchLabel
|
||||
.patch('/:id', zValidator('json', updateVersionSchema), async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const versionId = c.req.param('id');
|
||||
const input = c.req.valid('json');
|
||||
|
||||
const [version] = await db
|
||||
.select()
|
||||
.from(versions)
|
||||
.where(eq(versions.id, versionId))
|
||||
.limit(1);
|
||||
if (!version) return c.json({ error: 'Not found' }, 404);
|
||||
|
||||
const [track] = await db
|
||||
.select()
|
||||
.from(tracks)
|
||||
.where(eq(tracks.id, version.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.canUpload) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(versions)
|
||||
.set(input)
|
||||
.where(eq(versions.id, versionId))
|
||||
.returning();
|
||||
|
||||
return c.json({ version: updated });
|
||||
})
|
||||
|
||||
// Delete version
|
||||
.delete('/:id', async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const versionId = c.req.param('id');
|
||||
|
||||
const [version] = await db
|
||||
.select()
|
||||
.from(versions)
|
||||
.where(eq(versions.id, versionId))
|
||||
.limit(1);
|
||||
if (!version) return c.json({ error: 'Not found' }, 404);
|
||||
|
||||
const [track] = await db
|
||||
.select()
|
||||
.from(tracks)
|
||||
.where(eq(tracks.id, version.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') {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
await db.delete(versions).where(eq(versions.id, versionId));
|
||||
return c.json({ message: 'Version deleted' });
|
||||
})
|
||||
|
||||
// Get version tree (graph) for a track
|
||||
.get('/track/:trackId/tree', async (c) => {
|
||||
const db = c.get('db');
|
||||
|
||||
391
apps/api/src/scripts/seed-rich.ts
Normal file
391
apps/api/src/scripts/seed-rich.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* Reicher Demo-Seed: 4 Projekte mit verschiedenen Charakteren, mehreren Tracks,
|
||||
* Versionen, Mitgliedern, Comments und Share-Links.
|
||||
*
|
||||
* Voraussetzung: Audio-Dateien und Cover-PNGs liegen in /tmp/musichub-seed/
|
||||
* bass.wav drums.wav hi.wav lead.wav mix.wav pad.wav vox.wav warm.wav
|
||||
* sunset.png lichtjahre.png rooftop.png wave.png
|
||||
*
|
||||
* Usage:
|
||||
* bun run apps/api/src/scripts/seed-rich.ts <email>
|
||||
*/
|
||||
import { eq } from 'drizzle-orm';
|
||||
import {
|
||||
createDb,
|
||||
users,
|
||||
projects,
|
||||
projectMembers,
|
||||
tracks,
|
||||
versions,
|
||||
comments,
|
||||
shareLinks,
|
||||
} from '@music-hub/db';
|
||||
import { createUploadUrl } from '../storage/s3.js';
|
||||
import { processVersion } from '../services/audio-processor.js';
|
||||
|
||||
const email = process.argv[2];
|
||||
if (!email) {
|
||||
console.error('Usage: bun run seed-rich.ts <email>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const ASSET_DIR = '/tmp/musichub-seed';
|
||||
|
||||
async function readBytes(name: string): Promise<ArrayBuffer> {
|
||||
const f = Bun.file(`${ASSET_DIR}/${name}`);
|
||||
if (!(await f.exists())) throw new Error(`Missing asset: ${name}`);
|
||||
return await f.arrayBuffer();
|
||||
}
|
||||
|
||||
const db = createDb(process.env.DATABASE_URL!);
|
||||
|
||||
// === USER SETUP ===
|
||||
console.log(`→ User ${email}`);
|
||||
let [me] = await db.select().from(users).where(eq(users.email, email)).limit(1);
|
||||
if (!me) {
|
||||
[me] = await db.insert(users).values({ email, name: email.split('@')[0] }).returning();
|
||||
console.log(` created ${me.id}`);
|
||||
} else {
|
||||
console.log(` exists ${me.id}`);
|
||||
}
|
||||
|
||||
// Co-Mitglieder
|
||||
const memberDefs = [
|
||||
{ email: 'anna@example.com', name: 'Anna Berger', role: 'artist' as const },
|
||||
{ email: 'jonas@example.com', name: 'Jonas Klein', role: 'mixing_engineer' as const },
|
||||
{ email: 'lina@example.com', name: 'Lina Roth', role: 'mastering_engineer' as const },
|
||||
{ email: 'felix@example.com', name: 'Felix Lang', role: 'label' as const },
|
||||
];
|
||||
|
||||
const collaborators: Record<string, typeof users.$inferSelect> = {};
|
||||
for (const m of memberDefs) {
|
||||
let [u] = await db.select().from(users).where(eq(users.email, m.email)).limit(1);
|
||||
if (!u) [u] = await db.insert(users).values({ email: m.email, name: m.name }).returning();
|
||||
collaborators[m.email] = u;
|
||||
}
|
||||
console.log(`→ ${memberDefs.length} Mitwirkende vorbereitet`);
|
||||
|
||||
// === COVER UPLOAD HELPER ===
|
||||
async function uploadCover(filename: string): Promise<string> {
|
||||
const bytes = await readBytes(filename);
|
||||
const key = `covers/${crypto.randomUUID()}.png`;
|
||||
const url = await createUploadUrl(key, 'image/png', bytes.byteLength);
|
||||
const res = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'image/png' },
|
||||
body: bytes,
|
||||
});
|
||||
if (!res.ok) throw new Error(`Cover upload failed: ${res.status}`);
|
||||
return key;
|
||||
}
|
||||
|
||||
// === PROJECT FACTORY ===
|
||||
type TrackSpec = {
|
||||
name: string;
|
||||
description?: string;
|
||||
versions: VersionSpec[];
|
||||
comments?: CommentSpec[];
|
||||
};
|
||||
type VersionSpec = {
|
||||
label: string;
|
||||
audio: string;
|
||||
status?: 'ready' | 'approved' | 'rejected';
|
||||
parentLabel?: string;
|
||||
branchLabel?: string;
|
||||
notes?: string;
|
||||
createdBy?: string;
|
||||
};
|
||||
type CommentSpec = {
|
||||
versionLabel: string;
|
||||
body: string;
|
||||
at?: number;
|
||||
by?: string; // email or 'guest:Name'
|
||||
};
|
||||
type ProjectSpec = {
|
||||
name: string;
|
||||
description: string;
|
||||
cover: string;
|
||||
members: { email: string; role: any }[];
|
||||
tracks: TrackSpec[];
|
||||
shareVersionLabel?: { trackName: string; versionLabel: string };
|
||||
};
|
||||
|
||||
async function createProject(spec: ProjectSpec) {
|
||||
console.log(`\n📀 ${spec.name}`);
|
||||
const coverKey = await uploadCover(spec.cover);
|
||||
const [project] = await db
|
||||
.insert(projects)
|
||||
.values({
|
||||
name: spec.name,
|
||||
description: spec.description,
|
||||
coverImageUrl: coverKey,
|
||||
createdById: me.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await db.insert(projectMembers).values({
|
||||
projectId: project.id,
|
||||
userId: me.id,
|
||||
role: 'owner',
|
||||
canUpload: true,
|
||||
canComment: true,
|
||||
canApprove: true,
|
||||
});
|
||||
|
||||
for (const m of spec.members) {
|
||||
const u = collaborators[m.email];
|
||||
if (!u) continue;
|
||||
await db.insert(projectMembers).values({
|
||||
projectId: project.id,
|
||||
userId: u.id,
|
||||
role: m.role,
|
||||
canUpload: m.role.includes('engineer') || m.role === 'owner',
|
||||
canComment: true,
|
||||
canApprove: m.role === 'artist' || m.role === 'label' || m.role === 'management',
|
||||
});
|
||||
}
|
||||
|
||||
let shareToken: string | null = null;
|
||||
|
||||
for (const trackSpec of spec.tracks) {
|
||||
const [track] = await db
|
||||
.insert(tracks)
|
||||
.values({
|
||||
projectId: project.id,
|
||||
name: trackSpec.name,
|
||||
description: trackSpec.description,
|
||||
createdById: me.id,
|
||||
})
|
||||
.returning();
|
||||
console.log(` 🎵 ${trackSpec.name}`);
|
||||
|
||||
// Versionen in Reihenfolge anlegen, parent-Lookup über Label
|
||||
const versionMap = new Map<string, string>();
|
||||
|
||||
for (let i = 0; i < trackSpec.versions.length; i++) {
|
||||
const v = trackSpec.versions[i];
|
||||
const audioBytes = await readBytes(v.audio);
|
||||
const versionId = crypto.randomUUID();
|
||||
const fileName = `${v.audio}`;
|
||||
const fileKey = `projects/${project.id}/tracks/${track.id}/versions/${versionId}/original/${fileName}`;
|
||||
const uploadUrl = await createUploadUrl(fileKey, 'audio/wav', audioBytes.byteLength);
|
||||
const res = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'audio/wav' },
|
||||
body: audioBytes,
|
||||
});
|
||||
if (!res.ok) throw new Error(`Audio upload failed: ${res.status}`);
|
||||
|
||||
const parentVersionId = v.parentLabel ? versionMap.get(v.parentLabel) ?? null : null;
|
||||
const createdById = v.createdBy
|
||||
? collaborators[v.createdBy]?.id ?? me.id
|
||||
: me.id;
|
||||
|
||||
const [version] = await db
|
||||
.insert(versions)
|
||||
.values({
|
||||
id: versionId,
|
||||
trackId: track.id,
|
||||
versionNumber: i + 1,
|
||||
label: v.label,
|
||||
notes: v.notes,
|
||||
status: 'uploaded',
|
||||
parentVersionId,
|
||||
branchLabel: v.branchLabel ?? null,
|
||||
originalFileName: fileName,
|
||||
mimeType: 'audio/wav',
|
||||
fileSize: audioBytes.byteLength,
|
||||
originalFileKey: fileKey,
|
||||
createdById,
|
||||
})
|
||||
.returning();
|
||||
|
||||
versionMap.set(v.label, version.id);
|
||||
console.log(` V${i + 1} ${v.label}${v.branchLabel ? ` (Variante: ${v.branchLabel})` : ''}`);
|
||||
await processVersion(db, version.id);
|
||||
|
||||
if (v.status && v.status !== 'ready') {
|
||||
await db
|
||||
.update(versions)
|
||||
.set({ status: v.status })
|
||||
.where(eq(versions.id, version.id));
|
||||
}
|
||||
}
|
||||
|
||||
// Comments
|
||||
for (const c of trackSpec.comments ?? []) {
|
||||
const versionId = versionMap.get(c.versionLabel);
|
||||
if (!versionId) continue;
|
||||
const isGuest = c.by?.startsWith('guest:');
|
||||
const userId = isGuest
|
||||
? null
|
||||
: c.by
|
||||
? collaborators[c.by]?.id ?? me.id
|
||||
: me.id;
|
||||
const guestName = isGuest ? c.by!.slice(6) : null;
|
||||
await db.insert(comments).values({
|
||||
versionId,
|
||||
userId,
|
||||
guestName,
|
||||
body: c.body,
|
||||
timestampSeconds: c.at ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// Share-Link auf eine bestimmte Version
|
||||
if (
|
||||
spec.shareVersionLabel &&
|
||||
spec.shareVersionLabel.trackName === trackSpec.name
|
||||
) {
|
||||
const versionId = versionMap.get(spec.shareVersionLabel.versionLabel);
|
||||
if (versionId) {
|
||||
const token = Array.from(crypto.getRandomValues(new Uint8Array(32)), (b) =>
|
||||
b.toString(16).padStart(2, '0'),
|
||||
).join('');
|
||||
await db.insert(shareLinks).values({
|
||||
versionId,
|
||||
token,
|
||||
createdById: me.id,
|
||||
allowComments: true,
|
||||
allowDownload: false,
|
||||
});
|
||||
shareToken = token;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shareToken) {
|
||||
console.log(` 🔗 Share: http://localhost:5173/listen/${shareToken}`);
|
||||
}
|
||||
}
|
||||
|
||||
// === PROJEKT-KATALOG ===
|
||||
await createProject({
|
||||
name: 'Sunset Drive',
|
||||
description: 'Synthwave-EP. Mainline ist V3, V4 ist eine Variante mit anderem Bass.',
|
||||
cover: 'sunset.png',
|
||||
members: [
|
||||
{ email: 'anna@example.com', role: 'artist' },
|
||||
{ email: 'jonas@example.com', role: 'mixing_engineer' },
|
||||
{ email: 'lina@example.com', role: 'mastering_engineer' },
|
||||
],
|
||||
tracks: [
|
||||
{
|
||||
name: 'Hauptmix',
|
||||
description: 'Der zentrale Track der EP. Drei Iterationen plus eine Vocal-Variante.',
|
||||
versions: [
|
||||
{ label: 'Erster Wurf', audio: 'pad.wav' },
|
||||
{ label: 'Mehr Bass', audio: 'bass.wav', notes: 'Sub etwas runter, Punch nach vorne' },
|
||||
{ label: 'Final Mix', audio: 'mix.wav', status: 'approved', notes: 'Freigegeben von Lina nach Mastering' },
|
||||
{ label: 'Andere Vocals', audio: 'vox.wav', parentLabel: 'Mehr Bass', branchLabel: 'vocals-alt', notes: 'Variante mit dem zweiten Vocal-Take', createdBy: 'jonas@example.com' },
|
||||
],
|
||||
comments: [
|
||||
{ versionLabel: 'Mehr Bass', at: 3.2, by: 'anna@example.com', body: 'Hier ist es nice — aber bei 0:05 fehlt mir noch was im Mid-Range.' },
|
||||
{ versionLabel: 'Mehr Bass', at: 5.5, body: 'Verstanden, ziehe ich nach. EQ-Boost bei 800Hz.' },
|
||||
{ versionLabel: 'Final Mix', at: 1.8, by: 'lina@example.com', body: 'Master sitzt. -1 LUFS, dynamic range bleibt erhalten.' },
|
||||
{ versionLabel: 'Andere Vocals', at: 2.0, by: 'anna@example.com', body: 'Yes! Das ist die richtige Energie für den Refrain.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Drums',
|
||||
description: 'Drum-Bus für den Hauptmix.',
|
||||
versions: [
|
||||
{ label: 'Punchy', audio: 'drums.wav', createdBy: 'jonas@example.com' },
|
||||
{ label: 'Mehr Air', audio: 'hi.wav', notes: 'Top-Hi-Hats lauter, Reverb-Tail dazu' },
|
||||
],
|
||||
comments: [
|
||||
{ versionLabel: 'Punchy', at: 1.5, by: 'anna@example.com', body: 'Snare ist perfekt hier.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Lead Synth',
|
||||
description: 'Hauptmotiv. Sitzt zwischen Vox und Pad.',
|
||||
versions: [
|
||||
{ label: 'V1', audio: 'lead.wav' },
|
||||
],
|
||||
},
|
||||
],
|
||||
shareVersionLabel: { trackName: 'Hauptmix', versionLabel: 'Final Mix' },
|
||||
});
|
||||
|
||||
await createProject({
|
||||
name: 'Lichtjahre',
|
||||
description: 'Indie-Single. Acoustic-Demo, dann Studio-Take, dann Mix.',
|
||||
cover: 'lichtjahre.png',
|
||||
members: [
|
||||
{ email: 'anna@example.com', role: 'artist' },
|
||||
{ email: 'felix@example.com', role: 'label' },
|
||||
],
|
||||
tracks: [
|
||||
{
|
||||
name: 'Lichtjahre',
|
||||
description: 'Hauptsong der Single.',
|
||||
versions: [
|
||||
{ label: 'Acoustic Demo', audio: 'warm.wav', notes: 'Aufgenommen am Küchentisch' },
|
||||
{ label: 'Studio Take', audio: 'pad.wav', notes: 'Voller Mix mit Band' },
|
||||
{ label: 'Mix V1', audio: 'mix.wav', createdBy: 'jonas@example.com' },
|
||||
],
|
||||
comments: [
|
||||
{ versionLabel: 'Studio Take', at: 4.0, by: 'felix@example.com', body: 'Geht in die Richtung. Bei 0:08 könnten wir noch ein Break einbauen.' },
|
||||
{ versionLabel: 'Studio Take', at: 7.2, body: 'Notiert.' },
|
||||
{ versionLabel: 'Mix V1', at: 2.5, by: 'guest:Sarah (A&R)', body: 'Ich bin begeistert. Bei wann ist das fertig?' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'B-Side',
|
||||
description: 'Instrumental-Version für Sync-Lizenz.',
|
||||
versions: [
|
||||
{ label: 'Instrumental', audio: 'pad.wav' },
|
||||
],
|
||||
},
|
||||
],
|
||||
shareVersionLabel: { trackName: 'Lichtjahre', versionLabel: 'Mix V1' },
|
||||
});
|
||||
|
||||
await createProject({
|
||||
name: 'Rooftop Session',
|
||||
description: 'Live-Aufnahme vom letzten Sommer-Gig. Soundcheck + ein Take pro Song.',
|
||||
cover: 'rooftop.png',
|
||||
members: [
|
||||
{ email: 'jonas@example.com', role: 'recording_engineer' },
|
||||
],
|
||||
tracks: [
|
||||
{
|
||||
name: 'Soundcheck',
|
||||
versions: [{ label: 'Take 1', audio: 'drums.wav', createdBy: 'jonas@example.com' }],
|
||||
},
|
||||
{
|
||||
name: 'Opener',
|
||||
versions: [
|
||||
{ label: 'Live Take', audio: 'bass.wav' },
|
||||
{ label: 'Roh-Mix', audio: 'mix.wav', notes: 'Schneller Mix für die Band-Doku' },
|
||||
],
|
||||
comments: [
|
||||
{ versionLabel: 'Live Take', at: 6.0, by: 'jonas@example.com', body: 'Crowd-Geräusche bewusst drin gelassen.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Encore',
|
||||
versions: [{ label: 'Live Take', audio: 'lead.wav' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await createProject({
|
||||
name: 'Wave Goodbye',
|
||||
description: 'Closing-Track für die EP. Noch früh in der Entwicklung.',
|
||||
cover: 'wave.png',
|
||||
members: [
|
||||
{ email: 'anna@example.com', role: 'artist' },
|
||||
],
|
||||
tracks: [
|
||||
{
|
||||
name: 'Skizze',
|
||||
versions: [{ label: 'V1', audio: 'pad.wav', notes: 'Erste Idee, sehr roh' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
console.log('\n✅ Fertig.');
|
||||
console.log(` Login als ${email} via Magic Link.`);
|
||||
process.exit(0);
|
||||
159
apps/api/src/scripts/seed.ts
Normal file
159
apps/api/src/scripts/seed.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Demo seed script.
|
||||
*
|
||||
* Usage:
|
||||
* ffmpeg -f lavfi -i "sine=frequency=440:duration=8" -ac 2 /tmp/musichub-demo.wav
|
||||
* bun run apps/api/src/scripts/seed.ts demo@example.com [/tmp/musichub-demo.wav]
|
||||
*
|
||||
* Creates user (if missing), a demo project with 1 track and 3 versions
|
||||
* (V2 mainline, V3 as branch off V2), 2 sample comments and 1 share link.
|
||||
*/
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { createDb, users, projects, projectMembers, tracks, versions, comments, shareLinks } from '@music-hub/db';
|
||||
import { createUploadUrl } from '../storage/s3.js';
|
||||
import { processVersion } from '../services/audio-processor.js';
|
||||
|
||||
const email = process.argv[2];
|
||||
const audioPath = process.argv[3] || '/tmp/musichub-demo.wav';
|
||||
|
||||
if (!email) {
|
||||
console.error('Usage: bun run seed.ts <email> [audioPath]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const audioFile = Bun.file(audioPath);
|
||||
if (!(await audioFile.exists())) {
|
||||
console.error(`Audio file missing: ${audioPath}`);
|
||||
console.error('Generate one first:');
|
||||
console.error(` ffmpeg -f lavfi -i "sine=frequency=440:duration=8" -ac 2 ${audioPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const audioBytes = await audioFile.arrayBuffer();
|
||||
const audioSize = audioBytes.byteLength;
|
||||
|
||||
const db = createDb(process.env.DATABASE_URL!);
|
||||
|
||||
console.log(`→ User ${email}`);
|
||||
let [user] = await db.select().from(users).where(eq(users.email, email)).limit(1);
|
||||
if (!user) {
|
||||
[user] = await db.insert(users).values({ email, name: email.split('@')[0] }).returning();
|
||||
console.log(` created ${user.id}`);
|
||||
} else {
|
||||
console.log(` exists ${user.id}`);
|
||||
}
|
||||
|
||||
console.log('→ Projekt "Demo: Sunset Drive"');
|
||||
const [project] = await db
|
||||
.insert(projects)
|
||||
.values({
|
||||
name: 'Demo: Sunset Drive',
|
||||
description: 'Synthwave-Track in Arbeit. Probier den Graph, lade eine neue Variante hoch oder teile den Link.',
|
||||
createdById: user.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await db.insert(projectMembers).values({
|
||||
projectId: project.id,
|
||||
userId: user.id,
|
||||
role: 'owner',
|
||||
canUpload: true,
|
||||
canComment: true,
|
||||
canApprove: true,
|
||||
});
|
||||
|
||||
console.log('→ Track "Hauptmix"');
|
||||
const [track] = await db
|
||||
.insert(tracks)
|
||||
.values({
|
||||
projectId: project.id,
|
||||
name: 'Hauptmix',
|
||||
description: 'Der Hauptmix mit zwei Mainline-Versionen und einer Vocals-Variante.',
|
||||
createdById: user.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
async function uploadVersion(opts: {
|
||||
versionNumber: number;
|
||||
label: string;
|
||||
parentVersionId?: string | null;
|
||||
branchLabel?: string | null;
|
||||
}) {
|
||||
const versionId = crypto.randomUUID();
|
||||
const fileName = `demo-v${opts.versionNumber}.wav`;
|
||||
const fileKey = `projects/${project.id}/tracks/${track.id}/versions/${versionId}/original/${fileName}`;
|
||||
|
||||
const uploadUrl = await createUploadUrl(fileKey, 'audio/wav', audioSize);
|
||||
const res = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'audio/wav' },
|
||||
body: audioBytes,
|
||||
});
|
||||
if (!res.ok) throw new Error(`S3 upload failed: ${res.status} ${await res.text()}`);
|
||||
|
||||
const [version] = await db
|
||||
.insert(versions)
|
||||
.values({
|
||||
id: versionId,
|
||||
trackId: track.id,
|
||||
versionNumber: opts.versionNumber,
|
||||
label: opts.label,
|
||||
status: 'uploaded',
|
||||
parentVersionId: opts.parentVersionId ?? null,
|
||||
branchLabel: opts.branchLabel ?? null,
|
||||
originalFileName: fileName,
|
||||
mimeType: 'audio/wav',
|
||||
fileSize: audioSize,
|
||||
originalFileKey: fileKey,
|
||||
createdById: user.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
console.log(` V${opts.versionNumber} (${opts.branchLabel ?? 'main'}) → processing`);
|
||||
await processVersion(db, version.id);
|
||||
return version;
|
||||
}
|
||||
|
||||
const v1 = await uploadVersion({ versionNumber: 1, label: 'Erster Wurf' });
|
||||
const v2 = await uploadVersion({ versionNumber: 2, label: 'Mehr Bass' });
|
||||
const v3 = await uploadVersion({
|
||||
versionNumber: 3,
|
||||
label: 'Vocals neu',
|
||||
parentVersionId: v2.id,
|
||||
branchLabel: 'vocals-neu',
|
||||
});
|
||||
|
||||
console.log('→ Comments');
|
||||
await db.insert(comments).values([
|
||||
{
|
||||
versionId: v2.id,
|
||||
userId: user.id,
|
||||
body: 'Bei 0:03 sitzt der Drop, finde ich richtig stark.',
|
||||
timestampSeconds: 3.0,
|
||||
},
|
||||
{
|
||||
versionId: v2.id,
|
||||
userId: null,
|
||||
guestName: 'Anna (Artist)',
|
||||
body: 'Können wir die Vocals etwas weiter nach vorne ziehen?',
|
||||
timestampSeconds: 5.5,
|
||||
},
|
||||
]);
|
||||
|
||||
console.log('→ Share-Link');
|
||||
const token = Array.from(crypto.getRandomValues(new Uint8Array(32)), (b) =>
|
||||
b.toString(16).padStart(2, '0'),
|
||||
).join('');
|
||||
await db.insert(shareLinks).values({
|
||||
versionId: v2.id,
|
||||
token,
|
||||
createdById: user.id,
|
||||
allowComments: true,
|
||||
allowDownload: false,
|
||||
});
|
||||
|
||||
console.log('\n✅ Done.');
|
||||
console.log(` Login als ${email} via Magic Link.`);
|
||||
console.log(` Share-Link: http://localhost:5173/listen/${token}`);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
process.exit(0);
|
||||
@@ -17,23 +17,23 @@ export async function sendMagicLinkEmail(email: string, token: string) {
|
||||
await resend.emails.send({
|
||||
from: fromEmail,
|
||||
to: email,
|
||||
subject: 'Your Music Hub Login Link',
|
||||
subject: 'Dein Login-Link für Music Hub',
|
||||
html: `
|
||||
<div style="font-family: -apple-system, sans-serif; max-width: 400px; margin: 0 auto; padding: 2rem;">
|
||||
<h1 style="font-size: 1.5rem; color: #f0f0f0;">Music Hub</h1>
|
||||
<p style="color: #a0a0a0;">Click the button below to log in:</p>
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 460px; margin: 0 auto; padding: 2.5rem 2rem; color: #f4f0ec; background: #0a0910;">
|
||||
<h1 style="font-size: 1.6rem; margin: 0 0 1rem; background: linear-gradient(135deg, #f43f5e, #fb923c); -webkit-background-clip: text; background-clip: text; color: transparent; display: inline-block;">Music Hub</h1>
|
||||
<p style="color: #9b96a8; line-height: 1.55; margin: 0 0 1.5rem;">Klick auf den Button um dich einzuloggen:</p>
|
||||
<a href="${url}" style="
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #6366f1;
|
||||
padding: 0.8rem 1.6rem;
|
||||
background: linear-gradient(135deg, #f43f5e, #fb923c);
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
margin: 1rem 0;
|
||||
">Log in to Music Hub</a>
|
||||
<p style="color: #666; font-size: 0.85rem;">This link expires in 15 minutes.</p>
|
||||
<p style="color: #666; font-size: 0.85rem;">If you didn't request this, ignore this email.</p>
|
||||
font-weight: 600;
|
||||
margin: 0 0 1.5rem;
|
||||
">Einloggen</a>
|
||||
<p style="color: #5e596b; font-size: 0.85rem; margin: 0 0 0.5rem;">Der Link läuft in 15 Minuten ab.</p>
|
||||
<p style="color: #5e596b; font-size: 0.85rem; margin: 0;">Wenn du das nicht angefordert hast, ignorier diese Mail einfach.</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
@@ -50,21 +50,20 @@ export async function sendInviteEmail(email: string, projectName: string, invite
|
||||
await resend.emails.send({
|
||||
from: fromEmail,
|
||||
to: email,
|
||||
subject: `You've been invited to "${projectName}" on Music Hub`,
|
||||
subject: `${inviterName} hat dich zu "${projectName}" eingeladen`,
|
||||
html: `
|
||||
<div style="font-family: -apple-system, sans-serif; max-width: 400px; margin: 0 auto; padding: 2rem;">
|
||||
<h1 style="font-size: 1.5rem; color: #f0f0f0;">Music Hub</h1>
|
||||
<p style="color: #a0a0a0;">${inviterName} invited you to collaborate on <strong>"${projectName}"</strong>.</p>
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 460px; margin: 0 auto; padding: 2.5rem 2rem; color: #f4f0ec; background: #0a0910;">
|
||||
<h1 style="font-size: 1.6rem; margin: 0 0 1rem; background: linear-gradient(135deg, #f43f5e, #fb923c); -webkit-background-clip: text; background-clip: text; color: transparent; display: inline-block;">Music Hub</h1>
|
||||
<p style="color: #9b96a8; line-height: 1.55; margin: 0 0 1.5rem;"><strong style="color: #f4f0ec;">${inviterName}</strong> hat dich eingeladen, am Projekt <strong style="color: #f4f0ec;">"${projectName}"</strong> mitzuarbeiten.</p>
|
||||
<a href="${url}" style="
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #6366f1;
|
||||
padding: 0.8rem 1.6rem;
|
||||
background: linear-gradient(135deg, #f43f5e, #fb923c);
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
margin: 1rem 0;
|
||||
">Open Music Hub</a>
|
||||
font-weight: 600;
|
||||
">Projekt öffnen</a>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@music-hub/shared": "workspace:*",
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"wavesurfer.js": "^7.12.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="text-scale" content="scale" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#f43f5e" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Music Hub" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
||||
35
apps/web/src/hooks.server.ts
Normal file
35
apps/web/src/hooks.server.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
|
||||
/**
|
||||
* Proxy /api requests to the API service in production.
|
||||
* In dev, Vite's proxy handles this — this hook only fires
|
||||
* in the built/deployed SvelteKit server.
|
||||
*/
|
||||
const API_ORIGIN = process.env.API_INTERNAL_URL || 'http://api:3000';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
if (event.url.pathname.startsWith('/api/')) {
|
||||
const target = `${API_ORIGIN}${event.url.pathname}${event.url.search}`;
|
||||
|
||||
const headers = new Headers(event.request.headers);
|
||||
headers.delete('host');
|
||||
|
||||
const res = await fetch(target, {
|
||||
method: event.request.method,
|
||||
headers,
|
||||
body: event.request.method !== 'GET' && event.request.method !== 'HEAD'
|
||||
? event.request.body
|
||||
: undefined,
|
||||
// @ts-expect-error — Bun supports duplex
|
||||
duplex: 'half',
|
||||
});
|
||||
|
||||
return new Response(res.body, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers: res.headers,
|
||||
});
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
@@ -57,12 +57,12 @@
|
||||
|
||||
<div class="ab-compare">
|
||||
<div class="ab-header">
|
||||
<h2>A/B Compare</h2>
|
||||
<h2>A/B-Vergleich</h2>
|
||||
<div class="ab-toggle">
|
||||
<button class="toggle-btn" class:active={activePlayer === 'A'} onclick={() => switchTo('A')}>A</button>
|
||||
<button class="toggle-btn" class:active={activePlayer === 'B'} onclick={() => switchTo('B')}>B</button>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onclick={onClose}>Close</Button>
|
||||
<Button variant="ghost" size="sm" onclick={onClose}>Schließen</Button>
|
||||
</div>
|
||||
|
||||
<div class="players">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { SUPPORTED_EXTENSIONS, MAX_FILE_SIZE } from '@music-hub/shared';
|
||||
import { api } from '$lib/api/client.js';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
|
||||
let {
|
||||
trackId,
|
||||
@@ -47,13 +48,13 @@
|
||||
error = '';
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
error = 'File too large (max 500 MB)';
|
||||
error = 'Datei zu groß (max 500 MB)';
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
|
||||
if (!SUPPORTED_EXTENSIONS.includes(ext as any)) {
|
||||
error = `Unsupported format. Use: ${SUPPORTED_EXTENSIONS.join(', ')}`;
|
||||
error = `Format nicht unterstützt. Erlaubt: ${SUPPORTED_EXTENSIONS.join(', ')}`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -89,7 +90,7 @@
|
||||
label = '';
|
||||
onUploaded();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Upload failed';
|
||||
error = err instanceof Error ? err.message : 'Upload fehlgeschlagen';
|
||||
} finally {
|
||||
uploading = false;
|
||||
progress = 0;
|
||||
@@ -124,7 +125,7 @@
|
||||
<input
|
||||
type="text"
|
||||
bind:value={label}
|
||||
placeholder="Version label (e.g. 'Mix V2', 'Master Final')"
|
||||
placeholder="Versions-Bezeichnung (z.B. 'Mix V2', 'Final Master')"
|
||||
disabled={uploading}
|
||||
/>
|
||||
</div>
|
||||
@@ -156,8 +157,8 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="dropzone-content">
|
||||
<span class="dropzone-icon">🎵</span>
|
||||
<p>Drop audio file here or click to browse</p>
|
||||
<span class="dropzone-icon"><Icon name="upload" size={28} /></span>
|
||||
<p>Audio-Datei hier ablegen oder klicken zum Auswählen</p>
|
||||
<span class="formats">WAV, MP3, FLAC, AIFF — max 500 MB</span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -212,7 +213,9 @@
|
||||
}
|
||||
|
||||
.dropzone-icon {
|
||||
font-size: 2rem;
|
||||
color: var(--color-text-tertiary);
|
||||
display: inline-flex;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.formats {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import WaveSurfer from 'wavesurfer.js';
|
||||
import { formatTime } from '$lib/utils/format.js';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
|
||||
type CommentMarker = {
|
||||
id: string;
|
||||
@@ -16,6 +17,8 @@
|
||||
muted = false,
|
||||
compact = false,
|
||||
label = '',
|
||||
initialTime = 0,
|
||||
autoPlay = false,
|
||||
onTimeClick,
|
||||
onReady,
|
||||
onSeek,
|
||||
@@ -25,6 +28,8 @@
|
||||
muted?: boolean;
|
||||
compact?: boolean;
|
||||
label?: string;
|
||||
initialTime?: number;
|
||||
autoPlay?: boolean;
|
||||
onTimeClick?: (time: number) => void;
|
||||
onReady?: (duration: number) => void;
|
||||
onSeek?: (time: number) => void;
|
||||
@@ -33,32 +38,46 @@
|
||||
let container: HTMLDivElement;
|
||||
let ws: WaveSurfer | null = null;
|
||||
let isPlaying = $state(false);
|
||||
let isReady = $state(false);
|
||||
let currentTime = $state(0);
|
||||
let duration = $state(0);
|
||||
let volume = $state(0.8);
|
||||
let showVolume = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (ws) ws.setVolume(muted ? 0 : volume);
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
// Resolve CSS variables to real colors so wavesurfer renders correctly.
|
||||
const styles = getComputedStyle(document.documentElement);
|
||||
const waveColor = styles.getPropertyValue('--color-bg-subtle').trim() || '#262430';
|
||||
const progressColor = styles.getPropertyValue('--color-accent').trim() || '#f43f5e';
|
||||
|
||||
ws = WaveSurfer.create({
|
||||
container,
|
||||
waveColor: 'var(--color-bg-subtle, #4a4a5a)',
|
||||
progressColor: 'var(--color-accent, #6366f1)',
|
||||
cursorColor: '#818cf8',
|
||||
waveColor,
|
||||
progressColor,
|
||||
cursorColor: progressColor,
|
||||
cursorWidth: 2,
|
||||
barWidth: 2,
|
||||
barGap: 1,
|
||||
barRadius: 2,
|
||||
height: compact ? 48 : 80,
|
||||
barGap: 2,
|
||||
barRadius: 3,
|
||||
height: compact ? 56 : 96,
|
||||
normalize: true,
|
||||
url,
|
||||
});
|
||||
|
||||
ws.on('ready', () => {
|
||||
duration = ws!.getDuration();
|
||||
isReady = true;
|
||||
ws!.setVolume(muted ? 0 : volume);
|
||||
if (initialTime > 0 && initialTime < duration) {
|
||||
ws!.setTime(initialTime);
|
||||
}
|
||||
if (autoPlay) {
|
||||
ws!.play().catch(() => {});
|
||||
}
|
||||
onReady?.(duration);
|
||||
});
|
||||
|
||||
@@ -107,14 +126,34 @@
|
||||
return ws?.getCurrentTime() || 0;
|
||||
}
|
||||
|
||||
export { seekToTime, play, pause, getCurrentTime };
|
||||
function getIsPlaying(): boolean {
|
||||
return isPlaying;
|
||||
}
|
||||
|
||||
export { seekToTime, play, pause, togglePlay, getCurrentTime, getIsPlaying };
|
||||
|
||||
function initials(name: string) {
|
||||
return name.trim().split(/\s+/).map((p) => p[0]).slice(0, 2).join('').toUpperCase();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="waveform-player" class:compact>
|
||||
<div class="player" class:compact>
|
||||
{#if label}
|
||||
<span class="player-label">{label}</span>
|
||||
{/if}
|
||||
|
||||
<div class="player-row">
|
||||
<button
|
||||
class="play-btn"
|
||||
class:compact-btn={compact}
|
||||
onclick={togglePlay}
|
||||
disabled={!isReady}
|
||||
aria-label={isPlaying ? 'Pause' : 'Abspielen'}
|
||||
>
|
||||
<Icon name={isPlaying ? 'pause' : 'play'} size={compact ? 18 : 24} />
|
||||
</button>
|
||||
|
||||
<div class="waveform-block">
|
||||
<div class="waveform-container">
|
||||
<div bind:this={container} class="waveform"></div>
|
||||
|
||||
@@ -126,31 +165,29 @@
|
||||
style="left: {(marker.timestampSeconds / duration) * 100}%"
|
||||
title="{marker.userName}: {marker.body}"
|
||||
onclick={() => seekToTime(marker.timestampSeconds)}
|
||||
></button>
|
||||
>
|
||||
<span class="marker-dot">{initials(marker.userName)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="controls-left">
|
||||
{#if !compact}
|
||||
<button class="control-btn" onclick={() => skip(-10)} title="Back 10s">⏪</button>
|
||||
{/if}
|
||||
<button class="control-btn play-btn" onclick={togglePlay}>
|
||||
{isPlaying ? '⏸' : '▶'}
|
||||
<div class="meta-row">
|
||||
<span class="time">{formatTime(currentTime)}</span>
|
||||
<div class="meta-controls">
|
||||
<button class="ctl" onclick={() => skip(-10)} aria-label="10 Sekunden zurück">
|
||||
<Icon name="skip-back" size={14} />
|
||||
</button>
|
||||
{#if !compact}
|
||||
<button class="control-btn" onclick={() => skip(10)} title="Forward 10s">⏩</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="time">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</div>
|
||||
|
||||
{#if !compact}
|
||||
<div class="controls-right">
|
||||
<button class="ctl" onclick={() => skip(10)} aria-label="10 Sekunden vor">
|
||||
<Icon name="skip-forward" size={14} />
|
||||
</button>
|
||||
<div class="volume" onmouseenter={() => (showVolume = true)} onmouseleave={() => (showVolume = false)} role="group">
|
||||
<button class="ctl" aria-label="Lautstärke">
|
||||
<Icon name={volume === 0 ? 'volume-off' : 'volume'} size={14} />
|
||||
</button>
|
||||
{#if showVolume}
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
@@ -159,23 +196,21 @@
|
||||
value={volume}
|
||||
oninput={(e) => setVol(Number((e.target as HTMLInputElement).value))}
|
||||
class="volume-slider"
|
||||
title="Volume"
|
||||
aria-label="Lautstärke einstellen"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<span class="time muted">{formatTime(duration)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.waveform-player {
|
||||
background: var(--color-bg-overlay);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.waveform-player.compact {
|
||||
padding: var(--space-3);
|
||||
.player {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.player-label {
|
||||
@@ -184,15 +219,62 @@
|
||||
color: var(--color-text-tertiary);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: var(--space-2);
|
||||
letter-spacing: 0.08em;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.player-row {
|
||||
display: flex;
|
||||
gap: var(--space-5);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--gradient-accent);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow:
|
||||
0 1px 0 rgba(255, 255, 255, 0.2) inset,
|
||||
0 8px 24px rgba(244, 63, 94, 0.32);
|
||||
transition:
|
||||
transform var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
padding-left: 4px; /* visual centering for play triangle */
|
||||
}
|
||||
.play-btn:hover:not(:disabled) {
|
||||
transform: scale(1.05);
|
||||
box-shadow:
|
||||
0 1px 0 rgba(255, 255, 255, 0.25) inset,
|
||||
0 12px 36px rgba(244, 63, 94, 0.45);
|
||||
}
|
||||
.play-btn:active:not(:disabled) {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
.play-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.play-btn.compact-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.waveform-block {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.waveform-container {
|
||||
position: relative;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.waveform {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -202,80 +284,129 @@
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.marker {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 8px;
|
||||
height: 100%;
|
||||
top: -6px;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(251, 191, 36, 0.2);
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-left: 2px solid var(--color-warning);
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
padding: 0;
|
||||
transition: background var(--transition-fast);
|
||||
z-index: 2;
|
||||
}
|
||||
.marker::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 22px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 1px;
|
||||
height: calc(100% - 22px + 6px);
|
||||
background: var(--color-accent);
|
||||
opacity: 0.35;
|
||||
pointer-events: none;
|
||||
}
|
||||
.marker-dot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--gradient-accent);
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
border: 2px solid var(--color-bg-raised);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
.marker:hover .marker-dot {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
.marker:hover {
|
||||
background: rgba(251, 191, 36, 0.4);
|
||||
}
|
||||
|
||||
.controls {
|
||||
.meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.controls-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
font-size: 1.3rem;
|
||||
padding: var(--space-1) var(--space-3);
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.play-btn:hover {
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.time {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
min-width: 38px;
|
||||
}
|
||||
.time.muted {
|
||||
color: var(--color-text-tertiary);
|
||||
font-weight: 400;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.controls-right {
|
||||
.meta-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.ctl {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-tertiary);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.ctl:hover {
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-subtle);
|
||||
}
|
||||
|
||||
.volume {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.volume-slider {
|
||||
width: 80px;
|
||||
margin-left: var(--space-2);
|
||||
accent-color: var(--color-accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.compact .play-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
@media (max-width: 540px) {
|
||||
.player-row {
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.play-btn {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
}
|
||||
.meta-controls {
|
||||
gap: 0;
|
||||
}
|
||||
.ctl {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
.volume-slider {
|
||||
width: 60px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
176
apps/web/src/lib/components/dashboard/ActivityItem.svelte
Normal file
176
apps/web/src/lib/components/dashboard/ActivityItem.svelte
Normal file
@@ -0,0 +1,176 @@
|
||||
<script lang="ts">
|
||||
import Avatar from '$lib/components/ui/Avatar.svelte';
|
||||
import { formatTime, timeAgo } from '$lib/utils/format.js';
|
||||
|
||||
type Event = {
|
||||
type: 'comment' | 'version' | 'approval';
|
||||
id: string;
|
||||
createdAt: string;
|
||||
user: { id: string | null; name: string; avatarUrl: string | null } | null;
|
||||
guestName: string | null;
|
||||
project: { id: string; name: string };
|
||||
track: { id: string; name: string };
|
||||
version?: { id: string; versionNumber: number; label: string | null };
|
||||
body?: string;
|
||||
status?: string;
|
||||
timestampSeconds?: number | null;
|
||||
};
|
||||
|
||||
let { event }: { event: Event } = $props();
|
||||
|
||||
const displayName = $derived(event.user?.name ?? event.guestName ?? 'Gast');
|
||||
const isGuest = $derived(!event.user);
|
||||
const trackHref = $derived(`/projects/${event.project.id}/tracks/${event.track.id}`);
|
||||
const versionLabel = $derived(
|
||||
event.version
|
||||
? `V${event.version.versionNumber}${event.version.label ? ' · ' + event.version.label : ''}`
|
||||
: '',
|
||||
);
|
||||
</script>
|
||||
|
||||
<a href={trackHref} class="item">
|
||||
<Avatar name={displayName} src={event.user?.avatarUrl ?? null} size="sm" />
|
||||
|
||||
<div class="body">
|
||||
<div class="head">
|
||||
<strong>{displayName}</strong>
|
||||
{#if isGuest}<span class="guest">Gast</span>{/if}
|
||||
|
||||
{#if event.type === 'comment'}
|
||||
<span class="action">kommentierte</span>
|
||||
{:else if event.type === 'version'}
|
||||
{#if event.status === 'approved'}
|
||||
<span class="action ok">gab frei</span>
|
||||
{:else if event.status === 'rejected'}
|
||||
<span class="action err">lehnte ab</span>
|
||||
{:else}
|
||||
<span class="action">lud hoch</span>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<span class="target">
|
||||
{#if event.type === 'version' && event.version}
|
||||
<span class="strong">{versionLabel}</span> in
|
||||
{/if}
|
||||
<span class="strong">{event.project.name}</span>
|
||||
<span class="sep">·</span>
|
||||
<span>{event.track.name}</span>
|
||||
{#if event.type === 'comment' && event.timestampSeconds !== null && event.timestampSeconds !== undefined}
|
||||
<span class="ts">{formatTime(event.timestampSeconds)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<span class="when">{timeAgo(event.createdAt)}</span>
|
||||
</div>
|
||||
|
||||
{#if event.body}
|
||||
<p class="quote">"{event.body}"</p>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<style>
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
border: 1px solid transparent;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.item:hover {
|
||||
background: var(--color-bg-raised);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
.body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
column-gap: 6px;
|
||||
row-gap: 2px;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.head strong {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.strong {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
.action {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
.action.ok {
|
||||
color: var(--color-success);
|
||||
}
|
||||
.action.err {
|
||||
color: var(--color-error);
|
||||
}
|
||||
.target {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
.sep {
|
||||
color: var(--color-text-tertiary);
|
||||
margin: 0 2px;
|
||||
}
|
||||
.ts {
|
||||
color: var(--color-warning);
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: rgba(251, 191, 36, 0.12);
|
||||
border: 1px solid rgba(251, 191, 36, 0.3);
|
||||
padding: 0 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.when {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@media (max-width: 540px) {
|
||||
.item {
|
||||
padding: var(--space-3);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.when {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
order: 99;
|
||||
}
|
||||
}
|
||||
.guest {
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-tertiary);
|
||||
padding: 0 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 10px;
|
||||
}
|
||||
.quote {
|
||||
margin: 4px 0 0;
|
||||
padding-left: var(--space-3);
|
||||
border-left: 2px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
</style>
|
||||
83
apps/web/src/lib/components/dashboard/WelcomeModal.svelte
Normal file
83
apps/web/src/lib/components/dashboard/WelcomeModal.svelte
Normal file
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import { api } from '$lib/api/client.js';
|
||||
import { toastSuccess, toastError } from '$lib/stores/toast.js';
|
||||
|
||||
let { open = $bindable(false) }: { open?: boolean } = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
|
||||
async function loadDemo() {
|
||||
loading = true;
|
||||
try {
|
||||
const res = await api.post<{ projectId: string }>('/onboarding/seed-demo');
|
||||
toastSuccess('Demo-Projekt erstellt');
|
||||
open = false;
|
||||
await goto(`/projects/${res.projectId}`);
|
||||
} catch (e) {
|
||||
toastError(e instanceof Error ? e.message : 'Konnte Demo nicht laden');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startBlank() {
|
||||
open = false;
|
||||
goto('/projects/new');
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:open title="Willkommen bei Music Hub">
|
||||
<div class="welcome">
|
||||
<p class="lede">
|
||||
Starte mit einem <strong>Demo-Projekt</strong> um sofort zu sehen wie alles funktioniert —
|
||||
mit Versionen, Comments, Wellenform-Player und Share-Link.
|
||||
</p>
|
||||
<p class="lede">
|
||||
Oder leg gleich dein <strong>eigenes erstes Projekt</strong> an.
|
||||
</p>
|
||||
<ul class="features">
|
||||
<li>Du kannst das Demo jederzeit löschen</li>
|
||||
<li>Beide Wege bringen dich direkt ins Werkzeug</li>
|
||||
</ul>
|
||||
</div>
|
||||
{#snippet actions()}
|
||||
<Button variant="ghost" onclick={startBlank}>Eigenes Projekt starten</Button>
|
||||
<Button onclick={loadDemo} {loading}>Demo laden</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.welcome {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.lede {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.55;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.lede strong {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.features {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.features li {
|
||||
padding-left: 1em;
|
||||
position: relative;
|
||||
}
|
||||
.features li::before {
|
||||
content: '·';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -18,16 +18,19 @@
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
text-transform: capitalize;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.default {
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-secondary);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.success {
|
||||
@@ -47,6 +50,7 @@
|
||||
|
||||
.accent {
|
||||
background: var(--color-accent-subtle);
|
||||
color: var(--color-accent);
|
||||
color: #fb923c;
|
||||
border-color: rgba(244, 63, 94, 0.3);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -51,11 +51,23 @@
|
||||
border-radius: var(--radius-md);
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.01em;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
color var(--transition-fast),
|
||||
transform var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:active:not(:disabled) {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.btn:disabled, .btn.disabled {
|
||||
@@ -65,29 +77,33 @@
|
||||
}
|
||||
|
||||
/* Sizes */
|
||||
.sm { padding: 0.3rem 0.6rem; font-size: var(--text-xs); }
|
||||
.md { padding: 0.5rem 1rem; font-size: var(--text-sm); }
|
||||
.lg { padding: 0.65rem 1.25rem; font-size: var(--text-base); }
|
||||
.sm { padding: 0.35rem 0.7rem; font-size: var(--text-xs); height: 28px; }
|
||||
.md { padding: 0.5rem 1rem; font-size: var(--text-sm); height: 36px; }
|
||||
.lg { padding: 0.7rem 1.4rem; font-size: var(--text-base); height: 44px; }
|
||||
|
||||
/* Variants */
|
||||
.primary {
|
||||
background: var(--color-accent);
|
||||
background: var(--gradient-accent);
|
||||
color: #fff;
|
||||
border-color: var(--color-accent);
|
||||
border-color: transparent;
|
||||
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.15) inset, 0 4px 14px rgba(244, 63, 94, 0.25);
|
||||
}
|
||||
.primary:hover:not(:disabled) {
|
||||
background: var(--color-accent-hover);
|
||||
border-color: var(--color-accent-hover);
|
||||
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.2) inset, 0 8px 24px rgba(244, 63, 94, 0.35);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.primary:active:not(:disabled) {
|
||||
transform: scale(0.97) translateY(0);
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-secondary);
|
||||
border-color: var(--color-border-hover);
|
||||
background: var(--color-bg-raised);
|
||||
color: var(--color-text-primary);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
.secondary:hover:not(:disabled) {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-overlay);
|
||||
border-color: var(--color-border-hover);
|
||||
}
|
||||
|
||||
.ghost {
|
||||
@@ -95,28 +111,29 @@
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.ghost:hover:not(:disabled) {
|
||||
background: var(--color-bg-subtle);
|
||||
background: var(--color-bg-raised);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.danger {
|
||||
background: transparent;
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
.danger:hover:not(:disabled) {
|
||||
background: var(--color-error);
|
||||
color: #fff;
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #fff;
|
||||
border: 2px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
position: absolute;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
|
||||
86
apps/web/src/lib/components/ui/CoverImage.svelte
Normal file
86
apps/web/src/lib/components/ui/CoverImage.svelte
Normal file
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
src = null,
|
||||
name = '',
|
||||
size = 'md',
|
||||
rounded = 'md',
|
||||
}: {
|
||||
src?: string | null;
|
||||
name?: string;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'fill';
|
||||
rounded?: 'sm' | 'md' | 'lg';
|
||||
} = $props();
|
||||
|
||||
const initials = $derived(
|
||||
name
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((p) => p[0])
|
||||
.slice(0, 2)
|
||||
.join('')
|
||||
.toUpperCase() || '?'
|
||||
);
|
||||
|
||||
// Deterministic gradient angle based on name → variation per project
|
||||
const angle = $derived(
|
||||
name
|
||||
? (Array.from(name).reduce((a, c) => a + c.charCodeAt(0), 0) * 17) % 360
|
||||
: 135
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="cover {size} round-{rounded}">
|
||||
{#if src}
|
||||
<img {src} alt="" loading="lazy" />
|
||||
{:else}
|
||||
<div
|
||||
class="fallback"
|
||||
style="background: linear-gradient({angle}deg, #f43f5e 0%, #fb923c 100%)"
|
||||
>
|
||||
<span>{initials}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.cover {
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: var(--color-bg-subtle);
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
.cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.fallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.xs { width: 20px; height: 20px; }
|
||||
.sm { width: 32px; height: 32px; }
|
||||
.md { width: 48px; height: 48px; }
|
||||
.lg { width: 80px; height: 80px; }
|
||||
.xl { width: 120px; height: 120px; }
|
||||
.fill { width: 100%; height: 100%; aspect-ratio: 1 / 1; }
|
||||
|
||||
.xs .fallback span { font-size: 8px; }
|
||||
.sm .fallback span { font-size: 11px; }
|
||||
.md .fallback span { font-size: 16px; }
|
||||
.lg .fallback span { font-size: 24px; }
|
||||
.xl .fallback span { font-size: 36px; }
|
||||
.fill .fallback span { font-size: clamp(20px, 8cqw, 56px); }
|
||||
|
||||
.round-sm { border-radius: var(--radius-sm); }
|
||||
.round-md { border-radius: var(--radius-md); }
|
||||
.round-lg { border-radius: var(--radius-lg); }
|
||||
</style>
|
||||
125
apps/web/src/lib/components/ui/CoverUpload.svelte
Normal file
125
apps/web/src/lib/components/ui/CoverUpload.svelte
Normal file
@@ -0,0 +1,125 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api/client.js';
|
||||
import { toastError } from '$lib/stores/toast.js';
|
||||
import CoverImage from './CoverImage.svelte';
|
||||
import Icon from './Icon.svelte';
|
||||
|
||||
let {
|
||||
currentUrl = null,
|
||||
name = '',
|
||||
onUploaded,
|
||||
}: {
|
||||
currentUrl?: string | null;
|
||||
name?: string;
|
||||
onUploaded: (key: string) => void | Promise<void>;
|
||||
} = $props();
|
||||
|
||||
let uploading = $state(false);
|
||||
let dragOver = $state(false);
|
||||
const ALLOWED = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
const MAX = 2 * 1024 * 1024;
|
||||
|
||||
async function pickFile(file: File) {
|
||||
if (!ALLOWED.includes(file.type)) {
|
||||
toastError('Nur JPG, PNG oder WebP');
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX) {
|
||||
toastError('Bild zu groß (max 2 MB)');
|
||||
return;
|
||||
}
|
||||
uploading = true;
|
||||
try {
|
||||
const { uploadUrl, key } = await api.post<{ uploadUrl: string; key: string }>(
|
||||
'/uploads/cover',
|
||||
{ fileName: file.name, mimeType: file.type, fileSize: file.size },
|
||||
);
|
||||
const res = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': file.type },
|
||||
body: file,
|
||||
});
|
||||
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
|
||||
await onUploaded(key);
|
||||
} catch (e) {
|
||||
toastError(e instanceof Error ? e.message : 'Upload fehlgeschlagen');
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleChange(e: Event) {
|
||||
const f = (e.target as HTMLInputElement).files?.[0];
|
||||
if (f) pickFile(f);
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragOver = false;
|
||||
const f = e.dataTransfer?.files[0];
|
||||
if (f) pickFile(f);
|
||||
}
|
||||
</script>
|
||||
|
||||
<label
|
||||
class="cover-upload"
|
||||
class:drag={dragOver}
|
||||
ondragover={(e) => { e.preventDefault(); dragOver = true; }}
|
||||
ondragleave={() => (dragOver = false)}
|
||||
ondrop={handleDrop}
|
||||
>
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp" onchange={handleChange} hidden />
|
||||
<CoverImage src={currentUrl} {name} size="xl" rounded="lg" />
|
||||
<div class="overlay">
|
||||
{#if uploading}
|
||||
<span class="spinner"></span>
|
||||
{:else}
|
||||
<Icon name="upload" size={20} />
|
||||
<span class="hint">Bild ändern</span>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<style>
|
||||
.cover-upload {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
}
|
||||
.overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
.cover-upload:hover .overlay,
|
||||
.cover-upload.drag .overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
.hint {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
}
|
||||
.spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
icon = '📁',
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
@@ -15,7 +15,7 @@
|
||||
</script>
|
||||
|
||||
<div class="empty-state">
|
||||
<span class="icon">{icon}</span>
|
||||
{#if icon}<span class="icon">{icon}</span>{/if}
|
||||
<h3>{title}</h3>
|
||||
{#if description}
|
||||
<p>{description}</p>
|
||||
|
||||
146
apps/web/src/lib/components/ui/Icon.svelte
Normal file
146
apps/web/src/lib/components/ui/Icon.svelte
Normal file
@@ -0,0 +1,146 @@
|
||||
<script lang="ts">
|
||||
// Inline icon set — Lucide-inspired strokes, kept tiny.
|
||||
// Add icons here as needed; do not pull a lib.
|
||||
|
||||
type IconName =
|
||||
| 'play' | 'pause' | 'skip-back' | 'skip-forward' | 'volume' | 'volume-off'
|
||||
| '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';
|
||||
|
||||
let {
|
||||
name,
|
||||
size = 16,
|
||||
stroke = 2,
|
||||
}: { name: IconName; size?: number; stroke?: number } = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width={stroke}
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#if name === 'play'}
|
||||
<polygon points="6 3 21 12 6 21 6 3" fill="currentColor" />
|
||||
{:else if name === 'pause'}
|
||||
<rect x="6" y="4" width="4" height="16" rx="1" fill="currentColor" />
|
||||
<rect x="14" y="4" width="4" height="16" rx="1" fill="currentColor" />
|
||||
{:else if name === 'skip-back'}
|
||||
<polygon points="19 20 9 12 19 4 19 20" fill="currentColor" />
|
||||
<line x1="5" y1="19" x2="5" y2="5" />
|
||||
{:else if name === 'skip-forward'}
|
||||
<polygon points="5 4 15 12 5 20 5 4" fill="currentColor" />
|
||||
<line x1="19" y1="5" x2="19" y2="19" />
|
||||
{:else if name === 'volume'}
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" fill="currentColor" />
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
|
||||
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
|
||||
{:else if name === 'volume-off'}
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" fill="currentColor" />
|
||||
<line x1="23" y1="9" x2="17" y2="15" />
|
||||
<line x1="17" y1="9" x2="23" y2="15" />
|
||||
{:else if name === 'download'}
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
{:else if name === 'upload'}
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
{:else if name === 'share'}
|
||||
<circle cx="18" cy="5" r="3" />
|
||||
<circle cx="6" cy="12" r="3" />
|
||||
<circle cx="18" cy="19" r="3" />
|
||||
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
|
||||
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
|
||||
{:else if name === 'plus'}
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
{:else if name === 'check'}
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
{:else if name === 'x' || name === 'close'}
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
{:else if name === 'chevron-down'}
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
{:else if name === 'chevron-right'}
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
{:else if name === 'more'}
|
||||
<circle cx="12" cy="12" r="1" fill="currentColor" />
|
||||
<circle cx="19" cy="12" r="1" fill="currentColor" />
|
||||
<circle cx="5" cy="12" r="1" fill="currentColor" />
|
||||
{:else if name === 'home'}
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
<polyline points="9 22 9 12 15 12 15 22" />
|
||||
{:else if name === 'panel'}
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<line x1="15" y1="3" x2="15" y2="21" />
|
||||
{:else if name === 'panel-off'}
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
{:else if name === 'git-branch'}
|
||||
<line x1="6" y1="3" x2="6" y2="15" />
|
||||
<circle cx="18" cy="6" r="3" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<path d="M18 9a9 9 0 0 1-9 9" />
|
||||
{:else if name === 'arrow-up'}
|
||||
<line x1="12" y1="19" x2="12" y2="5" />
|
||||
<polyline points="5 12 12 5 19 12" />
|
||||
{:else if name === 'compare'}
|
||||
<polyline points="16 3 21 3 21 8" />
|
||||
<line x1="4" y1="20" x2="21" y2="3" />
|
||||
<polyline points="21 16 21 21 16 21" />
|
||||
<line x1="15" y1="15" x2="21" y2="21" />
|
||||
<line x1="4" y1="4" x2="9" y2="9" />
|
||||
{:else if name === 'comment'}
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
{:else if name === 'lock'}
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
{:else if name === 'link'}
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
{:else if name === 'settings'}
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
{:else if name === 'logout'}
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<polyline points="16 17 21 12 16 7" />
|
||||
<line x1="21" y1="12" x2="9" y2="12" />
|
||||
{:else if name === 'list'}
|
||||
<line x1="8" y1="6" x2="21" y2="6" />
|
||||
<line x1="8" y1="12" x2="21" y2="12" />
|
||||
<line x1="8" y1="18" x2="21" y2="18" />
|
||||
<line x1="3" y1="6" x2="3.01" y2="6" />
|
||||
<line x1="3" y1="12" x2="3.01" y2="12" />
|
||||
<line x1="3" y1="18" x2="3.01" y2="18" />
|
||||
{:else if name === 'graph'}
|
||||
<circle cx="6" cy="6" r="2" />
|
||||
<circle cx="6" cy="18" r="2" />
|
||||
<circle cx="18" cy="12" r="2" />
|
||||
<line x1="8" y1="7" x2="16" y2="11" />
|
||||
<line x1="8" y1="17" x2="16" y2="13" />
|
||||
{:else if name === 'menu'}
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
{:else if name === 'search'}
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
{/if}
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
svg {
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
@@ -44,24 +44,41 @@
|
||||
|
||||
.input-label {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
input {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
padding: 0.7rem 0.9rem;
|
||||
height: 42px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border-hover);
|
||||
background: var(--color-bg-base);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-raised);
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--text-base);
|
||||
font-size: var(--text-sm);
|
||||
font-family: inherit;
|
||||
transition: border-color var(--transition-fast);
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
background var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
input:hover:not(:disabled) {
|
||||
border-color: var(--color-border-hover);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-border-focus);
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-bg-overlay);
|
||||
box-shadow: 0 0 0 4px rgba(244, 63, 94, 0.12);
|
||||
}
|
||||
|
||||
input:disabled {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import Icon from './Icon.svelte';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
@@ -29,7 +30,9 @@
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h2>{title}</h2>
|
||||
<button class="close-btn" onclick={() => open = false}>×</button>
|
||||
<button class="close-btn" onclick={() => open = false} aria-label="Schließen">
|
||||
<Icon name="x" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{@render children()}
|
||||
@@ -47,13 +50,15 @@
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
background: rgba(8, 6, 14, 0.65);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: var(--z-modal);
|
||||
padding: var(--space-4);
|
||||
backdrop-filter: blur(4px);
|
||||
backdrop-filter: blur(12px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(140%);
|
||||
animation: fade-in 200ms var(--ease-out);
|
||||
}
|
||||
|
||||
.modal {
|
||||
@@ -65,6 +70,16 @@
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-lg);
|
||||
animation: pop-in 280ms var(--ease-spring);
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes pop-in {
|
||||
from { opacity: 0; transform: translateY(8px) scale(0.97); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
|
||||
93
apps/web/src/lib/components/ui/ShortcutsModal.svelte
Normal file
93
apps/web/src/lib/components/ui/ShortcutsModal.svelte
Normal file
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
import Modal from './Modal.svelte';
|
||||
import Button from './Button.svelte';
|
||||
|
||||
let { open = $bindable(false) }: { open?: boolean } = $props();
|
||||
|
||||
const groups: { title: string; rows: [string, string][] }[] = [
|
||||
{
|
||||
title: 'Allgemein',
|
||||
rows: [
|
||||
['/', 'Suche fokussieren'],
|
||||
['?', 'Diese Übersicht öffnen'],
|
||||
['Esc', 'Schließen / abbrechen'],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Player',
|
||||
rows: [
|
||||
['Space', 'Play / Pause'],
|
||||
['K', 'Play / Pause'],
|
||||
['J', '−10 Sekunden'],
|
||||
['L', '+10 Sekunden'],
|
||||
['C', 'Kommentar an aktueller Stelle'],
|
||||
['← →', 'Vorherige / nächste Version'],
|
||||
],
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<Modal bind:open title="Tastatur-Shortcuts">
|
||||
<div class="shortcuts">
|
||||
{#each groups as group}
|
||||
<div class="group">
|
||||
<h3>{group.title}</h3>
|
||||
<table>
|
||||
<tbody>
|
||||
{#each group.rows as [keys, label]}
|
||||
<tr>
|
||||
<td><kbd>{keys}</kbd></td>
|
||||
<td>{label}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#snippet actions()}
|
||||
<Button onclick={() => (open = false)}>Schließen</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.shortcuts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.group h3 {
|
||||
margin: 0 0 var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
td {
|
||||
padding: var(--space-2) 0;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
td:first-child {
|
||||
width: 100px;
|
||||
}
|
||||
td:last-child {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
kbd {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background: var(--color-bg-raised);
|
||||
border: 1px solid var(--color-border-hover);
|
||||
border-bottom-width: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -21,12 +21,12 @@
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-bg-subtle) 25%,
|
||||
var(--color-border) 50%,
|
||||
var(--color-bg-subtle) 75%
|
||||
var(--color-bg-raised) 0%,
|
||||
var(--color-bg-subtle) 50%,
|
||||
var(--color-bg-raised) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
animation: shimmer 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { toasts, removeToast, type ToastType } from '$lib/stores/toast.js';
|
||||
import Icon from './Icon.svelte';
|
||||
|
||||
const icons: Record<ToastType, string> = {
|
||||
success: '✓',
|
||||
error: '✕',
|
||||
info: 'i',
|
||||
warning: '!',
|
||||
const icons: Record<ToastType, 'check' | 'x' | 'comment' | 'comment'> = {
|
||||
success: 'check',
|
||||
error: 'x',
|
||||
info: 'comment',
|
||||
warning: 'comment',
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -13,9 +14,11 @@
|
||||
<div class="toast-container">
|
||||
{#each $toasts as t (t.id)}
|
||||
<div class="toast {t.type}" role="alert">
|
||||
<span class="toast-icon">{icons[t.type]}</span>
|
||||
<span class="toast-icon"><Icon name={icons[t.type]} size={12} stroke={3} /></span>
|
||||
<span class="toast-message">{t.message}</span>
|
||||
<button class="toast-close" onclick={() => removeToast(t.id)}>×</button>
|
||||
<button class="toast-close" onclick={() => removeToast(t.id)} aria-label="Schließen">
|
||||
<Icon name="x" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -38,11 +41,13 @@
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--color-bg-overlay);
|
||||
background: rgba(26, 24, 34, 0.85);
|
||||
backdrop-filter: blur(20px) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(150%);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
animation: slide-in 0.2s ease;
|
||||
animation: slide-in 280ms var(--ease-spring);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
@@ -85,11 +90,11 @@
|
||||
@keyframes slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
transform: translateY(12px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
68
apps/web/src/lib/components/ui/TrackStatusPill.svelte
Normal file
68
apps/web/src/lib/components/ui/TrackStatusPill.svelte
Normal file
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import { TRACK_STATUS_LABELS, type TrackStatus } from '@music-hub/shared';
|
||||
|
||||
let {
|
||||
status,
|
||||
size = 'sm',
|
||||
}: { status: TrackStatus; size?: 'sm' | 'md' } = $props();
|
||||
|
||||
const COLORS: Record<TrackStatus, { bg: string; fg: string; border: string }> = {
|
||||
sketch: {
|
||||
bg: 'rgba(155, 150, 168, 0.12)',
|
||||
fg: '#9b96a8',
|
||||
border: 'rgba(155, 150, 168, 0.3)',
|
||||
},
|
||||
in_progress: {
|
||||
bg: 'rgba(251, 146, 60, 0.12)',
|
||||
fg: '#fb923c',
|
||||
border: 'rgba(251, 146, 60, 0.35)',
|
||||
},
|
||||
final: {
|
||||
bg: 'rgba(34, 197, 94, 0.12)',
|
||||
fg: '#22c55e',
|
||||
border: 'rgba(34, 197, 94, 0.35)',
|
||||
},
|
||||
released: {
|
||||
bg: 'rgba(244, 63, 94, 0.12)',
|
||||
fg: '#f43f5e',
|
||||
border: 'rgba(244, 63, 94, 0.4)',
|
||||
},
|
||||
};
|
||||
|
||||
const c = $derived(COLORS[status]);
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="pill {size}"
|
||||
style="background: {c.bg}; color: {c.fg}; border-color: {c.border}"
|
||||
>
|
||||
<span class="dot" style="background: {c.fg}"></span>
|
||||
{TRACK_STATUS_LABELS[status]}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-full);
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sm {
|
||||
padding: 3px 8px 3px 7px;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.md {
|
||||
padding: 5px 11px 5px 10px;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
485
apps/web/src/lib/components/workspace/Sidebar.svelte
Normal file
485
apps/web/src/lib/components/workspace/Sidebar.svelte
Normal file
@@ -0,0 +1,485 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { user, logout } from '$lib/stores/auth.js';
|
||||
import { api } from '$lib/api/client.js';
|
||||
import Avatar from '$lib/components/ui/Avatar.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import CoverImage from '$lib/components/ui/CoverImage.svelte';
|
||||
|
||||
type Project = { id: string; name: string; coverUrl: string | null };
|
||||
type ProjectMembership = { project: Project; role: string; trackCount: number };
|
||||
type TrackStatus = 'sketch' | 'in_progress' | 'final' | 'released';
|
||||
type Track = { id: string; name: string; coverUrl: string | null; status: TrackStatus };
|
||||
|
||||
const STATUS_COLORS: Record<TrackStatus, string> = {
|
||||
sketch: '#9b96a8',
|
||||
in_progress: '#fb923c',
|
||||
final: '#22c55e',
|
||||
released: '#f43f5e',
|
||||
};
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
onClose,
|
||||
}: {
|
||||
open?: boolean;
|
||||
onClose?: () => void;
|
||||
} = $props();
|
||||
|
||||
let projects = $state<ProjectMembership[]>([]);
|
||||
let tracksByProject = $state<Record<string, Track[]>>({});
|
||||
let menuOpen = $state(false);
|
||||
let query = $state('');
|
||||
let searchInput = $state<HTMLInputElement | undefined>();
|
||||
|
||||
const activeProjectId = $derived(($page.params as Record<string, string>).projectId ?? null);
|
||||
const activeTrackId = $derived(($page.params as Record<string, string>).trackId ?? null);
|
||||
|
||||
// Filtered projects: a project matches if its name matches OR any of its loaded tracks match
|
||||
const filtered = $derived.by(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return projects;
|
||||
return projects.filter(({ project }) => {
|
||||
if (project.name.toLowerCase().includes(q)) return true;
|
||||
const tracks = tracksByProject[project.id];
|
||||
return tracks?.some((t) => t.name.toLowerCase().includes(q));
|
||||
});
|
||||
});
|
||||
|
||||
function trackMatches(track: Track) {
|
||||
const q = query.trim().toLowerCase();
|
||||
return !q || track.name.toLowerCase().includes(q);
|
||||
}
|
||||
|
||||
// Whether a given project should auto-expand for search
|
||||
function shouldExpand(projectId: string) {
|
||||
if (activeProjectId === projectId) return true;
|
||||
if (!query.trim()) return false;
|
||||
return tracksByProject[projectId]?.some(trackMatches);
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const res = await api.get<{ projects: ProjectMembership[] }>('/projects', true);
|
||||
projects = res.projects;
|
||||
} catch {
|
||||
// not logged in or error — sidebar stays empty, layout still renders
|
||||
}
|
||||
});
|
||||
|
||||
// Lazy-load tracks when a project becomes active
|
||||
$effect(() => {
|
||||
const id = activeProjectId;
|
||||
if (id && !tracksByProject[id]) {
|
||||
api.get<{ tracks: Track[] }>(`/tracks/project/${id}`, true).then((r) => {
|
||||
tracksByProject = { ...tracksByProject, [id]: r.tracks };
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
async function handleLogout() {
|
||||
await logout();
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
// Expose focus method for global / shortcut
|
||||
export function focusSearch() {
|
||||
searchInput?.focus();
|
||||
searchInput?.select();
|
||||
}
|
||||
|
||||
function handleNavClick() {
|
||||
if (open) onClose?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="sidebar" class:open>
|
||||
<div class="sb-head">
|
||||
<a href="/dashboard" class="logo" onclick={handleNavClick}>Music Hub</a>
|
||||
{#if open}
|
||||
<button class="close" onclick={onClose} aria-label="Schließen">
|
||||
<Icon name="x" size={18} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="search">
|
||||
<input
|
||||
bind:this={searchInput}
|
||||
type="text"
|
||||
bind:value={query}
|
||||
placeholder="Suchen… (/)"
|
||||
aria-label="Suchen"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav class="nav">
|
||||
<a
|
||||
href="/dashboard"
|
||||
class="nav-item"
|
||||
class:active={$page.url.pathname === '/dashboard'}
|
||||
onclick={handleNavClick}
|
||||
>
|
||||
<Icon name="home" size={16} /> Übersicht
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-head">
|
||||
<span>Projekte</span>
|
||||
<a href="/projects/new" class="add" title="Neues Projekt" aria-label="Neues Projekt" onclick={handleNavClick}>
|
||||
<Icon name="plus" size={14} />
|
||||
</a>
|
||||
</div>
|
||||
<ul class="projects">
|
||||
{#each filtered as { project, trackCount } (project.id)}
|
||||
<li>
|
||||
<a
|
||||
href="/projects/{project.id}"
|
||||
class="project"
|
||||
class:active={activeProjectId === project.id}
|
||||
onclick={handleNavClick}
|
||||
>
|
||||
<CoverImage src={project.coverUrl} name={project.name} size="xs" rounded="sm" />
|
||||
<span class="name">{project.name}</span>
|
||||
<span class="count">{trackCount}</span>
|
||||
</a>
|
||||
{#if shouldExpand(project.id) && tracksByProject[project.id]}
|
||||
<ul class="tracks">
|
||||
{#each tracksByProject[project.id].filter(trackMatches) as track (track.id)}
|
||||
<li>
|
||||
<a
|
||||
href="/projects/{project.id}/tracks/{track.id}"
|
||||
class="track"
|
||||
class:active={activeTrackId === track.id}
|
||||
onclick={handleNavClick}
|
||||
>
|
||||
<span class="status-dot" style="background: {STATUS_COLORS[track.status]}"></span>
|
||||
{track.name}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
{#if filtered.length === 0 && query}
|
||||
<li class="empty">Nichts gefunden für "{query}"</li>
|
||||
{:else if projects.length === 0}
|
||||
<li class="empty">Noch keine Projekte</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="user-block">
|
||||
<button class="user" onclick={() => (menuOpen = !menuOpen)}>
|
||||
{#if $user}
|
||||
<Avatar name={$user.name} src={$user.avatarUrl ?? null} size="sm" />
|
||||
<span class="user-name">{$user.name}</span>
|
||||
<span class="chev"><Icon name="more" size={14} /></span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if menuOpen}
|
||||
<div class="menu" role="menu">
|
||||
<a href="/account" onclick={() => { menuOpen = false; handleNavClick(); }}>Konto</a>
|
||||
<button onclick={handleLogout}>Abmelden</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at 0% 0%, rgba(244, 63, 94, 0.08), transparent 70%),
|
||||
var(--color-bg-raised);
|
||||
border-right: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--space-5) 0 var(--space-4);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.sb-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--space-5);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.logo {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
background: var(--gradient-accent);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
text-decoration: none;
|
||||
}
|
||||
.close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-tertiary);
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.search {
|
||||
padding: 0 var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.search input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-base);
|
||||
color: var(--color-text-primary);
|
||||
font-family: inherit;
|
||||
font-size: var(--text-sm);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.search input::placeholder {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
.search input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 4px rgba(244, 63, 94, 0.12);
|
||||
}
|
||||
|
||||
.nav {
|
||||
padding: 0 var(--space-3);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: var(--text-sm);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.nav-item:hover {
|
||||
background: var(--color-bg-overlay);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.nav-item.active {
|
||||
background: var(--color-bg-overlay);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.section {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 var(--space-3);
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 var(--space-3) var(--space-2);
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.add {
|
||||
color: var(--color-text-tertiary);
|
||||
text-decoration: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.add:hover {
|
||||
background: var(--color-bg-overlay);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.projects {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.project {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: var(--text-sm);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.project:hover {
|
||||
background: var(--color-bg-overlay);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.project.active {
|
||||
background: var(--color-bg-overlay);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.count {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.tracks {
|
||||
list-style: none;
|
||||
padding: var(--space-1) 0 var(--space-2) var(--space-6);
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
border-left: 1px solid var(--color-border);
|
||||
margin-left: var(--space-4);
|
||||
}
|
||||
.track {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0.3rem var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-tertiary);
|
||||
text-decoration: none;
|
||||
font-size: var(--text-xs);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.track:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.track.active {
|
||||
color: var(--color-accent);
|
||||
background: var(--color-accent-subtle);
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
.user-block {
|
||||
padding: var(--space-3) var(--space-3) 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin-top: var(--space-3);
|
||||
position: relative;
|
||||
}
|
||||
.user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: var(--space-2);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
.user:hover {
|
||||
background: var(--color-bg-overlay);
|
||||
}
|
||||
.user-name {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
font-size: var(--text-sm);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chev {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
.menu {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 4px);
|
||||
left: var(--space-3);
|
||||
right: var(--space-3);
|
||||
background: var(--color-bg-overlay);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
.menu button,
|
||||
.menu a {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-primary);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
font-family: inherit;
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.menu button:hover,
|
||||
.menu a:hover {
|
||||
background: var(--color-bg-subtle);
|
||||
}
|
||||
|
||||
/* MOBILE — Drawer overlay */
|
||||
@media (max-width: 880px) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
height: 100vh;
|
||||
width: min(280px, 85vw);
|
||||
z-index: 100;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 240ms var(--ease-out);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
.close {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
133
apps/web/src/lib/components/workspace/TopBar.svelte
Normal file
133
apps/web/src/lib/components/workspace/TopBar.svelte
Normal file
@@ -0,0 +1,133 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import { getContext } from 'svelte';
|
||||
|
||||
let {
|
||||
crumbs = [],
|
||||
actions,
|
||||
}: {
|
||||
crumbs?: { label: string; href?: string }[];
|
||||
actions?: Snippet;
|
||||
} = $props();
|
||||
|
||||
const openMobileMenu = getContext<() => void>('openMobileMenu');
|
||||
</script>
|
||||
|
||||
<header class="topbar">
|
||||
<button class="hamburger" onclick={() => openMobileMenu?.()} aria-label="Menü öffnen">
|
||||
<Icon name="menu" size={20} />
|
||||
</button>
|
||||
<nav class="crumbs" aria-label="Breadcrumb">
|
||||
{#each crumbs as crumb, i}
|
||||
{#if crumb.href && i < crumbs.length - 1}
|
||||
<a href={crumb.href}>{crumb.label}</a>
|
||||
{:else}
|
||||
<span class="current">{crumb.label}</span>
|
||||
{/if}
|
||||
{#if i < crumbs.length - 1}
|
||||
<span class="sep">/</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
{#if actions}
|
||||
<div class="actions">
|
||||
{@render actions()}
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4) var(--space-6);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: rgba(10, 9, 16, 0.85);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.crumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.crumbs a {
|
||||
color: var(--color-text-tertiary);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
.crumbs a:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.crumbs .current {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sep {
|
||||
color: var(--color-text-tertiary);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hamburger {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.hamburger:hover {
|
||||
background: var(--color-bg-raised);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.topbar {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
}
|
||||
.hamburger {
|
||||
display: inline-flex;
|
||||
}
|
||||
/* Hide all but the last crumb on tight viewports */
|
||||
.crumbs a,
|
||||
.crumbs .sep {
|
||||
display: none;
|
||||
}
|
||||
.crumbs a:last-of-type,
|
||||
.crumbs .current {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
@media (max-width: 540px) {
|
||||
.topbar {
|
||||
padding: var(--space-3);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
39
apps/web/src/lib/stores/player.ts
Normal file
39
apps/web/src/lib/stores/player.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
type PlayerState = {
|
||||
trackId: string | null;
|
||||
currentTime: number;
|
||||
isPlaying: boolean;
|
||||
};
|
||||
|
||||
export const playerState = writable<PlayerState>({
|
||||
trackId: null,
|
||||
currentTime: 0,
|
||||
isPlaying: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Snapshot the current playhead before changing version (within the same track).
|
||||
*/
|
||||
export function snapshotForTrack(trackId: string, currentTime: number, isPlaying: boolean) {
|
||||
playerState.set({ trackId, currentTime, isPlaying });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a continuation snapshot if we're staying within the same track.
|
||||
* Returns the previously snapshotted time + isPlaying, or null for a fresh start.
|
||||
*/
|
||||
export function continuationFor(trackId: string): { initialTime: number; autoPlay: boolean } | null {
|
||||
let snap: PlayerState | null = null;
|
||||
playerState.subscribe((s) => (snap = s))();
|
||||
if (!snap || (snap as PlayerState).trackId !== trackId) return null;
|
||||
const s = snap as PlayerState;
|
||||
return { initialTime: s.currentTime, autoPlay: s.isPlaying };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset state — call when navigating away from a track entirely.
|
||||
*/
|
||||
export function resetPlayer() {
|
||||
playerState.set({ trackId: null, currentTime: 0, isPlaying: false });
|
||||
}
|
||||
47
apps/web/src/lib/utils/shortcuts.ts
Normal file
47
apps/web/src/lib/utils/shortcuts.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Lightweight global keyboard shortcut helper.
|
||||
*
|
||||
* Usage in a Svelte component:
|
||||
* import { onKey } from '$lib/utils/shortcuts.js';
|
||||
* onKey({
|
||||
* ' ': () => playerRef?.play(),
|
||||
* j: () => skip(-10),
|
||||
* });
|
||||
*
|
||||
* Triggers are skipped when the user is typing in an <input>, <textarea>
|
||||
* or contenteditable element — except for keys explicitly listed in `always`.
|
||||
*/
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
type Handler = (e: KeyboardEvent) => void;
|
||||
type Map = Record<string, Handler>;
|
||||
|
||||
function isTyping(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
const tag = target.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
||||
if (target.isContentEditable) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function onKey(map: Map, options: { always?: string[] } = {}) {
|
||||
const always = new Set(options.always ?? []);
|
||||
function handler(e: KeyboardEvent) {
|
||||
// Modifier keys: ignore for now (we don't have any cmd-shortcuts)
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
|
||||
const key = e.key;
|
||||
const fn = map[key];
|
||||
if (!fn) return;
|
||||
|
||||
if (isTyping(e.target) && !always.has(key)) return;
|
||||
|
||||
e.preventDefault();
|
||||
fn(e);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
});
|
||||
}
|
||||
98
apps/web/src/routes/(app)/+layout.svelte
Normal file
98
apps/web/src/routes/(app)/+layout.svelte
Normal file
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
import { onMount, setContext } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { user, authLoading, checkAuth } from '$lib/stores/auth.js';
|
||||
import Sidebar from '$lib/components/workspace/Sidebar.svelte';
|
||||
import ShortcutsModal from '$lib/components/ui/ShortcutsModal.svelte';
|
||||
import { onKey } from '$lib/utils/shortcuts.js';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let mobileMenuOpen = $state(false);
|
||||
let shortcutsOpen = $state(false);
|
||||
let sidebarRef = $state<Sidebar | undefined>();
|
||||
|
||||
setContext('openMobileMenu', () => (mobileMenuOpen = true));
|
||||
|
||||
onMount(async () => {
|
||||
if ($user === null && !$authLoading) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
if ($authLoading) await checkAuth();
|
||||
if (!$user) goto('/login');
|
||||
});
|
||||
|
||||
onKey({
|
||||
'/': () => sidebarRef?.focusSearch(),
|
||||
'?': () => (shortcutsOpen = true),
|
||||
Escape: () => {
|
||||
if (mobileMenuOpen) mobileMenuOpen = false;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if $authLoading}
|
||||
<div class="loading"><div class="spinner"></div></div>
|
||||
{:else if $user}
|
||||
<div class="workspace">
|
||||
<Sidebar bind:this={sidebarRef} bind:open={mobileMenuOpen} onClose={() => (mobileMenuOpen = false)} />
|
||||
{#if mobileMenuOpen}
|
||||
<button class="backdrop" onclick={() => (mobileMenuOpen = false)} aria-label="Menü schließen"></button>
|
||||
{/if}
|
||||
<main class="main">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<ShortcutsModal bind:open={shortcutsOpen} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
}
|
||||
.spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid var(--color-border);
|
||||
border-top-color: var(--color-accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(8, 6, 14, 0.65);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 99;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.backdrop {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
115
apps/web/src/routes/(app)/account/+page.svelte
Normal file
115
apps/web/src/routes/(app)/account/+page.svelte
Normal file
@@ -0,0 +1,115 @@
|
||||
<script lang="ts">
|
||||
import { user } from '$lib/stores/auth.js';
|
||||
import { api } from '$lib/api/client.js';
|
||||
import { toastSuccess } from '$lib/stores/toast.js';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import Avatar from '$lib/components/ui/Avatar.svelte';
|
||||
import TopBar from '$lib/components/workspace/TopBar.svelte';
|
||||
|
||||
let name = $state('');
|
||||
let saving = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if ($user && !name) name = $user.name;
|
||||
});
|
||||
|
||||
async function save() {
|
||||
if (!name.trim()) return;
|
||||
saving = true;
|
||||
try {
|
||||
const res = await api.patch<{ user: typeof $user }>('/auth/me', { name: name.trim() });
|
||||
user.set(res.user);
|
||||
toastSuccess('Profil gespeichert');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<TopBar crumbs={[{ label: 'Konto' }]} />
|
||||
|
||||
<div class="page">
|
||||
<header>
|
||||
<h1>Konto</h1>
|
||||
<p class="sub">Dein Profil — sichtbar für andere im Projekt.</p>
|
||||
</header>
|
||||
|
||||
{#if $user}
|
||||
<section class="card">
|
||||
<h2>Profil</h2>
|
||||
<div class="profile-row">
|
||||
<Avatar name={$user.name} src={$user.avatarUrl ?? null} size="lg" />
|
||||
<div class="form">
|
||||
<Input label="Anzeige-Name" bind:value={name} />
|
||||
<p class="email-line">E-Mail: <span>{$user.email}</span></p>
|
||||
<Button onclick={save} loading={saving} disabled={!name.trim() || name === $user.name}>
|
||||
Speichern
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
padding: var(--space-6);
|
||||
max-width: 720px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.page {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.profile-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
header {
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 var(--space-1);
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
.sub {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
margin: 0;
|
||||
}
|
||||
.card {
|
||||
background: var(--color-bg-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-6);
|
||||
}
|
||||
h2 {
|
||||
margin: 0 0 var(--space-5);
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
.profile-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.form {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
align-items: flex-start;
|
||||
}
|
||||
.form :global(.input-group) {
|
||||
width: 100%;
|
||||
}
|
||||
.email-line {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
margin: 0;
|
||||
}
|
||||
.email-line span {
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
</style>
|
||||
277
apps/web/src/routes/(app)/dashboard/+page.svelte
Normal file
277
apps/web/src/routes/(app)/dashboard/+page.svelte
Normal file
@@ -0,0 +1,277 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api/client.js';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import CoverImage from '$lib/components/ui/CoverImage.svelte';
|
||||
import TopBar from '$lib/components/workspace/TopBar.svelte';
|
||||
import ActivityItem from '$lib/components/dashboard/ActivityItem.svelte';
|
||||
import WelcomeModal from '$lib/components/dashboard/WelcomeModal.svelte';
|
||||
import { timeAgo } from '$lib/utils/format.js';
|
||||
|
||||
type ProjectMembership = {
|
||||
project: { id: string; name: string; description?: string; updatedAt: string; coverUrl: string | null };
|
||||
role: string;
|
||||
trackCount: number;
|
||||
};
|
||||
|
||||
type Event = {
|
||||
type: 'comment' | 'version' | 'approval';
|
||||
id: string;
|
||||
createdAt: string;
|
||||
user: { id: string | null; name: string; avatarUrl: string | null } | null;
|
||||
guestName: string | null;
|
||||
project: { id: string; name: string };
|
||||
track: { id: string; name: string };
|
||||
version?: { id: string; versionNumber: number; label: string | null };
|
||||
body?: string;
|
||||
status?: string;
|
||||
timestampSeconds?: number | null;
|
||||
};
|
||||
|
||||
let projects = $state<ProjectMembership[]>([]);
|
||||
let events = $state<Event[]>([]);
|
||||
let loading = $state(true);
|
||||
let welcomeOpen = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const [pRes, aRes] = await Promise.all([
|
||||
api.get<{ projects: ProjectMembership[] }>('/projects'),
|
||||
api.get<{ events: Event[] }>('/activity?limit=40'),
|
||||
]);
|
||||
projects = pRes.projects;
|
||||
events = aRes.events;
|
||||
|
||||
if (projects.length === 0) {
|
||||
const dismissed = typeof localStorage !== 'undefined' && localStorage.getItem('welcome-dismissed') === '1';
|
||||
if (!dismissed) welcomeOpen = true;
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!welcomeOpen && typeof localStorage !== 'undefined' && projects.length === 0) {
|
||||
// Don't dismiss permanently if user hasn't acted — leave it for next visit if zero projects
|
||||
}
|
||||
});
|
||||
|
||||
// Group events by day bucket
|
||||
const groupedEvents = $derived.by(() => {
|
||||
const now = Date.now();
|
||||
const startOfToday = new Date(); startOfToday.setHours(0, 0, 0, 0);
|
||||
const startOfYesterday = new Date(startOfToday); startOfYesterday.setDate(startOfYesterday.getDate() - 1);
|
||||
const startOfWeek = new Date(startOfToday); startOfWeek.setDate(startOfWeek.getDate() - 7);
|
||||
|
||||
const buckets: { name: string; events: Event[] }[] = [
|
||||
{ name: 'Heute', events: [] },
|
||||
{ name: 'Gestern', events: [] },
|
||||
{ name: 'Diese Woche', events: [] },
|
||||
{ name: 'Älter', events: [] },
|
||||
];
|
||||
|
||||
for (const e of events) {
|
||||
const t = new Date(e.createdAt).getTime();
|
||||
if (t >= startOfToday.getTime()) buckets[0].events.push(e);
|
||||
else if (t >= startOfYesterday.getTime()) buckets[1].events.push(e);
|
||||
else if (t >= startOfWeek.getTime()) buckets[2].events.push(e);
|
||||
else buckets[3].events.push(e);
|
||||
}
|
||||
return buckets.filter((b) => b.events.length > 0);
|
||||
});
|
||||
</script>
|
||||
|
||||
<TopBar crumbs={[{ label: 'Übersicht' }]}>
|
||||
{#snippet actions()}
|
||||
<Button href="/projects/new" size="sm">Neues Projekt</Button>
|
||||
{/snippet}
|
||||
</TopBar>
|
||||
|
||||
<div class="content">
|
||||
<div class="header">
|
||||
<h1>Übersicht</h1>
|
||||
<p class="sub">Was zuletzt in deinen Projekten passiert ist.</p>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="loading">
|
||||
<Skeleton width="40%" height="1rem" />
|
||||
<Skeleton width="80%" height="3rem" variant="rect" />
|
||||
<Skeleton width="80%" height="3rem" variant="rect" />
|
||||
<Skeleton width="80%" height="3rem" variant="rect" />
|
||||
</div>
|
||||
{:else if events.length === 0 && projects.length === 0}
|
||||
<div class="empty">
|
||||
<h2>Noch nichts hier.</h2>
|
||||
<p>Lade dir ein Demo-Projekt oder leg gleich los.</p>
|
||||
<div class="empty-cta">
|
||||
<Button onclick={() => (welcomeOpen = true)}>Demo laden</Button>
|
||||
<Button variant="ghost" href="/projects/new">Eigenes Projekt</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{#if events.length > 0}
|
||||
<section class="activity">
|
||||
{#each groupedEvents as bucket}
|
||||
<div class="bucket">
|
||||
<h2 class="bucket-title">{bucket.name}</h2>
|
||||
<div class="events">
|
||||
{#each bucket.events as event (event.id + event.type)}
|
||||
<ActivityItem {event} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if projects.length > 0}
|
||||
<section class="projects-block">
|
||||
<h2 class="bucket-title">Deine Projekte</h2>
|
||||
<div class="grid">
|
||||
{#each projects as { project, trackCount }}
|
||||
<a href="/projects/{project.id}" class="card">
|
||||
<CoverImage src={project.coverUrl} name={project.name} size="md" rounded="md" />
|
||||
<div class="card-body">
|
||||
<div class="card-name">{project.name}</div>
|
||||
<div class="card-meta">
|
||||
{trackCount} {trackCount === 1 ? 'Track' : 'Tracks'} · {timeAgo(project.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<WelcomeModal bind:open={welcomeOpen} />
|
||||
|
||||
<style>
|
||||
.content {
|
||||
padding: var(--space-6);
|
||||
max-width: 920px;
|
||||
width: 100%;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.content {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.header {
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
h1 {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
}
|
||||
.header {
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 var(--space-1);
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
.sub {
|
||||
color: var(--color-text-tertiary);
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: var(--space-12) var(--space-4);
|
||||
}
|
||||
.empty h2 {
|
||||
font-size: var(--text-xl);
|
||||
margin: 0 0 var(--space-2);
|
||||
}
|
||||
.empty p {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
margin: 0 0 var(--space-5);
|
||||
}
|
||||
.empty-cta {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.activity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
margin-bottom: var(--space-12);
|
||||
}
|
||||
.bucket-title {
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--color-text-tertiary);
|
||||
font-weight: 600;
|
||||
margin: 0 0 var(--space-3);
|
||||
}
|
||||
.events {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.projects-block {
|
||||
margin-top: var(--space-8);
|
||||
padding-top: var(--space-8);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: var(--color-bg-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.card:hover {
|
||||
border-color: var(--color-border-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.card-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.card-name {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
font-size: var(--text-sm);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.card-meta {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
339
apps/web/src/routes/(app)/projects/[projectId]/+page.svelte
Normal file
339
apps/web/src/routes/(app)/projects/[projectId]/+page.svelte
Normal file
@@ -0,0 +1,339 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { api } from '$lib/api/client.js';
|
||||
import { toastSuccess } from '$lib/stores/toast.js';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import CoverImage from '$lib/components/ui/CoverImage.svelte';
|
||||
import TrackStatusPill from '$lib/components/ui/TrackStatusPill.svelte';
|
||||
import TopBar from '$lib/components/workspace/TopBar.svelte';
|
||||
import { timeAgo } from '$lib/utils/format.js';
|
||||
import type { TrackStatus } from '@music-hub/shared';
|
||||
|
||||
type Track = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
versionCount: number;
|
||||
branchCount: number;
|
||||
coverUrl: string | null;
|
||||
status: TrackStatus;
|
||||
section: string | null;
|
||||
};
|
||||
|
||||
type Group = { name: string; tracks: Track[] };
|
||||
|
||||
function groupBySection(list: Track[]): Group[] {
|
||||
const groups = new Map<string, Track[]>();
|
||||
for (const t of list) {
|
||||
const key = t.section?.trim() || 'Mainline';
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(t);
|
||||
}
|
||||
// Stable order: Mainline first, then alphabetical
|
||||
return Array.from(groups.entries())
|
||||
.sort(([a], [b]) => {
|
||||
if (a === 'Mainline') return -1;
|
||||
if (b === 'Mainline') return 1;
|
||||
return a.localeCompare(b);
|
||||
})
|
||||
.map(([name, tracks]) => ({ name, tracks }));
|
||||
}
|
||||
|
||||
type Project = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
coverUrl: string | null;
|
||||
};
|
||||
|
||||
let project = $state<Project | null>(null);
|
||||
let role = $state('');
|
||||
let tracks = $state<Track[]>([]);
|
||||
const grouped = $derived(groupBySection(tracks));
|
||||
let newTrackName = $state('');
|
||||
let showNewTrack = $state(false);
|
||||
let loading = $state(true);
|
||||
let creating = $state(false);
|
||||
|
||||
const projectId = $page.params.projectId;
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const [projectRes, trackRes] = await Promise.all([
|
||||
api.get<{ project: Project; role: string }>(`/projects/${projectId}`),
|
||||
api.get<{ tracks: Track[] }>(`/tracks/project/${projectId}`),
|
||||
]);
|
||||
project = projectRes.project;
|
||||
role = projectRes.role;
|
||||
tracks = trackRes.tracks;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function createTrack() {
|
||||
if (!newTrackName.trim()) return;
|
||||
creating = true;
|
||||
try {
|
||||
const res = await api.post<{ track: Track }>(`/tracks/${projectId}`, {
|
||||
name: newTrackName,
|
||||
});
|
||||
tracks = [...tracks, res.track];
|
||||
newTrackName = '';
|
||||
showNewTrack = false;
|
||||
toastSuccess('Track angelegt');
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
const canUpload = $derived(role === 'owner' || role.includes('engineer'));
|
||||
</script>
|
||||
|
||||
<TopBar
|
||||
crumbs={[
|
||||
{ label: 'Projekte', href: '/dashboard' },
|
||||
{ label: project?.name ?? '…' },
|
||||
]}
|
||||
>
|
||||
{#snippet actions()}
|
||||
{#if !loading && (role === 'owner' || role === 'management')}
|
||||
<Button variant="ghost" size="sm" href="/projects/{projectId}/settings">Einstellungen</Button>
|
||||
{/if}
|
||||
{#if canUpload}
|
||||
<Button size="sm" onclick={() => showNewTrack = !showNewTrack}>
|
||||
{showNewTrack ? 'Abbrechen' : 'Neuer Track'}
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</TopBar>
|
||||
|
||||
<div class="project-page">
|
||||
<header>
|
||||
{#if loading}
|
||||
<Skeleton width="200px" height="2rem" />
|
||||
{:else if project}
|
||||
<div class="project-head">
|
||||
<CoverImage src={project.coverUrl} name={project.name} size="lg" rounded="lg" />
|
||||
<div>
|
||||
<h1>{project.name}</h1>
|
||||
{#if project.description}
|
||||
<p class="description">{project.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if showNewTrack}
|
||||
<form class="new-track-form" onsubmit={(e) => { e.preventDefault(); createTrack(); }}>
|
||||
<Input bind:value={newTrackName} placeholder="Track-Name" autofocus />
|
||||
<Button type="submit" loading={creating}>Anlegen</Button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="track-list">
|
||||
{#each [1, 2] as _}
|
||||
<div class="track-item-skeleton">
|
||||
<Skeleton width="40%" height="1rem" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if tracks.length === 0}
|
||||
<EmptyState
|
||||
title="Noch keine Tracks"
|
||||
description="Lege einen Track an und lade dein erstes Audio hoch."
|
||||
/>
|
||||
{:else}
|
||||
{#each grouped as group}
|
||||
<section class="section">
|
||||
<h2 class="section-title">
|
||||
{group.name}
|
||||
<span class="section-count">{group.tracks.length}</span>
|
||||
</h2>
|
||||
<div class="track-list">
|
||||
{#each group.tracks as track}
|
||||
<a href="/projects/{projectId}/tracks/{track.id}" class="track-item">
|
||||
<CoverImage src={track.coverUrl} name={track.name} size="sm" rounded="sm" />
|
||||
<span class="track-name">{track.name}</span>
|
||||
<TrackStatusPill status={track.status} />
|
||||
<span class="track-meta">
|
||||
{track.versionCount} {track.versionCount === 1 ? 'Version' : 'Versionen'}
|
||||
{#if track.branchCount > 0}
|
||||
<span class="branch-pill"><Icon name="git-branch" size={11} /> {track.branchCount} {track.branchCount === 1 ? 'Variante' : 'Varianten'}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="track-time">{timeAgo(track.updatedAt)}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.project-page {
|
||||
padding: var(--space-6) var(--space-6) var(--space-12);
|
||||
max-width: 1100px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.project-page {
|
||||
padding: var(--space-4) var(--space-4) var(--space-12);
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
.project-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-5);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.project-head > div {
|
||||
min-width: 0;
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
@media (max-width: 540px) {
|
||||
.project-head {
|
||||
gap: var(--space-3);
|
||||
}
|
||||
h1 {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--color-text-secondary);
|
||||
margin: var(--space-1) 0 0;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
.section-title {
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--color-text-tertiary);
|
||||
font-weight: 600;
|
||||
margin: 0 0 var(--space-3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.section-count {
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-tertiary);
|
||||
padding: 1px 7px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.new-track-form {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-6);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.new-track-form :global(.input-group) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.track-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.track-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto auto auto;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--color-bg-overlay);
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
border: 1px solid var(--color-border);
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.track-meta {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.track-item {
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-areas:
|
||||
'cover name status'
|
||||
'cover meta time';
|
||||
row-gap: 4px;
|
||||
}
|
||||
.track-item :global(.cover) { grid-area: cover; }
|
||||
.track-item .track-name { grid-area: name; }
|
||||
.track-item :global(.pill) { grid-area: status; justify-self: end; }
|
||||
.track-item .track-meta { grid-area: meta; margin-left: 0; }
|
||||
.track-item .track-time { grid-area: time; min-width: auto; }
|
||||
}
|
||||
|
||||
.branch-pill {
|
||||
background: var(--color-accent-subtle);
|
||||
border: 1px solid rgba(244, 63, 94, 0.4);
|
||||
color: #fb923c;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.track-time {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
min-width: 80px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.track-item:hover {
|
||||
border-color: var(--color-border-hover);
|
||||
background: var(--color-bg-overlay);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.track-name {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.track-item-skeleton {
|
||||
padding: var(--space-4) var(--space-5);
|
||||
background: var(--color-bg-overlay);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
</style>
|
||||
@@ -10,6 +10,8 @@
|
||||
import Avatar from '$lib/components/ui/Avatar.svelte';
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import CoverUpload from '$lib/components/ui/CoverUpload.svelte';
|
||||
import TopBar from '$lib/components/workspace/TopBar.svelte';
|
||||
|
||||
type Member = {
|
||||
id: string;
|
||||
@@ -17,7 +19,7 @@
|
||||
user: { id: string; email: string; name: string; avatarUrl: string | null };
|
||||
};
|
||||
|
||||
type Project = { id: string; name: string; description: string | null };
|
||||
type Project = { id: string; name: string; description: string | null; coverUrl: string | null; coverImageUrl: string | null };
|
||||
|
||||
const projectId = $page.params.projectId!;
|
||||
|
||||
@@ -57,6 +59,12 @@
|
||||
}
|
||||
});
|
||||
|
||||
async function saveCover(key: string) {
|
||||
const res = await api.patch<{ project: Project }>(`/projects/${projectId}`, { coverImageUrl: key });
|
||||
project = res.project;
|
||||
toastSuccess('Cover gespeichert');
|
||||
}
|
||||
|
||||
async function saveProject() {
|
||||
saving = true;
|
||||
try {
|
||||
@@ -64,7 +72,7 @@
|
||||
name: editName,
|
||||
description: editDesc || undefined,
|
||||
});
|
||||
toastSuccess('Project updated');
|
||||
toastSuccess('Projekt gespeichert');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
@@ -79,7 +87,7 @@
|
||||
role: inviteRole,
|
||||
});
|
||||
inviteEmail = '';
|
||||
toastSuccess('Member invited');
|
||||
toastSuccess('Person eingeladen');
|
||||
const res = await api.get<{ members: Member[] }>(`/projects/${projectId}/members`);
|
||||
members = res.members;
|
||||
} finally {
|
||||
@@ -89,47 +97,57 @@
|
||||
|
||||
async function updateRole(memberId: string, newRole: string) {
|
||||
await api.patch(`/projects/${projectId}/members/${memberId}`, { role: newRole });
|
||||
toastSuccess('Role updated');
|
||||
toastSuccess('Rolle aktualisiert');
|
||||
const res = await api.get<{ members: Member[] }>(`/projects/${projectId}/members`);
|
||||
members = res.members;
|
||||
}
|
||||
|
||||
async function removeMember(memberId: string) {
|
||||
await api.delete(`/projects/${projectId}/members/${memberId}`);
|
||||
toastSuccess('Member removed');
|
||||
toastSuccess('Person entfernt');
|
||||
members = members.filter((m) => m.id !== memberId);
|
||||
}
|
||||
|
||||
async function archiveProject() {
|
||||
await api.delete(`/projects/${projectId}`);
|
||||
toastSuccess('Project archived');
|
||||
toastSuccess('Projekt archiviert');
|
||||
goto('/dashboard');
|
||||
}
|
||||
</script>
|
||||
|
||||
<TopBar
|
||||
crumbs={[
|
||||
{ label: 'Projekte', href: '/dashboard' },
|
||||
{ label: project?.name ?? '…', href: `/projects/${projectId}` },
|
||||
{ label: 'Einstellungen' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div class="settings-page">
|
||||
<header>
|
||||
<a href="/projects/{projectId}" class="back">← Back to project</a>
|
||||
<h1>Settings</h1>
|
||||
<h1>Einstellungen</h1>
|
||||
</header>
|
||||
|
||||
{#if !loading && project}
|
||||
<!-- Project Details -->
|
||||
<!-- Projekt-Details -->
|
||||
<section class="section">
|
||||
<h2>Project Details</h2>
|
||||
<form onsubmit={(e) => { e.preventDefault(); saveProject(); }}>
|
||||
<h2>Projekt-Details</h2>
|
||||
<div class="cover-row">
|
||||
<CoverUpload currentUrl={project.coverUrl} name={project.name} onUploaded={saveCover} />
|
||||
<form class="details-form" onsubmit={(e) => { e.preventDefault(); saveProject(); }}>
|
||||
<Input label="Name" bind:value={editName} />
|
||||
<div class="textarea-group">
|
||||
<label class="textarea-label">Description</label>
|
||||
<textarea bind:value={editDesc} rows="3" placeholder="Project description..."></textarea>
|
||||
<label class="textarea-label">Beschreibung</label>
|
||||
<textarea bind:value={editDesc} rows="3" placeholder="Worum geht's in diesem Projekt?"></textarea>
|
||||
</div>
|
||||
<Button type="submit" loading={saving}>Save</Button>
|
||||
<Button type="submit" loading={saving}>Speichern</Button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Members -->
|
||||
<!-- Mitwirkende -->
|
||||
<section class="section">
|
||||
<h2>Members</h2>
|
||||
<h2>Mitwirkende</h2>
|
||||
|
||||
<div class="member-list">
|
||||
{#each members as member}
|
||||
@@ -140,7 +158,7 @@
|
||||
<span class="member-email">{member.user.email}</span>
|
||||
</div>
|
||||
{#if member.role === 'owner'}
|
||||
<Badge variant="accent">Owner</Badge>
|
||||
<Badge variant="accent">Besitzer</Badge>
|
||||
{:else if role === 'owner'}
|
||||
<select
|
||||
value={member.role}
|
||||
@@ -151,7 +169,7 @@
|
||||
{/each}
|
||||
</select>
|
||||
<Button variant="ghost" size="sm" onclick={() => removeMember(member.id)}>
|
||||
<span style="color: var(--color-error)">Remove</span>
|
||||
<span style="color: var(--color-error)">Entfernen</span>
|
||||
</Button>
|
||||
{:else}
|
||||
<Badge>{ROLE_LABELS[member.role as keyof typeof ROLE_LABELS] || member.role}</Badge>
|
||||
@@ -163,64 +181,62 @@
|
||||
<!-- Invite -->
|
||||
{#if role === 'owner' || role === 'management'}
|
||||
<form class="invite-form" onsubmit={(e) => { e.preventDefault(); inviteMember(); }}>
|
||||
<Input type="email" bind:value={inviteEmail} placeholder="email@example.com" />
|
||||
<Input type="email" bind:value={inviteEmail} placeholder="name@email.de" />
|
||||
<select bind:value={inviteRole}>
|
||||
{#each assignableRoles as r}
|
||||
<option value={r}>{ROLE_LABELS[r]}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<Button type="submit" loading={inviting} size="sm">Invite</Button>
|
||||
<Button type="submit" loading={inviting} size="sm">Einladen</Button>
|
||||
</form>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Danger Zone -->
|
||||
<!-- Achtung-Zone -->
|
||||
{#if role === 'owner'}
|
||||
<section class="section danger-zone">
|
||||
<h2>Danger Zone</h2>
|
||||
<h2>Vorsicht</h2>
|
||||
<div class="danger-content">
|
||||
<div>
|
||||
<strong>Archive this project</strong>
|
||||
<p>The project will be hidden from all members.</p>
|
||||
<strong>Projekt archivieren</strong>
|
||||
<p>Das Projekt verschwindet für alle Beteiligten.</p>
|
||||
</div>
|
||||
<Button variant="danger" onclick={() => showArchiveModal = true}>Archive</Button>
|
||||
<Button variant="danger" onclick={() => showArchiveModal = true}>Archivieren</Button>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Modal bind:open={showArchiveModal} title="Archive Project">
|
||||
<p>Are you sure you want to archive <strong>{project?.name}</strong>? This will hide it from all members.</p>
|
||||
<Modal bind:open={showArchiveModal} title="Projekt archivieren">
|
||||
<p>Sicher dass du <strong>{project?.name}</strong> archivieren willst? Es verschwindet dann für alle Beteiligten.</p>
|
||||
{#snippet actions()}
|
||||
<Button variant="secondary" onclick={() => showArchiveModal = false}>Cancel</Button>
|
||||
<Button variant="danger" onclick={archiveProject}>Archive</Button>
|
||||
<Button variant="secondary" onclick={() => showArchiveModal = false}>Abbrechen</Button>
|
||||
<Button variant="danger" onclick={archiveProject}>Archivieren</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.settings-page {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
max-width: 720px;
|
||||
padding: var(--space-6);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.settings-page {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.section {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.back {
|
||||
color: var(--color-text-tertiary);
|
||||
text-decoration: none;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.back:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: var(--space-2) 0 0;
|
||||
margin: 0;
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
|
||||
.section {
|
||||
@@ -236,12 +252,26 @@
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
form {
|
||||
form,
|
||||
.details-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
align-items: flex-start;
|
||||
}
|
||||
.cover-row {
|
||||
display: flex;
|
||||
gap: var(--space-6);
|
||||
align-items: flex-start;
|
||||
}
|
||||
.cover-row .details-form {
|
||||
flex: 1;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.cover-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
form :global(.input-group) {
|
||||
width: 100%;
|
||||
@@ -0,0 +1,924 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { api } from '$lib/api/client.js';
|
||||
import { user } from '$lib/stores/auth.js';
|
||||
import { toastSuccess } from '$lib/stores/toast.js';
|
||||
import WaveformPlayer from '$lib/components/audio/WaveformPlayer.svelte';
|
||||
import UploadDropzone from '$lib/components/audio/UploadDropzone.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import ABCompare from '$lib/components/audio/ABCompare.svelte';
|
||||
import TopBar from '$lib/components/workspace/TopBar.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import CoverImage from '$lib/components/ui/CoverImage.svelte';
|
||||
import CoverUpload from '$lib/components/ui/CoverUpload.svelte';
|
||||
import TrackStatusPill from '$lib/components/ui/TrackStatusPill.svelte';
|
||||
import { onKey } from '$lib/utils/shortcuts.js';
|
||||
import { snapshotForTrack, continuationFor } from '$lib/stores/player.js';
|
||||
import { TRACK_STATUSES, TRACK_STATUS_LABELS, type TrackStatus } from '@music-hub/shared';
|
||||
import VersionInfo from './components/VersionInfo.svelte';
|
||||
import VersionGraph from './components/VersionGraph.svelte';
|
||||
import ShareModal from './components/ShareModal.svelte';
|
||||
import CommentSection from './components/CommentSection.svelte';
|
||||
|
||||
type Version = {
|
||||
id: string;
|
||||
versionNumber: number;
|
||||
label: string | null;
|
||||
notes: string | null;
|
||||
status: string;
|
||||
originalFileName: string;
|
||||
duration: number | null;
|
||||
createdAt: string;
|
||||
parentVersionId?: string | null;
|
||||
branchLabel?: string | null;
|
||||
};
|
||||
|
||||
type GraphNode = {
|
||||
id: string;
|
||||
parentVersionId: string | null;
|
||||
branchLabel: string | null;
|
||||
versionNumber: number;
|
||||
label: string | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type Comment = {
|
||||
id: string;
|
||||
body: string;
|
||||
timestampSeconds: number | null;
|
||||
parentId: string | null;
|
||||
resolvedAt: string | null;
|
||||
createdAt: string;
|
||||
guestName?: string | null;
|
||||
user: { id: string; name: string; avatarUrl: string | null } | null;
|
||||
};
|
||||
|
||||
const projectId = ($page.params as Record<string, string>).projectId;
|
||||
const trackId = ($page.params as Record<string, string>).trackId;
|
||||
|
||||
let projectName = $state('');
|
||||
let trackName = $state('');
|
||||
let trackStatus = $state<TrackStatus>('in_progress');
|
||||
let trackSection = $state<string | null>(null);
|
||||
let trackCoverUrl = $state<string | null>(null);
|
||||
let coverEditOpen = $state(false);
|
||||
let statusMenuOpen = $state(false);
|
||||
let nextInitialTime = $state(0);
|
||||
let nextAutoPlay = $state(false);
|
||||
let versions = $state<Version[]>([]);
|
||||
let selectedVersion = $state<Version | null>(null);
|
||||
let streamUrl = $state('');
|
||||
let comments = $state<Comment[]>([]);
|
||||
let showUpload = $state(false);
|
||||
let role = $state('');
|
||||
let loading = $state(true);
|
||||
let commentTimestamp = $state<number | null>(null);
|
||||
let playerRef = $state<WaveformPlayer>();
|
||||
let compareVersion = $state<Version | null>(null);
|
||||
let compareStreamUrl = $state('');
|
||||
let graphNodes = $state<GraphNode[]>([]);
|
||||
let branchFromId = $state<string | null>(null);
|
||||
let branchLabelInput = $state('');
|
||||
let shareOpen = $state(false);
|
||||
let panelTab = $state<'versions' | 'comments'>('versions');
|
||||
let panelOpen = $state(true);
|
||||
let editVersionOpen = $state(false);
|
||||
let editVersionLabel = $state('');
|
||||
let editVersionNotes = $state('');
|
||||
let savingVersion = $state(false);
|
||||
|
||||
const canUpload = $derived(role === 'owner' || role.includes('engineer'));
|
||||
const canApprove = $derived(['owner', 'artist', 'label', 'management'].includes(role));
|
||||
const canComment = $derived(role !== 'viewer');
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const [projectRes, trackVersions, tracksRes, treeRes] = 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`),
|
||||
]);
|
||||
|
||||
projectName = projectRes.project.name;
|
||||
role = projectRes.role;
|
||||
const t = tracksRes.tracks.find((t) => t.id === trackId) as any;
|
||||
trackName = t?.name || '';
|
||||
trackCoverUrl = t?.coverUrl ?? null;
|
||||
trackStatus = (t?.status ?? 'in_progress') as TrackStatus;
|
||||
trackSection = t?.section ?? null;
|
||||
versions = trackVersions.versions;
|
||||
graphNodes = treeRes.nodes;
|
||||
|
||||
if (versions.length > 0) await selectVersion(versions[0]);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function selectVersion(version: Version) {
|
||||
// Snapshot current playhead so the new version picks up where we left off
|
||||
if (playerRef && selectedVersion) {
|
||||
snapshotForTrack(trackId, playerRef.getCurrentTime(), playerRef.getIsPlaying());
|
||||
}
|
||||
const cont = continuationFor(trackId);
|
||||
nextInitialTime = cont?.initialTime ?? 0;
|
||||
nextAutoPlay = cont?.autoPlay ?? false;
|
||||
|
||||
selectedVersion = version;
|
||||
const [streamRes, commentRes] = await Promise.all([
|
||||
api.get<{ url: string }>(`/versions/${version.id}/stream-url`),
|
||||
api.get<{ comments: Comment[] }>(`/comments/version/${version.id}`),
|
||||
]);
|
||||
streamUrl = streamRes.url;
|
||||
comments = commentRes.comments;
|
||||
}
|
||||
|
||||
async function setTrackStatus(s: TrackStatus) {
|
||||
trackStatus = s;
|
||||
statusMenuOpen = false;
|
||||
await api.patch(`/tracks/${trackId}`, { status: s });
|
||||
toastSuccess(`Status: ${TRACK_STATUS_LABELS[s]}`);
|
||||
}
|
||||
|
||||
async function loadVersions() {
|
||||
const [res, treeRes] = await Promise.all([
|
||||
api.get<{ versions: Version[] }>(`/versions/track/${trackId}`),
|
||||
api.get<{ nodes: GraphNode[] }>(`/versions/track/${trackId}/tree`),
|
||||
]);
|
||||
versions = res.versions;
|
||||
graphNodes = treeRes.nodes;
|
||||
if (versions.length > 0) await selectVersion(versions[0]);
|
||||
}
|
||||
|
||||
async function handlePromote() {
|
||||
if (!selectedVersion) return;
|
||||
await api.post(`/versions/${selectedVersion.id}/promote`);
|
||||
toastSuccess('Als Hauptversion festgelegt');
|
||||
await loadVersions();
|
||||
}
|
||||
|
||||
function startBranch(id: string) {
|
||||
branchFromId = id;
|
||||
branchLabelInput = '';
|
||||
showUpload = true;
|
||||
}
|
||||
|
||||
async function handleApprove() {
|
||||
if (!selectedVersion) return;
|
||||
await api.post(`/versions/${selectedVersion.id}/approve`);
|
||||
toastSuccess('Version freigegeben');
|
||||
await loadVersions();
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
if (!selectedVersion) return;
|
||||
await api.post(`/versions/${selectedVersion.id}/reject`);
|
||||
toastSuccess('Version abgelehnt');
|
||||
await loadVersions();
|
||||
}
|
||||
|
||||
async function handleComment(body: string, timestamp: number | null, parentId?: string) {
|
||||
if (!selectedVersion) return;
|
||||
await api.post(`/comments/version/${selectedVersion.id}`, {
|
||||
body,
|
||||
timestampSeconds: timestamp ?? undefined,
|
||||
parentId,
|
||||
});
|
||||
const res = await api.get<{ comments: Comment[] }>(`/comments/version/${selectedVersion.id}`);
|
||||
comments = res.comments;
|
||||
toastSuccess('Kommentar gespeichert');
|
||||
}
|
||||
|
||||
async function handleEditComment(id: string, body: string) {
|
||||
await api.patch(`/comments/${id}`, { body });
|
||||
if (selectedVersion) {
|
||||
const res = await api.get<{ comments: Comment[] }>(`/comments/version/${selectedVersion.id}`);
|
||||
comments = res.comments;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteComment(id: string) {
|
||||
if (!confirm('Diesen Kommentar wirklich löschen?')) return;
|
||||
await api.delete(`/comments/${id}`);
|
||||
if (selectedVersion) {
|
||||
const res = await api.get<{ comments: Comment[] }>(`/comments/version/${selectedVersion.id}`);
|
||||
comments = res.comments;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResolve(commentId: string) {
|
||||
await api.post(`/comments/${commentId}/resolve`);
|
||||
if (selectedVersion) {
|
||||
const res = await api.get<{ comments: Comment[] }>(`/comments/version/${selectedVersion.id}`);
|
||||
comments = res.comments;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
if (!selectedVersion) return;
|
||||
api.get<{ url: string }>(`/versions/${selectedVersion.id}/download-url`).then((res) => {
|
||||
window.open(res.url, '_blank');
|
||||
});
|
||||
}
|
||||
|
||||
async function startCompare(version: Version) {
|
||||
const res = await api.get<{ url: string }>(`/versions/${version.id}/stream-url`);
|
||||
compareVersion = version;
|
||||
compareStreamUrl = res.url;
|
||||
}
|
||||
|
||||
function closeCompare() {
|
||||
compareVersion = null;
|
||||
compareStreamUrl = '';
|
||||
}
|
||||
|
||||
async function saveTrackCover(key: string) {
|
||||
const res = await api.patch<{ track: { coverImageUrl: string | null } }>(`/tracks/${trackId}`, { coverImageUrl: key });
|
||||
// Reload list to refresh signed URL via /tracks/project/:id
|
||||
const tracksRes = await api.get<{ tracks: { id: string; coverUrl: string | null }[] }>(`/tracks/project/${projectId}`);
|
||||
trackCoverUrl = tracksRes.tracks.find((t) => t.id === trackId)?.coverUrl ?? null;
|
||||
coverEditOpen = false;
|
||||
toastSuccess('Cover gespeichert');
|
||||
}
|
||||
|
||||
function openVersionEdit() {
|
||||
if (!selectedVersion) return;
|
||||
editVersionLabel = selectedVersion.label ?? '';
|
||||
editVersionNotes = selectedVersion.notes ?? '';
|
||||
editVersionOpen = true;
|
||||
}
|
||||
|
||||
async function saveVersion() {
|
||||
if (!selectedVersion) return;
|
||||
savingVersion = true;
|
||||
try {
|
||||
await api.patch(`/versions/${selectedVersion.id}`, {
|
||||
label: editVersionLabel.trim() || null,
|
||||
notes: editVersionNotes.trim() || null,
|
||||
});
|
||||
toastSuccess('Version aktualisiert');
|
||||
editVersionOpen = false;
|
||||
await loadVersions();
|
||||
} finally {
|
||||
savingVersion = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTrack() {
|
||||
if (!confirm(`Track "${trackName}" mit allen Versionen und Kommentaren wirklich löschen? Das kann nicht rückgängig gemacht werden.`)) return;
|
||||
await api.delete(`/tracks/${trackId}`);
|
||||
toastSuccess('Track gelöscht');
|
||||
window.location.href = `/projects/${projectId}`;
|
||||
}
|
||||
|
||||
function jumpVersion(direction: 1 | -1) {
|
||||
if (versions.length === 0 || !selectedVersion) return;
|
||||
const idx = versions.findIndex((v) => v.id === selectedVersion!.id);
|
||||
const next = versions[idx + direction];
|
||||
if (next) selectVersion(next);
|
||||
}
|
||||
|
||||
function focusComment() {
|
||||
if (!playerRef) return;
|
||||
commentTimestamp = Math.round(playerRef.getCurrentTime() * 10) / 10;
|
||||
panelTab = 'comments';
|
||||
panelOpen = true;
|
||||
setTimeout(() => {
|
||||
const input = document.querySelector<HTMLInputElement>('.comments-section input[type="text"]');
|
||||
input?.focus();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
onKey({
|
||||
' ': () => playerRef?.togglePlay(),
|
||||
k: () => playerRef?.togglePlay(),
|
||||
j: () => playerRef && playerRef.seekToTime(Math.max(0, playerRef.getCurrentTime() - 10)),
|
||||
l: () => playerRef && playerRef.seekToTime(playerRef.getCurrentTime() + 10),
|
||||
c: () => focusComment(),
|
||||
ArrowLeft: () => jumpVersion(-1),
|
||||
ArrowRight: () => jumpVersion(1),
|
||||
Escape: () => {
|
||||
if (compareVersion) closeCompare();
|
||||
},
|
||||
});
|
||||
|
||||
async function deleteVersion() {
|
||||
if (!selectedVersion) return;
|
||||
if (!confirm(`Version V${selectedVersion.versionNumber} wirklich löschen? Das kann nicht rückgängig gemacht werden.`)) return;
|
||||
await api.delete(`/versions/${selectedVersion.id}`);
|
||||
toastSuccess('Version gelöscht');
|
||||
await loadVersions();
|
||||
}
|
||||
</script>
|
||||
|
||||
<TopBar
|
||||
crumbs={[
|
||||
{ label: 'Projekte', href: '/dashboard' },
|
||||
{ label: projectName || '…', href: `/projects/${projectId}` },
|
||||
{ label: trackName || '…' },
|
||||
]}
|
||||
>
|
||||
{#snippet actions()}
|
||||
{#if canUpload}
|
||||
<Button size="sm" variant="ghost" onclick={() => { branchFromId = null; branchLabelInput = ''; showUpload = !showUpload; }}>
|
||||
<Icon name="upload" size={14} /> Hochladen
|
||||
</Button>
|
||||
{/if}
|
||||
<Button size="sm" variant="ghost" onclick={() => (shareOpen = true)}>
|
||||
<Icon name="share" size={14} /> Teilen
|
||||
</Button>
|
||||
<button class="panel-toggle" class:open={panelOpen} onclick={() => (panelOpen = !panelOpen)} title="Seitenleiste umschalten" aria-label="Seitenleiste umschalten">
|
||||
<Icon name="panel" size={16} />
|
||||
</button>
|
||||
{/snippet}
|
||||
</TopBar>
|
||||
|
||||
<div class="track-workspace">
|
||||
<main class="player-area">
|
||||
{#if loading}
|
||||
<div class="loading-block">
|
||||
<Skeleton width="60%" height="2rem" />
|
||||
<Skeleton height="120px" variant="rect" />
|
||||
</div>
|
||||
{:else if versions.length === 0}
|
||||
<EmptyState
|
||||
title="Noch keine Version"
|
||||
description="Lade dein erstes Audio hoch — wir kümmern uns um Wellenform und Vorschau."
|
||||
>
|
||||
{#snippet action()}
|
||||
{#if canUpload}
|
||||
<Button onclick={() => (showUpload = true)}>Audio hochladen</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else if selectedVersion && streamUrl}
|
||||
<div class="track-head">
|
||||
<button class="track-cover-btn" onclick={() => canUpload && (coverEditOpen = true)} disabled={!canUpload} aria-label="Cover ändern">
|
||||
<CoverImage src={trackCoverUrl} name={trackName} size="lg" rounded="lg" />
|
||||
</button>
|
||||
<div class="title-block">
|
||||
<h1>{trackName}</h1>
|
||||
<div class="meta-row">
|
||||
<button class="status-trigger" onclick={() => canUpload && (statusMenuOpen = !statusMenuOpen)} disabled={!canUpload}>
|
||||
<TrackStatusPill status={trackStatus} size="md" />
|
||||
</button>
|
||||
{#if statusMenuOpen}
|
||||
<div class="status-menu" role="menu">
|
||||
{#each TRACK_STATUSES as s}
|
||||
<button onclick={() => setTrackStatus(s)} class:active={s === trackStatus}>
|
||||
<TrackStatusPill status={s} />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if trackSection}
|
||||
<span class="section-tag">{trackSection}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<VersionInfo
|
||||
version={selectedVersion}
|
||||
{canApprove}
|
||||
onApprove={handleApprove}
|
||||
onReject={handleReject}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="player-card">
|
||||
{#key streamUrl}
|
||||
<WaveformPlayer
|
||||
bind:this={playerRef}
|
||||
url={streamUrl}
|
||||
initialTime={nextInitialTime}
|
||||
autoPlay={nextAutoPlay}
|
||||
markers={comments
|
||||
.filter((c) => c.timestampSeconds !== null)
|
||||
.map((c) => ({
|
||||
id: c.id,
|
||||
timestampSeconds: c.timestampSeconds!,
|
||||
body: c.body,
|
||||
userName: c.user?.name ?? c.guestName ?? 'Gast',
|
||||
}))}
|
||||
onTimeClick={(time) => (commentTimestamp = Math.round(time * 10) / 10)}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<Button variant="ghost" size="sm" onclick={handleDownload}>
|
||||
<Icon name="download" size={14} /> Download Original
|
||||
</Button>
|
||||
{#if canUpload}
|
||||
<Button variant="ghost" size="sm" onclick={openVersionEdit}>
|
||||
<Icon name="settings" size={14} /> Bearbeiten
|
||||
</Button>
|
||||
{/if}
|
||||
{#if role === 'owner'}
|
||||
<Button variant="ghost" size="sm" onclick={deleteVersion}>
|
||||
<span class="danger-text"><Icon name="x" size={14} /> Version löschen</span>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onclick={deleteTrack}>
|
||||
<span class="danger-text"><Icon name="x" size={14} /> Track löschen</span>
|
||||
</Button>
|
||||
{/if}
|
||||
{#if canApprove && selectedVersion.branchLabel}
|
||||
<Button variant="ghost" size="sm" onclick={handlePromote}>
|
||||
<Icon name="arrow-up" size={14} /> Als Hauptversion
|
||||
</Button>
|
||||
{/if}
|
||||
{#if versions.length > 1}
|
||||
<select
|
||||
class="compare-select"
|
||||
onchange={(e) => {
|
||||
const id = (e.target as HTMLSelectElement).value;
|
||||
if (id) {
|
||||
const v = versions.find((v) => v.id === id);
|
||||
if (v) startCompare(v);
|
||||
}
|
||||
(e.target as HTMLSelectElement).value = '';
|
||||
}}
|
||||
>
|
||||
<option value="">Vergleichen mit…</option>
|
||||
{#each versions.filter((v) => v.id !== selectedVersion?.id) as v}
|
||||
<option value={v.id}>V{v.versionNumber}{v.label ? ` — ${v.label}` : ''}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showUpload}
|
||||
<div class="upload-zone">
|
||||
{#if branchFromId}
|
||||
<div class="branch-banner">
|
||||
<span>Variante von <strong>V{graphNodes.find((n) => n.id === branchFromId)?.versionNumber}</strong></span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={branchLabelInput}
|
||||
placeholder="Name der Variante (z.B. 'andere Vocals')"
|
||||
/>
|
||||
<button class="cancel-branch" onclick={() => (branchFromId = null)}>×</button>
|
||||
</div>
|
||||
{/if}
|
||||
<UploadDropzone
|
||||
{trackId}
|
||||
parentVersionId={branchFromId}
|
||||
branchLabel={branchFromId ? branchLabelInput || 'Variante' : null}
|
||||
onUploaded={() => {
|
||||
showUpload = false;
|
||||
branchFromId = null;
|
||||
loadVersions();
|
||||
toastSuccess('Version hochgeladen');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if compareVersion && compareStreamUrl && selectedVersion && streamUrl}
|
||||
<div class="compare-overlay" role="dialog" aria-modal="true">
|
||||
<ABCompare
|
||||
versionA={selectedVersion}
|
||||
versionB={compareVersion}
|
||||
streamUrlA={streamUrl}
|
||||
streamUrlB={compareStreamUrl}
|
||||
onClose={closeCompare}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
{#if panelOpen}
|
||||
<aside class="side-panel">
|
||||
<div class="tabs">
|
||||
<button class:active={panelTab === 'versions'} onclick={() => (panelTab = 'versions')}>
|
||||
Versionen <span class="badge">{versions.length}</span>
|
||||
</button>
|
||||
<button class:active={panelTab === 'comments'} onclick={() => (panelTab = 'comments')}>
|
||||
Kommentare <span class="badge">{comments.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
{#if panelTab === 'versions'}
|
||||
{#if graphNodes.length === 0}
|
||||
<p class="muted">Noch keine Versionen.</p>
|
||||
{:else}
|
||||
<VersionGraph
|
||||
nodes={graphNodes}
|
||||
selectedId={selectedVersion?.id ?? null}
|
||||
onSelect={(id) => {
|
||||
const v = versions.find((v) => v.id === id);
|
||||
if (v) selectVersion(v);
|
||||
}}
|
||||
onBranch={canUpload ? startBranch : undefined}
|
||||
/>
|
||||
{/if}
|
||||
{:else if selectedVersion}
|
||||
<CommentSection
|
||||
{comments}
|
||||
{canComment}
|
||||
currentUserId={$user?.id ?? null}
|
||||
bind:commentTimestamp
|
||||
onSubmit={handleComment}
|
||||
onResolve={handleResolve}
|
||||
onEdit={handleEditComment}
|
||||
onDelete={handleDeleteComment}
|
||||
onSeek={(time) => playerRef?.seekToTime(time)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if selectedVersion}
|
||||
<ShareModal bind:open={shareOpen} versionId={selectedVersion.id} />
|
||||
{/if}
|
||||
|
||||
<Modal bind:open={coverEditOpen} title="Track-Cover ändern">
|
||||
<div class="cover-modal">
|
||||
<CoverUpload currentUrl={trackCoverUrl} name={trackName} onUploaded={saveTrackCover} />
|
||||
</div>
|
||||
{#snippet actions()}
|
||||
<Button onclick={() => (coverEditOpen = false)}>Schließen</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<Modal bind:open={editVersionOpen} title="Version bearbeiten">
|
||||
<div class="edit-form">
|
||||
<label>
|
||||
<span class="lbl">Bezeichnung</span>
|
||||
<input type="text" bind:value={editVersionLabel} placeholder="z.B. 'Mehr Bass'" />
|
||||
</label>
|
||||
<label>
|
||||
<span class="lbl">Notizen</span>
|
||||
<textarea bind:value={editVersionNotes} rows="4" placeholder="Was hat sich geändert?"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
{#snippet actions()}
|
||||
<Button variant="ghost" onclick={() => (editVersionOpen = false)}>Abbrechen</Button>
|
||||
<Button onclick={saveVersion} loading={savingVersion}>Speichern</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.track-workspace {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.player-area {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: var(--space-6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
overflow-y: auto;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.player-area {
|
||||
padding: var(--space-4);
|
||||
gap: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.track-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.track-head h1 {
|
||||
margin: 0;
|
||||
font-size: var(--text-2xl);
|
||||
word-break: break-word;
|
||||
}
|
||||
@media (max-width: 540px) {
|
||||
.track-head h1 {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
}
|
||||
.title-block {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
position: relative;
|
||||
}
|
||||
.status-trigger {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
.status-trigger:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
.status-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
background: var(--color-bg-overlay);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.status-menu button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.status-menu button:hover {
|
||||
background: var(--color-bg-raised);
|
||||
}
|
||||
.status-menu button.active {
|
||||
background: var(--color-bg-subtle);
|
||||
}
|
||||
.section-tag {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
.track-cover-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-lg);
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
.track-cover-btn:not(:disabled):hover {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
.track-cover-btn:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
.cover-modal {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--space-3) 0;
|
||||
}
|
||||
|
||||
.player-card {
|
||||
background: var(--color-bg-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
}
|
||||
@media (max-width: 540px) {
|
||||
.player-card {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.toolbar :global(.btn) {
|
||||
flex: 1 0 calc(50% - var(--space-1));
|
||||
min-width: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
.toolbar .compare-select {
|
||||
flex: 1 0 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.danger-text {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.edit-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.edit-form .lbl {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.edit-form input,
|
||||
.edit-form textarea {
|
||||
padding: 0.7rem 0.9rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-raised);
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--text-sm);
|
||||
font-family: inherit;
|
||||
}
|
||||
.edit-form input:focus,
|
||||
.edit-form textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 4px rgba(244, 63, 94, 0.12);
|
||||
}
|
||||
|
||||
.compare-select {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-raised);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.compare-select:hover {
|
||||
border-color: var(--color-border-hover);
|
||||
}
|
||||
|
||||
.upload-zone {
|
||||
background: var(--color-bg-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
}
|
||||
.branch-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--color-accent-subtle);
|
||||
border: 1px solid rgba(244, 63, 94, 0.3);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.branch-banner input {
|
||||
flex: 1;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border-hover);
|
||||
background: var(--color-bg-base);
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--text-sm);
|
||||
font-family: inherit;
|
||||
}
|
||||
.cancel-branch {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-tertiary);
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.compare-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(8, 6, 14, 0.7);
|
||||
backdrop-filter: blur(10px);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
/* SIDE PANEL */
|
||||
.side-panel {
|
||||
width: 320px;
|
||||
flex-shrink: 0;
|
||||
border-left: 1px solid var(--color-border);
|
||||
background: var(--color-bg-raised);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.tabs button {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
padding: var(--space-4) var(--space-3);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all var(--transition-fast);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.tabs button:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.tabs button.active {
|
||||
color: var(--color-text-primary);
|
||||
border-bottom-color: var(--color-accent);
|
||||
}
|
||||
.badge {
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-tertiary);
|
||||
padding: 0.05rem 0.45rem;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.tabs button.active .badge {
|
||||
background: var(--color-accent-subtle);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-5);
|
||||
}
|
||||
.muted {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.panel-toggle {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-tertiary);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.panel-toggle:hover {
|
||||
color: var(--color-text-primary);
|
||||
border-color: var(--color-border-hover);
|
||||
}
|
||||
.panel-toggle.open {
|
||||
color: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-accent-subtle);
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.side-panel {
|
||||
width: 280px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
.track-workspace {
|
||||
flex-direction: column;
|
||||
}
|
||||
.side-panel {
|
||||
width: 100%;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Avatar from '$lib/components/ui/Avatar.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import { formatTime, timeAgo } from '$lib/utils/format.js';
|
||||
|
||||
type Comment = {
|
||||
@@ -14,18 +15,38 @@
|
||||
|
||||
let {
|
||||
comment,
|
||||
currentUserId = null,
|
||||
onSeek,
|
||||
onResolve,
|
||||
onReply,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
comment: Comment;
|
||||
currentUserId?: string | null;
|
||||
onSeek?: (time: number) => void;
|
||||
onResolve: (id: string) => void;
|
||||
onReply?: (id: string) => void;
|
||||
onEdit?: (id: string, body: string) => Promise<void>;
|
||||
onDelete?: (id: string) => Promise<void>;
|
||||
} = $props();
|
||||
|
||||
const displayName = $derived(comment.user?.name ?? comment.guestName ?? 'Gast');
|
||||
const isGuest = $derived(!comment.user);
|
||||
const isMine = $derived(!!currentUserId && comment.user?.id === currentUserId);
|
||||
|
||||
let editing = $state(false);
|
||||
let editBody = $state('');
|
||||
|
||||
function startEdit() {
|
||||
editBody = comment.body;
|
||||
editing = true;
|
||||
}
|
||||
async function saveEdit() {
|
||||
if (!editBody.trim() || !onEdit) return;
|
||||
await onEdit(comment.id, editBody.trim());
|
||||
editing = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="comment" class:resolved={comment.resolvedAt}>
|
||||
@@ -43,14 +64,38 @@
|
||||
<span class="comment-date">{timeAgo(comment.createdAt)}</span>
|
||||
<div class="comment-actions">
|
||||
{#if onReply}
|
||||
<button class="action-btn" onclick={() => onReply?.(comment.id)} title="Reply">↩</button>
|
||||
<button class="action-btn" onclick={() => onReply?.(comment.id)} title="Antworten" aria-label="Antworten">
|
||||
<Icon name="comment" size={12} />
|
||||
</button>
|
||||
{/if}
|
||||
{#if isMine && onEdit && !editing}
|
||||
<button class="action-btn" onclick={startEdit} title="Bearbeiten" aria-label="Bearbeiten">
|
||||
<Icon name="settings" size={12} />
|
||||
</button>
|
||||
{/if}
|
||||
{#if isMine && onDelete}
|
||||
<button class="action-btn danger" onclick={() => onDelete?.(comment.id)} title="Löschen" aria-label="Löschen">
|
||||
<Icon name="x" size={12} />
|
||||
</button>
|
||||
{/if}
|
||||
{#if !comment.resolvedAt}
|
||||
<button class="action-btn resolve" onclick={() => onResolve(comment.id)} title="Resolve">✓</button>
|
||||
<button class="action-btn resolve" onclick={() => onResolve(comment.id)} title="Erledigt" aria-label="Erledigt">
|
||||
<Icon name="check" size={12} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if editing}
|
||||
<div class="edit">
|
||||
<textarea bind:value={editBody} rows="2"></textarea>
|
||||
<div class="edit-actions">
|
||||
<button class="link" onclick={() => (editing = false)}>Abbrechen</button>
|
||||
<button class="link save" onclick={saveEdit} disabled={!editBody.trim()}>Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="comment-body">{comment.body}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -126,6 +171,51 @@
|
||||
color: var(--color-success);
|
||||
border-color: var(--color-success);
|
||||
}
|
||||
.action-btn.danger:hover {
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.edit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.edit textarea {
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border-hover);
|
||||
background: var(--color-bg-base);
|
||||
color: var(--color-text-primary);
|
||||
font-family: inherit;
|
||||
font-size: var(--text-sm);
|
||||
resize: vertical;
|
||||
}
|
||||
.edit-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-tertiary);
|
||||
font-family: inherit;
|
||||
font-size: var(--text-xs);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.link:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.link.save {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
.link:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.guest-tag {
|
||||
font-size: var(--text-xs);
|
||||
@@ -18,17 +18,23 @@
|
||||
let {
|
||||
comments,
|
||||
canComment = false,
|
||||
currentUserId = null,
|
||||
commentTimestamp = $bindable<number | null>(null),
|
||||
onSubmit,
|
||||
onResolve,
|
||||
onSeek,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
comments: Comment[];
|
||||
canComment?: boolean;
|
||||
currentUserId?: string | null;
|
||||
commentTimestamp: number | null;
|
||||
onSubmit: (body: string, timestamp: number | null, parentId?: string) => void;
|
||||
onResolve: (id: string) => void;
|
||||
onSeek?: (time: number) => void;
|
||||
onEdit?: (id: string, body: string) => Promise<void>;
|
||||
onDelete?: (id: string) => Promise<void>;
|
||||
} = $props();
|
||||
|
||||
let body = $state('');
|
||||
@@ -94,10 +100,10 @@
|
||||
|
||||
<div class="comment-list">
|
||||
{#each topLevel as comment}
|
||||
<CommentItem {comment} {onSeek} {onResolve} onReply={handleReply} />
|
||||
<CommentItem {comment} {currentUserId} {onSeek} {onResolve} {onEdit} {onDelete} onReply={handleReply} />
|
||||
{#each replies(comment.id) as reply}
|
||||
<div class="reply">
|
||||
<CommentItem comment={reply} {onSeek} {onResolve} />
|
||||
<CommentItem comment={reply} {currentUserId} {onSeek} {onResolve} {onEdit} {onDelete} />
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
@@ -0,0 +1,322 @@
|
||||
<script lang="ts">
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import { api } from '$lib/api/client.js';
|
||||
import { toastSuccess } from '$lib/stores/toast.js';
|
||||
import { timeAgo } from '$lib/utils/format.js';
|
||||
|
||||
type ShareLink = {
|
||||
id: string;
|
||||
token: string;
|
||||
expiresAt: string | null;
|
||||
allowComments: boolean;
|
||||
allowDownload: boolean;
|
||||
hasPassword: boolean;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
versionId,
|
||||
}: {
|
||||
open: boolean;
|
||||
versionId: string;
|
||||
} = $props();
|
||||
|
||||
let tab = $state<'create' | 'manage'>('create');
|
||||
let allowComments = $state(true);
|
||||
let allowDownload = $state(false);
|
||||
let password = $state('');
|
||||
let creating = $state(false);
|
||||
let createdUrl = $state('');
|
||||
|
||||
let links = $state<ShareLink[]>([]);
|
||||
let loadingLinks = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (open && tab === 'manage') loadLinks();
|
||||
});
|
||||
|
||||
async function loadLinks() {
|
||||
loadingLinks = true;
|
||||
try {
|
||||
const res = await api.get<{ links: ShareLink[] }>(`/share/version/${versionId}`);
|
||||
links = res.links;
|
||||
} finally {
|
||||
loadingLinks = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function create() {
|
||||
creating = true;
|
||||
try {
|
||||
const res = await api.post<{ link: { token: string } }>(
|
||||
`/share/version/${versionId}`,
|
||||
{
|
||||
allowComments,
|
||||
allowDownload,
|
||||
password: password || undefined,
|
||||
},
|
||||
);
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
createdUrl = `${origin}/listen/${res.link.token}`;
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copy(url: string) {
|
||||
await navigator.clipboard.writeText(url);
|
||||
toastSuccess('Link kopiert');
|
||||
}
|
||||
|
||||
async function revoke(linkId: string) {
|
||||
if (!confirm('Diesen Link wirklich widerrufen? Niemand kann ihn dann mehr öffnen.')) return;
|
||||
await api.delete(`/share/${linkId}`);
|
||||
toastSuccess('Link widerrufen');
|
||||
await loadLinks();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
createdUrl = '';
|
||||
password = '';
|
||||
}
|
||||
|
||||
function urlFor(token: string) {
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
return `${origin}/listen/${token}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:open title="Teilen">
|
||||
<div class="tabs">
|
||||
<button class:active={tab === 'create'} onclick={() => (tab = 'create')}>Neuer Link</button>
|
||||
<button class:active={tab === 'manage'} onclick={() => (tab = 'manage')}>Aktive Links</button>
|
||||
</div>
|
||||
|
||||
{#if tab === 'create'}
|
||||
{#if !createdUrl}
|
||||
<div class="form">
|
||||
<label class="row">
|
||||
<input type="checkbox" bind:checked={allowComments} />
|
||||
<span>Kommentare erlauben (auch ohne Account)</span>
|
||||
</label>
|
||||
<label class="row">
|
||||
<input type="checkbox" bind:checked={allowDownload} />
|
||||
<span>Download des Originals erlauben</span>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Passwort (optional)</span>
|
||||
<input type="text" bind:value={password} placeholder="leer = kein Passwort" />
|
||||
</label>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="result">
|
||||
<p>Link erstellt:</p>
|
||||
<input type="text" readonly value={createdUrl} onclick={(e) => (e.target as HTMLInputElement).select()} />
|
||||
<Button size="sm" onclick={() => copy(createdUrl)}>Kopieren</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if loadingLinks}
|
||||
<p class="muted">Lädt…</p>
|
||||
{:else if links.length === 0}
|
||||
<p class="muted">Keine aktiven Links für diese Version.</p>
|
||||
{:else}
|
||||
<ul class="link-list">
|
||||
{#each links as link}
|
||||
<li>
|
||||
<div class="link-meta">
|
||||
<span class="token">/listen/{link.token.slice(0, 12)}…</span>
|
||||
<div class="flags">
|
||||
{#if link.hasPassword}<span class="flag"><Icon name="lock" size={11} /> Passwort</span>{/if}
|
||||
{#if link.allowComments}<span class="flag">Kommentare</span>{/if}
|
||||
{#if link.allowDownload}<span class="flag">Download</span>{/if}
|
||||
<span class="flag age">erstellt {timeAgo(link.createdAt)}</span>
|
||||
{#if link.expiresAt}<span class="flag expire">läuft {timeAgo(link.expiresAt)}</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="link-actions">
|
||||
<button class="icon-btn" onclick={() => copy(urlFor(link.token))} title="Link kopieren" aria-label="Kopieren">
|
||||
<Icon name="link" size={14} />
|
||||
</button>
|
||||
<button class="icon-btn danger" onclick={() => revoke(link.id)} title="Widerrufen" aria-label="Widerrufen">
|
||||
<Icon name="x" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#snippet actions()}
|
||||
{#if tab === 'create'}
|
||||
{#if !createdUrl}
|
||||
<Button variant="ghost" onclick={() => (open = false)}>Abbrechen</Button>
|
||||
<Button loading={creating} onclick={create}>Link erzeugen</Button>
|
||||
{:else}
|
||||
<Button variant="ghost" onclick={reset}>Weiteren erzeugen</Button>
|
||||
<Button onclick={() => { open = false; reset(); }}>Fertig</Button>
|
||||
{/if}
|
||||
{:else}
|
||||
<Button onclick={() => (open = false)}>Schließen</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
margin-bottom: var(--space-5);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.tabs button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-tertiary);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: var(--text-sm);
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.tabs button:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.tabs button.active {
|
||||
color: var(--color-text-primary);
|
||||
border-bottom-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.field input,
|
||||
.result input {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border-hover);
|
||||
background: var(--color-bg-base);
|
||||
color: var(--color-text-primary);
|
||||
font-family: inherit;
|
||||
font-size: var(--text-sm);
|
||||
width: 100%;
|
||||
}
|
||||
.result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.result p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
text-align: center;
|
||||
padding: var(--space-4) 0;
|
||||
}
|
||||
|
||||
.link-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.link-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: var(--color-bg-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.link-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.token {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.flags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.flag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.flag.expire {
|
||||
color: var(--color-warning);
|
||||
border-color: rgba(251, 191, 36, 0.3);
|
||||
}
|
||||
.link-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.icon-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-tertiary);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.icon-btn:hover {
|
||||
color: var(--color-text-primary);
|
||||
border-color: var(--color-border-hover);
|
||||
}
|
||||
.icon-btn.danger:hover {
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
</style>
|
||||
@@ -68,7 +68,7 @@
|
||||
</script>
|
||||
|
||||
<div class="graph">
|
||||
<h2>Version Graph</h2>
|
||||
<h2>Versionen</h2>
|
||||
{#if nodes.length === 0}
|
||||
<p class="empty">Noch keine Versionen.</p>
|
||||
{:else}
|
||||
@@ -117,14 +117,14 @@
|
||||
font-size="12"
|
||||
fill="#ccc"
|
||||
>
|
||||
{p.node.label || p.node.branchLabel || (p.col === 0 ? 'main' : 'branch')}
|
||||
{p.node.label || p.node.branchLabel || (p.col === 0 ? 'Hauptversion' : 'Variante')}
|
||||
</text>
|
||||
</g>
|
||||
{/each}
|
||||
</svg>
|
||||
{#if onBranch && selectedId}
|
||||
<button class="branch-btn" onclick={() => onBranch?.(selectedId!)}>
|
||||
⑂ Neue Variante von dieser Version
|
||||
Neue Variante von dieser Version
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
|
||||
type Version = {
|
||||
id: string;
|
||||
@@ -26,6 +27,14 @@
|
||||
({ approved: 'success', rejected: 'error', processing: 'warning', ready: 'accent', uploaded: 'default' } as const)[version.status] || 'default'
|
||||
);
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
uploaded: 'hochgeladen',
|
||||
processing: 'wird verarbeitet',
|
||||
ready: 'bereit',
|
||||
approved: 'freigegeben',
|
||||
rejected: 'abgelehnt',
|
||||
};
|
||||
|
||||
const showActions = $derived(canApprove && version.status !== 'approved' && version.status !== 'rejected');
|
||||
</script>
|
||||
|
||||
@@ -35,16 +44,16 @@
|
||||
V{version.versionNumber}
|
||||
{#if version.label} — {version.label}{/if}
|
||||
</span>
|
||||
<Badge variant={statusVariant}>{version.status}</Badge>
|
||||
<Badge variant={statusVariant}>{STATUS_LABEL[version.status] || version.status}</Badge>
|
||||
</div>
|
||||
|
||||
{#if showActions}
|
||||
<div class="version-actions">
|
||||
<Button variant="ghost" size="sm" onclick={onApprove}>
|
||||
<span style="color: var(--color-success)">✓ Approve</span>
|
||||
<span class="ok"><Icon name="check" size={14} /> Freigeben</span>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onclick={onReject}>
|
||||
<span style="color: var(--color-error)">✕ Reject</span>
|
||||
<span class="err"><Icon name="x" size={14} /> Ablehnen</span>
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -77,6 +86,14 @@
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.ok, .err {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.ok { color: var(--color-success); }
|
||||
.err { color: var(--color-error); }
|
||||
|
||||
.version-notes {
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
@@ -4,6 +4,7 @@
|
||||
import { toastSuccess } from '$lib/stores/toast.js';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import TopBar from '$lib/components/workspace/TopBar.svelte';
|
||||
|
||||
let name = $state('');
|
||||
let description = $state('');
|
||||
@@ -17,7 +18,7 @@
|
||||
name,
|
||||
description: description || undefined,
|
||||
});
|
||||
toastSuccess('Project created');
|
||||
toastSuccess('Projekt erstellt');
|
||||
goto(`/projects/${res.project.id}`);
|
||||
} finally {
|
||||
loading = false;
|
||||
@@ -25,21 +26,28 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<TopBar
|
||||
crumbs={[
|
||||
{ label: 'Projekte', href: '/dashboard' },
|
||||
{ label: 'Neues Projekt' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div class="page">
|
||||
<div class="card">
|
||||
<h1>New Project</h1>
|
||||
<h1>Neues Projekt</h1>
|
||||
|
||||
<form onsubmit={handleSubmit}>
|
||||
<Input label="Name" bind:value={name} placeholder="My Album" />
|
||||
<Input label="Name" bind:value={name} placeholder="Mein Album" />
|
||||
|
||||
<div class="textarea-group">
|
||||
<label class="textarea-label">Description (optional)</label>
|
||||
<textarea bind:value={description} placeholder="What's this project about?" rows="3"></textarea>
|
||||
<label class="textarea-label">Beschreibung (optional)</label>
|
||||
<textarea bind:value={description} placeholder="Worum geht's in diesem Projekt?" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<Button variant="secondary" href="/dashboard">Cancel</Button>
|
||||
<Button type="submit" {loading}>Create</Button>
|
||||
<Button variant="secondary" href="/dashboard">Abbrechen</Button>
|
||||
<Button type="submit" {loading}>Anlegen</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -49,7 +57,7 @@
|
||||
.page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--space-8) var(--space-4);
|
||||
padding: var(--space-12) var(--space-4);
|
||||
}
|
||||
|
||||
.card {
|
||||
@@ -1,10 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { checkAuth, user, authLoading } from '$lib/stores/auth.js';
|
||||
import { page } from '$app/stores';
|
||||
import { checkAuth, authLoading } from '$lib/stores/auth.js';
|
||||
import ToastContainer from '$lib/components/ui/ToastContainer.svelte';
|
||||
import '@fontsource-variable/inter';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// Public routes that should never block on auth check
|
||||
const isPublic = $derived(
|
||||
$page.url.pathname === '/' ||
|
||||
$page.url.pathname === '/login' ||
|
||||
$page.url.pathname.startsWith('/listen/'),
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
checkAuth();
|
||||
});
|
||||
@@ -15,7 +24,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</svelte:head>
|
||||
|
||||
{#if $authLoading}
|
||||
{#if $authLoading && !isPublic}
|
||||
<div class="loading">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
@@ -27,33 +36,35 @@
|
||||
|
||||
<style>
|
||||
:global(:root) {
|
||||
/* Background */
|
||||
--color-bg-base: #0a0a0a;
|
||||
--color-bg-raised: #111111;
|
||||
--color-bg-overlay: #1a1a1a;
|
||||
--color-bg-subtle: #222222;
|
||||
/* Background — warm neutrals */
|
||||
--color-bg-base: #0a0910;
|
||||
--color-bg-raised: #131119;
|
||||
--color-bg-overlay: #1a1822;
|
||||
--color-bg-subtle: #221f2c;
|
||||
|
||||
/* Borders */
|
||||
--color-border: #2a2a2a;
|
||||
--color-border-hover: #333333;
|
||||
--color-border-focus: #6366f1;
|
||||
--color-border: #24222e;
|
||||
--color-border-hover: #32303c;
|
||||
--color-border-focus: #f43f5e;
|
||||
|
||||
/* Text */
|
||||
--color-text-primary: #f0f0f0;
|
||||
--color-text-secondary: #a0a0a0;
|
||||
--color-text-tertiary: #666666;
|
||||
--color-text-primary: #f4f0ec;
|
||||
--color-text-secondary: #9b96a8;
|
||||
--color-text-tertiary: #5e596b;
|
||||
|
||||
/* Accent */
|
||||
--color-accent: #6366f1;
|
||||
--color-accent-hover: #5558e6;
|
||||
--color-accent-subtle: #1a1a2e;
|
||||
/* Accent — warm magenta → orange */
|
||||
--color-accent: #f43f5e;
|
||||
--color-accent-2: #fb923c;
|
||||
--color-accent-hover: #e11d48;
|
||||
--color-accent-subtle: #2a121c;
|
||||
--gradient-accent: linear-gradient(135deg, #f43f5e 0%, #fb923c 100%);
|
||||
|
||||
/* Semantic */
|
||||
--color-success: #22c55e;
|
||||
--color-warning: #fbbf24;
|
||||
--color-error: #ef4444;
|
||||
|
||||
/* Spacing */
|
||||
/* Spacing — fluid scale */
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
@@ -63,31 +74,39 @@
|
||||
--space-8: 2rem;
|
||||
--space-10: 2.5rem;
|
||||
--space-12: 3rem;
|
||||
--space-16: 4rem;
|
||||
--space-20: 5rem;
|
||||
|
||||
/* Radii */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
--radius-xl: 20px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.6);
|
||||
/* Shadows — soft + warm */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 24px 60px rgba(0, 0, 0, 0.55);
|
||||
--shadow-glow: 0 0 0 1px rgba(244, 63, 94, 0.4), 0 8px 32px rgba(244, 63, 94, 0.18);
|
||||
|
||||
/* Typography */
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-mono: 'SF Mono', 'Fira Code', monospace;
|
||||
/* Typography — Inter first, system never */
|
||||
--font-sans: 'Inter Variable', 'Inter', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.85rem;
|
||||
--text-base: 0.9rem;
|
||||
--text-lg: 1.1rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-base: 0.9375rem;
|
||||
--text-lg: 1.125rem;
|
||||
--text-xl: 1.5rem;
|
||||
--text-2xl: 2rem;
|
||||
--text-3xl: 2.75rem;
|
||||
--text-4xl: 3.75rem;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-base: 200ms ease;
|
||||
/* Transitions — opinionated easing */
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
--transition-fast: 120ms var(--ease-out);
|
||||
--transition-base: 200ms var(--ease-out);
|
||||
|
||||
/* Z-Index */
|
||||
--z-dropdown: 100;
|
||||
@@ -95,31 +114,76 @@
|
||||
--z-toast: 300;
|
||||
}
|
||||
|
||||
:global(html) {
|
||||
background: var(--color-bg-base);
|
||||
}
|
||||
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
background: var(--color-bg-base);
|
||||
background:
|
||||
radial-gradient(ellipse 900px 500px at 12% -10%, rgba(244, 63, 94, 0.10), transparent 55%),
|
||||
radial-gradient(ellipse 700px 400px at 92% 110%, rgba(251, 146, 60, 0.06), transparent 60%),
|
||||
var(--color-bg-base);
|
||||
background-attachment: fixed;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.5;
|
||||
line-height: 1.55;
|
||||
font-feature-settings: 'cv11', 'ss01', 'ss03';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
min-height: 100vh;
|
||||
/* Avoid iOS rubber-band white flash */
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
:global(*) {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:global(h1, h2, h3) {
|
||||
:global(h1, h2, h3, h4) {
|
||||
color: var(--color-text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:global(h1) {
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
:global(a) {
|
||||
color: var(--color-accent);
|
||||
color: var(--color-text-primary);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
:global(a:hover) {
|
||||
color: var(--color-accent-hover);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
:global(*:focus) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:global(*:focus-visible) {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
:global(::selection) {
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:global(*),
|
||||
:global(*::before),
|
||||
:global(*::after) {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
|
||||
@@ -1,89 +1,701 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { user, sendMagicLink } from '$lib/stores/auth.js';
|
||||
import { user } from '$lib/stores/auth.js';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
|
||||
let email = $state('');
|
||||
let sent = $state(false);
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if ($user) goto('/dashboard');
|
||||
});
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
loading = true;
|
||||
try {
|
||||
await sendMagicLink(email);
|
||||
sent = true;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Something went wrong';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
// TODO: Demo-Token ist statisch. Phase 2: dynamisch via /api/v1/share/demo laden
|
||||
// oder beim Seed in eine Settings-Tabelle schreiben.
|
||||
const DEMO_SHARE_TOKEN = '0b75a672afaa14aa5d97c5af0343f93edd3aa78e5b3ce30d1d695977ac4a3fc1';
|
||||
</script>
|
||||
|
||||
<div class="login-page">
|
||||
<div class="login-card">
|
||||
<h1>Music Hub</h1>
|
||||
<p class="subtitle">Collaboration for music production</p>
|
||||
<svelte:head>
|
||||
<title>Music Hub — Versionen für Musik. Ohne Chaos.</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Music Hub macht Schluss mit dem Versions-Chaos in der Musikproduktion. Jede Version sauber an einem Ort, Feedback per Klick auf die Wellenform, teilen ohne Account."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
{#if sent}
|
||||
<div class="success">
|
||||
<p>Check your email for the login link.</p>
|
||||
<Button variant="secondary" onclick={() => { sent = false; email = ''; }}>Try again</Button>
|
||||
</div>
|
||||
<div class="page">
|
||||
<!-- NAV -->
|
||||
<nav class="nav">
|
||||
<a href="/" class="logo">Music Hub</a>
|
||||
<div class="nav-right">
|
||||
{#if $user}
|
||||
<Button href="/dashboard" size="sm">Zum Dashboard</Button>
|
||||
{:else}
|
||||
<form onsubmit={handleSubmit}>
|
||||
<Input type="email" bind:value={email} placeholder="your@email.com" {error} />
|
||||
<Button type="submit" size="lg" {loading}>Send Login Link</Button>
|
||||
</form>
|
||||
<a href="/login" class="nav-link">Einloggen</a>
|
||||
<Button href="/login" size="sm">Kostenlos starten</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 1. HERO -->
|
||||
<section class="hero">
|
||||
<div class="hero-text">
|
||||
<p class="eyebrow">Für Producer, Artists, Studios</p>
|
||||
<h1>
|
||||
Versionen für Musik.<br />
|
||||
<span class="grad">Ohne Chaos.</span>
|
||||
</h1>
|
||||
<p class="lede">
|
||||
Schluss mit "Final_v3_REAL.wav". Jede Version deines Tracks an einem Ort,
|
||||
Feedback direkt auf der Wellenform, und dein Artist hört rein —
|
||||
ohne Account, ohne Anmeldung, ohne Stress.
|
||||
</p>
|
||||
<div class="hero-cta">
|
||||
<Button href="/login" size="lg">Kostenlos starten</Button>
|
||||
<a href="/listen/{DEMO_SHARE_TOKEN}" target="_blank" rel="noopener" class="cta-secondary">
|
||||
Live-Demo ansehen <span class="arrow">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HERO MOCKUP -->
|
||||
<div class="hero-mockup" aria-hidden="true">
|
||||
<svg viewBox="0 0 480 360" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#f43f5e" />
|
||||
<stop offset="100%" stop-color="#fb923c" />
|
||||
</linearGradient>
|
||||
<linearGradient id="wave-fade" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#f43f5e" stop-opacity="0.8" />
|
||||
<stop offset="100%" stop-color="#fb923c" stop-opacity="0.4" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Card background -->
|
||||
<rect x="20" y="20" width="440" height="320" rx="16" fill="#1a1822" stroke="#24222e" />
|
||||
|
||||
<!-- Header -->
|
||||
<text x="40" y="55" fill="#9b96a8" font-family="Inter Variable, system-ui" font-size="11" font-weight="500">HAUPTMIX · V2</text>
|
||||
<circle cx="430" cy="50" r="5" fill="#22c55e" />
|
||||
<text x="412" y="54" fill="#22c55e" font-family="Inter Variable, system-ui" font-size="10" text-anchor="end">ready</text>
|
||||
|
||||
<!-- Waveform -->
|
||||
<g transform="translate(40, 80)">
|
||||
{#each Array(60) as _, i}
|
||||
{@const h = 8 + Math.abs(Math.sin(i * 0.4)) * 38 + Math.abs(Math.cos(i * 0.7)) * 12}
|
||||
<rect
|
||||
x={i * 6.6}
|
||||
y={(60 - h) / 2}
|
||||
width="3"
|
||||
height={h}
|
||||
rx="1.5"
|
||||
fill={i < 28 ? 'url(#wave-fade)' : '#32303c'}
|
||||
/>
|
||||
{/each}
|
||||
<!-- Comment marker -->
|
||||
<circle cx="155" cy="-4" r="6" fill="url(#grad)" />
|
||||
<line x1="155" y1="2" x2="155" y2="62" stroke="#f43f5e" stroke-width="1.5" stroke-dasharray="2 2" opacity="0.6" />
|
||||
</g>
|
||||
|
||||
<!-- Comment bubble -->
|
||||
<g transform="translate(40, 165)">
|
||||
<rect width="400" height="48" rx="10" fill="#221f2c" stroke="#24222e" />
|
||||
<circle cx="22" cy="24" r="11" fill="url(#grad)" />
|
||||
<text x="22" y="28" text-anchor="middle" fill="#fff" font-size="10" font-family="Inter Variable, system-ui" font-weight="600">A</text>
|
||||
<text x="42" y="20" fill="#f4f0ec" font-family="Inter Variable, system-ui" font-size="11" font-weight="500">Anna</text>
|
||||
<text x="78" y="20" fill="#fb923c" font-family="Inter Variable, system-ui" font-size="9">1:17</text>
|
||||
<text x="42" y="36" fill="#9b96a8" font-family="Inter Variable, system-ui" font-size="10">Vocals etwas weiter nach vorn ziehen?</text>
|
||||
</g>
|
||||
|
||||
<!-- Mini graph -->
|
||||
<g transform="translate(40, 235)">
|
||||
<text x="0" y="-4" fill="#9b96a8" font-family="Inter Variable, system-ui" font-size="10" font-weight="500">VERSIONEN</text>
|
||||
<!-- mainline -->
|
||||
<line x1="20" y1="20" x2="20" y2="80" stroke="#32303c" stroke-width="2" />
|
||||
<!-- branch -->
|
||||
<path d="M 20 50 C 20 60, 80 60, 80 70" stroke="#fb923c" stroke-width="2" fill="none" />
|
||||
<circle cx="20" cy="20" r="9" fill="#1a1822" stroke="#32303c" stroke-width="2" />
|
||||
<text x="20" y="24" text-anchor="middle" fill="#9b96a8" font-size="9" font-family="Inter Variable, system-ui">1</text>
|
||||
<circle cx="20" cy="50" r="9" fill="url(#grad)" />
|
||||
<text x="20" y="54" text-anchor="middle" fill="#fff" font-size="9" font-family="Inter Variable, system-ui" font-weight="600">2</text>
|
||||
<circle cx="20" cy="80" r="9" fill="#1a1822" stroke="#32303c" stroke-width="2" />
|
||||
<text x="20" y="84" text-anchor="middle" fill="#9b96a8" font-size="9" font-family="Inter Variable, system-ui">4</text>
|
||||
<circle cx="80" cy="70" r="9" fill="#1a1822" stroke="#fb923c" stroke-width="2" />
|
||||
<text x="80" y="74" text-anchor="middle" fill="#fb923c" font-size="9" font-family="Inter Variable, system-ui">3</text>
|
||||
<text x="38" y="24" fill="#5e596b" font-family="Inter Variable, system-ui" font-size="10">main · Erster Wurf</text>
|
||||
<text x="38" y="54" fill="#9b96a8" font-family="Inter Variable, system-ui" font-size="10">main · Mehr Bass</text>
|
||||
<text x="98" y="74" fill="#fb923c" font-family="Inter Variable, system-ui" font-size="10">vocals-neu</text>
|
||||
<text x="38" y="84" fill="#5e596b" font-family="Inter Variable, system-ui" font-size="10">main · Final</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 2. PROBLEM -->
|
||||
<section class="problem">
|
||||
<h2>Wenn dir <code>Final_v3_REAL.wav</code> bekannt vorkommt, wissen wir, wovon wir reden.</h2>
|
||||
<p class="lede center">
|
||||
Gemeinsam an einem Track zu arbeiten ist 2026 immer noch eine Mischung aus Dropbox-Links,
|
||||
WhatsApp-Sprachnachrichten und der leisen Hoffnung, dass alle die richtige Datei haben.
|
||||
</p>
|
||||
<div class="problem-grid">
|
||||
<div class="problem-card">
|
||||
<span class="icon">🎙️</span>
|
||||
<h3>Sprachnachrichten statt Comments</h3>
|
||||
<p>Wertvolles Feedback verschwindet im WhatsApp-Verlauf. Keine Historie, kein Kontext, kein Status.</p>
|
||||
</div>
|
||||
<div class="problem-card">
|
||||
<span class="icon">📎</span>
|
||||
<h3>Dropbox-Links pro Version</h3>
|
||||
<p>Niemand weiß welche Version aktuell ist. Niemand weiß welche freigegeben wurde. Niemand traut sich zu fragen.</p>
|
||||
</div>
|
||||
<div class="problem-card">
|
||||
<span class="icon">✅</span>
|
||||
<h3>Approval per Bauchgefühl</h3>
|
||||
<p>Was war jetzt der finale Mix? Wer hat freigegeben? Wann? Niemand weiß es mehr — und der Master ist morgen fällig.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 3. LÖSUNG -->
|
||||
<section class="solution">
|
||||
<p class="eyebrow center">So funktioniert's</p>
|
||||
<h2 class="center">Drei Dinge. <span class="grad">Eine ruhige Produktion.</span></h2>
|
||||
|
||||
<div class="solution-row">
|
||||
<div class="solution-text">
|
||||
<h3>① Jede Version bleibt erhalten</h3>
|
||||
<p>
|
||||
Lade eine neue Version hoch — die alte verschwindet nicht.
|
||||
Probier eine Variante mit anderen Vocals, mehr Bass, neuem Mix:
|
||||
alles bleibt nebeneinander, du springst in einem Klick zwischen ihnen,
|
||||
und du verlierst niemals einen Stand, der dir gefallen hat.
|
||||
</p>
|
||||
</div>
|
||||
<div class="solution-visual">
|
||||
<svg viewBox="0 0 200 140" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<line x1="40" y1="20" x2="40" y2="120" stroke="#32303c" stroke-width="2" />
|
||||
<path d="M 40 60 C 40 75, 120 75, 120 90" stroke="#fb923c" stroke-width="2" fill="none" />
|
||||
<path d="M 120 90 C 120 100, 40 100, 40 110" stroke="#fb923c" stroke-width="2" fill="none" stroke-dasharray="3 3" />
|
||||
<circle cx="40" cy="20" r="10" fill="#1a1822" stroke="#32303c" stroke-width="2" />
|
||||
<circle cx="40" cy="60" r="10" fill="url(#grad)" />
|
||||
<circle cx="120" cy="90" r="10" fill="#1a1822" stroke="#fb923c" stroke-width="2" />
|
||||
<circle cx="40" cy="110" r="10" fill="url(#grad)" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="solution-row reverse">
|
||||
<div class="solution-text">
|
||||
<h3>② Feedback direkt auf der Wellenform</h3>
|
||||
<p>
|
||||
Kein "bei ungefähr 1:30 ist die Snare zu laut" mehr. Klick auf die Stelle in der Welle,
|
||||
schreib was dir auffällt, fertig. Der andere sieht deine Anmerkung an genau dieser Sekunde,
|
||||
klickt drauf, springt hin, hört es selbst.
|
||||
</p>
|
||||
</div>
|
||||
<div class="solution-visual">
|
||||
<svg viewBox="0 0 200 140" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
{#each Array(28) as _, i}
|
||||
{@const h = 6 + Math.abs(Math.sin(i * 0.5)) * 32 + Math.abs(Math.cos(i * 0.8)) * 8}
|
||||
<rect
|
||||
x={10 + i * 6.7}
|
||||
y={(80 - h) / 2 + 30}
|
||||
width="3"
|
||||
height={h}
|
||||
rx="1.5"
|
||||
fill={i < 14 ? 'url(#wave-fade)' : '#32303c'}
|
||||
/>
|
||||
{/each}
|
||||
<circle cx="100" cy="20" r="8" fill="url(#grad)" />
|
||||
<line x1="100" y1="28" x2="100" y2="105" stroke="#f43f5e" stroke-width="1.5" stroke-dasharray="2 2" opacity="0.6" />
|
||||
<text x="100" y="24" text-anchor="middle" fill="#fff" font-size="9" font-family="Inter Variable, system-ui" font-weight="600">!</text>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="solution-row">
|
||||
<div class="solution-text">
|
||||
<h3>③ Teilen ohne Anmeldung</h3>
|
||||
<p>
|
||||
Schick deinem Artist, deinem Label, deiner Mama einen Link.
|
||||
Sie öffnen ihn, hören rein, kommentieren — ganz ohne Account, ohne Passwort,
|
||||
ohne irgendwas zu installieren. Du siehst ihre Anmerkungen direkt in deinem Editor.
|
||||
</p>
|
||||
</div>
|
||||
<div class="solution-visual">
|
||||
<svg viewBox="0 0 200 140" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<rect x="20" y="40" width="160" height="60" rx="10" fill="#1a1822" stroke="#32303c" stroke-width="2" />
|
||||
<text x="35" y="64" fill="#9b96a8" font-family="Inter Variable, system-ui" font-size="10">music-hub.app/listen/</text>
|
||||
<text x="35" y="80" fill="url(#grad)" font-family="JetBrains Mono, monospace" font-size="11" font-weight="600">a7b3...4f9c</text>
|
||||
<g transform="translate(150, 60)">
|
||||
<circle r="14" fill="url(#grad)" />
|
||||
<text y="5" text-anchor="middle" fill="#fff" font-size="14" font-weight="700">↗</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 4. WER ES IST (Two-Sided) -->
|
||||
<section class="two-sided">
|
||||
<h2 class="center">Für beide Seiten gemacht.</h2>
|
||||
<div class="cards">
|
||||
<div class="persona-card">
|
||||
<p class="eyebrow">Für Producer & Tontechniker</p>
|
||||
<h3>Endlich Ordnung im Track-Ordner</h3>
|
||||
<ul>
|
||||
<li>Keine "Final_REAL_v3_master.wav" mehr — jede Version hat ihren Platz</li>
|
||||
<li>Zwei Versionen direkt nebeneinander vergleichen, im Browser</li>
|
||||
<li>Sehe schwarz auf weiß, was freigegeben wurde und von wem</li>
|
||||
<li>Deine Daten gehören dir — auch zum Selber-Hosten</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="persona-card">
|
||||
<p class="eyebrow">Für Artists, Labels & Kunden</p>
|
||||
<h3>Feedback geben, ohne Hürden</h3>
|
||||
<ul>
|
||||
<li>Link öffnen reicht — kein Account, kein Passwort, nichts</li>
|
||||
<li>Klick auf die Welle, schreib was du denkst — fertig</li>
|
||||
<li>Du siehst immer die aktuelle Version, ohne nachzufragen</li>
|
||||
<li>Funktioniert im Bus, im Studio, auf der Couch</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 5. TRUST -->
|
||||
<section class="trust">
|
||||
<p class="trust-line">
|
||||
<strong>Open Source</strong> · <strong>Self-Hostable</strong> · <strong>Daten in der EU</strong>
|
||||
</p>
|
||||
<p class="stack">
|
||||
Gebaut mit SvelteKit · Hono · PostgreSQL · MinIO · FFmpeg
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- 6. FINAL CTA -->
|
||||
<section class="final-cta">
|
||||
<h2>In aktiver Entwicklung.<br /><span class="grad">Sei dabei.</span></h2>
|
||||
<p class="lede center">
|
||||
Music Hub ist im Beta-Stadium. Probier den aktuellen Build aus, gib Feedback,
|
||||
präg die Roadmap mit. Gratis, ohne Verpflichtung.
|
||||
</p>
|
||||
<div class="hero-cta center">
|
||||
<Button href="/login" size="lg">Account anlegen</Button>
|
||||
<!-- TODO: GitHub-Link sobald Repo öffentlich -->
|
||||
<a href="https://git.mydrugismusic.com/robin/music-hub" target="_blank" rel="noopener" class="cta-secondary">
|
||||
Auf Git anschauen <span class="arrow">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 7. FOOTER -->
|
||||
<footer class="footer">
|
||||
<div class="footer-grid">
|
||||
<div class="footer-brand">
|
||||
<p class="logo">Music Hub</p>
|
||||
<p class="footer-tag">Versionen für Musik. Ohne Chaos.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Produkt</h4>
|
||||
<a href="/login">Einloggen</a>
|
||||
<a href="/listen/{DEMO_SHARE_TOKEN}" target="_blank" rel="noopener">Live-Demo</a>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Open Source</h4>
|
||||
<a href="#">Repository</a>
|
||||
<a href="#">Self-Hosting</a>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Rechtliches</h4>
|
||||
<a href="#">Datenschutz</a>
|
||||
<a href="#">Impressum</a>
|
||||
</div>
|
||||
</div>
|
||||
<p class="copy">© 2026 Music Hub</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.login-page {
|
||||
.page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--space-6);
|
||||
}
|
||||
|
||||
/* NAV */
|
||||
.nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-5) 0;
|
||||
}
|
||||
.logo {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
background: var(--gradient-accent);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: var(--color-bg-overlay);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-10);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
text-align: center;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 var(--space-1);
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--color-text-tertiary);
|
||||
margin: 0 0 var(--space-8);
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.nav-link {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav-link:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.success p {
|
||||
color: var(--color-success);
|
||||
margin-bottom: var(--space-4);
|
||||
/* SECTIONS */
|
||||
section {
|
||||
padding: var(--space-16) 0;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--color-text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
margin: 0 0 var(--space-4);
|
||||
}
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
.grad {
|
||||
background: var(--gradient-accent);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: var(--text-3xl);
|
||||
line-height: 1.1;
|
||||
margin: 0 0 var(--space-5);
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.lede {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-lg);
|
||||
max-width: 56ch;
|
||||
line-height: 1.55;
|
||||
margin: 0 0 var(--space-8);
|
||||
}
|
||||
.lede.center {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* HERO */
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 1fr;
|
||||
gap: var(--space-12);
|
||||
align-items: center;
|
||||
padding: var(--space-10) 0 var(--space-16);
|
||||
}
|
||||
.hero h1 {
|
||||
font-size: clamp(2.5rem, 5.5vw, 4rem);
|
||||
line-height: 1.04;
|
||||
letter-spacing: -0.035em;
|
||||
font-weight: 700;
|
||||
margin: 0 0 var(--space-5);
|
||||
}
|
||||
.hero .lede {
|
||||
font-size: var(--text-lg);
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
.hero-cta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.hero-cta.center {
|
||||
justify-content: center;
|
||||
}
|
||||
.cta-secondary {
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: var(--text-sm);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
.cta-secondary:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
.cta-secondary .arrow {
|
||||
display: inline-block;
|
||||
transition: transform var(--transition-base);
|
||||
}
|
||||
.cta-secondary:hover .arrow {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.hero-mockup svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
filter: drop-shadow(0 24px 60px rgba(244, 63, 94, 0.18));
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-8px); }
|
||||
}
|
||||
|
||||
/* PROBLEM */
|
||||
.problem h2 {
|
||||
text-align: center;
|
||||
max-width: 22ch;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
.problem h2 code {
|
||||
background: var(--color-bg-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.1em 0.4em;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85em;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
.problem-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-5);
|
||||
margin-top: var(--space-10);
|
||||
}
|
||||
.problem-card {
|
||||
background: var(--color-bg-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-6);
|
||||
transition: border-color var(--transition-base), transform var(--transition-base);
|
||||
}
|
||||
.problem-card:hover {
|
||||
border-color: var(--color-border-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.problem-card .icon {
|
||||
font-size: 1.8rem;
|
||||
display: block;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.problem-card h3 {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
.problem-card p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* SOLUTION */
|
||||
.solution h2 {
|
||||
margin-bottom: var(--space-12);
|
||||
}
|
||||
.solution-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 1fr;
|
||||
gap: var(--space-12);
|
||||
align-items: center;
|
||||
padding: var(--space-8) 0;
|
||||
}
|
||||
.solution-row.reverse {
|
||||
direction: rtl;
|
||||
}
|
||||
.solution-row.reverse > * {
|
||||
direction: ltr;
|
||||
}
|
||||
.solution-text h3 {
|
||||
font-size: var(--text-xl);
|
||||
margin: 0 0 var(--space-3);
|
||||
}
|
||||
.solution-text p {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
.solution-visual {
|
||||
background: var(--color-bg-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-6);
|
||||
}
|
||||
.solution-visual svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
/* TWO-SIDED */
|
||||
.two-sided h2 {
|
||||
margin-bottom: var(--space-10);
|
||||
}
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.persona-card {
|
||||
background: var(--color-bg-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-8);
|
||||
transition: border-color var(--transition-base);
|
||||
}
|
||||
.persona-card:hover {
|
||||
border-color: var(--color-border-hover);
|
||||
}
|
||||
.persona-card h3 {
|
||||
font-size: var(--text-xl);
|
||||
margin: 0 0 var(--space-5);
|
||||
}
|
||||
.persona-card ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.persona-card li {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
padding-left: 1.4em;
|
||||
position: relative;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.persona-card li::before {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--color-accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* TRUST */
|
||||
.trust {
|
||||
text-align: center;
|
||||
border-top: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: var(--space-10) 0;
|
||||
}
|
||||
.trust-line {
|
||||
margin: 0 0 var(--space-2);
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
.trust-line strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
.stack {
|
||||
margin: 0;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* FINAL CTA */
|
||||
.final-cta {
|
||||
text-align: center;
|
||||
}
|
||||
.final-cta h2 {
|
||||
font-size: clamp(2rem, 4vw, 3rem);
|
||||
}
|
||||
|
||||
/* FOOTER */
|
||||
.footer {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding: var(--space-10) 0 var(--space-8);
|
||||
margin-top: var(--space-12);
|
||||
}
|
||||
.footer-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr 1fr;
|
||||
gap: var(--space-8);
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
.footer-brand .logo {
|
||||
margin: 0 0 var(--space-2);
|
||||
}
|
||||
.footer-tag {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
margin: 0;
|
||||
}
|
||||
.footer h4 {
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
margin: 0 0 var(--space-3);
|
||||
font-weight: 600;
|
||||
}
|
||||
.footer a {
|
||||
display: block;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
text-decoration: none;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.footer a:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.copy {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
padding-top: var(--space-6);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* MOBILE */
|
||||
@media (max-width: 880px) {
|
||||
.hero,
|
||||
.solution-row,
|
||||
.solution-row.reverse,
|
||||
.cards {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
.solution-row.reverse {
|
||||
direction: ltr;
|
||||
}
|
||||
.problem-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.footer-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
section {
|
||||
padding: var(--space-12) 0;
|
||||
}
|
||||
.hero h1 {
|
||||
font-size: clamp(2rem, 8vw, 2.75rem);
|
||||
}
|
||||
h2 {
|
||||
font-size: clamp(1.75rem, 6vw, 2.25rem);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.footer-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.hero-cta {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.hero-cta.center {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,14 +9,14 @@
|
||||
onMount(async () => {
|
||||
const token = $page.url.searchParams.get('token');
|
||||
if (!token) {
|
||||
error = 'No token provided';
|
||||
error = 'Kein Token angegeben';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await verifyToken(token);
|
||||
goto('/dashboard');
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Verification failed';
|
||||
error = err instanceof Error ? err.message : 'Login fehlgeschlagen';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -24,12 +24,12 @@
|
||||
<div class="verify-page">
|
||||
{#if error}
|
||||
<div class="error-card">
|
||||
<h2>Login Failed</h2>
|
||||
<h2>Login fehlgeschlagen</h2>
|
||||
<p>{error}</p>
|
||||
<a href="/">Try again</a>
|
||||
<a href="/login">Erneut versuchen</a>
|
||||
</div>
|
||||
{:else}
|
||||
<p>Verifying...</p>
|
||||
<p>Login läuft…</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { user, logout } from '$lib/stores/auth.js';
|
||||
import { api } from '$lib/api/client.js';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import Avatar from '$lib/components/ui/Avatar.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
|
||||
type ProjectMembership = {
|
||||
project: { id: string; name: string; description?: string; createdAt: string };
|
||||
role: string;
|
||||
};
|
||||
|
||||
let projects = $state<ProjectMembership[]>([]);
|
||||
let loading = $state(true);
|
||||
|
||||
$effect(() => {
|
||||
if (!$user) goto('/');
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const res = await api.get<{ projects: ProjectMembership[] }>('/projects');
|
||||
projects = res.projects;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleLogout() {
|
||||
await logout();
|
||||
goto('/');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="dashboard">
|
||||
<header>
|
||||
<h1>Music Hub</h1>
|
||||
<div class="header-right">
|
||||
{#if $user}
|
||||
<Avatar name={$user.name} src={$user.avatarUrl ?? null} size="sm" />
|
||||
<span class="user-name">{$user.name}</span>
|
||||
{/if}
|
||||
<Button variant="ghost" size="sm" onclick={handleLogout}>Logout</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="section-header">
|
||||
<h2>Projects</h2>
|
||||
<Button href="/projects/new">New Project</Button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="project-grid">
|
||||
{#each [1, 2, 3] as _}
|
||||
<div class="project-card skeleton-card">
|
||||
<Skeleton width="60%" height="1.2rem" />
|
||||
<Skeleton width="80%" height="0.9rem" />
|
||||
<Skeleton width="5rem" height="1.2rem" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if projects.length === 0}
|
||||
<EmptyState
|
||||
icon="🎵"
|
||||
title="No projects yet"
|
||||
description="Create your first project to start collaborating."
|
||||
>
|
||||
{#snippet action()}
|
||||
<Button href="/projects/new">Create Project</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else}
|
||||
<div class="project-grid">
|
||||
{#each projects as { project, role }}
|
||||
<a href="/projects/{project.id}" class="project-card">
|
||||
<h3>{project.name}</h3>
|
||||
{#if project.description}
|
||||
<p class="description">{project.description}</p>
|
||||
{/if}
|
||||
<Badge>{role.replaceAll('_', ' ')}</Badge>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dashboard {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-4) 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: var(--text-xl);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.user-name {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.project-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.project-card {
|
||||
background: var(--color-bg-overlay);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-6);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
border: 1px solid var(--color-border);
|
||||
transition: border-color var(--transition-base), box-shadow var(--transition-base);
|
||||
}
|
||||
|
||||
.project-card:hover {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.project-card h3 {
|
||||
margin: 0 0 var(--space-2);
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0 0 var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.project-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
112
apps/web/src/routes/login/+page.svelte
Normal file
112
apps/web/src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,112 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { user, sendMagicLink } from '$lib/stores/auth.js';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
|
||||
let email = $state('');
|
||||
let sent = $state(false);
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if ($user) goto('/dashboard');
|
||||
});
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
loading = true;
|
||||
try {
|
||||
await sendMagicLink(email);
|
||||
sent = true;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Etwas ist schiefgelaufen';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="login-page">
|
||||
<a href="/" class="back">← Zurück</a>
|
||||
|
||||
<div class="login-card">
|
||||
<p class="brand">Music Hub</p>
|
||||
<h1>Einloggen</h1>
|
||||
<p class="card-sub">Magic Link per E-Mail. Kein Passwort, keine Hürden.</p>
|
||||
|
||||
{#if sent}
|
||||
<div class="success">
|
||||
<p>📬 Check deine E-Mails — der Link ist unterwegs.</p>
|
||||
<Button variant="secondary" onclick={() => { sent = false; email = ''; }}>Andere Adresse</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<form onsubmit={handleSubmit}>
|
||||
<Input type="email" bind:value={email} placeholder="deine@email.de" {error} />
|
||||
<Button type="submit" size="lg" {loading}>Login-Link senden</Button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.login-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: var(--space-8) var(--space-4);
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.back {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
text-decoration: none;
|
||||
}
|
||||
.back:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: var(--color-bg-overlay);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-10);
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: 0 20px 60px rgba(244, 63, 94, 0.08);
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
color: var(--color-text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2em;
|
||||
font-size: var(--text-xs);
|
||||
margin: 0 0 var(--space-2);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 var(--space-1);
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
|
||||
.card-sub {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
margin: 0 0 var(--space-6);
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.success p {
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
</style>
|
||||
@@ -1,218 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { api } from '$lib/api/client.js';
|
||||
import { toastSuccess } from '$lib/stores/toast.js';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
|
||||
type Track = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type Project = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
let project = $state<Project | null>(null);
|
||||
let role = $state('');
|
||||
let tracks = $state<Track[]>([]);
|
||||
let newTrackName = $state('');
|
||||
let showNewTrack = $state(false);
|
||||
let loading = $state(true);
|
||||
let creating = $state(false);
|
||||
|
||||
const projectId = $page.params.projectId;
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const [projectRes, trackRes] = await Promise.all([
|
||||
api.get<{ project: Project; role: string }>(`/projects/${projectId}`),
|
||||
api.get<{ tracks: Track[] }>(`/tracks/project/${projectId}`),
|
||||
]);
|
||||
project = projectRes.project;
|
||||
role = projectRes.role;
|
||||
tracks = trackRes.tracks;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function createTrack() {
|
||||
if (!newTrackName.trim()) return;
|
||||
creating = true;
|
||||
try {
|
||||
const res = await api.post<{ track: Track }>(`/tracks/${projectId}`, {
|
||||
name: newTrackName,
|
||||
});
|
||||
tracks = [...tracks, res.track];
|
||||
newTrackName = '';
|
||||
showNewTrack = false;
|
||||
toastSuccess('Track created');
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
const canUpload = $derived(role === 'owner' || role.includes('engineer'));
|
||||
</script>
|
||||
|
||||
<div class="project-page">
|
||||
<header>
|
||||
<a href="/dashboard" class="back">← Projects</a>
|
||||
{#if loading}
|
||||
<Skeleton width="200px" height="2rem" />
|
||||
{:else if project}
|
||||
<div class="project-header">
|
||||
<h1>{project.name}</h1>
|
||||
{#if role === 'owner' || role === 'management'}
|
||||
<Button variant="ghost" size="sm" href="/projects/{projectId}/settings">Settings</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if project.description}
|
||||
<p class="description">{project.description}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<div class="section-header">
|
||||
<h2>Tracks</h2>
|
||||
{#if canUpload}
|
||||
<Button variant="secondary" onclick={() => showNewTrack = !showNewTrack}>
|
||||
{showNewTrack ? 'Cancel' : 'New Track'}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showNewTrack}
|
||||
<form class="new-track-form" onsubmit={(e) => { e.preventDefault(); createTrack(); }}>
|
||||
<Input bind:value={newTrackName} placeholder="Track name" autofocus />
|
||||
<Button type="submit" loading={creating}>Create</Button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="track-list">
|
||||
{#each [1, 2] as _}
|
||||
<div class="track-item-skeleton">
|
||||
<Skeleton width="40%" height="1rem" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if tracks.length === 0}
|
||||
<EmptyState
|
||||
icon="🎶"
|
||||
title="No tracks yet"
|
||||
description="Create a track and upload your first audio file."
|
||||
/>
|
||||
{:else}
|
||||
<div class="track-list">
|
||||
{#each tracks as track}
|
||||
<a href="/projects/{projectId}/tracks/{track.id}" class="track-item">
|
||||
<span class="track-name">{track.name}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.project-page {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.back {
|
||||
color: var(--color-text-tertiary);
|
||||
text-decoration: none;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.back:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.project-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--color-text-secondary);
|
||||
margin: var(--space-1) 0 0;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.new-track-form {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-6);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.new-track-form :global(.input-group) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.track-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.track-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
background: var(--color-bg-overlay);
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
border: 1px solid var(--color-border);
|
||||
transition: border-color var(--transition-base);
|
||||
}
|
||||
|
||||
.track-item:hover {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.track-name {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.track-item-skeleton {
|
||||
padding: var(--space-4) var(--space-5);
|
||||
background: var(--color-bg-overlay);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
</style>
|
||||
@@ -1,436 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { api } from '$lib/api/client.js';
|
||||
import { toastSuccess } from '$lib/stores/toast.js';
|
||||
import WaveformPlayer from '$lib/components/audio/WaveformPlayer.svelte';
|
||||
import UploadDropzone from '$lib/components/audio/UploadDropzone.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import ABCompare from '$lib/components/audio/ABCompare.svelte';
|
||||
import VersionInfo from './components/VersionInfo.svelte';
|
||||
import VersionHistory from './components/VersionHistory.svelte';
|
||||
import VersionGraph from './components/VersionGraph.svelte';
|
||||
import ShareModal from './components/ShareModal.svelte';
|
||||
import CommentSection from './components/CommentSection.svelte';
|
||||
|
||||
type Version = {
|
||||
id: string;
|
||||
versionNumber: number;
|
||||
label: string | null;
|
||||
notes: string | null;
|
||||
status: string;
|
||||
originalFileName: string;
|
||||
duration: number | null;
|
||||
createdAt: string;
|
||||
parentVersionId?: string | null;
|
||||
branchLabel?: string | null;
|
||||
};
|
||||
|
||||
type GraphNode = {
|
||||
id: string;
|
||||
parentVersionId: string | null;
|
||||
branchLabel: string | null;
|
||||
versionNumber: number;
|
||||
label: string | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type Comment = {
|
||||
id: string;
|
||||
body: string;
|
||||
timestampSeconds: number | null;
|
||||
parentId: string | null;
|
||||
resolvedAt: string | null;
|
||||
createdAt: string;
|
||||
guestName?: string | null;
|
||||
user: { id: string; name: string; avatarUrl: string | null } | null;
|
||||
};
|
||||
|
||||
const projectId = $page.params.projectId!;
|
||||
const trackId = $page.params.trackId!;
|
||||
|
||||
let trackName = $state('');
|
||||
let versions = $state<Version[]>([]);
|
||||
let selectedVersion = $state<Version | null>(null);
|
||||
let streamUrl = $state('');
|
||||
let comments = $state<Comment[]>([]);
|
||||
let showUpload = $state(false);
|
||||
let role = $state('');
|
||||
let loading = $state(true);
|
||||
let commentTimestamp = $state<number | null>(null);
|
||||
let playerRef = $state<WaveformPlayer>();
|
||||
let compareVersion = $state<Version | null>(null);
|
||||
let compareStreamUrl = $state('');
|
||||
let graphNodes = $state<GraphNode[]>([]);
|
||||
let viewMode = $state<'list' | 'graph'>('list');
|
||||
let branchFromId = $state<string | null>(null);
|
||||
let branchLabelInput = $state('');
|
||||
let shareOpen = $state(false);
|
||||
|
||||
const canUpload = $derived(role === 'owner' || role.includes('engineer'));
|
||||
const canApprove = $derived(['owner', 'artist', 'label', 'management'].includes(role));
|
||||
const canComment = $derived(role !== 'viewer');
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const [projectRes, trackVersions, tracksRes, treeRes] = await Promise.all([
|
||||
api.get<{ project: any; role: string }>(`/projects/${projectId}`),
|
||||
api.get<{ versions: Version[] }>(`/versions/track/${trackId}`),
|
||||
api.get<{ tracks: { id: string; name: string }[] }>(`/tracks/project/${projectId}`),
|
||||
api.get<{ nodes: GraphNode[] }>(`/versions/track/${trackId}/tree`),
|
||||
]);
|
||||
|
||||
role = projectRes.role;
|
||||
trackName = tracksRes.tracks.find((t) => t.id === trackId)?.name || '';
|
||||
versions = trackVersions.versions;
|
||||
graphNodes = treeRes.nodes;
|
||||
|
||||
if (versions.length > 0) await selectVersion(versions[0]);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function selectVersion(version: Version) {
|
||||
selectedVersion = version;
|
||||
const [streamRes, commentRes] = await Promise.all([
|
||||
api.get<{ url: string }>(`/versions/${version.id}/stream-url`),
|
||||
api.get<{ comments: Comment[] }>(`/comments/version/${version.id}`),
|
||||
]);
|
||||
streamUrl = streamRes.url;
|
||||
comments = commentRes.comments;
|
||||
}
|
||||
|
||||
async function loadVersions() {
|
||||
const [res, treeRes] = await Promise.all([
|
||||
api.get<{ versions: Version[] }>(`/versions/track/${trackId}`),
|
||||
api.get<{ nodes: GraphNode[] }>(`/versions/track/${trackId}/tree`),
|
||||
]);
|
||||
versions = res.versions;
|
||||
graphNodes = treeRes.nodes;
|
||||
if (versions.length > 0) await selectVersion(versions[0]);
|
||||
}
|
||||
|
||||
async function handlePromote() {
|
||||
if (!selectedVersion) return;
|
||||
await api.post(`/versions/${selectedVersion.id}/promote`);
|
||||
toastSuccess('Version übernommen');
|
||||
await loadVersions();
|
||||
}
|
||||
|
||||
function startBranch(id: string) {
|
||||
branchFromId = id;
|
||||
branchLabelInput = '';
|
||||
showUpload = true;
|
||||
}
|
||||
|
||||
async function handleApprove() {
|
||||
if (!selectedVersion) return;
|
||||
await api.post(`/versions/${selectedVersion.id}/approve`);
|
||||
toastSuccess('Version approved');
|
||||
await loadVersions();
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
if (!selectedVersion) return;
|
||||
await api.post(`/versions/${selectedVersion.id}/reject`);
|
||||
toastSuccess('Version rejected');
|
||||
await loadVersions();
|
||||
}
|
||||
|
||||
async function handleComment(body: string, timestamp: number | null, parentId?: string) {
|
||||
if (!selectedVersion) return;
|
||||
await api.post(`/comments/version/${selectedVersion.id}`, {
|
||||
body,
|
||||
timestampSeconds: timestamp ?? undefined,
|
||||
parentId,
|
||||
});
|
||||
const res = await api.get<{ comments: Comment[] }>(`/comments/version/${selectedVersion.id}`);
|
||||
comments = res.comments;
|
||||
toastSuccess('Comment added');
|
||||
}
|
||||
|
||||
async function handleResolve(commentId: string) {
|
||||
await api.post(`/comments/${commentId}/resolve`);
|
||||
if (selectedVersion) {
|
||||
const res = await api.get<{ comments: Comment[] }>(`/comments/version/${selectedVersion.id}`);
|
||||
comments = res.comments;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
if (!selectedVersion) return;
|
||||
api.get<{ url: string }>(`/versions/${selectedVersion.id}/download-url`).then((res) => {
|
||||
window.open(res.url, '_blank');
|
||||
});
|
||||
}
|
||||
|
||||
async function startCompare(version: Version) {
|
||||
const res = await api.get<{ url: string }>(`/versions/${version.id}/stream-url`);
|
||||
compareVersion = version;
|
||||
compareStreamUrl = res.url;
|
||||
}
|
||||
|
||||
function closeCompare() {
|
||||
compareVersion = null;
|
||||
compareStreamUrl = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="track-page">
|
||||
<header>
|
||||
<a href="/projects/{projectId}" class="back">← Back to project</a>
|
||||
{#if loading}
|
||||
<Skeleton width="200px" height="2rem" />
|
||||
{:else}
|
||||
<h1>{trackName}</h1>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<!-- Player -->
|
||||
{#if selectedVersion && streamUrl}
|
||||
{#key streamUrl}
|
||||
<WaveformPlayer
|
||||
bind:this={playerRef}
|
||||
url={streamUrl}
|
||||
markers={comments
|
||||
.filter((c) => c.timestampSeconds !== null)
|
||||
.map((c) => ({
|
||||
id: c.id,
|
||||
timestampSeconds: c.timestampSeconds!,
|
||||
body: c.body,
|
||||
userName: c.user?.name ?? c.guestName ?? 'Gast',
|
||||
}))}
|
||||
onTimeClick={(time) => commentTimestamp = Math.round(time * 10) / 10}
|
||||
/>
|
||||
{/key}
|
||||
|
||||
<VersionInfo
|
||||
version={selectedVersion}
|
||||
{canApprove}
|
||||
onApprove={handleApprove}
|
||||
onReject={handleReject}
|
||||
/>
|
||||
|
||||
<div class="track-actions">
|
||||
{#if canUpload}
|
||||
<Button variant="secondary" size="sm" onclick={() => { branchFromId = null; branchLabelInput = ''; showUpload = !showUpload; }}>
|
||||
{showUpload ? 'Cancel' : 'Upload new version'}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button variant="ghost" size="sm" onclick={handleDownload}>
|
||||
↓ Download
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onclick={() => (shareOpen = true)}>
|
||||
↗ Share
|
||||
</Button>
|
||||
{#if canApprove && selectedVersion.branchLabel}
|
||||
<Button variant="ghost" size="sm" onclick={handlePromote}>
|
||||
⤴ Übernehmen (Mainline)
|
||||
</Button>
|
||||
{/if}
|
||||
{#if versions.length > 1}
|
||||
<select
|
||||
class="compare-select"
|
||||
onchange={(e) => {
|
||||
const id = (e.target as HTMLSelectElement).value;
|
||||
if (id) {
|
||||
const v = versions.find((v) => v.id === id);
|
||||
if (v) startCompare(v);
|
||||
}
|
||||
(e.target as HTMLSelectElement).value = '';
|
||||
}}
|
||||
>
|
||||
<option value="">Compare with...</option>
|
||||
{#each versions.filter((v) => v.id !== selectedVersion?.id) as v}
|
||||
<option value={v.id}>V{v.versionNumber}{v.label ? ` — ${v.label}` : ''}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- A/B Compare -->
|
||||
{#if compareVersion && compareStreamUrl && selectedVersion && streamUrl}
|
||||
<ABCompare
|
||||
versionA={selectedVersion}
|
||||
versionB={compareVersion}
|
||||
streamUrlA={streamUrl}
|
||||
streamUrlB={compareStreamUrl}
|
||||
onClose={closeCompare}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showUpload}
|
||||
{#if branchFromId}
|
||||
<div class="branch-banner">
|
||||
<span>Neue Variante von <strong>V{graphNodes.find((n) => n.id === branchFromId)?.versionNumber}</strong></span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={branchLabelInput}
|
||||
placeholder="Branch-Name (z.B. 'vocals-neu')"
|
||||
/>
|
||||
<button class="cancel-branch" onclick={() => (branchFromId = null)}>×</button>
|
||||
</div>
|
||||
{/if}
|
||||
<UploadDropzone
|
||||
{trackId}
|
||||
parentVersionId={branchFromId}
|
||||
branchLabel={branchFromId ? branchLabelInput || 'branch' : null}
|
||||
onUploaded={() => { showUpload = false; branchFromId = null; loadVersions(); toastSuccess('Version uploaded'); }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<Skeleton height="80px" variant="rect" />
|
||||
{:else if versions.length === 0}
|
||||
<EmptyState
|
||||
icon="🎵"
|
||||
title="No versions yet"
|
||||
description="Upload your first audio file to get started."
|
||||
>
|
||||
{#snippet action()}
|
||||
{#if canUpload}
|
||||
<Button onclick={() => showUpload = true}>Upload Audio</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{/if}
|
||||
|
||||
{#if selectedVersion}
|
||||
<CommentSection
|
||||
{comments}
|
||||
{canComment}
|
||||
bind:commentTimestamp
|
||||
onSubmit={handleComment}
|
||||
onResolve={handleResolve}
|
||||
onSeek={(time) => playerRef?.seekToTime(time)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if versions.length > 1}
|
||||
<div class="view-toggle">
|
||||
<button class:active={viewMode === 'list'} onclick={() => (viewMode = 'list')}>Liste</button>
|
||||
<button class:active={viewMode === 'graph'} onclick={() => (viewMode = 'graph')}>Graph</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if viewMode === 'graph'}
|
||||
<VersionGraph
|
||||
nodes={graphNodes}
|
||||
selectedId={selectedVersion?.id ?? null}
|
||||
onSelect={(id) => {
|
||||
const v = versions.find((v) => v.id === id);
|
||||
if (v) selectVersion(v);
|
||||
}}
|
||||
onBranch={canUpload ? startBranch : undefined}
|
||||
/>
|
||||
{:else}
|
||||
<VersionHistory
|
||||
{versions}
|
||||
selectedId={selectedVersion?.id ?? null}
|
||||
onSelect={selectVersion}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if selectedVersion}
|
||||
<ShareModal bind:open={shareOpen} versionId={selectedVersion.id} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.track-page {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.back {
|
||||
color: var(--color-text-tertiary);
|
||||
text-decoration: none;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.back:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: var(--space-2) 0 0;
|
||||
}
|
||||
|
||||
.track-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.view-toggle button {
|
||||
background: var(--color-bg-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-xs);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.view-toggle button.active {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
.branch-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--color-accent-subtle);
|
||||
border: 1px solid var(--color-accent);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.branch-banner input {
|
||||
flex: 1;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border-hover);
|
||||
background: var(--color-bg-base);
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--text-sm);
|
||||
font-family: inherit;
|
||||
}
|
||||
.cancel-branch {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-tertiary);
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.compare-select {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border-hover);
|
||||
background: var(--color-bg-base);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -1,127 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import { api } from '$lib/api/client.js';
|
||||
import { toastSuccess } from '$lib/stores/toast.js';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
versionId,
|
||||
}: {
|
||||
open: boolean;
|
||||
versionId: string;
|
||||
} = $props();
|
||||
|
||||
let allowComments = $state(true);
|
||||
let allowDownload = $state(false);
|
||||
let password = $state('');
|
||||
let creating = $state(false);
|
||||
let createdUrl = $state('');
|
||||
|
||||
async function create() {
|
||||
creating = true;
|
||||
try {
|
||||
const res = await api.post<{ link: { token: string } }>(
|
||||
`/share/version/${versionId}`,
|
||||
{
|
||||
allowComments,
|
||||
allowDownload,
|
||||
password: password || undefined,
|
||||
},
|
||||
);
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
createdUrl = `${origin}/listen/${res.link.token}`;
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copy() {
|
||||
await navigator.clipboard.writeText(createdUrl);
|
||||
toastSuccess('Link kopiert');
|
||||
}
|
||||
|
||||
function reset() {
|
||||
createdUrl = '';
|
||||
password = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:open title="Link teilen">
|
||||
{#if !createdUrl}
|
||||
<div class="form">
|
||||
<label class="row">
|
||||
<input type="checkbox" bind:checked={allowComments} />
|
||||
<span>Kommentare erlauben (auch ohne Account)</span>
|
||||
</label>
|
||||
<label class="row">
|
||||
<input type="checkbox" bind:checked={allowDownload} />
|
||||
<span>Download des Originals erlauben</span>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Passwort (optional)</span>
|
||||
<input type="text" bind:value={password} placeholder="leer = kein Passwort" />
|
||||
</label>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="result">
|
||||
<p>Link erstellt:</p>
|
||||
<input type="text" readonly value={createdUrl} onclick={(e) => (e.target as HTMLInputElement).select()} />
|
||||
<Button size="sm" onclick={copy}>Kopieren</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#snippet actions()}
|
||||
{#if !createdUrl}
|
||||
<Button variant="ghost" onclick={() => (open = false)}>Abbrechen</Button>
|
||||
<Button loading={creating} onclick={create}>Link erzeugen</Button>
|
||||
{:else}
|
||||
<Button variant="ghost" onclick={reset}>Weiteren erzeugen</Button>
|
||||
<Button onclick={() => { open = false; reset(); }}>Fertig</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.field input,
|
||||
.result input {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border-hover);
|
||||
background: var(--color-bg-base);
|
||||
color: var(--color-text-primary);
|
||||
font-family: inherit;
|
||||
font-size: var(--text-sm);
|
||||
width: 100%;
|
||||
}
|
||||
.result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.result p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
</style>
|
||||
51
apps/web/src/service-worker.ts
Normal file
51
apps/web/src/service-worker.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/// <reference types="@sveltejs/kit" />
|
||||
/// <reference no-default-lib="true"/>
|
||||
/// <reference lib="esnext" />
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
// Minimal service worker — primarily exists so the PWA install prompt
|
||||
// is offered on iOS and Android. We cache the app shell + static assets
|
||||
// for offline-aware behaviour, but never cache API responses.
|
||||
|
||||
import { build, files, version } from '$service-worker';
|
||||
|
||||
const sw = self as unknown as ServiceWorkerGlobalScope;
|
||||
const CACHE = `musichub-${version}`;
|
||||
const ASSETS = [...build, ...files];
|
||||
|
||||
sw.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE).then((cache) => cache.addAll(ASSETS)).then(() => sw.skipWaiting()),
|
||||
);
|
||||
});
|
||||
|
||||
sw.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
|
||||
.then(() => sw.clients.claim()),
|
||||
);
|
||||
});
|
||||
|
||||
sw.addEventListener('fetch', (event) => {
|
||||
const req = event.request;
|
||||
if (req.method !== 'GET') return;
|
||||
|
||||
const url = new URL(req.url);
|
||||
|
||||
// Don't intercept API or S3 traffic
|
||||
if (url.pathname.startsWith('/api/') || url.hostname !== sw.location.hostname) return;
|
||||
|
||||
// Cache-first for built assets, network-first for everything else
|
||||
if (ASSETS.includes(url.pathname)) {
|
||||
event.respondWith(
|
||||
caches.match(req).then((cached) => cached ?? fetch(req)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith(
|
||||
fetch(req).catch(() => caches.match(req).then((c) => c ?? new Response('', { status: 504 }))),
|
||||
);
|
||||
});
|
||||
BIN
apps/web/static/apple-touch-icon.png
Normal file
BIN
apps/web/static/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
BIN
apps/web/static/favicon-16.png
Normal file
BIN
apps/web/static/favicon-16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 447 B |
BIN
apps/web/static/favicon-32.png
Normal file
BIN
apps/web/static/favicon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 958 B |
BIN
apps/web/static/icon-192.png
Normal file
BIN
apps/web/static/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
apps/web/static/icon-512.png
Normal file
BIN
apps/web/static/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.6 KiB |
33
apps/web/static/manifest.webmanifest
Normal file
33
apps/web/static/manifest.webmanifest
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "Music Hub",
|
||||
"short_name": "Music Hub",
|
||||
"description": "Versionen für Musik. Ohne Chaos.",
|
||||
"start_url": "/dashboard",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#0a0910",
|
||||
"theme_color": "#f43f5e",
|
||||
"lang": "de",
|
||||
"categories": ["music", "productivity"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import adapter from '@sveltejs/adapter-auto';
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
import { relative, sep } from 'node:path';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
@@ -17,7 +17,7 @@ const config = {
|
||||
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
||||
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
||||
adapter: adapter()
|
||||
adapter: adapter({ out: 'build' })
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
36
bun.lock
36
bun.lock
@@ -28,7 +28,9 @@
|
||||
"name": "@music-hub/web",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@music-hub/shared": "workspace:*",
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"wavesurfer.js": "^7.12.5",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -204,6 +206,8 @@
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||
|
||||
"@fontsource-variable/inter": ["@fontsource-variable/inter@5.2.8", "", {}, "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ=="],
|
||||
|
||||
"@hono/zod-validator": ["@hono/zod-validator@0.5.0", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-ds5bW6DCgAnNHP33E3ieSbaZFd5dkV52ZjyaXtGoR06APFrCtzAsKZxTHwOrJNBdXsi0e5wNwo5L4nVEVnJUdg=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
@@ -226,6 +230,14 @@
|
||||
|
||||
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
|
||||
|
||||
"@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@29.0.2", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-S/ggWH1LU7jTyi9DxZOKyxpVd4hF/OZ0JrEbeLjXk/DFXwRny0tjD2c992zOUYQobLrVkRVMDdmHP16HKP7GRg=="],
|
||||
|
||||
"@rollup/plugin-json": ["@rollup/plugin-json@6.1.0", "", { "dependencies": { "@rollup/pluginutils": "^5.1.0" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA=="],
|
||||
|
||||
"@rollup/plugin-node-resolve": ["@rollup/plugin-node-resolve@16.0.3", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", "is-module": "^1.0.0", "resolve": "^1.22.1" }, "peerDependencies": { "rollup": "^2.78.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg=="],
|
||||
|
||||
"@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.1", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="],
|
||||
@@ -384,6 +396,8 @@
|
||||
|
||||
"@sveltejs/adapter-auto": ["@sveltejs/adapter-auto@7.0.1", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ=="],
|
||||
|
||||
"@sveltejs/adapter-node": ["@sveltejs/adapter-node@5.5.4", "", { "dependencies": { "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", "rollup": "^4.59.0" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0" } }, "sha512-45X92CXW+2J8ZUzPv3eLlKWEzINKiiGeFWTjyER4ZN4sGgNoaoeSkCY/QYNxHpPXy71QPsctwccBo9jJs0ySPQ=="],
|
||||
|
||||
"@sveltejs/kit": ["@sveltejs/kit@2.55.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.6.4", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA=="],
|
||||
|
||||
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.4", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA=="],
|
||||
@@ -410,6 +424,8 @@
|
||||
|
||||
"@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
||||
|
||||
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
|
||||
|
||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||
|
||||
"@typescript-eslint/types": ["@typescript-eslint/types@8.58.0", "", {}, "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww=="],
|
||||
@@ -430,6 +446,8 @@
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="],
|
||||
|
||||
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
|
||||
|
||||
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
||||
@@ -446,6 +464,8 @@
|
||||
|
||||
"esrap": ["esrap@2.2.4", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15", "@typescript-eslint/types": "^8.2.0" } }, "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg=="],
|
||||
|
||||
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="],
|
||||
|
||||
"fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="],
|
||||
@@ -456,10 +476,18 @@
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"get-tsconfig": ["get-tsconfig@4.13.7", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q=="],
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
|
||||
|
||||
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
|
||||
|
||||
"is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="],
|
||||
|
||||
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
|
||||
|
||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
@@ -478,6 +506,8 @@
|
||||
|
||||
"path-expression-matcher": ["path-expression-matcher@1.2.0", "", {}, "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ=="],
|
||||
|
||||
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
@@ -492,6 +522,8 @@
|
||||
|
||||
"resend": ["resend@6.10.0", "", { "dependencies": { "postal-mime": "2.7.4", "svix": "1.88.0" }, "peerDependencies": { "@react-email/render": "*" }, "optionalPeers": ["@react-email/render"] }, "sha512-i7CwZpYj4Oho1RxsTpLcCUkO08+HiL4NXrm6jLJ2WzJ89UGI8eROSieLONJA3hnUrf1OYnCyfq5F6POnHUMv1Q=="],
|
||||
|
||||
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="],
|
||||
@@ -512,6 +544,8 @@
|
||||
|
||||
"strnum": ["strnum@2.2.2", "", {}, "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA=="],
|
||||
|
||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
|
||||
"svelte": ["svelte@5.55.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.4", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-QjvU7EFemf6mRzdMGlAFttMWtAAVXrax61SZYHdkD6yoVGQ89VeyKfZD4H1JrV1WLmJBxWhFch9H6ig/87VGjw=="],
|
||||
|
||||
"svelte-check": ["svelte-check@4.4.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-kP1zG81EWaFe9ZyTv4ZXv44Csi6Pkdpb7S3oj6m+K2ec/IcDg/a8LsFsnVLqm2nxtkSwsd5xPj/qFkTBgXHXjg=="],
|
||||
@@ -552,6 +586,8 @@
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
||||
|
||||
"@rollup/plugin-commonjs/is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="],
|
||||
|
||||
"tsx/esbuild": ["esbuild@0.27.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.5", "@esbuild/android-arm": "0.27.5", "@esbuild/android-arm64": "0.27.5", "@esbuild/android-x64": "0.27.5", "@esbuild/darwin-arm64": "0.27.5", "@esbuild/darwin-x64": "0.27.5", "@esbuild/freebsd-arm64": "0.27.5", "@esbuild/freebsd-x64": "0.27.5", "@esbuild/linux-arm": "0.27.5", "@esbuild/linux-arm64": "0.27.5", "@esbuild/linux-ia32": "0.27.5", "@esbuild/linux-loong64": "0.27.5", "@esbuild/linux-mips64el": "0.27.5", "@esbuild/linux-ppc64": "0.27.5", "@esbuild/linux-riscv64": "0.27.5", "@esbuild/linux-s390x": "0.27.5", "@esbuild/linux-x64": "0.27.5", "@esbuild/netbsd-arm64": "0.27.5", "@esbuild/netbsd-x64": "0.27.5", "@esbuild/openbsd-arm64": "0.27.5", "@esbuild/openbsd-x64": "0.27.5", "@esbuild/openharmony-arm64": "0.27.5", "@esbuild/sunos-x64": "0.27.5", "@esbuild/win32-arm64": "0.27.5", "@esbuild/win32-ia32": "0.27.5", "@esbuild/win32-x64": "0.27.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-zdQoHBjuDqKsvV5OPaWansOwfSQ0Js+Uj9J85TBvj3bFW1JjWTSULMRwdQAc8qMeIScbClxeMK0jlrtB9linhA=="],
|
||||
|
||||
"vite/esbuild": ["esbuild@0.27.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.5", "@esbuild/android-arm": "0.27.5", "@esbuild/android-arm64": "0.27.5", "@esbuild/android-x64": "0.27.5", "@esbuild/darwin-arm64": "0.27.5", "@esbuild/darwin-x64": "0.27.5", "@esbuild/freebsd-arm64": "0.27.5", "@esbuild/freebsd-x64": "0.27.5", "@esbuild/linux-arm": "0.27.5", "@esbuild/linux-arm64": "0.27.5", "@esbuild/linux-ia32": "0.27.5", "@esbuild/linux-loong64": "0.27.5", "@esbuild/linux-mips64el": "0.27.5", "@esbuild/linux-ppc64": "0.27.5", "@esbuild/linux-riscv64": "0.27.5", "@esbuild/linux-s390x": "0.27.5", "@esbuild/linux-x64": "0.27.5", "@esbuild/netbsd-arm64": "0.27.5", "@esbuild/netbsd-x64": "0.27.5", "@esbuild/openbsd-arm64": "0.27.5", "@esbuild/openbsd-x64": "0.27.5", "@esbuild/openharmony-arm64": "0.27.5", "@esbuild/sunos-x64": "0.27.5", "@esbuild/win32-arm64": "0.27.5", "@esbuild/win32-ia32": "0.27.5", "@esbuild/win32-x64": "0.27.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-zdQoHBjuDqKsvV5OPaWansOwfSQ0Js+Uj9J85TBvj3bFW1JjWTSULMRwdQAc8qMeIScbClxeMK0jlrtB9linhA=="],
|
||||
|
||||
@@ -1,35 +1,48 @@
|
||||
services:
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.api
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- S3_ENDPOINT=${S3_ENDPOINT}
|
||||
- S3_ACCESS_KEY=${S3_ACCESS_KEY}
|
||||
- S3_SECRET_KEY=${S3_SECRET_KEY}
|
||||
- S3_BUCKET=${S3_BUCKET}
|
||||
- APP_URL=${APP_URL}
|
||||
- MAGIC_LINK_SECRET=${MAGIC_LINK_SECRET}
|
||||
- RESEND_API_KEY=${RESEND_API_KEY}
|
||||
- EMAIL_FROM=${EMAIL_FROM}
|
||||
- NODE_ENV=production
|
||||
depends_on:
|
||||
- postgres
|
||||
restart: unless-stopped
|
||||
|
||||
# === WEB (Einstiegspunkt, einziger Service mit externem Port) ===
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.web
|
||||
ports:
|
||||
- "5173:3000"
|
||||
- "${PORT:-3000}:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- API_INTERNAL_URL=http://api:3000
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
# === API ===
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.api
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://musichub:${POSTGRES_PASSWORD}@postgres:5432/musichub
|
||||
- S3_ENDPOINT=http://minio:9000
|
||||
- S3_ACCESS_KEY=${S3_ACCESS_KEY:-minioadmin}
|
||||
- S3_SECRET_KEY=${S3_SECRET_KEY:-minioadmin}
|
||||
- S3_BUCKET=${S3_BUCKET:-music-hub}
|
||||
- APP_URL=${APP_URL}
|
||||
- MAGIC_LINK_SECRET=${MAGIC_LINK_SECRET}
|
||||
- RESEND_API_KEY=${RESEND_API_KEY:-}
|
||||
- EMAIL_FROM=${EMAIL_FROM:-Music Hub <noreply@example.com>}
|
||||
- NODE_ENV=production
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
minio-init:
|
||||
condition: service_completed_successfully
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
# === POSTGRES ===
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
@@ -38,7 +51,56 @@ services:
|
||||
POSTGRES_DB: musichub
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U musichub"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
|
||||
# === MINIO (S3-kompatibel) ===
|
||||
minio:
|
||||
image: minio/minio
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${S3_ACCESS_KEY:-minioadmin}
|
||||
MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY:-minioadmin}
|
||||
volumes:
|
||||
- miniodata:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "mc", "ready", "local"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
# === MINIO INIT (Bucket anlegen, einmalig) ===
|
||||
minio-init:
|
||||
image: minio/mc
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
mc alias set local http://minio:9000 $${S3_ACCESS_KEY:-minioadmin} $${S3_SECRET_KEY:-minioadmin} &&
|
||||
mc mb -p local/$${S3_BUCKET:-music-hub} &&
|
||||
echo 'Bucket ready'
|
||||
"
|
||||
|
||||
# === DB MIGRATION (einmalig bei Deploy) ===
|
||||
migrate:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.api
|
||||
command: bunx drizzle-kit migrate
|
||||
working_dir: /app/packages/db
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://musichub:${POSTGRES_PASSWORD}@postgres:5432/musichub
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
miniodata:
|
||||
|
||||
1
packages/db/src/migrations/0002_loud_masque.sql
Normal file
1
packages/db/src/migrations/0002_loud_masque.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "tracks" ADD COLUMN "cover_image_url" text;
|
||||
3
packages/db/src/migrations/0003_huge_mystique.sql
Normal file
3
packages/db/src/migrations/0003_huge_mystique.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
CREATE TYPE "public"."track_status" AS ENUM('sketch', 'in_progress', 'final', 'released');--> statement-breakpoint
|
||||
ALTER TABLE "tracks" ADD COLUMN "status" "track_status" DEFAULT 'in_progress' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "tracks" ADD COLUMN "section" varchar(100);
|
||||
891
packages/db/src/migrations/meta/0002_snapshot.json
Normal file
891
packages/db/src/migrations/meta/0002_snapshot.json
Normal file
@@ -0,0 +1,891 @@
|
||||
{
|
||||
"id": "3065b19e-d647-4c33-90fb-2a4ae29e9e44",
|
||||
"prevId": "7e1d45fa-02c2-43ae-96f3-d5c8367c15ed",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"avatar_url": {
|
||||
"name": "avatar_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.magic_links": {
|
||||
"name": "magic_links",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"used_at": {
|
||||
"name": "used_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"magic_links_token_unique": {
|
||||
"name": "magic_links_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"token"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.sessions": {
|
||||
"name": "sessions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token_hash": {
|
||||
"name": "token_hash",
|
||||
"type": "varchar(128)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sessions_user_id_users_id_fk": {
|
||||
"name": "sessions_user_id_users_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"sessions_token_hash_unique": {
|
||||
"name": "sessions_token_hash_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"token_hash"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.project_members": {
|
||||
"name": "project_members",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "project_role",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"can_upload": {
|
||||
"name": "can_upload",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"can_comment": {
|
||||
"name": "can_comment",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
},
|
||||
"can_approve": {
|
||||
"name": "can_approve",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"invited_at": {
|
||||
"name": "invited_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"project_members_project_id_projects_id_fk": {
|
||||
"name": "project_members_project_id_projects_id_fk",
|
||||
"tableFrom": "project_members",
|
||||
"tableTo": "projects",
|
||||
"columnsFrom": [
|
||||
"project_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"project_members_user_id_users_id_fk": {
|
||||
"name": "project_members_user_id_users_id_fk",
|
||||
"tableFrom": "project_members",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"project_members_project_id_user_id_unique": {
|
||||
"name": "project_members_project_id_user_id_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"project_id",
|
||||
"user_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.projects": {
|
||||
"name": "projects",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"cover_image_url": {
|
||||
"name": "cover_image_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_by_id": {
|
||||
"name": "created_by_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"is_archived": {
|
||||
"name": "is_archived",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"projects_created_by_id_users_id_fk": {
|
||||
"name": "projects_created_by_id_users_id_fk",
|
||||
"tableFrom": "projects",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"created_by_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.tracks": {
|
||||
"name": "tracks",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"cover_image_url": {
|
||||
"name": "cover_image_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"created_by_id": {
|
||||
"name": "created_by_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"tracks_project_id_projects_id_fk": {
|
||||
"name": "tracks_project_id_projects_id_fk",
|
||||
"tableFrom": "tracks",
|
||||
"tableTo": "projects",
|
||||
"columnsFrom": [
|
||||
"project_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"tracks_created_by_id_users_id_fk": {
|
||||
"name": "tracks_created_by_id_users_id_fk",
|
||||
"tableFrom": "tracks",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"created_by_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.versions": {
|
||||
"name": "versions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"track_id": {
|
||||
"name": "track_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"version_number": {
|
||||
"name": "version_number",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"label": {
|
||||
"name": "label",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "version_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'uploaded'"
|
||||
},
|
||||
"parent_version_id": {
|
||||
"name": "parent_version_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"branch_label": {
|
||||
"name": "branch_label",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"original_file_name": {
|
||||
"name": "original_file_name",
|
||||
"type": "varchar(500)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"mime_type": {
|
||||
"name": "mime_type",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"file_size": {
|
||||
"name": "file_size",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"duration": {
|
||||
"name": "duration",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"sample_rate": {
|
||||
"name": "sample_rate",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"bit_depth": {
|
||||
"name": "bit_depth",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"original_file_key": {
|
||||
"name": "original_file_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"stream_file_key": {
|
||||
"name": "stream_file_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"waveform_data_key": {
|
||||
"name": "waveform_data_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_by_id": {
|
||||
"name": "created_by_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"versions_track_id_tracks_id_fk": {
|
||||
"name": "versions_track_id_tracks_id_fk",
|
||||
"tableFrom": "versions",
|
||||
"tableTo": "tracks",
|
||||
"columnsFrom": [
|
||||
"track_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"versions_parent_version_id_versions_id_fk": {
|
||||
"name": "versions_parent_version_id_versions_id_fk",
|
||||
"tableFrom": "versions",
|
||||
"tableTo": "versions",
|
||||
"columnsFrom": [
|
||||
"parent_version_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"versions_created_by_id_users_id_fk": {
|
||||
"name": "versions_created_by_id_users_id_fk",
|
||||
"tableFrom": "versions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"created_by_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.comments": {
|
||||
"name": "comments",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"version_id": {
|
||||
"name": "version_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"guest_name": {
|
||||
"name": "guest_name",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"body": {
|
||||
"name": "body",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"timestamp_seconds": {
|
||||
"name": "timestamp_seconds",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"parent_id": {
|
||||
"name": "parent_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"resolved_at": {
|
||||
"name": "resolved_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"comments_version_id_versions_id_fk": {
|
||||
"name": "comments_version_id_versions_id_fk",
|
||||
"tableFrom": "comments",
|
||||
"tableTo": "versions",
|
||||
"columnsFrom": [
|
||||
"version_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"comments_user_id_users_id_fk": {
|
||||
"name": "comments_user_id_users_id_fk",
|
||||
"tableFrom": "comments",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.share_links": {
|
||||
"name": "share_links",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"version_id": {
|
||||
"name": "version_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_by_id": {
|
||||
"name": "created_by_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"allow_comments": {
|
||||
"name": "allow_comments",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
},
|
||||
"allow_download": {
|
||||
"name": "allow_download",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"share_links_version_id_versions_id_fk": {
|
||||
"name": "share_links_version_id_versions_id_fk",
|
||||
"tableFrom": "share_links",
|
||||
"tableTo": "versions",
|
||||
"columnsFrom": [
|
||||
"version_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"share_links_created_by_id_users_id_fk": {
|
||||
"name": "share_links_created_by_id_users_id_fk",
|
||||
"tableFrom": "share_links",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"created_by_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"share_links_token_unique": {
|
||||
"name": "share_links_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"token"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.project_role": {
|
||||
"name": "project_role",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"owner",
|
||||
"recording_engineer",
|
||||
"mixing_engineer",
|
||||
"mastering_engineer",
|
||||
"artist",
|
||||
"label",
|
||||
"management",
|
||||
"viewer"
|
||||
]
|
||||
},
|
||||
"public.version_status": {
|
||||
"name": "version_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"uploaded",
|
||||
"processing",
|
||||
"ready",
|
||||
"approved",
|
||||
"rejected"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
915
packages/db/src/migrations/meta/0003_snapshot.json
Normal file
915
packages/db/src/migrations/meta/0003_snapshot.json
Normal file
@@ -0,0 +1,915 @@
|
||||
{
|
||||
"id": "c17d41fa-dca4-4e1a-96c0-e3d574ce67e3",
|
||||
"prevId": "3065b19e-d647-4c33-90fb-2a4ae29e9e44",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"avatar_url": {
|
||||
"name": "avatar_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.magic_links": {
|
||||
"name": "magic_links",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"used_at": {
|
||||
"name": "used_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"magic_links_token_unique": {
|
||||
"name": "magic_links_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"token"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.sessions": {
|
||||
"name": "sessions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token_hash": {
|
||||
"name": "token_hash",
|
||||
"type": "varchar(128)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sessions_user_id_users_id_fk": {
|
||||
"name": "sessions_user_id_users_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"sessions_token_hash_unique": {
|
||||
"name": "sessions_token_hash_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"token_hash"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.project_members": {
|
||||
"name": "project_members",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "project_role",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"can_upload": {
|
||||
"name": "can_upload",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"can_comment": {
|
||||
"name": "can_comment",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
},
|
||||
"can_approve": {
|
||||
"name": "can_approve",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"invited_at": {
|
||||
"name": "invited_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"project_members_project_id_projects_id_fk": {
|
||||
"name": "project_members_project_id_projects_id_fk",
|
||||
"tableFrom": "project_members",
|
||||
"tableTo": "projects",
|
||||
"columnsFrom": [
|
||||
"project_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"project_members_user_id_users_id_fk": {
|
||||
"name": "project_members_user_id_users_id_fk",
|
||||
"tableFrom": "project_members",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"project_members_project_id_user_id_unique": {
|
||||
"name": "project_members_project_id_user_id_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"project_id",
|
||||
"user_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.projects": {
|
||||
"name": "projects",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"cover_image_url": {
|
||||
"name": "cover_image_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_by_id": {
|
||||
"name": "created_by_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"is_archived": {
|
||||
"name": "is_archived",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"projects_created_by_id_users_id_fk": {
|
||||
"name": "projects_created_by_id_users_id_fk",
|
||||
"tableFrom": "projects",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"created_by_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.tracks": {
|
||||
"name": "tracks",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"cover_image_url": {
|
||||
"name": "cover_image_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "track_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'in_progress'"
|
||||
},
|
||||
"section": {
|
||||
"name": "section",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"created_by_id": {
|
||||
"name": "created_by_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"tracks_project_id_projects_id_fk": {
|
||||
"name": "tracks_project_id_projects_id_fk",
|
||||
"tableFrom": "tracks",
|
||||
"tableTo": "projects",
|
||||
"columnsFrom": [
|
||||
"project_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"tracks_created_by_id_users_id_fk": {
|
||||
"name": "tracks_created_by_id_users_id_fk",
|
||||
"tableFrom": "tracks",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"created_by_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.versions": {
|
||||
"name": "versions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"track_id": {
|
||||
"name": "track_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"version_number": {
|
||||
"name": "version_number",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"label": {
|
||||
"name": "label",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "version_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'uploaded'"
|
||||
},
|
||||
"parent_version_id": {
|
||||
"name": "parent_version_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"branch_label": {
|
||||
"name": "branch_label",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"original_file_name": {
|
||||
"name": "original_file_name",
|
||||
"type": "varchar(500)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"mime_type": {
|
||||
"name": "mime_type",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"file_size": {
|
||||
"name": "file_size",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"duration": {
|
||||
"name": "duration",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"sample_rate": {
|
||||
"name": "sample_rate",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"bit_depth": {
|
||||
"name": "bit_depth",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"original_file_key": {
|
||||
"name": "original_file_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"stream_file_key": {
|
||||
"name": "stream_file_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"waveform_data_key": {
|
||||
"name": "waveform_data_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_by_id": {
|
||||
"name": "created_by_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"versions_track_id_tracks_id_fk": {
|
||||
"name": "versions_track_id_tracks_id_fk",
|
||||
"tableFrom": "versions",
|
||||
"tableTo": "tracks",
|
||||
"columnsFrom": [
|
||||
"track_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"versions_parent_version_id_versions_id_fk": {
|
||||
"name": "versions_parent_version_id_versions_id_fk",
|
||||
"tableFrom": "versions",
|
||||
"tableTo": "versions",
|
||||
"columnsFrom": [
|
||||
"parent_version_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"versions_created_by_id_users_id_fk": {
|
||||
"name": "versions_created_by_id_users_id_fk",
|
||||
"tableFrom": "versions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"created_by_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.comments": {
|
||||
"name": "comments",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"version_id": {
|
||||
"name": "version_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"guest_name": {
|
||||
"name": "guest_name",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"body": {
|
||||
"name": "body",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"timestamp_seconds": {
|
||||
"name": "timestamp_seconds",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"parent_id": {
|
||||
"name": "parent_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"resolved_at": {
|
||||
"name": "resolved_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"comments_version_id_versions_id_fk": {
|
||||
"name": "comments_version_id_versions_id_fk",
|
||||
"tableFrom": "comments",
|
||||
"tableTo": "versions",
|
||||
"columnsFrom": [
|
||||
"version_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"comments_user_id_users_id_fk": {
|
||||
"name": "comments_user_id_users_id_fk",
|
||||
"tableFrom": "comments",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.share_links": {
|
||||
"name": "share_links",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"version_id": {
|
||||
"name": "version_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_by_id": {
|
||||
"name": "created_by_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"allow_comments": {
|
||||
"name": "allow_comments",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
},
|
||||
"allow_download": {
|
||||
"name": "allow_download",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"share_links_version_id_versions_id_fk": {
|
||||
"name": "share_links_version_id_versions_id_fk",
|
||||
"tableFrom": "share_links",
|
||||
"tableTo": "versions",
|
||||
"columnsFrom": [
|
||||
"version_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"share_links_created_by_id_users_id_fk": {
|
||||
"name": "share_links_created_by_id_users_id_fk",
|
||||
"tableFrom": "share_links",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"created_by_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"share_links_token_unique": {
|
||||
"name": "share_links_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"token"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.project_role": {
|
||||
"name": "project_role",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"owner",
|
||||
"recording_engineer",
|
||||
"mixing_engineer",
|
||||
"mastering_engineer",
|
||||
"artist",
|
||||
"label",
|
||||
"management",
|
||||
"viewer"
|
||||
]
|
||||
},
|
||||
"public.track_status": {
|
||||
"name": "track_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"sketch",
|
||||
"in_progress",
|
||||
"final",
|
||||
"released"
|
||||
]
|
||||
},
|
||||
"public.version_status": {
|
||||
"name": "version_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"uploaded",
|
||||
"processing",
|
||||
"ready",
|
||||
"approved",
|
||||
"rejected"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,20 @@
|
||||
"when": 1775571497577,
|
||||
"tag": "0001_many_sir_ram",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1775655179619,
|
||||
"tag": "0002_loud_masque",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1775723994470,
|
||||
"tag": "0003_huge_mystique",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -21,6 +21,13 @@ export const versionStatusEnum = pgEnum('version_status', [
|
||||
'rejected',
|
||||
]);
|
||||
|
||||
export const trackStatusEnum = pgEnum('track_status', [
|
||||
'sketch',
|
||||
'in_progress',
|
||||
'final',
|
||||
'released',
|
||||
]);
|
||||
|
||||
export const tracks = pgTable('tracks', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
projectId: uuid('project_id')
|
||||
@@ -28,6 +35,9 @@ export const tracks = pgTable('tracks', {
|
||||
.notNull(),
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
description: text('description'),
|
||||
coverImageUrl: text('cover_image_url'),
|
||||
status: trackStatusEnum('status').default('in_progress').notNull(),
|
||||
section: varchar('section', { length: 100 }),
|
||||
sortOrder: integer('sort_order').default(0).notNull(),
|
||||
createdById: uuid('created_by_id')
|
||||
.references(() => users.id)
|
||||
|
||||
@@ -12,14 +12,14 @@ export const PROJECT_ROLES = [
|
||||
export type ProjectRole = (typeof PROJECT_ROLES)[number];
|
||||
|
||||
export const ROLE_LABELS: Record<ProjectRole, string> = {
|
||||
owner: 'Owner',
|
||||
recording_engineer: 'Recording Engineer',
|
||||
mixing_engineer: 'Mixing Engineer',
|
||||
mastering_engineer: 'Mastering Engineer',
|
||||
owner: 'Besitzer',
|
||||
recording_engineer: 'Aufnahme',
|
||||
mixing_engineer: 'Mixing',
|
||||
mastering_engineer: 'Mastering',
|
||||
artist: 'Artist',
|
||||
label: 'Label',
|
||||
management: 'Management',
|
||||
viewer: 'Viewer',
|
||||
viewer: 'Nur Zuhören',
|
||||
};
|
||||
|
||||
export const ENGINEER_ROLES: ProjectRole[] = [
|
||||
|
||||
@@ -9,6 +9,7 @@ export const createProjectSchema = z.object({
|
||||
export const updateProjectSchema = z.object({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
description: z.string().max(2000).optional(),
|
||||
coverImageUrl: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export const inviteMemberSchema = z.object({
|
||||
|
||||
@@ -6,9 +6,28 @@ export const createTrackSchema = z.object({
|
||||
description: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
export const TRACK_STATUSES = ['sketch', 'in_progress', 'final', 'released'] as const;
|
||||
export type TrackStatus = (typeof TRACK_STATUSES)[number];
|
||||
|
||||
export const TRACK_STATUS_LABELS: Record<TrackStatus, string> = {
|
||||
sketch: 'Skizze',
|
||||
in_progress: 'In Arbeit',
|
||||
final: 'Final',
|
||||
released: 'Veröffentlicht',
|
||||
};
|
||||
|
||||
export const updateTrackSchema = z.object({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
description: z.string().max(2000).optional(),
|
||||
coverImageUrl: z.string().nullable().optional(),
|
||||
status: z.enum(TRACK_STATUSES).optional(),
|
||||
section: z.string().max(100).nullable().optional(),
|
||||
});
|
||||
|
||||
export const coverUploadSchema = z.object({
|
||||
fileName: z.string().min(1).max(200),
|
||||
mimeType: z.enum(['image/jpeg', 'image/png', 'image/webp']),
|
||||
fileSize: z.number().int().positive().max(2 * 1024 * 1024),
|
||||
});
|
||||
|
||||
export const requestUploadUrlSchema = z.object({
|
||||
@@ -28,6 +47,12 @@ export const createVersionSchema = z.object({
|
||||
branchLabel: z.string().max(100).optional(),
|
||||
});
|
||||
|
||||
export const updateVersionSchema = z.object({
|
||||
label: z.string().max(100).nullable().optional(),
|
||||
notes: z.string().max(2000).nullable().optional(),
|
||||
branchLabel: z.string().max(100).nullable().optional(),
|
||||
});
|
||||
|
||||
export const createShareLinkSchema = z.object({
|
||||
expiresAt: z.string().datetime().optional(),
|
||||
allowComments: z.boolean().optional(),
|
||||
@@ -46,5 +71,7 @@ export type CreateTrackInput = z.infer<typeof createTrackSchema>;
|
||||
export type UpdateTrackInput = z.infer<typeof updateTrackSchema>;
|
||||
export type RequestUploadUrlInput = z.infer<typeof requestUploadUrlSchema>;
|
||||
export type CreateVersionInput = z.infer<typeof createVersionSchema>;
|
||||
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>;
|
||||
|
||||
Reference in New Issue
Block a user