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/packages/shared/node_modules ./packages/shared/node_modules
|
||||||
COPY --from=install /app/apps/web/node_modules ./apps/web/node_modules
|
COPY --from=install /app/apps/web/node_modules ./apps/web/node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
ENV PUBLIC_API_URL=/api
|
|
||||||
RUN cd apps/web && bun run build
|
RUN cd apps/web && bun run build
|
||||||
|
|
||||||
FROM base AS production
|
FROM base AS production
|
||||||
COPY --from=build /app/apps/web/build ./build
|
COPY --from=build /app/apps/web/build ./build
|
||||||
COPY --from=build /app/apps/web/package.json .
|
COPY --from=build /app/apps/web/package.json .
|
||||||
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
CMD ["bun", "./build/index.js"]
|
CMD ["bun", "./build/index.js"]
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --hot src/index.ts",
|
"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": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3",
|
"@aws-sdk/client-s3": "^3",
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import { trackRoutes } from './routes/tracks.js';
|
|||||||
import { versionRoutes } from './routes/versions.js';
|
import { versionRoutes } from './routes/versions.js';
|
||||||
import { commentRoutes } from './routes/comments.js';
|
import { commentRoutes } from './routes/comments.js';
|
||||||
import { shareRoutes } from './routes/share.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';
|
import type { AppEnv } from './types.js';
|
||||||
|
|
||||||
const db = createDb(process.env.DATABASE_URL!);
|
const db = createDb(process.env.DATABASE_URL!);
|
||||||
@@ -36,7 +39,10 @@ const app = new Hono<AppEnv>()
|
|||||||
.route('/tracks', trackRoutes)
|
.route('/tracks', trackRoutes)
|
||||||
.route('/versions', versionRoutes)
|
.route('/versions', versionRoutes)
|
||||||
.route('/comments', commentRoutes)
|
.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');
|
const port = parseInt(process.env.PORT || '3000');
|
||||||
console.log(`Music Hub API running on port ${port}`);
|
console.log(`Music Hub API running on port ${port}`);
|
||||||
|
|||||||
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 { Hono } from 'hono';
|
||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and, sql } from 'drizzle-orm';
|
||||||
import {
|
import {
|
||||||
createProjectSchema,
|
createProjectSchema,
|
||||||
updateProjectSchema,
|
updateProjectSchema,
|
||||||
inviteMemberSchema,
|
inviteMemberSchema,
|
||||||
updateMemberSchema,
|
updateMemberSchema,
|
||||||
} from '@music-hub/shared';
|
} 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 { requireAuth } from '../middleware/auth.js';
|
||||||
|
import { createDownloadUrl } from '../storage/s3.js';
|
||||||
import type { AppEnv } from '../types.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>()
|
export const projectRoutes = new Hono<AppEnv>()
|
||||||
.use('*', requireAuth)
|
.use('*', requireAuth)
|
||||||
|
|
||||||
@@ -22,12 +30,19 @@ export const projectRoutes = new Hono<AppEnv>()
|
|||||||
.select({
|
.select({
|
||||||
project: projects,
|
project: projects,
|
||||||
role: projectMembers.role,
|
role: projectMembers.role,
|
||||||
|
trackCount: sql<number>`(select count(*)::int from ${tracks} where ${tracks.projectId} = ${projects.id})`,
|
||||||
})
|
})
|
||||||
.from(projectMembers)
|
.from(projectMembers)
|
||||||
.innerJoin(projects, eq(projects.id, projectMembers.projectId))
|
.innerJoin(projects, eq(projects.id, projectMembers.projectId))
|
||||||
.where(and(eq(projectMembers.userId, userId), eq(projects.isArchived, false)));
|
.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) => {
|
.post('/', zValidator('json', createProjectSchema), async (c) => {
|
||||||
@@ -73,7 +88,7 @@ export const projectRoutes = new Hono<AppEnv>()
|
|||||||
.where(eq(projects.id, projectId))
|
.where(eq(projects.id, projectId))
|
||||||
.limit(1);
|
.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) => {
|
.patch('/:id', zValidator('json', updateProjectSchema), async (c) => {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { zValidator } from '@hono/zod-validator';
|
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 { 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 { requireAuth } from '../middleware/auth.js';
|
||||||
|
import { createDownloadUrl } from '../storage/s3.js';
|
||||||
import type { AppEnv } from '../types.js';
|
import type { AppEnv } from '../types.js';
|
||||||
|
|
||||||
export const trackRoutes = new Hono<AppEnv>()
|
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);
|
if (!membership) return c.json({ error: 'Not found' }, 404);
|
||||||
|
|
||||||
const projectTracks = await db
|
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)
|
.from(tracks)
|
||||||
.where(eq(tracks.projectId, projectId))
|
.where(eq(tracks.projectId, projectId))
|
||||||
.orderBy(asc(tracks.sortOrder), asc(tracks.createdAt));
|
.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) => {
|
.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 { Hono } from 'hono';
|
||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
import { eq, and, desc, asc, sql } from 'drizzle-orm';
|
import { eq, and, desc, asc, sql } from 'drizzle-orm';
|
||||||
import { requestUploadUrlSchema, createVersionSchema } from '@music-hub/shared';
|
import { requestUploadUrlSchema, createVersionSchema, updateVersionSchema } from '@music-hub/shared';
|
||||||
import { tracks, versions, projectMembers } from '@music-hub/db';
|
import { tracks, versions, projectMembers } from '@music-hub/db';
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
import { createUploadUrl, createDownloadUrl } from '../storage/s3.js';
|
import { createUploadUrl, createDownloadUrl } from '../storage/s3.js';
|
||||||
@@ -115,6 +115,82 @@ export const versionRoutes = new Hono<AppEnv>()
|
|||||||
return c.json({ version }, 201);
|
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 version tree (graph) for a track
|
||||||
.get('/track/:trackId/tree', async (c) => {
|
.get('/track/:trackId/tree', async (c) => {
|
||||||
const db = c.get('db');
|
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({
|
await resend.emails.send({
|
||||||
from: fromEmail,
|
from: fromEmail,
|
||||||
to: email,
|
to: email,
|
||||||
subject: 'Your Music Hub Login Link',
|
subject: 'Dein Login-Link für Music Hub',
|
||||||
html: `
|
html: `
|
||||||
<div style="font-family: -apple-system, sans-serif; max-width: 400px; margin: 0 auto; padding: 2rem;">
|
<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.5rem; color: #f0f0f0;">Music Hub</h1>
|
<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: #a0a0a0;">Click the button below to log in:</p>
|
<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="
|
<a href="${url}" style="
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.8rem 1.6rem;
|
||||||
background: #6366f1;
|
background: linear-gradient(135deg, #f43f5e, #fb923c);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
margin: 1rem 0;
|
margin: 0 0 1.5rem;
|
||||||
">Log in to Music Hub</a>
|
">Einloggen</a>
|
||||||
<p style="color: #666; font-size: 0.85rem;">This link expires in 15 minutes.</p>
|
<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: #666; font-size: 0.85rem;">If you didn't request this, ignore this email.</p>
|
<p style="color: #5e596b; font-size: 0.85rem; margin: 0;">Wenn du das nicht angefordert hast, ignorier diese Mail einfach.</p>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
@@ -50,21 +50,20 @@ export async function sendInviteEmail(email: string, projectName: string, invite
|
|||||||
await resend.emails.send({
|
await resend.emails.send({
|
||||||
from: fromEmail,
|
from: fromEmail,
|
||||||
to: email,
|
to: email,
|
||||||
subject: `You've been invited to "${projectName}" on Music Hub`,
|
subject: `${inviterName} hat dich zu "${projectName}" eingeladen`,
|
||||||
html: `
|
html: `
|
||||||
<div style="font-family: -apple-system, sans-serif; max-width: 400px; margin: 0 auto; padding: 2rem;">
|
<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.5rem; color: #f0f0f0;">Music Hub</h1>
|
<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: #a0a0a0;">${inviterName} invited you to collaborate on <strong>"${projectName}"</strong>.</p>
|
<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="
|
<a href="${url}" style="
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.8rem 1.6rem;
|
||||||
background: #6366f1;
|
background: linear-gradient(135deg, #f43f5e, #fb923c);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
margin: 1rem 0;
|
">Projekt öffnen</a>
|
||||||
">Open Music Hub</a>
|
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,9 @@
|
|||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
"@music-hub/shared": "workspace:*",
|
"@music-hub/shared": "workspace:*",
|
||||||
|
"@sveltejs/adapter-node": "^5.5.4",
|
||||||
"wavesurfer.js": "^7.12.5"
|
"wavesurfer.js": "^7.12.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<meta name="text-scale" content="scale" />
|
<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%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<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-compare">
|
||||||
<div class="ab-header">
|
<div class="ab-header">
|
||||||
<h2>A/B Compare</h2>
|
<h2>A/B-Vergleich</h2>
|
||||||
<div class="ab-toggle">
|
<div class="ab-toggle">
|
||||||
<button class="toggle-btn" class:active={activePlayer === 'A'} onclick={() => switchTo('A')}>A</button>
|
<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>
|
<button class="toggle-btn" class:active={activePlayer === 'B'} onclick={() => switchTo('B')}>B</button>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" onclick={onClose}>Close</Button>
|
<Button variant="ghost" size="sm" onclick={onClose}>Schließen</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="players">
|
<div class="players">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { SUPPORTED_EXTENSIONS, MAX_FILE_SIZE } from '@music-hub/shared';
|
import { SUPPORTED_EXTENSIONS, MAX_FILE_SIZE } from '@music-hub/shared';
|
||||||
import { api } from '$lib/api/client.js';
|
import { api } from '$lib/api/client.js';
|
||||||
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
trackId,
|
trackId,
|
||||||
@@ -47,13 +48,13 @@
|
|||||||
error = '';
|
error = '';
|
||||||
|
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
error = 'File too large (max 500 MB)';
|
error = 'Datei zu groß (max 500 MB)';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
|
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
|
||||||
if (!SUPPORTED_EXTENSIONS.includes(ext as any)) {
|
if (!SUPPORTED_EXTENSIONS.includes(ext as any)) {
|
||||||
error = `Unsupported format. Use: ${SUPPORTED_EXTENSIONS.join(', ')}`;
|
error = `Format nicht unterstützt. Erlaubt: ${SUPPORTED_EXTENSIONS.join(', ')}`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +90,7 @@
|
|||||||
label = '';
|
label = '';
|
||||||
onUploaded();
|
onUploaded();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof Error ? err.message : 'Upload failed';
|
error = err instanceof Error ? err.message : 'Upload fehlgeschlagen';
|
||||||
} finally {
|
} finally {
|
||||||
uploading = false;
|
uploading = false;
|
||||||
progress = 0;
|
progress = 0;
|
||||||
@@ -124,7 +125,7 @@
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={label}
|
bind:value={label}
|
||||||
placeholder="Version label (e.g. 'Mix V2', 'Master Final')"
|
placeholder="Versions-Bezeichnung (z.B. 'Mix V2', 'Final Master')"
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,8 +157,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="dropzone-content">
|
<div class="dropzone-content">
|
||||||
<span class="dropzone-icon">🎵</span>
|
<span class="dropzone-icon"><Icon name="upload" size={28} /></span>
|
||||||
<p>Drop audio file here or click to browse</p>
|
<p>Audio-Datei hier ablegen oder klicken zum Auswählen</p>
|
||||||
<span class="formats">WAV, MP3, FLAC, AIFF — max 500 MB</span>
|
<span class="formats">WAV, MP3, FLAC, AIFF — max 500 MB</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -212,7 +213,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dropzone-icon {
|
.dropzone-icon {
|
||||||
font-size: 2rem;
|
color: var(--color-text-tertiary);
|
||||||
|
display: inline-flex;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.formats {
|
.formats {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import WaveSurfer from 'wavesurfer.js';
|
import WaveSurfer from 'wavesurfer.js';
|
||||||
import { formatTime } from '$lib/utils/format.js';
|
import { formatTime } from '$lib/utils/format.js';
|
||||||
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
|
|
||||||
type CommentMarker = {
|
type CommentMarker = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -16,6 +17,8 @@
|
|||||||
muted = false,
|
muted = false,
|
||||||
compact = false,
|
compact = false,
|
||||||
label = '',
|
label = '',
|
||||||
|
initialTime = 0,
|
||||||
|
autoPlay = false,
|
||||||
onTimeClick,
|
onTimeClick,
|
||||||
onReady,
|
onReady,
|
||||||
onSeek,
|
onSeek,
|
||||||
@@ -25,6 +28,8 @@
|
|||||||
muted?: boolean;
|
muted?: boolean;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
initialTime?: number;
|
||||||
|
autoPlay?: boolean;
|
||||||
onTimeClick?: (time: number) => void;
|
onTimeClick?: (time: number) => void;
|
||||||
onReady?: (duration: number) => void;
|
onReady?: (duration: number) => void;
|
||||||
onSeek?: (time: number) => void;
|
onSeek?: (time: number) => void;
|
||||||
@@ -33,32 +38,46 @@
|
|||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
let ws: WaveSurfer | null = null;
|
let ws: WaveSurfer | null = null;
|
||||||
let isPlaying = $state(false);
|
let isPlaying = $state(false);
|
||||||
|
let isReady = $state(false);
|
||||||
let currentTime = $state(0);
|
let currentTime = $state(0);
|
||||||
let duration = $state(0);
|
let duration = $state(0);
|
||||||
let volume = $state(0.8);
|
let volume = $state(0.8);
|
||||||
|
let showVolume = $state(false);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (ws) ws.setVolume(muted ? 0 : volume);
|
if (ws) ws.setVolume(muted ? 0 : volume);
|
||||||
});
|
});
|
||||||
|
|
||||||
onMount(() => {
|
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({
|
ws = WaveSurfer.create({
|
||||||
container,
|
container,
|
||||||
waveColor: 'var(--color-bg-subtle, #4a4a5a)',
|
waveColor,
|
||||||
progressColor: 'var(--color-accent, #6366f1)',
|
progressColor,
|
||||||
cursorColor: '#818cf8',
|
cursorColor: progressColor,
|
||||||
cursorWidth: 2,
|
cursorWidth: 2,
|
||||||
barWidth: 2,
|
barWidth: 2,
|
||||||
barGap: 1,
|
barGap: 2,
|
||||||
barRadius: 2,
|
barRadius: 3,
|
||||||
height: compact ? 48 : 80,
|
height: compact ? 56 : 96,
|
||||||
normalize: true,
|
normalize: true,
|
||||||
url,
|
url,
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on('ready', () => {
|
ws.on('ready', () => {
|
||||||
duration = ws!.getDuration();
|
duration = ws!.getDuration();
|
||||||
|
isReady = true;
|
||||||
ws!.setVolume(muted ? 0 : volume);
|
ws!.setVolume(muted ? 0 : volume);
|
||||||
|
if (initialTime > 0 && initialTime < duration) {
|
||||||
|
ws!.setTime(initialTime);
|
||||||
|
}
|
||||||
|
if (autoPlay) {
|
||||||
|
ws!.play().catch(() => {});
|
||||||
|
}
|
||||||
onReady?.(duration);
|
onReady?.(duration);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -107,14 +126,34 @@
|
|||||||
return ws?.getCurrentTime() || 0;
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="waveform-player" class:compact>
|
<div class="player" class:compact>
|
||||||
{#if label}
|
{#if label}
|
||||||
<span class="player-label">{label}</span>
|
<span class="player-label">{label}</span>
|
||||||
{/if}
|
{/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 class="waveform-container">
|
||||||
<div bind:this={container} class="waveform"></div>
|
<div bind:this={container} class="waveform"></div>
|
||||||
|
|
||||||
@@ -126,31 +165,29 @@
|
|||||||
style="left: {(marker.timestampSeconds / duration) * 100}%"
|
style="left: {(marker.timestampSeconds / duration) * 100}%"
|
||||||
title="{marker.userName}: {marker.body}"
|
title="{marker.userName}: {marker.body}"
|
||||||
onclick={() => seekToTime(marker.timestampSeconds)}
|
onclick={() => seekToTime(marker.timestampSeconds)}
|
||||||
></button>
|
>
|
||||||
|
<span class="marker-dot">{initials(marker.userName)}</span>
|
||||||
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="controls">
|
|
||||||
<div class="controls-left">
|
|
||||||
{#if !compact}
|
{#if !compact}
|
||||||
<button class="control-btn" onclick={() => skip(-10)} title="Back 10s">⏪</button>
|
<div class="meta-row">
|
||||||
{/if}
|
<span class="time">{formatTime(currentTime)}</span>
|
||||||
<button class="control-btn play-btn" onclick={togglePlay}>
|
<div class="meta-controls">
|
||||||
{isPlaying ? '⏸' : '▶'}
|
<button class="ctl" onclick={() => skip(-10)} aria-label="10 Sekunden zurück">
|
||||||
|
<Icon name="skip-back" size={14} />
|
||||||
</button>
|
</button>
|
||||||
{#if !compact}
|
<button class="ctl" onclick={() => skip(10)} aria-label="10 Sekunden vor">
|
||||||
<button class="control-btn" onclick={() => skip(10)} title="Forward 10s">⏩</button>
|
<Icon name="skip-forward" size={14} />
|
||||||
{/if}
|
</button>
|
||||||
</div>
|
<div class="volume" onmouseenter={() => (showVolume = true)} onmouseleave={() => (showVolume = false)} role="group">
|
||||||
|
<button class="ctl" aria-label="Lautstärke">
|
||||||
<div class="time">
|
<Icon name={volume === 0 ? 'volume-off' : 'volume'} size={14} />
|
||||||
{formatTime(currentTime)} / {formatTime(duration)}
|
</button>
|
||||||
</div>
|
{#if showVolume}
|
||||||
|
|
||||||
{#if !compact}
|
|
||||||
<div class="controls-right">
|
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="0"
|
min="0"
|
||||||
@@ -159,23 +196,21 @@
|
|||||||
value={volume}
|
value={volume}
|
||||||
oninput={(e) => setVol(Number((e.target as HTMLInputElement).value))}
|
oninput={(e) => setVol(Number((e.target as HTMLInputElement).value))}
|
||||||
class="volume-slider"
|
class="volume-slider"
|
||||||
title="Volume"
|
aria-label="Lautstärke einstellen"
|
||||||
/>
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="time muted">{formatTime(duration)}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.waveform-player {
|
.player {
|
||||||
background: var(--color-bg-overlay);
|
width: 100%;
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: var(--space-5);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.waveform-player.compact {
|
|
||||||
padding: var(--space-3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-label {
|
.player-label {
|
||||||
@@ -184,15 +219,62 @@
|
|||||||
color: var(--color-text-tertiary);
|
color: var(--color-text-tertiary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.08em;
|
||||||
margin-bottom: var(--space-2);
|
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 {
|
.waveform-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-bottom: var(--space-3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.waveform {
|
.waveform {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -202,80 +284,129 @@
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.marker {
|
.marker {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: -6px;
|
||||||
width: 8px;
|
|
||||||
height: 100%;
|
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
background: rgba(251, 191, 36, 0.2);
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
border-left: 2px solid var(--color-warning);
|
padding: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
padding: 0;
|
z-index: 2;
|
||||||
transition: background var(--transition-fast);
|
}
|
||||||
|
.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 {
|
.meta-row {
|
||||||
background: rgba(251, 191, 36, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--space-4);
|
gap: var(--space-3);
|
||||||
}
|
margin-top: var(--space-2);
|
||||||
|
font-size: var(--text-xs);
|
||||||
.controls-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-text-secondary);
|
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;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
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 {
|
.volume-slider {
|
||||||
width: 80px;
|
width: 80px;
|
||||||
|
margin-left: var(--space-2);
|
||||||
accent-color: var(--color-accent);
|
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>
|
</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 {
|
.badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.15rem 0.5rem;
|
padding: 0.2rem 0.55rem;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-full);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
|
border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.default {
|
.default {
|
||||||
background: var(--color-bg-subtle);
|
background: var(--color-bg-subtle);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
|
border-color: var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.success {
|
.success {
|
||||||
@@ -47,6 +50,7 @@
|
|||||||
|
|
||||||
.accent {
|
.accent {
|
||||||
background: var(--color-accent-subtle);
|
background: var(--color-accent-subtle);
|
||||||
color: var(--color-accent);
|
color: #fb923c;
|
||||||
|
border-color: rgba(244, 63, 94, 0.3);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -51,11 +51,23 @@
|
|||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
cursor: pointer;
|
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;
|
text-decoration: none;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active:not(:disabled) {
|
||||||
|
transform: scale(0.97);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:disabled, .btn.disabled {
|
.btn:disabled, .btn.disabled {
|
||||||
@@ -65,29 +77,33 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Sizes */
|
/* Sizes */
|
||||||
.sm { padding: 0.3rem 0.6rem; font-size: var(--text-xs); }
|
.sm { padding: 0.35rem 0.7rem; font-size: var(--text-xs); height: 28px; }
|
||||||
.md { padding: 0.5rem 1rem; font-size: var(--text-sm); }
|
.md { padding: 0.5rem 1rem; font-size: var(--text-sm); height: 36px; }
|
||||||
.lg { padding: 0.65rem 1.25rem; font-size: var(--text-base); }
|
.lg { padding: 0.7rem 1.4rem; font-size: var(--text-base); height: 44px; }
|
||||||
|
|
||||||
/* Variants */
|
/* Variants */
|
||||||
.primary {
|
.primary {
|
||||||
background: var(--color-accent);
|
background: var(--gradient-accent);
|
||||||
color: #fff;
|
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) {
|
.primary:hover:not(:disabled) {
|
||||||
background: 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);
|
||||||
border-color: var(--color-accent-hover);
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.primary:active:not(:disabled) {
|
||||||
|
transform: scale(0.97) translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary {
|
.secondary {
|
||||||
background: var(--color-bg-subtle);
|
background: var(--color-bg-raised);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-primary);
|
||||||
border-color: var(--color-border-hover);
|
border-color: var(--color-border);
|
||||||
}
|
}
|
||||||
.secondary:hover:not(:disabled) {
|
.secondary:hover:not(:disabled) {
|
||||||
border-color: var(--color-accent);
|
background: var(--color-bg-overlay);
|
||||||
color: var(--color-text-primary);
|
border-color: var(--color-border-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ghost {
|
.ghost {
|
||||||
@@ -95,28 +111,29 @@
|
|||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
.ghost:hover:not(:disabled) {
|
.ghost:hover:not(:disabled) {
|
||||||
background: var(--color-bg-subtle);
|
background: var(--color-bg-raised);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.danger {
|
.danger {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-error);
|
color: var(--color-error);
|
||||||
border-color: var(--color-error);
|
border-color: rgba(239, 68, 68, 0.4);
|
||||||
}
|
}
|
||||||
.danger:hover:not(:disabled) {
|
.danger:hover:not(:disabled) {
|
||||||
background: var(--color-error);
|
background: rgba(239, 68, 68, 0.12);
|
||||||
color: #fff;
|
border-color: var(--color-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
border: 2px solid currentColor;
|
||||||
border-top-color: #fff;
|
border-right-color: transparent;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 0.6s linear infinite;
|
animation: spin 0.6s linear infinite;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hidden {
|
.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';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
icon = '📁',
|
icon,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
action,
|
action,
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<span class="icon">{icon}</span>
|
{#if icon}<span class="icon">{icon}</span>{/if}
|
||||||
<h3>{title}</h3>
|
<h3>{title}</h3>
|
||||||
{#if description}
|
{#if description}
|
||||||
<p>{description}</p>
|
<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 {
|
.input-label {
|
||||||
color: var(--color-text-secondary);
|
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 {
|
input {
|
||||||
padding: var(--space-3) var(--space-4);
|
padding: 0.7rem 0.9rem;
|
||||||
|
height: 42px;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
border: 1px solid var(--color-border-hover);
|
border: 1px solid var(--color-border);
|
||||||
background: var(--color-bg-base);
|
background: var(--color-bg-raised);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
font-size: var(--text-base);
|
font-size: var(--text-sm);
|
||||||
font-family: inherit;
|
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%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input::placeholder {
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:hover:not(:disabled) {
|
||||||
|
border-color: var(--color-border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
input:focus {
|
input:focus {
|
||||||
outline: none;
|
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 {
|
input:disabled {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
open = $bindable(false),
|
open = $bindable(false),
|
||||||
@@ -29,7 +30,9 @@
|
|||||||
<div class="modal">
|
<div class="modal">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2>{title}</h2>
|
<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>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
@@ -47,13 +50,15 @@
|
|||||||
.backdrop {
|
.backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0, 0, 0, 0.6);
|
background: rgba(8, 6, 14, 0.65);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: var(--z-modal);
|
z-index: var(--z-modal);
|
||||||
padding: var(--space-4);
|
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 {
|
.modal {
|
||||||
@@ -65,6 +70,16 @@
|
|||||||
max-height: 85vh;
|
max-height: 85vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
box-shadow: var(--shadow-lg);
|
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 {
|
.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 {
|
.skeleton {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
var(--color-bg-subtle) 25%,
|
var(--color-bg-raised) 0%,
|
||||||
var(--color-border) 50%,
|
var(--color-bg-subtle) 50%,
|
||||||
var(--color-bg-subtle) 75%
|
var(--color-bg-raised) 100%
|
||||||
);
|
);
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
animation: shimmer 1.5s infinite;
|
animation: shimmer 1.8s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes shimmer {
|
@keyframes shimmer {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { toasts, removeToast, type ToastType } from '$lib/stores/toast.js';
|
import { toasts, removeToast, type ToastType } from '$lib/stores/toast.js';
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
|
||||||
const icons: Record<ToastType, string> = {
|
const icons: Record<ToastType, 'check' | 'x' | 'comment' | 'comment'> = {
|
||||||
success: '✓',
|
success: 'check',
|
||||||
error: '✕',
|
error: 'x',
|
||||||
info: 'i',
|
info: 'comment',
|
||||||
warning: '!',
|
warning: 'comment',
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -13,9 +14,11 @@
|
|||||||
<div class="toast-container">
|
<div class="toast-container">
|
||||||
{#each $toasts as t (t.id)}
|
{#each $toasts as t (t.id)}
|
||||||
<div class="toast {t.type}" role="alert">
|
<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>
|
<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>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -38,11 +41,13 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
padding: var(--space-3) var(--space-4);
|
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: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
animation: slide-in 0.2s ease;
|
animation: slide-in 280ms var(--ease-spring);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,11 +90,11 @@
|
|||||||
@keyframes slide-in {
|
@keyframes slide-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(20px);
|
transform: translateY(12px) scale(0.95);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
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 Avatar from '$lib/components/ui/Avatar.svelte';
|
||||||
import Badge from '$lib/components/ui/Badge.svelte';
|
import Badge from '$lib/components/ui/Badge.svelte';
|
||||||
import Modal from '$lib/components/ui/Modal.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 = {
|
type Member = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -17,7 +19,7 @@
|
|||||||
user: { id: string; email: string; name: string; avatarUrl: string | null };
|
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!;
|
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() {
|
async function saveProject() {
|
||||||
saving = true;
|
saving = true;
|
||||||
try {
|
try {
|
||||||
@@ -64,7 +72,7 @@
|
|||||||
name: editName,
|
name: editName,
|
||||||
description: editDesc || undefined,
|
description: editDesc || undefined,
|
||||||
});
|
});
|
||||||
toastSuccess('Project updated');
|
toastSuccess('Projekt gespeichert');
|
||||||
} finally {
|
} finally {
|
||||||
saving = false;
|
saving = false;
|
||||||
}
|
}
|
||||||
@@ -79,7 +87,7 @@
|
|||||||
role: inviteRole,
|
role: inviteRole,
|
||||||
});
|
});
|
||||||
inviteEmail = '';
|
inviteEmail = '';
|
||||||
toastSuccess('Member invited');
|
toastSuccess('Person eingeladen');
|
||||||
const res = await api.get<{ members: Member[] }>(`/projects/${projectId}/members`);
|
const res = await api.get<{ members: Member[] }>(`/projects/${projectId}/members`);
|
||||||
members = res.members;
|
members = res.members;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -89,47 +97,57 @@
|
|||||||
|
|
||||||
async function updateRole(memberId: string, newRole: string) {
|
async function updateRole(memberId: string, newRole: string) {
|
||||||
await api.patch(`/projects/${projectId}/members/${memberId}`, { role: newRole });
|
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`);
|
const res = await api.get<{ members: Member[] }>(`/projects/${projectId}/members`);
|
||||||
members = res.members;
|
members = res.members;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeMember(memberId: string) {
|
async function removeMember(memberId: string) {
|
||||||
await api.delete(`/projects/${projectId}/members/${memberId}`);
|
await api.delete(`/projects/${projectId}/members/${memberId}`);
|
||||||
toastSuccess('Member removed');
|
toastSuccess('Person entfernt');
|
||||||
members = members.filter((m) => m.id !== memberId);
|
members = members.filter((m) => m.id !== memberId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function archiveProject() {
|
async function archiveProject() {
|
||||||
await api.delete(`/projects/${projectId}`);
|
await api.delete(`/projects/${projectId}`);
|
||||||
toastSuccess('Project archived');
|
toastSuccess('Projekt archiviert');
|
||||||
goto('/dashboard');
|
goto('/dashboard');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<TopBar
|
||||||
|
crumbs={[
|
||||||
|
{ label: 'Projekte', href: '/dashboard' },
|
||||||
|
{ label: project?.name ?? '…', href: `/projects/${projectId}` },
|
||||||
|
{ label: 'Einstellungen' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="settings-page">
|
<div class="settings-page">
|
||||||
<header>
|
<header>
|
||||||
<a href="/projects/{projectId}" class="back">← Back to project</a>
|
<h1>Einstellungen</h1>
|
||||||
<h1>Settings</h1>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if !loading && project}
|
{#if !loading && project}
|
||||||
<!-- Project Details -->
|
<!-- Projekt-Details -->
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<h2>Project Details</h2>
|
<h2>Projekt-Details</h2>
|
||||||
<form onsubmit={(e) => { e.preventDefault(); saveProject(); }}>
|
<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} />
|
<Input label="Name" bind:value={editName} />
|
||||||
<div class="textarea-group">
|
<div class="textarea-group">
|
||||||
<label class="textarea-label">Description</label>
|
<label class="textarea-label">Beschreibung</label>
|
||||||
<textarea bind:value={editDesc} rows="3" placeholder="Project description..."></textarea>
|
<textarea bind:value={editDesc} rows="3" placeholder="Worum geht's in diesem Projekt?"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" loading={saving}>Save</Button>
|
<Button type="submit" loading={saving}>Speichern</Button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Members -->
|
<!-- Mitwirkende -->
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<h2>Members</h2>
|
<h2>Mitwirkende</h2>
|
||||||
|
|
||||||
<div class="member-list">
|
<div class="member-list">
|
||||||
{#each members as member}
|
{#each members as member}
|
||||||
@@ -140,7 +158,7 @@
|
|||||||
<span class="member-email">{member.user.email}</span>
|
<span class="member-email">{member.user.email}</span>
|
||||||
</div>
|
</div>
|
||||||
{#if member.role === 'owner'}
|
{#if member.role === 'owner'}
|
||||||
<Badge variant="accent">Owner</Badge>
|
<Badge variant="accent">Besitzer</Badge>
|
||||||
{:else if role === 'owner'}
|
{:else if role === 'owner'}
|
||||||
<select
|
<select
|
||||||
value={member.role}
|
value={member.role}
|
||||||
@@ -151,7 +169,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
<Button variant="ghost" size="sm" onclick={() => removeMember(member.id)}>
|
<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>
|
</Button>
|
||||||
{:else}
|
{:else}
|
||||||
<Badge>{ROLE_LABELS[member.role as keyof typeof ROLE_LABELS] || member.role}</Badge>
|
<Badge>{ROLE_LABELS[member.role as keyof typeof ROLE_LABELS] || member.role}</Badge>
|
||||||
@@ -163,64 +181,62 @@
|
|||||||
<!-- Invite -->
|
<!-- Invite -->
|
||||||
{#if role === 'owner' || role === 'management'}
|
{#if role === 'owner' || role === 'management'}
|
||||||
<form class="invite-form" onsubmit={(e) => { e.preventDefault(); inviteMember(); }}>
|
<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}>
|
<select bind:value={inviteRole}>
|
||||||
{#each assignableRoles as r}
|
{#each assignableRoles as r}
|
||||||
<option value={r}>{ROLE_LABELS[r]}</option>
|
<option value={r}>{ROLE_LABELS[r]}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
<Button type="submit" loading={inviting} size="sm">Invite</Button>
|
<Button type="submit" loading={inviting} size="sm">Einladen</Button>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Danger Zone -->
|
<!-- Achtung-Zone -->
|
||||||
{#if role === 'owner'}
|
{#if role === 'owner'}
|
||||||
<section class="section danger-zone">
|
<section class="section danger-zone">
|
||||||
<h2>Danger Zone</h2>
|
<h2>Vorsicht</h2>
|
||||||
<div class="danger-content">
|
<div class="danger-content">
|
||||||
<div>
|
<div>
|
||||||
<strong>Archive this project</strong>
|
<strong>Projekt archivieren</strong>
|
||||||
<p>The project will be hidden from all members.</p>
|
<p>Das Projekt verschwindet für alle Beteiligten.</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="danger" onclick={() => showArchiveModal = true}>Archive</Button>
|
<Button variant="danger" onclick={() => showArchiveModal = true}>Archivieren</Button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal bind:open={showArchiveModal} title="Archive Project">
|
<Modal bind:open={showArchiveModal} title="Projekt archivieren">
|
||||||
<p>Are you sure you want to archive <strong>{project?.name}</strong>? This will hide it from all members.</p>
|
<p>Sicher dass du <strong>{project?.name}</strong> archivieren willst? Es verschwindet dann für alle Beteiligten.</p>
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
<Button variant="secondary" onclick={() => showArchiveModal = false}>Cancel</Button>
|
<Button variant="secondary" onclick={() => showArchiveModal = false}>Abbrechen</Button>
|
||||||
<Button variant="danger" onclick={archiveProject}>Archive</Button>
|
<Button variant="danger" onclick={archiveProject}>Archivieren</Button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.settings-page {
|
.settings-page {
|
||||||
max-width: 600px;
|
max-width: 720px;
|
||||||
margin: 0 auto;
|
padding: var(--space-6);
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.settings-page {
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
}
|
}
|
||||||
|
.section {
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
margin-bottom: var(--space-8);
|
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 {
|
h1 {
|
||||||
margin: var(--space-2) 0 0;
|
margin: 0;
|
||||||
|
font-size: var(--text-2xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
@@ -236,12 +252,26 @@
|
|||||||
font-size: var(--text-lg);
|
font-size: var(--text-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
form {
|
form,
|
||||||
|
.details-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
align-items: flex-start;
|
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) {
|
form :global(.input-group) {
|
||||||
width: 100%;
|
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">
|
<script lang="ts">
|
||||||
import Avatar from '$lib/components/ui/Avatar.svelte';
|
import Avatar from '$lib/components/ui/Avatar.svelte';
|
||||||
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
import { formatTime, timeAgo } from '$lib/utils/format.js';
|
import { formatTime, timeAgo } from '$lib/utils/format.js';
|
||||||
|
|
||||||
type Comment = {
|
type Comment = {
|
||||||
@@ -14,18 +15,38 @@
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
comment,
|
comment,
|
||||||
|
currentUserId = null,
|
||||||
onSeek,
|
onSeek,
|
||||||
onResolve,
|
onResolve,
|
||||||
onReply,
|
onReply,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
comment: Comment;
|
comment: Comment;
|
||||||
|
currentUserId?: string | null;
|
||||||
onSeek?: (time: number) => void;
|
onSeek?: (time: number) => void;
|
||||||
onResolve: (id: string) => void;
|
onResolve: (id: string) => void;
|
||||||
onReply?: (id: string) => void;
|
onReply?: (id: string) => void;
|
||||||
|
onEdit?: (id: string, body: string) => Promise<void>;
|
||||||
|
onDelete?: (id: string) => Promise<void>;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const displayName = $derived(comment.user?.name ?? comment.guestName ?? 'Gast');
|
const displayName = $derived(comment.user?.name ?? comment.guestName ?? 'Gast');
|
||||||
const isGuest = $derived(!comment.user);
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="comment" class:resolved={comment.resolvedAt}>
|
<div class="comment" class:resolved={comment.resolvedAt}>
|
||||||
@@ -43,14 +64,38 @@
|
|||||||
<span class="comment-date">{timeAgo(comment.createdAt)}</span>
|
<span class="comment-date">{timeAgo(comment.createdAt)}</span>
|
||||||
<div class="comment-actions">
|
<div class="comment-actions">
|
||||||
{#if onReply}
|
{#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}
|
||||||
{#if !comment.resolvedAt}
|
{#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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<p class="comment-body">{comment.body}</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -126,6 +171,51 @@
|
|||||||
color: var(--color-success);
|
color: var(--color-success);
|
||||||
border-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 {
|
.guest-tag {
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
@@ -18,17 +18,23 @@
|
|||||||
let {
|
let {
|
||||||
comments,
|
comments,
|
||||||
canComment = false,
|
canComment = false,
|
||||||
|
currentUserId = null,
|
||||||
commentTimestamp = $bindable<number | null>(null),
|
commentTimestamp = $bindable<number | null>(null),
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onResolve,
|
onResolve,
|
||||||
onSeek,
|
onSeek,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
comments: Comment[];
|
comments: Comment[];
|
||||||
canComment?: boolean;
|
canComment?: boolean;
|
||||||
|
currentUserId?: string | null;
|
||||||
commentTimestamp: number | null;
|
commentTimestamp: number | null;
|
||||||
onSubmit: (body: string, timestamp: number | null, parentId?: string) => void;
|
onSubmit: (body: string, timestamp: number | null, parentId?: string) => void;
|
||||||
onResolve: (id: string) => void;
|
onResolve: (id: string) => void;
|
||||||
onSeek?: (time: number) => void;
|
onSeek?: (time: number) => void;
|
||||||
|
onEdit?: (id: string, body: string) => Promise<void>;
|
||||||
|
onDelete?: (id: string) => Promise<void>;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let body = $state('');
|
let body = $state('');
|
||||||
@@ -94,10 +100,10 @@
|
|||||||
|
|
||||||
<div class="comment-list">
|
<div class="comment-list">
|
||||||
{#each topLevel as comment}
|
{#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}
|
{#each replies(comment.id) as reply}
|
||||||
<div class="reply">
|
<div class="reply">
|
||||||
<CommentItem comment={reply} {onSeek} {onResolve} />
|
<CommentItem comment={reply} {currentUserId} {onSeek} {onResolve} {onEdit} {onDelete} />
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/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>
|
</script>
|
||||||
|
|
||||||
<div class="graph">
|
<div class="graph">
|
||||||
<h2>Version Graph</h2>
|
<h2>Versionen</h2>
|
||||||
{#if nodes.length === 0}
|
{#if nodes.length === 0}
|
||||||
<p class="empty">Noch keine Versionen.</p>
|
<p class="empty">Noch keine Versionen.</p>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -117,14 +117,14 @@
|
|||||||
font-size="12"
|
font-size="12"
|
||||||
fill="#ccc"
|
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>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
{/each}
|
{/each}
|
||||||
</svg>
|
</svg>
|
||||||
{#if onBranch && selectedId}
|
{#if onBranch && selectedId}
|
||||||
<button class="branch-btn" onclick={() => onBranch?.(selectedId!)}>
|
<button class="branch-btn" onclick={() => onBranch?.(selectedId!)}>
|
||||||
⑂ Neue Variante von dieser Version
|
Neue Variante von dieser Version
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Badge from '$lib/components/ui/Badge.svelte';
|
import Badge from '$lib/components/ui/Badge.svelte';
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
|
|
||||||
type Version = {
|
type Version = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -26,6 +27,14 @@
|
|||||||
({ approved: 'success', rejected: 'error', processing: 'warning', ready: 'accent', uploaded: 'default' } as const)[version.status] || 'default'
|
({ 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');
|
const showActions = $derived(canApprove && version.status !== 'approved' && version.status !== 'rejected');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -35,16 +44,16 @@
|
|||||||
V{version.versionNumber}
|
V{version.versionNumber}
|
||||||
{#if version.label} — {version.label}{/if}
|
{#if version.label} — {version.label}{/if}
|
||||||
</span>
|
</span>
|
||||||
<Badge variant={statusVariant}>{version.status}</Badge>
|
<Badge variant={statusVariant}>{STATUS_LABEL[version.status] || version.status}</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showActions}
|
{#if showActions}
|
||||||
<div class="version-actions">
|
<div class="version-actions">
|
||||||
<Button variant="ghost" size="sm" onclick={onApprove}>
|
<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>
|
||||||
<Button variant="ghost" size="sm" onclick={onReject}>
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -77,6 +86,14 @@
|
|||||||
gap: var(--space-1);
|
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 {
|
.version-notes {
|
||||||
margin: var(--space-2) 0 0;
|
margin: var(--space-2) 0 0;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
import { toastSuccess } from '$lib/stores/toast.js';
|
import { toastSuccess } from '$lib/stores/toast.js';
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
import Input from '$lib/components/ui/Input.svelte';
|
import Input from '$lib/components/ui/Input.svelte';
|
||||||
|
import TopBar from '$lib/components/workspace/TopBar.svelte';
|
||||||
|
|
||||||
let name = $state('');
|
let name = $state('');
|
||||||
let description = $state('');
|
let description = $state('');
|
||||||
@@ -17,7 +18,7 @@
|
|||||||
name,
|
name,
|
||||||
description: description || undefined,
|
description: description || undefined,
|
||||||
});
|
});
|
||||||
toastSuccess('Project created');
|
toastSuccess('Projekt erstellt');
|
||||||
goto(`/projects/${res.project.id}`);
|
goto(`/projects/${res.project.id}`);
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
@@ -25,21 +26,28 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<TopBar
|
||||||
|
crumbs={[
|
||||||
|
{ label: 'Projekte', href: '/dashboard' },
|
||||||
|
{ label: 'Neues Projekt' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h1>New Project</h1>
|
<h1>Neues Projekt</h1>
|
||||||
|
|
||||||
<form onsubmit={handleSubmit}>
|
<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">
|
<div class="textarea-group">
|
||||||
<label class="textarea-label">Description (optional)</label>
|
<label class="textarea-label">Beschreibung (optional)</label>
|
||||||
<textarea bind:value={description} placeholder="What's this project about?" rows="3"></textarea>
|
<textarea bind:value={description} placeholder="Worum geht's in diesem Projekt?" rows="3"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<Button variant="secondary" href="/dashboard">Cancel</Button>
|
<Button variant="secondary" href="/dashboard">Abbrechen</Button>
|
||||||
<Button type="submit" {loading}>Create</Button>
|
<Button type="submit" {loading}>Anlegen</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,7 +57,7 @@
|
|||||||
.page {
|
.page {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: var(--space-8) var(--space-4);
|
padding: var(--space-12) var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
@@ -1,10 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
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 ToastContainer from '$lib/components/ui/ToastContainer.svelte';
|
||||||
|
import '@fontsource-variable/inter';
|
||||||
|
|
||||||
let { children } = $props();
|
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(() => {
|
onMount(() => {
|
||||||
checkAuth();
|
checkAuth();
|
||||||
});
|
});
|
||||||
@@ -15,7 +24,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
{#if $authLoading}
|
{#if $authLoading && !isPublic}
|
||||||
<div class="loading">
|
<div class="loading">
|
||||||
<div class="loading-spinner"></div>
|
<div class="loading-spinner"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -27,33 +36,35 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
:global(:root) {
|
:global(:root) {
|
||||||
/* Background */
|
/* Background — warm neutrals */
|
||||||
--color-bg-base: #0a0a0a;
|
--color-bg-base: #0a0910;
|
||||||
--color-bg-raised: #111111;
|
--color-bg-raised: #131119;
|
||||||
--color-bg-overlay: #1a1a1a;
|
--color-bg-overlay: #1a1822;
|
||||||
--color-bg-subtle: #222222;
|
--color-bg-subtle: #221f2c;
|
||||||
|
|
||||||
/* Borders */
|
/* Borders */
|
||||||
--color-border: #2a2a2a;
|
--color-border: #24222e;
|
||||||
--color-border-hover: #333333;
|
--color-border-hover: #32303c;
|
||||||
--color-border-focus: #6366f1;
|
--color-border-focus: #f43f5e;
|
||||||
|
|
||||||
/* Text */
|
/* Text */
|
||||||
--color-text-primary: #f0f0f0;
|
--color-text-primary: #f4f0ec;
|
||||||
--color-text-secondary: #a0a0a0;
|
--color-text-secondary: #9b96a8;
|
||||||
--color-text-tertiary: #666666;
|
--color-text-tertiary: #5e596b;
|
||||||
|
|
||||||
/* Accent */
|
/* Accent — warm magenta → orange */
|
||||||
--color-accent: #6366f1;
|
--color-accent: #f43f5e;
|
||||||
--color-accent-hover: #5558e6;
|
--color-accent-2: #fb923c;
|
||||||
--color-accent-subtle: #1a1a2e;
|
--color-accent-hover: #e11d48;
|
||||||
|
--color-accent-subtle: #2a121c;
|
||||||
|
--gradient-accent: linear-gradient(135deg, #f43f5e 0%, #fb923c 100%);
|
||||||
|
|
||||||
/* Semantic */
|
/* Semantic */
|
||||||
--color-success: #22c55e;
|
--color-success: #22c55e;
|
||||||
--color-warning: #fbbf24;
|
--color-warning: #fbbf24;
|
||||||
--color-error: #ef4444;
|
--color-error: #ef4444;
|
||||||
|
|
||||||
/* Spacing */
|
/* Spacing — fluid scale */
|
||||||
--space-1: 0.25rem;
|
--space-1: 0.25rem;
|
||||||
--space-2: 0.5rem;
|
--space-2: 0.5rem;
|
||||||
--space-3: 0.75rem;
|
--space-3: 0.75rem;
|
||||||
@@ -63,31 +74,39 @@
|
|||||||
--space-8: 2rem;
|
--space-8: 2rem;
|
||||||
--space-10: 2.5rem;
|
--space-10: 2.5rem;
|
||||||
--space-12: 3rem;
|
--space-12: 3rem;
|
||||||
|
--space-16: 4rem;
|
||||||
|
--space-20: 5rem;
|
||||||
|
|
||||||
/* Radii */
|
/* Radii */
|
||||||
--radius-sm: 4px;
|
--radius-sm: 6px;
|
||||||
--radius-md: 8px;
|
--radius-md: 10px;
|
||||||
--radius-lg: 12px;
|
--radius-lg: 14px;
|
||||||
|
--radius-xl: 20px;
|
||||||
--radius-full: 9999px;
|
--radius-full: 9999px;
|
||||||
|
|
||||||
/* Shadows */
|
/* Shadows — soft + warm */
|
||||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5);
|
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.6);
|
--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 */
|
/* Typography — Inter first, system never */
|
||||||
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
--font-sans: 'Inter Variable', 'Inter', system-ui, sans-serif;
|
||||||
--font-mono: 'SF Mono', 'Fira Code', monospace;
|
--font-mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
|
||||||
--text-xs: 0.75rem;
|
--text-xs: 0.75rem;
|
||||||
--text-sm: 0.85rem;
|
--text-sm: 0.875rem;
|
||||||
--text-base: 0.9rem;
|
--text-base: 0.9375rem;
|
||||||
--text-lg: 1.1rem;
|
--text-lg: 1.125rem;
|
||||||
--text-xl: 1.5rem;
|
--text-xl: 1.5rem;
|
||||||
--text-2xl: 2rem;
|
--text-2xl: 2rem;
|
||||||
|
--text-3xl: 2.75rem;
|
||||||
|
--text-4xl: 3.75rem;
|
||||||
|
|
||||||
/* Transitions */
|
/* Transitions — opinionated easing */
|
||||||
--transition-fast: 150ms ease;
|
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
--transition-base: 200ms ease;
|
--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-Index */
|
||||||
--z-dropdown: 100;
|
--z-dropdown: 100;
|
||||||
@@ -95,31 +114,76 @@
|
|||||||
--z-toast: 300;
|
--z-toast: 300;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(html) {
|
||||||
|
background: var(--color-bg-base);
|
||||||
|
}
|
||||||
|
|
||||||
:global(body) {
|
:global(body) {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: var(--font-sans);
|
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);
|
color: var(--color-text-secondary);
|
||||||
font-size: var(--text-base);
|
font-size: var(--text-base);
|
||||||
line-height: 1.5;
|
line-height: 1.55;
|
||||||
|
font-feature-settings: 'cv11', 'ss01', 'ss03';
|
||||||
-webkit-font-smoothing: antialiased;
|
-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(*) {
|
:global(*) {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(h1, h2, h3) {
|
:global(h1, h2, h3, h4) {
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(h1) {
|
||||||
|
letter-spacing: -0.03em;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(a) {
|
:global(a) {
|
||||||
color: var(--color-accent);
|
color: var(--color-text-primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
transition: color var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(a:hover) {
|
: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 {
|
.loading {
|
||||||
|
|||||||
@@ -1,89 +1,701 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { user } from '$lib/stores/auth.js';
|
||||||
import { user, sendMagicLink } from '$lib/stores/auth.js';
|
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
import Input from '$lib/components/ui/Input.svelte';
|
|
||||||
|
|
||||||
let email = $state('');
|
// TODO: Demo-Token ist statisch. Phase 2: dynamisch via /api/v1/share/demo laden
|
||||||
let sent = $state(false);
|
// oder beim Seed in eine Settings-Tabelle schreiben.
|
||||||
let loading = $state(false);
|
const DEMO_SHARE_TOKEN = '0b75a672afaa14aa5d97c5af0343f93edd3aa78e5b3ce30d1d695977ac4a3fc1';
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="login-page">
|
<svelte:head>
|
||||||
<div class="login-card">
|
<title>Music Hub — Versionen für Musik. Ohne Chaos.</title>
|
||||||
<h1>Music Hub</h1>
|
<meta
|
||||||
<p class="subtitle">Collaboration for music production</p>
|
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="page">
|
||||||
<div class="success">
|
<!-- NAV -->
|
||||||
<p>Check your email for the login link.</p>
|
<nav class="nav">
|
||||||
<Button variant="secondary" onclick={() => { sent = false; email = ''; }}>Try again</Button>
|
<a href="/" class="logo">Music Hub</a>
|
||||||
</div>
|
<div class="nav-right">
|
||||||
|
{#if $user}
|
||||||
|
<Button href="/dashboard" size="sm">Zum Dashboard</Button>
|
||||||
{:else}
|
{:else}
|
||||||
<form onsubmit={handleSubmit}>
|
<a href="/login" class="nav-link">Einloggen</a>
|
||||||
<Input type="email" bind:value={email} placeholder="your@email.com" {error} />
|
<Button href="/login" size="sm">Kostenlos starten</Button>
|
||||||
<Button type="submit" size="lg" {loading}>Send Login Link</Button>
|
|
||||||
</form>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<style>
|
<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;
|
display: flex;
|
||||||
align-items: center;
|
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);
|
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 {
|
/* SECTIONS */
|
||||||
color: var(--color-success);
|
section {
|
||||||
margin-bottom: var(--space-4);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -9,14 +9,14 @@
|
|||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const token = $page.url.searchParams.get('token');
|
const token = $page.url.searchParams.get('token');
|
||||||
if (!token) {
|
if (!token) {
|
||||||
error = 'No token provided';
|
error = 'Kein Token angegeben';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await verifyToken(token);
|
await verifyToken(token);
|
||||||
goto('/dashboard');
|
goto('/dashboard');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof Error ? err.message : 'Verification failed';
|
error = err instanceof Error ? err.message : 'Login fehlgeschlagen';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -24,12 +24,12 @@
|
|||||||
<div class="verify-page">
|
<div class="verify-page">
|
||||||
{#if error}
|
{#if error}
|
||||||
<div class="error-card">
|
<div class="error-card">
|
||||||
<h2>Login Failed</h2>
|
<h2>Login fehlgeschlagen</h2>
|
||||||
<p>{error}</p>
|
<p>{error}</p>
|
||||||
<a href="/">Try again</a>
|
<a href="/login">Erneut versuchen</a>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p>Verifying...</p>
|
<p>Login läuft…</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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';
|
import { relative, sep } from 'node:path';
|
||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
/** @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.
|
// 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.
|
// 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.
|
// 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",
|
"name": "@music-hub/web",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
"@music-hub/shared": "workspace:*",
|
"@music-hub/shared": "workspace:*",
|
||||||
|
"@sveltejs/adapter-node": "^5.5.4",
|
||||||
"wavesurfer.js": "^7.12.5",
|
"wavesurfer.js": "^7.12.5",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -204,6 +206,8 @@
|
|||||||
|
|
||||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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-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=="],
|
"@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-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/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=="],
|
"@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/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=="],
|
"@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=="],
|
"@typescript-eslint/types": ["@typescript-eslint/types@8.58.0", "", {}, "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww=="],
|
||||||
@@ -430,6 +446,8 @@
|
|||||||
|
|
||||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
"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=="],
|
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
|
||||||
|
|
||||||
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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": ["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=="],
|
"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=="],
|
"@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=="],
|
"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=="],
|
"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:
|
services:
|
||||||
api:
|
# === WEB (Einstiegspunkt, einziger Service mit externem Port) ===
|
||||||
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:
|
web:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.web
|
dockerfile: Dockerfile.web
|
||||||
ports:
|
ports:
|
||||||
- "5173:3000"
|
- "${PORT:-3000}:3000"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
|
- API_INTERNAL_URL=http://api:3000
|
||||||
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
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:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
environment:
|
environment:
|
||||||
@@ -38,7 +51,56 @@ services:
|
|||||||
POSTGRES_DB: musichub
|
POSTGRES_DB: musichub
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U musichub"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
restart: unless-stopped
|
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:
|
volumes:
|
||||||
pgdata:
|
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,
|
"when": 1775571497577,
|
||||||
"tag": "0001_many_sir_ram",
|
"tag": "0001_many_sir_ram",
|
||||||
"breakpoints": true
|
"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',
|
'rejected',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const trackStatusEnum = pgEnum('track_status', [
|
||||||
|
'sketch',
|
||||||
|
'in_progress',
|
||||||
|
'final',
|
||||||
|
'released',
|
||||||
|
]);
|
||||||
|
|
||||||
export const tracks = pgTable('tracks', {
|
export const tracks = pgTable('tracks', {
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
projectId: uuid('project_id')
|
projectId: uuid('project_id')
|
||||||
@@ -28,6 +35,9 @@ export const tracks = pgTable('tracks', {
|
|||||||
.notNull(),
|
.notNull(),
|
||||||
name: varchar('name', { length: 255 }).notNull(),
|
name: varchar('name', { length: 255 }).notNull(),
|
||||||
description: text('description'),
|
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(),
|
sortOrder: integer('sort_order').default(0).notNull(),
|
||||||
createdById: uuid('created_by_id')
|
createdById: uuid('created_by_id')
|
||||||
.references(() => users.id)
|
.references(() => users.id)
|
||||||
|
|||||||
@@ -12,14 +12,14 @@ export const PROJECT_ROLES = [
|
|||||||
export type ProjectRole = (typeof PROJECT_ROLES)[number];
|
export type ProjectRole = (typeof PROJECT_ROLES)[number];
|
||||||
|
|
||||||
export const ROLE_LABELS: Record<ProjectRole, string> = {
|
export const ROLE_LABELS: Record<ProjectRole, string> = {
|
||||||
owner: 'Owner',
|
owner: 'Besitzer',
|
||||||
recording_engineer: 'Recording Engineer',
|
recording_engineer: 'Aufnahme',
|
||||||
mixing_engineer: 'Mixing Engineer',
|
mixing_engineer: 'Mixing',
|
||||||
mastering_engineer: 'Mastering Engineer',
|
mastering_engineer: 'Mastering',
|
||||||
artist: 'Artist',
|
artist: 'Artist',
|
||||||
label: 'Label',
|
label: 'Label',
|
||||||
management: 'Management',
|
management: 'Management',
|
||||||
viewer: 'Viewer',
|
viewer: 'Nur Zuhören',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ENGINEER_ROLES: ProjectRole[] = [
|
export const ENGINEER_ROLES: ProjectRole[] = [
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const createProjectSchema = z.object({
|
|||||||
export const updateProjectSchema = z.object({
|
export const updateProjectSchema = z.object({
|
||||||
name: z.string().min(1).max(255).optional(),
|
name: z.string().min(1).max(255).optional(),
|
||||||
description: z.string().max(2000).optional(),
|
description: z.string().max(2000).optional(),
|
||||||
|
coverImageUrl: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const inviteMemberSchema = z.object({
|
export const inviteMemberSchema = z.object({
|
||||||
|
|||||||
@@ -6,9 +6,28 @@ export const createTrackSchema = z.object({
|
|||||||
description: z.string().max(2000).optional(),
|
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({
|
export const updateTrackSchema = z.object({
|
||||||
name: z.string().min(1).max(255).optional(),
|
name: z.string().min(1).max(255).optional(),
|
||||||
description: z.string().max(2000).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({
|
export const requestUploadUrlSchema = z.object({
|
||||||
@@ -28,6 +47,12 @@ export const createVersionSchema = z.object({
|
|||||||
branchLabel: z.string().max(100).optional(),
|
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({
|
export const createShareLinkSchema = z.object({
|
||||||
expiresAt: z.string().datetime().optional(),
|
expiresAt: z.string().datetime().optional(),
|
||||||
allowComments: z.boolean().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 UpdateTrackInput = z.infer<typeof updateTrackSchema>;
|
||||||
export type RequestUploadUrlInput = z.infer<typeof requestUploadUrlSchema>;
|
export type RequestUploadUrlInput = z.infer<typeof requestUploadUrlSchema>;
|
||||||
export type CreateVersionInput = z.infer<typeof createVersionSchema>;
|
export type CreateVersionInput = z.infer<typeof createVersionSchema>;
|
||||||
|
export type UpdateVersionInput = z.infer<typeof updateVersionSchema>;
|
||||||
export type CreateShareLinkInput = z.infer<typeof createShareLinkSchema>;
|
export type CreateShareLinkInput = z.infer<typeof createShareLinkSchema>;
|
||||||
|
export type CoverUploadInput = z.infer<typeof coverUploadSchema>;
|
||||||
export type GuestCommentInput = z.infer<typeof guestCommentSchema>;
|
export type GuestCommentInput = z.infer<typeof guestCommentSchema>;
|
||||||
|
|||||||
Reference in New Issue
Block a user