From 78c7383d5487cb7e02110998cefc7f49653eb56b Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Mon, 16 Feb 2026 11:13:08 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(lightwrite):=20add=20Beat/Lyri?= =?UTF-8?q?cs=20Editor=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add NestJS backend with project, beat, marker, lyrics, export modules - Add SvelteKit web app with wavesurfer.js waveform visualization - Add BPM detection using Web Audio API peak detection - Add marker timeline for parts, hooks, bridges - Add lyrics editor with timestamp sync - Add export to LRC, SRT, JSON formats - Add shared-storage support for lightwrite - Fix mana-core-auth env loading (add dotenv before validation) - Add lightwrite to setup-databases.sh - Fix matrix-onboarding-bot type errors (displayName → fullName) Co-Authored-By: Claude Opus 4.5 --- apps/lightwrite/CLAUDE.md | 165 +++++++++ apps/lightwrite/apps/backend/.env.example | 15 + .../lightwrite/apps/backend/drizzle.config.ts | 6 + apps/lightwrite/apps/backend/nest-cli.json | 8 + apps/lightwrite/apps/backend/package.json | 57 +++ .../lightwrite/apps/backend/src/app.module.ts | 26 ++ .../apps/backend/src/beat/beat.controller.ts | 69 ++++ .../apps/backend/src/beat/beat.module.ts | 10 + .../apps/backend/src/beat/beat.service.ts | 130 +++++++ .../apps/backend/src/beat/dto/beat.dto.ts | 33 ++ .../apps/backend/src/db/connection.ts | 38 ++ .../apps/backend/src/db/database.module.ts | 29 ++ .../lightwrite/apps/backend/src/db/migrate.ts | 26 ++ .../backend/src/db/schema/beats.schema.ts | 19 + .../apps/backend/src/db/schema/index.ts | 4 + .../backend/src/db/schema/lyrics.schema.ts | 27 ++ .../backend/src/db/schema/markers.schema.ts | 18 + .../backend/src/db/schema/projects.schema.ts | 13 + apps/lightwrite/apps/backend/src/db/seed.ts | 34 ++ .../backend/src/export/export.controller.ts | 25 ++ .../apps/backend/src/export/export.module.ts | 15 + .../apps/backend/src/export/export.service.ts | 173 +++++++++ .../apps/backend/src/lyrics/dto/lyrics.dto.ts | 53 +++ .../backend/src/lyrics/lyrics.controller.ts | 53 +++ .../apps/backend/src/lyrics/lyrics.module.ts | 10 + .../apps/backend/src/lyrics/lyrics.service.ts | 133 +++++++ apps/lightwrite/apps/backend/src/main.ts | 8 + .../apps/backend/src/marker/dto/marker.dto.ts | 137 +++++++ .../backend/src/marker/marker.controller.ts | 79 +++++ .../apps/backend/src/marker/marker.module.ts | 10 + .../apps/backend/src/marker/marker.service.ts | 110 ++++++ .../backend/src/project/dto/project.dto.ts | 23 ++ .../backend/src/project/project.controller.ts | 58 +++ .../backend/src/project/project.module.ts | 10 + .../backend/src/project/project.service.ts | 73 ++++ apps/lightwrite/apps/backend/tsconfig.json | 27 ++ apps/lightwrite/apps/landing/astro.config.mjs | 7 + apps/lightwrite/apps/landing/package.json | 20 ++ .../apps/landing/src/layouts/Layout.astro | 48 +++ .../apps/landing/src/pages/index.astro | 212 +++++++++++ apps/lightwrite/apps/landing/tsconfig.json | 6 + apps/lightwrite/apps/web/.env.example | 7 + apps/lightwrite/apps/web/package.json | 47 +++ apps/lightwrite/apps/web/src/app.css | 121 +++++++ apps/lightwrite/apps/web/src/app.html | 19 + .../src/lib/components/BeatUploader.svelte | 163 +++++++++ .../src/lib/components/KaraokePreview.svelte | 109 ++++++ .../src/lib/components/LyricsEditor.svelte | 214 +++++++++++ .../src/lib/components/MarkerTimeline.svelte | 185 ++++++++++ .../lib/components/PlaybackControls.svelte | 150 ++++++++ .../src/lib/components/WaveformEditor.svelte | 179 ++++++++++ .../apps/web/src/lib/stores/audio.svelte.ts | 81 +++++ .../apps/web/src/lib/stores/auth.svelte.ts | 191 ++++++++++ .../apps/web/src/lib/stores/editor.svelte.ts | 143 ++++++++ .../apps/web/src/lib/stores/project.svelte.ts | 264 ++++++++++++++ .../apps/web/src/lib/utils/bpm-detector.ts | 234 ++++++++++++ .../apps/web/src/lib/utils/time-format.ts | 44 +++ .../apps/web/src/routes/+layout.svelte | 29 ++ .../apps/web/src/routes/+page.svelte | 335 ++++++++++++++++++ .../web/src/routes/editor/[id]/+page.svelte | 327 +++++++++++++++++ .../apps/web/src/routes/health/+server.ts | 5 + apps/lightwrite/apps/web/svelte.config.js | 14 + apps/lightwrite/apps/web/tsconfig.json | 14 + apps/lightwrite/apps/web/vite.config.ts | 17 + apps/lightwrite/package.json | 7 + apps/lightwrite/packages/shared/package.json | 17 + apps/lightwrite/packages/shared/src/index.ts | 1 + .../packages/shared/src/types/beat.ts | 34 ++ .../packages/shared/src/types/export.ts | 57 +++ .../packages/shared/src/types/index.ts | 5 + .../packages/shared/src/types/lyrics.ts | 55 +++ .../packages/shared/src/types/marker.ts | 49 +++ .../packages/shared/src/types/project.ts | 18 + apps/lightwrite/packages/shared/tsconfig.json | 15 + packages/shared-storage/src/factory.ts | 7 + packages/shared-storage/src/index.ts | 1 + packages/shared-storage/src/types.ts | 1 + scripts/setup-databases.sh | 9 +- .../src/config/env.validation.ts | 4 + .../src/bot/matrix.service.ts | 4 +- .../src/config/configuration.ts | 85 +++-- 81 files changed, 5244 insertions(+), 34 deletions(-) create mode 100644 apps/lightwrite/CLAUDE.md create mode 100644 apps/lightwrite/apps/backend/.env.example create mode 100644 apps/lightwrite/apps/backend/drizzle.config.ts create mode 100644 apps/lightwrite/apps/backend/nest-cli.json create mode 100644 apps/lightwrite/apps/backend/package.json create mode 100644 apps/lightwrite/apps/backend/src/app.module.ts create mode 100644 apps/lightwrite/apps/backend/src/beat/beat.controller.ts create mode 100644 apps/lightwrite/apps/backend/src/beat/beat.module.ts create mode 100644 apps/lightwrite/apps/backend/src/beat/beat.service.ts create mode 100644 apps/lightwrite/apps/backend/src/beat/dto/beat.dto.ts create mode 100644 apps/lightwrite/apps/backend/src/db/connection.ts create mode 100644 apps/lightwrite/apps/backend/src/db/database.module.ts create mode 100644 apps/lightwrite/apps/backend/src/db/migrate.ts create mode 100644 apps/lightwrite/apps/backend/src/db/schema/beats.schema.ts create mode 100644 apps/lightwrite/apps/backend/src/db/schema/index.ts create mode 100644 apps/lightwrite/apps/backend/src/db/schema/lyrics.schema.ts create mode 100644 apps/lightwrite/apps/backend/src/db/schema/markers.schema.ts create mode 100644 apps/lightwrite/apps/backend/src/db/schema/projects.schema.ts create mode 100644 apps/lightwrite/apps/backend/src/db/seed.ts create mode 100644 apps/lightwrite/apps/backend/src/export/export.controller.ts create mode 100644 apps/lightwrite/apps/backend/src/export/export.module.ts create mode 100644 apps/lightwrite/apps/backend/src/export/export.service.ts create mode 100644 apps/lightwrite/apps/backend/src/lyrics/dto/lyrics.dto.ts create mode 100644 apps/lightwrite/apps/backend/src/lyrics/lyrics.controller.ts create mode 100644 apps/lightwrite/apps/backend/src/lyrics/lyrics.module.ts create mode 100644 apps/lightwrite/apps/backend/src/lyrics/lyrics.service.ts create mode 100644 apps/lightwrite/apps/backend/src/main.ts create mode 100644 apps/lightwrite/apps/backend/src/marker/dto/marker.dto.ts create mode 100644 apps/lightwrite/apps/backend/src/marker/marker.controller.ts create mode 100644 apps/lightwrite/apps/backend/src/marker/marker.module.ts create mode 100644 apps/lightwrite/apps/backend/src/marker/marker.service.ts create mode 100644 apps/lightwrite/apps/backend/src/project/dto/project.dto.ts create mode 100644 apps/lightwrite/apps/backend/src/project/project.controller.ts create mode 100644 apps/lightwrite/apps/backend/src/project/project.module.ts create mode 100644 apps/lightwrite/apps/backend/src/project/project.service.ts create mode 100644 apps/lightwrite/apps/backend/tsconfig.json create mode 100644 apps/lightwrite/apps/landing/astro.config.mjs create mode 100644 apps/lightwrite/apps/landing/package.json create mode 100644 apps/lightwrite/apps/landing/src/layouts/Layout.astro create mode 100644 apps/lightwrite/apps/landing/src/pages/index.astro create mode 100644 apps/lightwrite/apps/landing/tsconfig.json create mode 100644 apps/lightwrite/apps/web/.env.example create mode 100644 apps/lightwrite/apps/web/package.json create mode 100644 apps/lightwrite/apps/web/src/app.css create mode 100644 apps/lightwrite/apps/web/src/app.html create mode 100644 apps/lightwrite/apps/web/src/lib/components/BeatUploader.svelte create mode 100644 apps/lightwrite/apps/web/src/lib/components/KaraokePreview.svelte create mode 100644 apps/lightwrite/apps/web/src/lib/components/LyricsEditor.svelte create mode 100644 apps/lightwrite/apps/web/src/lib/components/MarkerTimeline.svelte create mode 100644 apps/lightwrite/apps/web/src/lib/components/PlaybackControls.svelte create mode 100644 apps/lightwrite/apps/web/src/lib/components/WaveformEditor.svelte create mode 100644 apps/lightwrite/apps/web/src/lib/stores/audio.svelte.ts create mode 100644 apps/lightwrite/apps/web/src/lib/stores/auth.svelte.ts create mode 100644 apps/lightwrite/apps/web/src/lib/stores/editor.svelte.ts create mode 100644 apps/lightwrite/apps/web/src/lib/stores/project.svelte.ts create mode 100644 apps/lightwrite/apps/web/src/lib/utils/bpm-detector.ts create mode 100644 apps/lightwrite/apps/web/src/lib/utils/time-format.ts create mode 100644 apps/lightwrite/apps/web/src/routes/+layout.svelte create mode 100644 apps/lightwrite/apps/web/src/routes/+page.svelte create mode 100644 apps/lightwrite/apps/web/src/routes/editor/[id]/+page.svelte create mode 100644 apps/lightwrite/apps/web/src/routes/health/+server.ts create mode 100644 apps/lightwrite/apps/web/svelte.config.js create mode 100644 apps/lightwrite/apps/web/tsconfig.json create mode 100644 apps/lightwrite/apps/web/vite.config.ts create mode 100644 apps/lightwrite/package.json create mode 100644 apps/lightwrite/packages/shared/package.json create mode 100644 apps/lightwrite/packages/shared/src/index.ts create mode 100644 apps/lightwrite/packages/shared/src/types/beat.ts create mode 100644 apps/lightwrite/packages/shared/src/types/export.ts create mode 100644 apps/lightwrite/packages/shared/src/types/index.ts create mode 100644 apps/lightwrite/packages/shared/src/types/lyrics.ts create mode 100644 apps/lightwrite/packages/shared/src/types/marker.ts create mode 100644 apps/lightwrite/packages/shared/src/types/project.ts create mode 100644 apps/lightwrite/packages/shared/tsconfig.json diff --git a/apps/lightwrite/CLAUDE.md b/apps/lightwrite/CLAUDE.md new file mode 100644 index 000000000..56e98c66d --- /dev/null +++ b/apps/lightwrite/CLAUDE.md @@ -0,0 +1,165 @@ +# LightWrite - Beat & Lyrics Editor + +LightWrite is a web application for creating and editing beats with synchronized lyrics. It provides waveform visualization, BPM detection, timestamp markers, and exports to multiple formats. + +## Architecture + +``` +apps/lightwrite/ +├── apps/ +│ ├── backend/ # NestJS API (port 3010) +│ ├── web/ # SvelteKit app (port 5180) +│ └── landing/ # Astro marketing page +├── packages/ +│ └── shared/ # Shared types (@lightwrite/shared) +└── package.json +``` + +## Quick Start + +```bash +# Start with full database setup +pnpm dev:lightwrite:full + +# Or start components individually +pnpm docker:up # Start PostgreSQL, Redis, MinIO +pnpm --filter @lightwrite/backend dev # Backend on port 3010 +pnpm --filter @lightwrite/web dev # Web on port 5180 +pnpm --filter @lightwrite/landing dev # Landing page +``` + +## Backend API Endpoints + +### Projects +- `GET /projects` - List user's projects +- `GET /projects/:id` - Get project with beat and lyrics +- `POST /projects` - Create project +- `PUT /projects/:id` - Update project +- `DELETE /projects/:id` - Delete project + +### Beats +- `GET /beats/project/:projectId` - Get beat for project +- `GET /beats/:id` - Get beat with markers +- `GET /beats/:id/download-url` - Get presigned download URL +- `POST /beats/upload` - Create beat and get upload URL +- `PUT /beats/:id/metadata` - Update BPM, duration, waveform data +- `DELETE /beats/:id` - Delete beat + +### Markers +- `GET /markers/beat/:beatId` - Get markers for beat +- `POST /markers` - Create marker +- `POST /markers/bulk` - Bulk create markers +- `PUT /markers/:id` - Update marker +- `PUT /markers/bulk` - Bulk update markers +- `DELETE /markers/:id` - Delete marker +- `DELETE /markers/beat/:beatId` - Delete all markers for beat + +### Lyrics +- `GET /lyrics/project/:projectId` - Get lyrics with synced lines +- `POST /lyrics/project/:projectId` - Create/update lyrics content +- `POST /lyrics/:id/sync` - Sync line timestamps +- `PUT /lyrics/line/:lineId/timestamp` - Update single line timestamp + +### Export +- `GET /export/:projectId?format=lrc|srt|json` - Export project + +## Database Schema + +```typescript +// projects - User projects +{ id, userId, title, description, createdAt, updatedAt } + +// beats - Audio files +{ id, projectId, storagePath, filename, duration, bpm, bpmConfidence, waveformData } + +// markers - Part/section markers +{ id, beatId, type, label, startTime, endTime, color, sortOrder } + +// lyrics - Full lyrics text +{ id, projectId, content } + +// lyric_lines - Individual synced lines +{ id, lyricsId, lineNumber, text, startTime, endTime } +``` + +## Marker Types + +- `intro` - Introduction +- `verse` - Verse section +- `hook` - Hook/Chorus +- `bridge` - Bridge section +- `drop` - Drop +- `breakdown` - Breakdown +- `outro` - Outro +- `custom` - Custom marker + +## Key Technologies + +| Component | Technology | +|-----------|------------| +| Frontend | SvelteKit 2, Svelte 5, Tailwind CSS 4 | +| Waveform | wavesurfer.js 7.x | +| BPM Detection | Web Audio API (peak detection) | +| Backend | NestJS 10, Drizzle ORM | +| Database | PostgreSQL | +| Storage | MinIO (dev) / Hetzner S3 (prod) | +| Auth | mana-core-auth | + +## Environment Variables + +### Backend (.env) +``` +DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/lightwrite +MANA_CORE_AUTH_URL=http://localhost:3001 +S3_ENDPOINT=http://localhost:9000 +S3_REGION=us-east-1 +S3_ACCESS_KEY=minioadmin +S3_SECRET_KEY=minioadmin +S3_BUCKET=lightwrite-storage +``` + +### Web (.env) +``` +PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001 +PUBLIC_BACKEND_URL=http://localhost:3010 +``` + +## Export Formats + +| Format | Use Case | +|--------|----------| +| LRC | Standard lyrics format for music players | +| SRT | Subtitles for video players | +| JSON | API/integration, full project data | + +## Development Commands + +```bash +# Database +pnpm --filter @lightwrite/backend db:push # Push schema +pnpm --filter @lightwrite/backend db:studio # Open Drizzle Studio + +# Type checking +pnpm --filter @lightwrite/backend type-check +pnpm --filter @lightwrite/web type-check + +# Build +pnpm --filter @lightwrite/backend build +pnpm --filter @lightwrite/web build +``` + +## Feature Implementation Status + +- [x] Project CRUD +- [x] Beat upload with S3 storage +- [x] Waveform visualization (wavesurfer.js) +- [x] BPM detection (Web Audio API) +- [x] Part markers with regions +- [x] Lyrics editor with line sync +- [x] Karaoke preview +- [x] LRC export +- [x] SRT export +- [x] JSON export +- [ ] Video export (client-side Canvas → WebM) +- [ ] Word-by-word sync +- [ ] essentia.js WASM for better BPM detection diff --git a/apps/lightwrite/apps/backend/.env.example b/apps/lightwrite/apps/backend/.env.example new file mode 100644 index 000000000..13c7e6581 --- /dev/null +++ b/apps/lightwrite/apps/backend/.env.example @@ -0,0 +1,15 @@ +# Database +DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/lightwrite + +# Auth +MANA_CORE_AUTH_URL=http://localhost:3001 +NODE_ENV=development +DEV_BYPASS_AUTH=true +DEV_USER_ID=dev-user-id + +# Storage (S3/MinIO) +S3_ENDPOINT=http://localhost:9000 +S3_REGION=us-east-1 +S3_ACCESS_KEY=minioadmin +S3_SECRET_KEY=minioadmin +S3_BUCKET=lightwrite-storage diff --git a/apps/lightwrite/apps/backend/drizzle.config.ts b/apps/lightwrite/apps/backend/drizzle.config.ts new file mode 100644 index 000000000..9c94ba17b --- /dev/null +++ b/apps/lightwrite/apps/backend/drizzle.config.ts @@ -0,0 +1,6 @@ +import { createDrizzleConfig } from '@manacore/shared-drizzle-config'; + +export default createDrizzleConfig({ + dbName: 'lightwrite', + additionalEnvVars: ['LIGHTWRITE_DATABASE_URL'], +}); diff --git a/apps/lightwrite/apps/backend/nest-cli.json b/apps/lightwrite/apps/backend/nest-cli.json new file mode 100644 index 000000000..95538fb90 --- /dev/null +++ b/apps/lightwrite/apps/backend/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/apps/lightwrite/apps/backend/package.json b/apps/lightwrite/apps/backend/package.json new file mode 100644 index 000000000..354f61ed6 --- /dev/null +++ b/apps/lightwrite/apps/backend/package.json @@ -0,0 +1,57 @@ +{ + "name": "@lightwrite/backend", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "nest build", + "start": "nest start", + "dev": "nest start --watch", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "type-check": "tsc --noEmit", + "migration:generate": "drizzle-kit generate", + "migration:run": "tsx src/db/migrate.ts", + "db:push": "drizzle-kit push", + "db:studio": "drizzle-kit studio", + "db:seed": "tsx src/db/seed.ts" + }, + "dependencies": { + "@lightwrite/shared": "workspace:*", + "@manacore/shared-nestjs-auth": "workspace:*", + "@manacore/shared-nestjs-health": "workspace:*", + "@manacore/shared-nestjs-setup": "workspace:*", + "@manacore/shared-storage": "workspace:*", + "@nestjs/common": "^10.4.15", + "@nestjs/config": "^3.3.0", + "@nestjs/core": "^10.4.15", + "@nestjs/platform-express": "^10.4.15", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "dotenv": "^16.4.7", + "drizzle-kit": "^0.30.2", + "drizzle-orm": "^0.38.3", + "postgres": "^3.4.5", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.4.9", + "@nestjs/schematics": "^10.2.3", + "@types/express": "^5.0.0", + "@types/node": "^22.10.2", + "@typescript-eslint/eslint-plugin": "^8.18.1", + "@typescript-eslint/parser": "^8.18.1", + "eslint": "^9.17.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.2.1", + "prettier": "^3.4.2", + "source-map-support": "^0.5.21", + "ts-loader": "^9.5.1", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } +} diff --git a/apps/lightwrite/apps/backend/src/app.module.ts b/apps/lightwrite/apps/backend/src/app.module.ts new file mode 100644 index 000000000..eebcea8d1 --- /dev/null +++ b/apps/lightwrite/apps/backend/src/app.module.ts @@ -0,0 +1,26 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { DatabaseModule } from './db/database.module'; +import { ProjectModule } from './project/project.module'; +import { BeatModule } from './beat/beat.module'; +import { MarkerModule } from './marker/marker.module'; +import { LyricsModule } from './lyrics/lyrics.module'; +import { ExportModule } from './export/export.module'; +import { HealthModule } from '@manacore/shared-nestjs-health'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: '.env', + }), + DatabaseModule, + ProjectModule, + BeatModule, + MarkerModule, + LyricsModule, + ExportModule, + HealthModule.forRoot({ serviceName: 'lightwrite-backend' }), + ], +}) +export class AppModule {} diff --git a/apps/lightwrite/apps/backend/src/beat/beat.controller.ts b/apps/lightwrite/apps/backend/src/beat/beat.controller.ts new file mode 100644 index 000000000..3771ab125 --- /dev/null +++ b/apps/lightwrite/apps/backend/src/beat/beat.controller.ts @@ -0,0 +1,69 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + UseGuards, + ParseUUIDPipe, +} from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { BeatService } from './beat.service'; +import { CreateBeatUploadDto, UpdateBeatMetadataDto } from './dto/beat.dto'; + +@Controller('beats') +@UseGuards(JwtAuthGuard) +export class BeatController { + constructor(private readonly beatService: BeatService) {} + + @Get('project/:projectId') + async findByProject( + @CurrentUser() user: CurrentUserData, + @Param('projectId', ParseUUIDPipe) projectId: string + ) { + await this.beatService.verifyProjectOwnership(projectId, user.userId); + const beat = await this.beatService.findByProjectId(projectId); + return { beat }; + } + + @Get(':id') + async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { + const beat = await this.beatService.findByIdOrThrow(id); + await this.beatService.verifyProjectOwnership(beat.projectId, user.userId); + const beatMarkers = await this.beatService.getMarkersForBeat(id); + return { beat, markers: beatMarkers }; + } + + @Get(':id/download-url') + async getDownloadUrl( + @CurrentUser() user: CurrentUserData, + @Param('id', ParseUUIDPipe) id: string + ) { + const url = await this.beatService.getDownloadUrl(id, user.userId); + return { url }; + } + + @Post('upload') + async createUploadUrl(@CurrentUser() user: CurrentUserData, @Body() dto: CreateBeatUploadDto) { + const result = await this.beatService.createUploadUrl(dto.projectId, user.userId, dto.filename); + return result; + } + + @Put(':id/metadata') + async updateMetadata( + @CurrentUser() user: CurrentUserData, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateBeatMetadataDto + ) { + const beat = await this.beatService.updateBeatMetadata(id, user.userId, dto); + return { beat }; + } + + @Delete(':id') + async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { + await this.beatService.delete(id, user.userId); + return { success: true }; + } +} diff --git a/apps/lightwrite/apps/backend/src/beat/beat.module.ts b/apps/lightwrite/apps/backend/src/beat/beat.module.ts new file mode 100644 index 000000000..549054fa4 --- /dev/null +++ b/apps/lightwrite/apps/backend/src/beat/beat.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { BeatController } from './beat.controller'; +import { BeatService } from './beat.service'; + +@Module({ + controllers: [BeatController], + providers: [BeatService], + exports: [BeatService], +}) +export class BeatModule {} diff --git a/apps/lightwrite/apps/backend/src/beat/beat.service.ts b/apps/lightwrite/apps/backend/src/beat/beat.service.ts new file mode 100644 index 000000000..a514b0cab --- /dev/null +++ b/apps/lightwrite/apps/backend/src/beat/beat.service.ts @@ -0,0 +1,130 @@ +import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common'; +import { eq, and } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { Database } from '../db/connection'; +import { beats, projects, markers } from '../db/schema'; +import type { Beat, Marker } from '../db/schema'; +import { + createLightWriteStorage, + generateUserFileKey, + getContentType, + type StorageClient, +} from '@manacore/shared-storage'; + +@Injectable() +export class BeatService { + private storage: StorageClient; + + constructor(@Inject(DATABASE_CONNECTION) private db: Database) { + this.storage = createLightWriteStorage(); + } + + async findByProjectId(projectId: string): Promise { + const [beat] = await this.db.select().from(beats).where(eq(beats.projectId, projectId)); + return beat || null; + } + + async findById(id: string): Promise { + const [beat] = await this.db.select().from(beats).where(eq(beats.id, id)); + return beat || null; + } + + async findByIdOrThrow(id: string): Promise { + const beat = await this.findById(id); + if (!beat) { + throw new NotFoundException('Beat not found'); + } + return beat; + } + + async verifyProjectOwnership(projectId: string, userId: string): Promise { + const [project] = await this.db + .select() + .from(projects) + .where(and(eq(projects.id, projectId), eq(projects.userId, userId))); + if (!project) { + throw new NotFoundException('Project not found'); + } + } + + async createUploadUrl( + projectId: string, + userId: string, + filename: string + ): Promise<{ beat: Beat; uploadUrl: string }> { + await this.verifyProjectOwnership(projectId, userId); + + // Check if beat already exists for this project + const existingBeat = await this.findByProjectId(projectId); + if (existingBeat) { + throw new BadRequestException('Beat already exists for this project. Delete it first.'); + } + + const key = generateUserFileKey(userId, filename); + const contentType = getContentType(filename); + + if (!contentType.startsWith('audio/') && !['application/octet-stream'].includes(contentType)) { + throw new BadRequestException('Invalid file type. Only audio files are allowed.'); + } + + // Create beat record + const [beat] = await this.db + .insert(beats) + .values({ + projectId, + storagePath: key, + filename, + }) + .returning(); + + // Generate presigned upload URL + const uploadUrl = await this.storage.getUploadUrl(key, { + expiresIn: 3600, + }); + + return { beat, uploadUrl }; + } + + async updateBeatMetadata( + id: string, + userId: string, + data: { + duration?: number; + bpm?: number; + bpmConfidence?: number; + waveformData?: unknown; + } + ): Promise { + const beat = await this.findByIdOrThrow(id); + await this.verifyProjectOwnership(beat.projectId, userId); + + const [updatedBeat] = await this.db.update(beats).set(data).where(eq(beats.id, id)).returning(); + return updatedBeat; + } + + async getDownloadUrl(id: string, userId: string): Promise { + const beat = await this.findByIdOrThrow(id); + await this.verifyProjectOwnership(beat.projectId, userId); + + return this.storage.getDownloadUrl(beat.storagePath, { expiresIn: 3600 }); + } + + async delete(id: string, userId: string): Promise { + const beat = await this.findByIdOrThrow(id); + await this.verifyProjectOwnership(beat.projectId, userId); + + // Delete from storage + try { + await this.storage.delete(beat.storagePath); + } catch { + // Ignore storage errors, continue with DB deletion + } + + // Delete from database (markers will be cascade deleted) + await this.db.delete(beats).where(eq(beats.id, id)); + } + + async getMarkersForBeat(beatId: string): Promise { + return this.db.select().from(markers).where(eq(markers.beatId, beatId)); + } +} diff --git a/apps/lightwrite/apps/backend/src/beat/dto/beat.dto.ts b/apps/lightwrite/apps/backend/src/beat/dto/beat.dto.ts new file mode 100644 index 000000000..894391bb4 --- /dev/null +++ b/apps/lightwrite/apps/backend/src/beat/dto/beat.dto.ts @@ -0,0 +1,33 @@ +import { IsString, IsNotEmpty, IsUUID, IsNumber, IsOptional, IsObject } from 'class-validator'; + +export class CreateBeatUploadDto { + @IsUUID() + @IsNotEmpty() + projectId!: string; + + @IsString() + @IsNotEmpty() + filename!: string; +} + +export class UpdateBeatMetadataDto { + @IsNumber() + @IsOptional() + duration?: number; + + @IsNumber() + @IsOptional() + bpm?: number; + + @IsNumber() + @IsOptional() + bpmConfidence?: number; + + @IsObject() + @IsOptional() + waveformData?: { + peaks: number[]; + sampleRate: number; + duration: number; + }; +} diff --git a/apps/lightwrite/apps/backend/src/db/connection.ts b/apps/lightwrite/apps/backend/src/db/connection.ts new file mode 100644 index 000000000..fccc63f4a --- /dev/null +++ b/apps/lightwrite/apps/backend/src/db/connection.ts @@ -0,0 +1,38 @@ +import { drizzle } from 'drizzle-orm/postgres-js'; +import * as schema from './schema'; + +// Use require for postgres to avoid ESM/CommonJS interop issues +// eslint-disable-next-line @typescript-eslint/no-var-requires +const postgres = require('postgres'); + +let connection: ReturnType | null = null; +let db: ReturnType | null = null; + +export function getConnection(databaseUrl: string) { + if (!connection) { + connection = postgres(databaseUrl, { + max: 10, + idle_timeout: 20, + connect_timeout: 10, + }); + } + return connection; +} + +export function getDb(databaseUrl: string) { + if (!db) { + const conn = getConnection(databaseUrl); + db = drizzle(conn, { schema }); + } + return db; +} + +export async function closeConnection() { + if (connection) { + await connection.end(); + connection = null; + db = null; + } +} + +export type Database = ReturnType; diff --git a/apps/lightwrite/apps/backend/src/db/database.module.ts b/apps/lightwrite/apps/backend/src/db/database.module.ts new file mode 100644 index 000000000..5a0a033b3 --- /dev/null +++ b/apps/lightwrite/apps/backend/src/db/database.module.ts @@ -0,0 +1,29 @@ +import { Module, Global, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { getDb, closeConnection } from './connection'; +import type { Database } from './connection'; + +export const DATABASE_CONNECTION = 'DATABASE_CONNECTION'; + +@Global() +@Module({ + providers: [ + { + provide: DATABASE_CONNECTION, + useFactory: (configService: ConfigService): Database => { + const databaseUrl = configService.get('DATABASE_URL'); + if (!databaseUrl) { + throw new Error('DATABASE_URL environment variable is not set'); + } + return getDb(databaseUrl); + }, + inject: [ConfigService], + }, + ], + exports: [DATABASE_CONNECTION], +}) +export class DatabaseModule implements OnModuleDestroy { + async onModuleDestroy() { + await closeConnection(); + } +} diff --git a/apps/lightwrite/apps/backend/src/db/migrate.ts b/apps/lightwrite/apps/backend/src/db/migrate.ts new file mode 100644 index 000000000..902f9f6a8 --- /dev/null +++ b/apps/lightwrite/apps/backend/src/db/migrate.ts @@ -0,0 +1,26 @@ +import 'dotenv/config'; +import { drizzle } from 'drizzle-orm/postgres-js'; +import { migrate } from 'drizzle-orm/postgres-js/migrator'; +import postgres from 'postgres'; + +async function main() { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) { + throw new Error('DATABASE_URL environment variable is not set'); + } + + const connection = postgres(databaseUrl, { max: 1 }); + const db = drizzle(connection); + + console.log('Running migrations...'); + await migrate(db, { migrationsFolder: './drizzle' }); + console.log('Migrations complete!'); + + await connection.end(); + process.exit(0); +} + +main().catch((err) => { + console.error('Migration failed:', err); + process.exit(1); +}); diff --git a/apps/lightwrite/apps/backend/src/db/schema/beats.schema.ts b/apps/lightwrite/apps/backend/src/db/schema/beats.schema.ts new file mode 100644 index 000000000..ed5555c3a --- /dev/null +++ b/apps/lightwrite/apps/backend/src/db/schema/beats.schema.ts @@ -0,0 +1,19 @@ +import { pgTable, uuid, text, timestamp, varchar, real, jsonb } from 'drizzle-orm/pg-core'; +import { projects } from './projects.schema'; + +export const beats = pgTable('beats', { + id: uuid('id').primaryKey().defaultRandom(), + projectId: uuid('project_id') + .references(() => projects.id, { onDelete: 'cascade' }) + .notNull(), + storagePath: text('storage_path').notNull(), + filename: varchar('filename', { length: 255 }), + duration: real('duration'), + bpm: real('bpm'), + bpmConfidence: real('bpm_confidence'), + waveformData: jsonb('waveform_data'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), +}); + +export type Beat = typeof beats.$inferSelect; +export type NewBeat = typeof beats.$inferInsert; diff --git a/apps/lightwrite/apps/backend/src/db/schema/index.ts b/apps/lightwrite/apps/backend/src/db/schema/index.ts new file mode 100644 index 000000000..13b0571a6 --- /dev/null +++ b/apps/lightwrite/apps/backend/src/db/schema/index.ts @@ -0,0 +1,4 @@ +export * from './projects.schema'; +export * from './beats.schema'; +export * from './markers.schema'; +export * from './lyrics.schema'; diff --git a/apps/lightwrite/apps/backend/src/db/schema/lyrics.schema.ts b/apps/lightwrite/apps/backend/src/db/schema/lyrics.schema.ts new file mode 100644 index 000000000..1a4195f43 --- /dev/null +++ b/apps/lightwrite/apps/backend/src/db/schema/lyrics.schema.ts @@ -0,0 +1,27 @@ +import { pgTable, uuid, text, real, integer } from 'drizzle-orm/pg-core'; +import { projects } from './projects.schema'; + +export const lyrics = pgTable('lyrics', { + id: uuid('id').primaryKey().defaultRandom(), + projectId: uuid('project_id') + .references(() => projects.id, { onDelete: 'cascade' }) + .notNull() + .unique(), + content: text('content'), +}); + +export const lyricLines = pgTable('lyric_lines', { + id: uuid('id').primaryKey().defaultRandom(), + lyricsId: uuid('lyrics_id') + .references(() => lyrics.id, { onDelete: 'cascade' }) + .notNull(), + lineNumber: integer('line_number').notNull(), + text: text('text').notNull(), + startTime: real('start_time'), + endTime: real('end_time'), +}); + +export type Lyrics = typeof lyrics.$inferSelect; +export type NewLyrics = typeof lyrics.$inferInsert; +export type LyricLine = typeof lyricLines.$inferSelect; +export type NewLyricLine = typeof lyricLines.$inferInsert; diff --git a/apps/lightwrite/apps/backend/src/db/schema/markers.schema.ts b/apps/lightwrite/apps/backend/src/db/schema/markers.schema.ts new file mode 100644 index 000000000..d39cef761 --- /dev/null +++ b/apps/lightwrite/apps/backend/src/db/schema/markers.schema.ts @@ -0,0 +1,18 @@ +import { pgTable, uuid, varchar, real, integer } from 'drizzle-orm/pg-core'; +import { beats } from './beats.schema'; + +export const markers = pgTable('markers', { + id: uuid('id').primaryKey().defaultRandom(), + beatId: uuid('beat_id') + .references(() => beats.id, { onDelete: 'cascade' }) + .notNull(), + type: varchar('type', { length: 50 }).notNull(), + label: varchar('label', { length: 100 }), + startTime: real('start_time').notNull(), + endTime: real('end_time'), + color: varchar('color', { length: 7 }), + sortOrder: integer('sort_order'), +}); + +export type Marker = typeof markers.$inferSelect; +export type NewMarker = typeof markers.$inferInsert; diff --git a/apps/lightwrite/apps/backend/src/db/schema/projects.schema.ts b/apps/lightwrite/apps/backend/src/db/schema/projects.schema.ts new file mode 100644 index 000000000..9336137f1 --- /dev/null +++ b/apps/lightwrite/apps/backend/src/db/schema/projects.schema.ts @@ -0,0 +1,13 @@ +import { pgTable, uuid, text, timestamp, varchar } from 'drizzle-orm/pg-core'; + +export const projects = pgTable('projects', { + id: uuid('id').primaryKey().defaultRandom(), + userId: text('user_id').notNull(), + title: varchar('title', { length: 255 }).notNull(), + description: text('description'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}); + +export type Project = typeof projects.$inferSelect; +export type NewProject = typeof projects.$inferInsert; diff --git a/apps/lightwrite/apps/backend/src/db/seed.ts b/apps/lightwrite/apps/backend/src/db/seed.ts new file mode 100644 index 000000000..17e70faaa --- /dev/null +++ b/apps/lightwrite/apps/backend/src/db/seed.ts @@ -0,0 +1,34 @@ +import 'dotenv/config'; +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import * as schema from './schema'; + +async function main() { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) { + throw new Error('DATABASE_URL environment variable is not set'); + } + + const connection = postgres(databaseUrl, { max: 1 }); + const db = drizzle(connection, { schema }); + + console.log('Seeding database...'); + + // Add seed data here if needed + // Example: + // await db.insert(schema.projects).values({ + // userId: 'test-user', + // title: 'Demo Project', + // description: 'A demo project for testing', + // }); + + console.log('Seeding complete!'); + + await connection.end(); + process.exit(0); +} + +main().catch((err) => { + console.error('Seeding failed:', err); + process.exit(1); +}); diff --git a/apps/lightwrite/apps/backend/src/export/export.controller.ts b/apps/lightwrite/apps/backend/src/export/export.controller.ts new file mode 100644 index 000000000..242425dfa --- /dev/null +++ b/apps/lightwrite/apps/backend/src/export/export.controller.ts @@ -0,0 +1,25 @@ +import { Controller, Get, Param, Query, UseGuards, ParseUUIDPipe, Res } from '@nestjs/common'; +import { Response } from 'express'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { ExportService } from './export.service'; +import type { ExportFormat } from '@lightwrite/shared'; + +@Controller('export') +@UseGuards(JwtAuthGuard) +export class ExportController { + constructor(private readonly exportService: ExportService) {} + + @Get(':projectId') + async exportProject( + @CurrentUser() user: CurrentUserData, + @Param('projectId', ParseUUIDPipe) projectId: string, + @Query('format') format: ExportFormat = 'json', + @Res() res: Response + ) { + const result = await this.exportService.exportProject(projectId, user.userId, format); + + res.setHeader('Content-Type', result.contentType); + res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`); + res.send(result.content); + } +} diff --git a/apps/lightwrite/apps/backend/src/export/export.module.ts b/apps/lightwrite/apps/backend/src/export/export.module.ts new file mode 100644 index 000000000..f760abd25 --- /dev/null +++ b/apps/lightwrite/apps/backend/src/export/export.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { ExportController } from './export.controller'; +import { ExportService } from './export.service'; +import { ProjectModule } from '../project/project.module'; +import { BeatModule } from '../beat/beat.module'; +import { MarkerModule } from '../marker/marker.module'; +import { LyricsModule } from '../lyrics/lyrics.module'; + +@Module({ + imports: [ProjectModule, BeatModule, MarkerModule, LyricsModule], + controllers: [ExportController], + providers: [ExportService], + exports: [ExportService], +}) +export class ExportModule {} diff --git a/apps/lightwrite/apps/backend/src/export/export.service.ts b/apps/lightwrite/apps/backend/src/export/export.service.ts new file mode 100644 index 000000000..d935fc693 --- /dev/null +++ b/apps/lightwrite/apps/backend/src/export/export.service.ts @@ -0,0 +1,173 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; +import { ProjectService } from '../project/project.service'; +import { BeatService } from '../beat/beat.service'; +import { MarkerService } from '../marker/marker.service'; +import { LyricsService } from '../lyrics/lyrics.service'; +import type { ExportFormat, JsonExportData } from '@lightwrite/shared'; + +@Injectable() +export class ExportService { + constructor( + private projectService: ProjectService, + private beatService: BeatService, + private markerService: MarkerService, + private lyricsService: LyricsService + ) {} + + async exportProject( + projectId: string, + userId: string, + format: ExportFormat + ): Promise<{ content: string; filename: string; contentType: string }> { + const project = await this.projectService.findByIdOrThrow(projectId, userId); + const beat = await this.beatService.findByProjectId(projectId); + const lyricsData = await this.lyricsService.getWithLines(projectId, userId); + const markerList = beat ? await this.markerService.findByBeatId(beat.id) : []; + + const lines = lyricsData?.lines || []; + const safeTitle = project.title.replace(/[^a-z0-9]/gi, '_').toLowerCase(); + + switch (format) { + case 'lrc': + return { + content: this.generateLrc(lines, beat?.bpm), + filename: `${safeTitle}.lrc`, + contentType: 'text/plain', + }; + case 'srt': + return { + content: this.generateSrt(lines), + filename: `${safeTitle}.srt`, + contentType: 'text/plain', + }; + case 'json': + return { + content: this.generateJson(project, beat, markerList, lines), + filename: `${safeTitle}.json`, + contentType: 'application/json', + }; + case 'video': + throw new BadRequestException( + 'Video export is not yet supported. Use client-side video generation.' + ); + default: + throw new BadRequestException(`Unknown export format: ${format}`); + } + } + + private formatTime(seconds: number, format: 'lrc' | 'srt'): string { + const minutes = Math.floor(seconds / 60); + const secs = seconds % 60; + + if (format === 'lrc') { + // LRC format: [mm:ss.xx] + const hundredths = Math.round((secs % 1) * 100); + const wholeSecs = Math.floor(secs); + return `[${minutes.toString().padStart(2, '0')}:${wholeSecs.toString().padStart(2, '0')}.${hundredths.toString().padStart(2, '0')}]`; + } else { + // SRT format: hh:mm:ss,mmm + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + const millis = Math.round((secs % 1) * 1000); + const wholeSecs = Math.floor(secs); + return `${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${wholeSecs.toString().padStart(2, '0')},${millis.toString().padStart(3, '0')}`; + } + } + + private generateLrc( + lines: Array<{ text: string; startTime?: number | null; lineNumber: number }>, + bpm?: number | null + ): string { + const output: string[] = []; + + // Add metadata + output.push('[ti:LightWrite Export]'); + output.push('[ar:Unknown Artist]'); + if (bpm) { + output.push(`[bpm:${bpm}]`); + } + output.push(''); + + // Add synced lines + for (const line of lines) { + if (line.startTime !== null && line.startTime !== undefined) { + const timestamp = this.formatTime(line.startTime, 'lrc'); + output.push(`${timestamp}${line.text}`); + } else { + output.push(line.text); + } + } + + return output.join('\n'); + } + + private generateSrt( + lines: Array<{ + text: string; + startTime?: number | null; + endTime?: number | null; + lineNumber: number; + }> + ): string { + const output: string[] = []; + let index = 1; + + for (const line of lines) { + if (line.startTime !== null && line.startTime !== undefined) { + const start = this.formatTime(line.startTime, 'srt'); + const end = this.formatTime(line.endTime ?? line.startTime + 3, 'srt'); + + output.push(index.toString()); + output.push(`${start} --> ${end}`); + output.push(line.text); + output.push(''); + index++; + } + } + + return output.join('\n'); + } + + private generateJson( + project: { id: string; title: string; description?: string | null }, + beat: { bpm?: number | null; duration?: number | null } | null, + markers: Array<{ + type: string; + label?: string | null; + startTime: number; + endTime?: number | null; + }>, + lines: Array<{ + lineNumber: number; + text: string; + startTime?: number | null; + endTime?: number | null; + }> + ): string { + const data: JsonExportData = { + project: { + id: project.id, + title: project.title, + description: project.description || undefined, + }, + beat: { + bpm: beat?.bpm || undefined, + duration: beat?.duration || undefined, + }, + markers: markers.map((m) => ({ + type: m.type, + label: m.label || undefined, + startTime: m.startTime, + endTime: m.endTime || undefined, + })), + lyrics: lines.map((l) => ({ + lineNumber: l.lineNumber, + text: l.text, + startTime: l.startTime || undefined, + endTime: l.endTime || undefined, + })), + }; + + return JSON.stringify(data, null, 2); + } +} diff --git a/apps/lightwrite/apps/backend/src/lyrics/dto/lyrics.dto.ts b/apps/lightwrite/apps/backend/src/lyrics/dto/lyrics.dto.ts new file mode 100644 index 000000000..019252484 --- /dev/null +++ b/apps/lightwrite/apps/backend/src/lyrics/dto/lyrics.dto.ts @@ -0,0 +1,53 @@ +import { + IsString, + IsNumber, + IsOptional, + IsArray, + ValidateNested, + IsInt, + Min, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class CreateOrUpdateLyricsDto { + @IsString() + content!: string; +} + +class LyricLineDto { + @IsInt() + @Min(0) + lineNumber!: number; + + @IsString() + text!: string; + + @IsNumber() + @IsOptional() + @Min(0) + startTime?: number; + + @IsNumber() + @IsOptional() + @Min(0) + endTime?: number; +} + +export class SyncLinesDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => LyricLineDto) + lines!: LyricLineDto[]; +} + +export class UpdateLineTimestampDto { + @IsNumber() + @IsOptional() + @Min(0) + startTime?: number; + + @IsNumber() + @IsOptional() + @Min(0) + endTime?: number; +} diff --git a/apps/lightwrite/apps/backend/src/lyrics/lyrics.controller.ts b/apps/lightwrite/apps/backend/src/lyrics/lyrics.controller.ts new file mode 100644 index 000000000..fd3525c0e --- /dev/null +++ b/apps/lightwrite/apps/backend/src/lyrics/lyrics.controller.ts @@ -0,0 +1,53 @@ +import { Controller, Get, Post, Put, Body, Param, UseGuards, ParseUUIDPipe } from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { LyricsService } from './lyrics.service'; +import { CreateOrUpdateLyricsDto, SyncLinesDto, UpdateLineTimestampDto } from './dto/lyrics.dto'; + +@Controller('lyrics') +@UseGuards(JwtAuthGuard) +export class LyricsController { + constructor(private readonly lyricsService: LyricsService) {} + + @Get('project/:projectId') + async findByProject( + @CurrentUser() user: CurrentUserData, + @Param('projectId', ParseUUIDPipe) projectId: string + ) { + const result = await this.lyricsService.getWithLines(projectId, user.userId); + return { lyrics: result }; + } + + @Post('project/:projectId') + async createOrUpdate( + @CurrentUser() user: CurrentUserData, + @Param('projectId', ParseUUIDPipe) projectId: string, + @Body() dto: CreateOrUpdateLyricsDto + ) { + const lyricsRecord = await this.lyricsService.createOrUpdate( + projectId, + user.userId, + dto.content + ); + return { lyrics: lyricsRecord }; + } + + @Post(':id/sync') + async syncLines( + @CurrentUser() user: CurrentUserData, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SyncLinesDto + ) { + const lines = await this.lyricsService.syncLines(id, user.userId, dto.lines); + return { lines }; + } + + @Put('line/:lineId/timestamp') + async updateLineTimestamp( + @CurrentUser() user: CurrentUserData, + @Param('lineId', ParseUUIDPipe) lineId: string, + @Body() dto: UpdateLineTimestampDto + ) { + const line = await this.lyricsService.updateLineTimestamp(lineId, user.userId, dto); + return { line }; + } +} diff --git a/apps/lightwrite/apps/backend/src/lyrics/lyrics.module.ts b/apps/lightwrite/apps/backend/src/lyrics/lyrics.module.ts new file mode 100644 index 000000000..203ca8185 --- /dev/null +++ b/apps/lightwrite/apps/backend/src/lyrics/lyrics.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { LyricsController } from './lyrics.controller'; +import { LyricsService } from './lyrics.service'; + +@Module({ + controllers: [LyricsController], + providers: [LyricsService], + exports: [LyricsService], +}) +export class LyricsModule {} diff --git a/apps/lightwrite/apps/backend/src/lyrics/lyrics.service.ts b/apps/lightwrite/apps/backend/src/lyrics/lyrics.service.ts new file mode 100644 index 000000000..2125b3081 --- /dev/null +++ b/apps/lightwrite/apps/backend/src/lyrics/lyrics.service.ts @@ -0,0 +1,133 @@ +import { Injectable, Inject, NotFoundException } from '@nestjs/common'; +import { eq, and, asc } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { Database } from '../db/connection'; +import { lyrics, lyricLines, projects } from '../db/schema'; +import type { Lyrics, NewLyrics, LyricLine, NewLyricLine } from '../db/schema'; + +@Injectable() +export class LyricsService { + constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} + + async verifyProjectOwnership(projectId: string, userId: string): Promise { + const [project] = await this.db + .select() + .from(projects) + .where(and(eq(projects.id, projectId), eq(projects.userId, userId))); + if (!project) { + throw new NotFoundException('Project not found'); + } + } + + async findByProjectId(projectId: string): Promise { + const [lyricsRecord] = await this.db + .select() + .from(lyrics) + .where(eq(lyrics.projectId, projectId)); + return lyricsRecord || null; + } + + async findById(id: string): Promise { + const [lyricsRecord] = await this.db.select().from(lyrics).where(eq(lyrics.id, id)); + return lyricsRecord || null; + } + + async findByIdOrThrow(id: string): Promise { + const lyricsRecord = await this.findById(id); + if (!lyricsRecord) { + throw new NotFoundException('Lyrics not found'); + } + return lyricsRecord; + } + + async createOrUpdate(projectId: string, userId: string, content: string): Promise { + await this.verifyProjectOwnership(projectId, userId); + + const existing = await this.findByProjectId(projectId); + if (existing) { + const [updated] = await this.db + .update(lyrics) + .set({ content }) + .where(eq(lyrics.id, existing.id)) + .returning(); + return updated; + } + + const [created] = await this.db.insert(lyrics).values({ projectId, content }).returning(); + return created; + } + + async getLinesForLyrics(lyricsId: string): Promise { + return this.db + .select() + .from(lyricLines) + .where(eq(lyricLines.lyricsId, lyricsId)) + .orderBy(asc(lyricLines.lineNumber)); + } + + async syncLines( + lyricsId: string, + userId: string, + lines: Array<{ + lineNumber: number; + text: string; + startTime?: number; + endTime?: number; + }> + ): Promise { + const lyricsRecord = await this.findByIdOrThrow(lyricsId); + await this.verifyProjectOwnership(lyricsRecord.projectId, userId); + + // Delete existing lines + await this.db.delete(lyricLines).where(eq(lyricLines.lyricsId, lyricsId)); + + if (lines.length === 0) return []; + + // Insert new lines + const values: NewLyricLine[] = lines.map((line) => ({ + lyricsId, + lineNumber: line.lineNumber, + text: line.text, + startTime: line.startTime, + endTime: line.endTime, + })); + + return this.db.insert(lyricLines).values(values).returning(); + } + + async updateLineTimestamp( + lineId: string, + userId: string, + data: { startTime?: number; endTime?: number } + ): Promise { + const [line] = await this.db.select().from(lyricLines).where(eq(lyricLines.id, lineId)); + if (!line) { + throw new NotFoundException('Lyric line not found'); + } + + const lyricsRecord = await this.findByIdOrThrow(line.lyricsId); + await this.verifyProjectOwnership(lyricsRecord.projectId, userId); + + const [updated] = await this.db + .update(lyricLines) + .set(data) + .where(eq(lyricLines.id, lineId)) + .returning(); + return updated; + } + + async getWithLines(projectId: string, userId: string) { + await this.verifyProjectOwnership(projectId, userId); + + const lyricsRecord = await this.findByProjectId(projectId); + if (!lyricsRecord) { + return null; + } + + const lines = await this.getLinesForLyrics(lyricsRecord.id); + return { + ...lyricsRecord, + lines, + }; + } +} diff --git a/apps/lightwrite/apps/backend/src/main.ts b/apps/lightwrite/apps/backend/src/main.ts new file mode 100644 index 000000000..29f7746d4 --- /dev/null +++ b/apps/lightwrite/apps/backend/src/main.ts @@ -0,0 +1,8 @@ +import { bootstrapApp } from '@manacore/shared-nestjs-setup'; +import { AppModule } from './app.module'; + +bootstrapApp(AppModule, { + defaultPort: 3010, + serviceName: 'LightWrite', + additionalCorsOrigins: ['http://localhost:5180'], +}); diff --git a/apps/lightwrite/apps/backend/src/marker/dto/marker.dto.ts b/apps/lightwrite/apps/backend/src/marker/dto/marker.dto.ts new file mode 100644 index 000000000..36f00162d --- /dev/null +++ b/apps/lightwrite/apps/backend/src/marker/dto/marker.dto.ts @@ -0,0 +1,137 @@ +import { + IsString, + IsNotEmpty, + IsUUID, + IsNumber, + IsOptional, + IsIn, + IsArray, + ValidateNested, + MaxLength, + Min, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +const MARKER_TYPES = [ + 'verse', + 'hook', + 'bridge', + 'intro', + 'outro', + 'drop', + 'breakdown', + 'custom', +] as const; + +export class CreateMarkerDto { + @IsUUID() + @IsNotEmpty() + beatId!: string; + + @IsString() + @IsIn(MARKER_TYPES) + type!: string; + + @IsString() + @IsOptional() + @MaxLength(100) + label?: string; + + @IsNumber() + @Min(0) + startTime!: number; + + @IsNumber() + @IsOptional() + @Min(0) + endTime?: number; + + @IsString() + @IsOptional() + @MaxLength(7) + color?: string; +} + +export class UpdateMarkerDto { + @IsString() + @IsIn(MARKER_TYPES) + @IsOptional() + type?: string; + + @IsString() + @IsOptional() + @MaxLength(100) + label?: string; + + @IsNumber() + @IsOptional() + @Min(0) + startTime?: number; + + @IsNumber() + @IsOptional() + @Min(0) + endTime?: number; + + @IsString() + @IsOptional() + @MaxLength(7) + color?: string; + + @IsNumber() + @IsOptional() + sortOrder?: number; +} + +class MarkerItemDto { + @IsString() + @IsIn(MARKER_TYPES) + type!: string; + + @IsString() + @IsOptional() + @MaxLength(100) + label?: string; + + @IsNumber() + @Min(0) + startTime!: number; + + @IsNumber() + @IsOptional() + @Min(0) + endTime?: number; + + @IsString() + @IsOptional() + @MaxLength(7) + color?: string; +} + +export class BulkCreateMarkersDto { + @IsUUID() + @IsNotEmpty() + beatId!: string; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => MarkerItemDto) + markers!: MarkerItemDto[]; +} + +class MarkerUpdateItemDto { + @IsUUID() + @IsNotEmpty() + id!: string; + + @ValidateNested() + @Type(() => UpdateMarkerDto) + data!: UpdateMarkerDto; +} + +export class BulkUpdateMarkersDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => MarkerUpdateItemDto) + updates!: MarkerUpdateItemDto[]; +} diff --git a/apps/lightwrite/apps/backend/src/marker/marker.controller.ts b/apps/lightwrite/apps/backend/src/marker/marker.controller.ts new file mode 100644 index 000000000..c02d79607 --- /dev/null +++ b/apps/lightwrite/apps/backend/src/marker/marker.controller.ts @@ -0,0 +1,79 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + UseGuards, + ParseUUIDPipe, +} from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { MarkerService } from './marker.service'; +import { + CreateMarkerDto, + UpdateMarkerDto, + BulkCreateMarkersDto, + BulkUpdateMarkersDto, +} from './dto/marker.dto'; + +@Controller('markers') +@UseGuards(JwtAuthGuard) +export class MarkerController { + constructor(private readonly markerService: MarkerService) {} + + @Get('beat/:beatId') + async findByBeat( + @CurrentUser() user: CurrentUserData, + @Param('beatId', ParseUUIDPipe) beatId: string + ) { + await this.markerService.verifyBeatOwnership(beatId, user.userId); + const markerList = await this.markerService.findByBeatId(beatId); + return { markers: markerList }; + } + + @Post() + async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateMarkerDto) { + await this.markerService.verifyBeatOwnership(dto.beatId, user.userId); + const marker = await this.markerService.create(dto); + return { marker }; + } + + @Post('bulk') + async bulkCreate(@CurrentUser() user: CurrentUserData, @Body() dto: BulkCreateMarkersDto) { + const markerList = await this.markerService.bulkCreate(dto.beatId, user.userId, dto.markers); + return { markers: markerList }; + } + + @Put('bulk') + async bulkUpdate(@CurrentUser() user: CurrentUserData, @Body() dto: BulkUpdateMarkersDto) { + const markerList = await this.markerService.bulkUpdate(user.userId, dto.updates); + return { markers: markerList }; + } + + @Put(':id') + async update( + @CurrentUser() user: CurrentUserData, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateMarkerDto + ) { + const marker = await this.markerService.update(id, user.userId, dto); + return { marker }; + } + + @Delete(':id') + async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { + await this.markerService.delete(id, user.userId); + return { success: true }; + } + + @Delete('beat/:beatId') + async deleteAllForBeat( + @CurrentUser() user: CurrentUserData, + @Param('beatId', ParseUUIDPipe) beatId: string + ) { + await this.markerService.deleteAllForBeat(beatId, user.userId); + return { success: true }; + } +} diff --git a/apps/lightwrite/apps/backend/src/marker/marker.module.ts b/apps/lightwrite/apps/backend/src/marker/marker.module.ts new file mode 100644 index 000000000..f44725e04 --- /dev/null +++ b/apps/lightwrite/apps/backend/src/marker/marker.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { MarkerController } from './marker.controller'; +import { MarkerService } from './marker.service'; + +@Module({ + controllers: [MarkerController], + providers: [MarkerService], + exports: [MarkerService], +}) +export class MarkerModule {} diff --git a/apps/lightwrite/apps/backend/src/marker/marker.service.ts b/apps/lightwrite/apps/backend/src/marker/marker.service.ts new file mode 100644 index 000000000..4ecabfb62 --- /dev/null +++ b/apps/lightwrite/apps/backend/src/marker/marker.service.ts @@ -0,0 +1,110 @@ +import { Injectable, Inject, NotFoundException } from '@nestjs/common'; +import { eq, and, asc } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { Database } from '../db/connection'; +import { markers, beats, projects } from '../db/schema'; +import type { Marker, NewMarker } from '../db/schema'; + +@Injectable() +export class MarkerService { + constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} + + async verifyBeatOwnership(beatId: string, userId: string): Promise { + const [beat] = await this.db.select().from(beats).where(eq(beats.id, beatId)); + if (!beat) { + throw new NotFoundException('Beat not found'); + } + const [project] = await this.db + .select() + .from(projects) + .where(and(eq(projects.id, beat.projectId), eq(projects.userId, userId))); + if (!project) { + throw new NotFoundException('Project not found'); + } + } + + async findByBeatId(beatId: string): Promise { + return this.db + .select() + .from(markers) + .where(eq(markers.beatId, beatId)) + .orderBy(asc(markers.startTime)); + } + + async findById(id: string): Promise { + const [marker] = await this.db.select().from(markers).where(eq(markers.id, id)); + return marker || null; + } + + async findByIdOrThrow(id: string): Promise { + const marker = await this.findById(id); + if (!marker) { + throw new NotFoundException('Marker not found'); + } + return marker; + } + + async create(data: NewMarker): Promise { + const [marker] = await this.db.insert(markers).values(data).returning(); + return marker; + } + + async update( + id: string, + userId: string, + data: Partial> + ): Promise { + const marker = await this.findByIdOrThrow(id); + await this.verifyBeatOwnership(marker.beatId, userId); + + const [updatedMarker] = await this.db + .update(markers) + .set(data) + .where(eq(markers.id, id)) + .returning(); + return updatedMarker; + } + + async delete(id: string, userId: string): Promise { + const marker = await this.findByIdOrThrow(id); + await this.verifyBeatOwnership(marker.beatId, userId); + await this.db.delete(markers).where(eq(markers.id, id)); + } + + async deleteAllForBeat(beatId: string, userId: string): Promise { + await this.verifyBeatOwnership(beatId, userId); + await this.db.delete(markers).where(eq(markers.beatId, beatId)); + } + + async bulkCreate( + beatId: string, + userId: string, + items: Omit[] + ): Promise { + await this.verifyBeatOwnership(beatId, userId); + + if (items.length === 0) return []; + + const values = items.map((item) => ({ + ...item, + beatId, + })); + + return this.db.insert(markers).values(values).returning(); + } + + async bulkUpdate( + userId: string, + updates: Array<{ + id: string; + data: Partial>; + }> + ): Promise { + const results: Marker[] = []; + for (const update of updates) { + const marker = await this.update(update.id, userId, update.data); + results.push(marker); + } + return results; + } +} diff --git a/apps/lightwrite/apps/backend/src/project/dto/project.dto.ts b/apps/lightwrite/apps/backend/src/project/dto/project.dto.ts new file mode 100644 index 000000000..9bd0098fa --- /dev/null +++ b/apps/lightwrite/apps/backend/src/project/dto/project.dto.ts @@ -0,0 +1,23 @@ +import { IsString, IsNotEmpty, IsOptional, MaxLength } from 'class-validator'; + +export class CreateProjectDto { + @IsString() + @IsNotEmpty() + @MaxLength(255) + title!: string; + + @IsString() + @IsOptional() + description?: string; +} + +export class UpdateProjectDto { + @IsString() + @IsOptional() + @MaxLength(255) + title?: string; + + @IsString() + @IsOptional() + description?: string; +} diff --git a/apps/lightwrite/apps/backend/src/project/project.controller.ts b/apps/lightwrite/apps/backend/src/project/project.controller.ts new file mode 100644 index 000000000..0b36318ab --- /dev/null +++ b/apps/lightwrite/apps/backend/src/project/project.controller.ts @@ -0,0 +1,58 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + UseGuards, + ParseUUIDPipe, +} from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { ProjectService } from './project.service'; +import { CreateProjectDto, UpdateProjectDto } from './dto/project.dto'; + +@Controller('projects') +@UseGuards(JwtAuthGuard) +export class ProjectController { + constructor(private readonly projectService: ProjectService) {} + + @Get() + async findAll(@CurrentUser() user: CurrentUserData) { + const projectsList = await this.projectService.findByUserId(user.userId); + return { projects: projectsList }; + } + + @Get(':id') + async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { + const project = await this.projectService.getProjectWithRelations(id, user.userId); + return { project }; + } + + @Post() + async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateProjectDto) { + const project = await this.projectService.create({ + userId: user.userId, + title: dto.title, + description: dto.description, + }); + return { project }; + } + + @Put(':id') + async update( + @CurrentUser() user: CurrentUserData, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateProjectDto + ) { + const project = await this.projectService.update(id, user.userId, dto); + return { project }; + } + + @Delete(':id') + async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { + await this.projectService.delete(id, user.userId); + return { success: true }; + } +} diff --git a/apps/lightwrite/apps/backend/src/project/project.module.ts b/apps/lightwrite/apps/backend/src/project/project.module.ts new file mode 100644 index 000000000..19ddb7bbb --- /dev/null +++ b/apps/lightwrite/apps/backend/src/project/project.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ProjectController } from './project.controller'; +import { ProjectService } from './project.service'; + +@Module({ + controllers: [ProjectController], + providers: [ProjectService], + exports: [ProjectService], +}) +export class ProjectModule {} diff --git a/apps/lightwrite/apps/backend/src/project/project.service.ts b/apps/lightwrite/apps/backend/src/project/project.service.ts new file mode 100644 index 000000000..6734e3c13 --- /dev/null +++ b/apps/lightwrite/apps/backend/src/project/project.service.ts @@ -0,0 +1,73 @@ +import { Injectable, Inject, NotFoundException } from '@nestjs/common'; +import { eq, and, desc } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { Database } from '../db/connection'; +import { projects, beats, lyrics } from '../db/schema'; +import type { Project, NewProject } from '../db/schema'; + +@Injectable() +export class ProjectService { + constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} + + async findByUserId(userId: string): Promise { + return this.db + .select() + .from(projects) + .where(eq(projects.userId, userId)) + .orderBy(desc(projects.updatedAt)); + } + + async findById(id: string, userId: string): Promise { + const [project] = await this.db + .select() + .from(projects) + .where(and(eq(projects.id, id), eq(projects.userId, userId))); + return project || null; + } + + async findByIdOrThrow(id: string, userId: string): Promise { + const project = await this.findById(id, userId); + if (!project) { + throw new NotFoundException('Project not found'); + } + return project; + } + + async create(data: NewProject): Promise { + const [project] = await this.db.insert(projects).values(data).returning(); + return project; + } + + async update( + id: string, + userId: string, + data: Partial> + ): Promise { + await this.findByIdOrThrow(id, userId); + const [project] = await this.db + .update(projects) + .set({ ...data, updatedAt: new Date() }) + .where(and(eq(projects.id, id), eq(projects.userId, userId))) + .returning(); + return project; + } + + async delete(id: string, userId: string): Promise { + await this.findByIdOrThrow(id, userId); + await this.db.delete(projects).where(and(eq(projects.id, id), eq(projects.userId, userId))); + } + + async getProjectWithRelations(id: string, userId: string) { + const project = await this.findByIdOrThrow(id, userId); + + const [projectBeat] = await this.db.select().from(beats).where(eq(beats.projectId, id)); + + const [projectLyrics] = await this.db.select().from(lyrics).where(eq(lyrics.projectId, id)); + + return { + ...project, + beat: projectBeat || null, + lyrics: projectLyrics || null, + }; + } +} diff --git a/apps/lightwrite/apps/backend/tsconfig.json b/apps/lightwrite/apps/backend/tsconfig.json new file mode 100644 index 000000000..27971033a --- /dev/null +++ b/apps/lightwrite/apps/backend/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2021", + "module": "commonjs", + "moduleResolution": "node", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "outDir": "./dist", + "baseUrl": "./", + "rootDir": "./src", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/lightwrite/apps/landing/astro.config.mjs b/apps/lightwrite/apps/landing/astro.config.mjs new file mode 100644 index 000000000..0e44a7e46 --- /dev/null +++ b/apps/lightwrite/apps/landing/astro.config.mjs @@ -0,0 +1,7 @@ +import { defineConfig } from 'astro/config'; +import sitemap from '@astrojs/sitemap'; + +export default defineConfig({ + site: 'https://lightwrite.app', + integrations: [sitemap()], +}); diff --git a/apps/lightwrite/apps/landing/package.json b/apps/lightwrite/apps/landing/package.json new file mode 100644 index 000000000..0002a49da --- /dev/null +++ b/apps/lightwrite/apps/landing/package.json @@ -0,0 +1,20 @@ +{ + "name": "@lightwrite/landing", + "type": "module", + "version": "1.0.0", + "scripts": { + "dev": "astro dev", + "start": "astro dev", + "build": "astro check && astro build", + "preview": "astro preview", + "astro": "astro", + "type-check": "astro check" + }, + "dependencies": { + "@astrojs/check": "^0.9.4", + "@astrojs/sitemap": "^3.3.0", + "@manacore/shared-landing-ui": "workspace:*", + "astro": "^5.1.1", + "typescript": "^5.7.2" + } +} diff --git a/apps/lightwrite/apps/landing/src/layouts/Layout.astro b/apps/lightwrite/apps/landing/src/layouts/Layout.astro new file mode 100644 index 000000000..5d0a9bb04 --- /dev/null +++ b/apps/lightwrite/apps/landing/src/layouts/Layout.astro @@ -0,0 +1,48 @@ +--- +interface Props { + title: string; +} + +const { title } = Astro.props; +--- + + + + + + + + + + {title} + + + + + + diff --git a/apps/lightwrite/apps/landing/src/pages/index.astro b/apps/lightwrite/apps/landing/src/pages/index.astro new file mode 100644 index 000000000..6e8e7cce9 --- /dev/null +++ b/apps/lightwrite/apps/landing/src/pages/index.astro @@ -0,0 +1,212 @@ +--- +import Layout from '../layouts/Layout.astro'; +--- + + +
+ +
+
+
+
+

+ LightWrite +

+

+ Create synchronized lyrics for your beats with precision timing and beautiful karaoke + exports. +

+ +
+
+
+ + +
+
+

+ Everything You Need for Lyric Sync +

+ +
+
+
+ + + +
+

Waveform Editor

+

+ Visualize your audio with an interactive waveform. Zoom, scroll, and navigate with + precision. +

+
+ +
+
+ + + +
+

BPM Detection

+

+ Automatic tempo detection helps you sync lyrics to the beat with snap-to-beat + functionality. +

+
+ +
+
+ + + +
+

Part Markers

+

+ Mark verses, hooks, bridges, and more. Organize your song structure visually. +

+
+ +
+
+ + + +
+

Live Sync Recording

+

+ Record timestamps in real-time as the song plays. Just tap to sync each line. +

+
+ +
+
+ + + + +
+

Karaoke Preview

+

+ Preview your synced lyrics in real-time with smooth karaoke-style highlighting. +

+
+ +
+
+ + + +
+

Multiple Exports

+

+ Export to LRC, SRT, JSON, or generate karaoke videos for social media. +

+
+
+
+
+ + +
+
+

Ready to Create?

+

+ Start syncing your lyrics today. Free to use, no credit card required. +

+ + Start Creating + +
+
+ + +
+
+

© {new Date().getFullYear()} LightWrite. Part of the ManaCore ecosystem.

+
+
+
+
diff --git a/apps/lightwrite/apps/landing/tsconfig.json b/apps/lightwrite/apps/landing/tsconfig.json new file mode 100644 index 000000000..adb44640f --- /dev/null +++ b/apps/lightwrite/apps/landing/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "astro/tsconfigs/strict", + "compilerOptions": { + "strictNullChecks": true + } +} diff --git a/apps/lightwrite/apps/web/.env.example b/apps/lightwrite/apps/web/.env.example new file mode 100644 index 000000000..f3a566531 --- /dev/null +++ b/apps/lightwrite/apps/web/.env.example @@ -0,0 +1,7 @@ +# Auth +PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001 +PUBLIC_MANA_CORE_AUTH_URL_CLIENT=http://localhost:3001 + +# Backend +PUBLIC_BACKEND_URL=http://localhost:3010 +PUBLIC_BACKEND_URL_CLIENT=http://localhost:3010 diff --git a/apps/lightwrite/apps/web/package.json b/apps/lightwrite/apps/web/package.json new file mode 100644 index 000000000..149a30a6f --- /dev/null +++ b/apps/lightwrite/apps/web/package.json @@ -0,0 +1,47 @@ +{ + "name": "@lightwrite/web", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "lint": "eslint .", + "format": "prettier --write .", + "type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json" + }, + "devDependencies": { + "@manacore/shared-vite-config": "workspace:*", + "@sveltejs/adapter-node": "^5.0.0", + "@sveltejs/kit": "^2.47.1", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tailwindcss/vite": "^4.1.7", + "@types/node": "^20.0.0", + "prettier": "^3.1.1", + "prettier-plugin-svelte": "^3.1.2", + "svelte": "^5.41.0", + "svelte-check": "^4.3.3", + "tailwindcss": "^4.1.7", + "tslib": "^2.4.1", + "typescript": "^5.9.3", + "vite": "^6.0.0" + }, + "dependencies": { + "@lightwrite/shared": "workspace:*", + "@manacore/shared-api-client": "workspace:*", + "@manacore/shared-auth": "workspace:*", + "@manacore/shared-auth-ui": "workspace:*", + "@manacore/shared-branding": "workspace:*", + "@manacore/shared-icons": "workspace:*", + "@manacore/shared-stores": "workspace:*", + "@manacore/shared-tailwind": "workspace:*", + "@manacore/shared-theme": "workspace:*", + "@manacore/shared-theme-ui": "workspace:*", + "@manacore/shared-ui": "workspace:*", + "wavesurfer.js": "^7.8.0" + }, + "type": "module" +} diff --git a/apps/lightwrite/apps/web/src/app.css b/apps/lightwrite/apps/web/src/app.css new file mode 100644 index 000000000..db85d8754 --- /dev/null +++ b/apps/lightwrite/apps/web/src/app.css @@ -0,0 +1,121 @@ +@import "tailwindcss"; +@import "@manacore/shared-tailwind/themes.css"; + +/* Scan shared packages for Tailwind classes */ +@source "../../../../packages/shared-ui/src"; +@source "../../../../packages/shared-auth-ui/src"; +@source "../../../../packages/shared-branding/src"; +@source "../../../../packages/shared-theme-ui/src"; +@source "../../../../packages/shared-theme-ui/src/components"; +@source "../../../../packages/shared-theme-ui/src/pages"; +@source "../../../../packages/shared-stores/src"; + +/* Waveform styles */ +.waveform-container { + position: relative; + width: 100%; + height: 128px; + background: var(--color-surface); + border-radius: 8px; + overflow: hidden; +} + +/* Marker colors */ +.marker-verse { background-color: #3B82F6; } +.marker-hook { background-color: #EF4444; } +.marker-bridge { background-color: #8B5CF6; } +.marker-intro { background-color: #22C55E; } +.marker-outro { background-color: #F97316; } +.marker-drop { background-color: #EC4899; } +.marker-breakdown { background-color: #14B8A6; } +.marker-custom { background-color: #6B7280; } + +/* Lyrics editor styles */ +.lyrics-editor { + font-family: 'JetBrains Mono', 'Fira Code', monospace; + line-height: 1.8; +} + +.lyrics-line { + padding: 4px 8px; + border-radius: 4px; + transition: background-color 0.2s; +} + +.lyrics-line:hover { + background-color: var(--color-surface); +} + +.lyrics-line.active { + background-color: var(--color-primary); + color: white; +} + +.lyrics-line.synced { + border-left: 3px solid var(--color-primary); +} + +/* Karaoke animation */ +@keyframes karaoke-highlight { + 0% { background-position: 0% 50%; } + 100% { background-position: 100% 50%; } +} + +.karaoke-word { + transition: color 0.1s, transform 0.1s; +} + +.karaoke-word.active { + color: var(--color-primary); + transform: scale(1.05); +} + +.karaoke-word.past { + color: var(--color-foreground); +} + +.karaoke-word.future { + color: var(--color-foreground-secondary); +} + +/* Timeline styles */ +.timeline-ruler { + height: 24px; + background: var(--color-surface); + position: relative; +} + +.timeline-marker { + position: absolute; + top: 0; + height: 100%; + cursor: pointer; + opacity: 0.8; + transition: opacity 0.2s; +} + +.timeline-marker:hover { + opacity: 1; +} + +/* Playhead */ +.playhead { + position: absolute; + top: 0; + bottom: 0; + width: 2px; + background: var(--color-primary); + z-index: 10; + pointer-events: none; +} + +.playhead::after { + content: ''; + position: absolute; + top: 0; + left: -4px; + width: 10px; + height: 10px; + background: var(--color-primary); + border-radius: 50%; +} diff --git a/apps/lightwrite/apps/web/src/app.html b/apps/lightwrite/apps/web/src/app.html new file mode 100644 index 000000000..357ad64a6 --- /dev/null +++ b/apps/lightwrite/apps/web/src/app.html @@ -0,0 +1,19 @@ + + + + + + + + + LightWrite + %sveltekit.head% + + +
%sveltekit.body%
+ + + diff --git a/apps/lightwrite/apps/web/src/lib/components/BeatUploader.svelte b/apps/lightwrite/apps/web/src/lib/components/BeatUploader.svelte new file mode 100644 index 000000000..7678fabcc --- /dev/null +++ b/apps/lightwrite/apps/web/src/lib/components/BeatUploader.svelte @@ -0,0 +1,163 @@ + + +
+ + + {#if isUploading} +
+
+ {#if isDetectingBpm} + + + + {:else} +
+ {/if} +
+

+ {isDetectingBpm ? 'Detecting BPM...' : 'Uploading...'} +

+
+
+
+
+ {:else} + + {/if} + + {#if errorMessage} +

{errorMessage}

+ {/if} +
diff --git a/apps/lightwrite/apps/web/src/lib/components/KaraokePreview.svelte b/apps/lightwrite/apps/web/src/lib/components/KaraokePreview.svelte new file mode 100644 index 000000000..92622df2c --- /dev/null +++ b/apps/lightwrite/apps/web/src/lib/components/KaraokePreview.svelte @@ -0,0 +1,109 @@ + + +
+ {#each visibleLines as line} +
+ {#if line.relativeIndex === 0} + +
+ + {line.text} + + + + {line.text} + +
+ {:else if line.relativeIndex < 0} + + {line.text} + {:else} + + {line.text} + {/if} +
+ {/each} + + {#if projectStore.currentLines.length === 0} +

No synced lyrics to preview.

+ {/if} +
diff --git a/apps/lightwrite/apps/web/src/lib/components/LyricsEditor.svelte b/apps/lightwrite/apps/web/src/lib/components/LyricsEditor.svelte new file mode 100644 index 000000000..aa768a7d8 --- /dev/null +++ b/apps/lightwrite/apps/web/src/lib/components/LyricsEditor.svelte @@ -0,0 +1,214 @@ + + +
+ +
+

Lyrics

+
+ + +
+
+ + +
+ + +
+ + +
+ {#if editorStore.mode === 'edit'} + + + {:else} + +
+ {#each projectStore.currentLines as line, index} +
handleLineClick(index)} + onkeydown={(e) => e.key === 'Enter' && handleLineClick(index)} + class="lyrics-line w-full text-left flex items-center gap-3 cursor-pointer {activeLineIndex === + index + ? 'active' + : ''} {line.startTime !== null ? 'synced' : ''} {editorStore.selectedLineIndex === + index + ? 'ring-2 ring-primary' + : ''}" + > + + + {line.startTime !== null && line.startTime !== undefined + ? formatTimeWithMs(line.startTime) + : '--:--'} + + + + {line.text} + + + {#if editorStore.isRecordingTimestamps} + + {:else if line.startTime === null || line.startTime === undefined} + + {/if} +
+ {/each} + + {#if projectStore.currentLines.length === 0} +

+ No lyrics yet. Switch to Edit mode to add lyrics. +

+ {/if} +
+ {/if} +
+
diff --git a/apps/lightwrite/apps/web/src/lib/components/MarkerTimeline.svelte b/apps/lightwrite/apps/web/src/lib/components/MarkerTimeline.svelte new file mode 100644 index 000000000..9d6f5b36e --- /dev/null +++ b/apps/lightwrite/apps/web/src/lib/components/MarkerTimeline.svelte @@ -0,0 +1,185 @@ + + +
+ +
+
+ Markers + + + + + +
+ + +
+ {#each markerTypes.slice(0, 5) as type} +
+ + {type} +
+ {/each} +
+
+ + +
+ + {#each projectStore.currentMarkers as marker} + + {/each} + + + {#if audioStore.duration > 0} +
+ {/if} +
+ + + {#if editorStore.selectedMarkerId} + {@const selectedMarker = projectStore.currentMarkers.find( + (m) => m.id === editorStore.selectedMarkerId + )} + {#if selectedMarker} +
+
+ + {selectedMarker.type} + {#if selectedMarker.label} + - {selectedMarker.label} + {/if} +
+
+ + {selectedMarker.startTime.toFixed(2)}s - {( + selectedMarker.endTime || selectedMarker.startTime + ).toFixed(2)}s + + +
+
+ {/if} + {/if} +
diff --git a/apps/lightwrite/apps/web/src/lib/components/PlaybackControls.svelte b/apps/lightwrite/apps/web/src/lib/components/PlaybackControls.svelte new file mode 100644 index 000000000..bd6f16ea9 --- /dev/null +++ b/apps/lightwrite/apps/web/src/lib/components/PlaybackControls.svelte @@ -0,0 +1,150 @@ + + +
+ +
+ {formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)} +
+ + +
+ + + + + +
+ + +
+ +
+ + +
+ + + {Math.round(editorStore.zoom * 100)}% + + +
+ + + {#if audioStore.bpm} +
+ {audioStore.bpm} BPM +
+ {/if} +
diff --git a/apps/lightwrite/apps/web/src/lib/components/WaveformEditor.svelte b/apps/lightwrite/apps/web/src/lib/components/WaveformEditor.svelte new file mode 100644 index 000000000..6b513211d --- /dev/null +++ b/apps/lightwrite/apps/web/src/lib/components/WaveformEditor.svelte @@ -0,0 +1,179 @@ + + +
+ {#if !audioStore.isLoaded && audioUrl} +
+
+
+ {/if} +
diff --git a/apps/lightwrite/apps/web/src/lib/stores/audio.svelte.ts b/apps/lightwrite/apps/web/src/lib/stores/audio.svelte.ts new file mode 100644 index 000000000..cd830bbf9 --- /dev/null +++ b/apps/lightwrite/apps/web/src/lib/stores/audio.svelte.ts @@ -0,0 +1,81 @@ +interface AudioState { + isPlaying: boolean; + currentTime: number; + duration: number; + isLoaded: boolean; + bpm: number | null; + audioUrl: string | null; +} + +function createAudioStore() { + let state = $state({ + isPlaying: false, + currentTime: 0, + duration: 0, + isLoaded: false, + bpm: null, + audioUrl: null, + }); + + return { + get isPlaying() { + return state.isPlaying; + }, + get currentTime() { + return state.currentTime; + }, + get duration() { + return state.duration; + }, + get isLoaded() { + return state.isLoaded; + }, + get bpm() { + return state.bpm; + }, + get audioUrl() { + return state.audioUrl; + }, + + setPlaying(playing: boolean) { + state.isPlaying = playing; + }, + + setCurrentTime(time: number) { + state.currentTime = time; + }, + + setDuration(duration: number) { + state.duration = duration; + }, + + setLoaded(loaded: boolean) { + state.isLoaded = loaded; + }, + + setBpm(bpm: number | null) { + state.bpm = bpm; + }, + + setAudioUrl(url: string | null) { + state.audioUrl = url; + if (!url) { + state.isLoaded = false; + state.duration = 0; + state.currentTime = 0; + state.isPlaying = false; + } + }, + + reset() { + state.isPlaying = false; + state.currentTime = 0; + state.duration = 0; + state.isLoaded = false; + state.bpm = null; + state.audioUrl = null; + }, + }; +} + +export const audioStore = createAudioStore(); diff --git a/apps/lightwrite/apps/web/src/lib/stores/auth.svelte.ts b/apps/lightwrite/apps/web/src/lib/stores/auth.svelte.ts new file mode 100644 index 000000000..423f01fe8 --- /dev/null +++ b/apps/lightwrite/apps/web/src/lib/stores/auth.svelte.ts @@ -0,0 +1,191 @@ +/** + * Auth Store - Manages authentication state using Svelte 5 runes + * Uses Mana Core Auth + */ + +import { browser } from '$app/environment'; +import { initializeWebAuth } from '@manacore/shared-auth'; +import type { UserData } from '@manacore/shared-auth'; + +// Get auth URL dynamically at runtime - fallback for SSR and client +function getAuthUrl(): string { + if (browser && typeof window !== 'undefined') { + const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string }) + .__PUBLIC_MANA_CORE_AUTH_URL__; + return injectedUrl || 'http://localhost:3001'; + } + return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001'; +} + +// Get backend URL dynamically at runtime +function getBackendUrl(): string { + if (browser && typeof window !== 'undefined') { + const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string }) + .__PUBLIC_BACKEND_URL__; + return injectedUrl || 'http://localhost:3010'; + } + return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3010'; +} + +// Lazy initialization to avoid SSR issues with localStorage +let _authService: ReturnType['authService'] | null = null; +let _tokenManager: ReturnType['tokenManager'] | null = null; + +function getAuthService() { + if (!browser) return null; + if (!_authService) { + const auth = initializeWebAuth({ + baseUrl: getAuthUrl(), + backendUrl: getBackendUrl(), + }); + _authService = auth.authService; + _tokenManager = auth.tokenManager; + } + return _authService; +} + +function getTokenManager() { + if (!browser) return null; + getAuthService(); + return _tokenManager; +} + +// State +let user = $state(null); +let loading = $state(true); +let initialized = $state(false); + +export const authStore = { + get user() { + return user; + }, + get isLoading() { + return loading; + }, + get isAuthenticated() { + return !!user; + }, + get initialized() { + return initialized; + }, + + async initialize() { + if (initialized) return; + + const authService = getAuthService(); + if (!authService) { + initialized = true; + loading = false; + return; + } + + loading = true; + try { + let authenticated = await authService.isAuthenticated(); + + if (!authenticated) { + const ssoResult = await authService.trySSO(); + if (ssoResult.success) { + authenticated = true; + } + } + + if (authenticated) { + const userData = await authService.getUserFromToken(); + user = userData; + } + initialized = true; + } catch (error) { + console.error('Failed to initialize auth:', error); + user = null; + } finally { + loading = false; + } + }, + + async signIn(email: string, password: string) { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server' }; + } + + try { + const result = await authService.signIn(email, password); + + if (!result.success) { + return { success: false, error: result.error || 'Login failed' }; + } + + const userData = await authService.getUserFromToken(); + user = userData; + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage }; + } + }, + + async signUp(email: string, password: string) { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server', needsVerification: false }; + } + + try { + const sourceAppUrl = browser ? window.location.origin : undefined; + const result = await authService.signUp(email, password, undefined, sourceAppUrl); + + if (!result.success) { + return { success: false, error: result.error || 'Signup failed', needsVerification: false }; + } + + if (result.needsVerification) { + return { success: true, needsVerification: true }; + } + + const signInResult = await this.signIn(email, password); + return { ...signInResult, needsVerification: false }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage, needsVerification: false }; + } + }, + + async signOut() { + const authService = getAuthService(); + if (!authService) { + user = null; + return; + } + + try { + await authService.signOut(); + user = null; + } catch (error) { + console.error('Sign out error:', error); + user = null; + } + }, + + async getValidToken(): Promise { + const tokenManager = getTokenManager(); + if (!tokenManager) { + return null; + } + return await tokenManager.getValidToken(); + }, + + getAuthHeaders(): Record { + const authService = getAuthService(); + if (!authService) return {}; + + // Get token synchronously from storage if available + const token = + typeof localStorage !== 'undefined' ? localStorage.getItem('manacore_access_token') : null; + if (token) { + return { Authorization: `Bearer ${token}` }; + } + return {}; + }, +}; diff --git a/apps/lightwrite/apps/web/src/lib/stores/editor.svelte.ts b/apps/lightwrite/apps/web/src/lib/stores/editor.svelte.ts new file mode 100644 index 000000000..b888d43a0 --- /dev/null +++ b/apps/lightwrite/apps/web/src/lib/stores/editor.svelte.ts @@ -0,0 +1,143 @@ +import type { MarkerType } from '@lightwrite/shared'; + +type EditorMode = 'edit' | 'preview'; +type SyncMode = 'line' | 'word'; + +interface EditorState { + mode: EditorMode; + syncMode: SyncMode; + selectedMarkerId: string | null; + selectedLineIndex: number | null; + isRecordingTimestamps: boolean; + zoom: number; + scrollPosition: number; + markerTypeToCreate: MarkerType; + snapToBeat: boolean; + showWaveform: boolean; + showMarkers: boolean; + showLyrics: boolean; +} + +function createEditorStore() { + let state = $state({ + mode: 'edit', + syncMode: 'line', + selectedMarkerId: null, + selectedLineIndex: null, + isRecordingTimestamps: false, + zoom: 1, + scrollPosition: 0, + markerTypeToCreate: 'verse', + snapToBeat: true, + showWaveform: true, + showMarkers: true, + showLyrics: true, + }); + + return { + get mode() { + return state.mode; + }, + get syncMode() { + return state.syncMode; + }, + get selectedMarkerId() { + return state.selectedMarkerId; + }, + get selectedLineIndex() { + return state.selectedLineIndex; + }, + get isRecordingTimestamps() { + return state.isRecordingTimestamps; + }, + get zoom() { + return state.zoom; + }, + get scrollPosition() { + return state.scrollPosition; + }, + get markerTypeToCreate() { + return state.markerTypeToCreate; + }, + get snapToBeat() { + return state.snapToBeat; + }, + get showWaveform() { + return state.showWaveform; + }, + get showMarkers() { + return state.showMarkers; + }, + get showLyrics() { + return state.showLyrics; + }, + + setMode(mode: EditorMode) { + state.mode = mode; + }, + + setSyncMode(syncMode: SyncMode) { + state.syncMode = syncMode; + }, + + selectMarker(markerId: string | null) { + state.selectedMarkerId = markerId; + }, + + selectLine(lineIndex: number | null) { + state.selectedLineIndex = lineIndex; + }, + + setRecordingTimestamps(recording: boolean) { + state.isRecordingTimestamps = recording; + }, + + setZoom(zoom: number) { + state.zoom = Math.max(0.5, Math.min(10, zoom)); + }, + + zoomIn() { + state.zoom = Math.min(10, state.zoom * 1.25); + }, + + zoomOut() { + state.zoom = Math.max(0.5, state.zoom / 1.25); + }, + + setScrollPosition(position: number) { + state.scrollPosition = position; + }, + + setMarkerTypeToCreate(type: MarkerType) { + state.markerTypeToCreate = type; + }, + + toggleSnapToBeat() { + state.snapToBeat = !state.snapToBeat; + }, + + toggleWaveform() { + state.showWaveform = !state.showWaveform; + }, + + toggleMarkers() { + state.showMarkers = !state.showMarkers; + }, + + toggleLyrics() { + state.showLyrics = !state.showLyrics; + }, + + reset() { + state.mode = 'edit'; + state.syncMode = 'line'; + state.selectedMarkerId = null; + state.selectedLineIndex = null; + state.isRecordingTimestamps = false; + state.zoom = 1; + state.scrollPosition = 0; + }, + }; +} + +export const editorStore = createEditorStore(); diff --git a/apps/lightwrite/apps/web/src/lib/stores/project.svelte.ts b/apps/lightwrite/apps/web/src/lib/stores/project.svelte.ts new file mode 100644 index 000000000..af60d8245 --- /dev/null +++ b/apps/lightwrite/apps/web/src/lib/stores/project.svelte.ts @@ -0,0 +1,264 @@ +import type { Project, Beat, Lyrics, LyricLine, Marker } from '@lightwrite/shared'; +import { authStore } from './auth.svelte'; + +interface ProjectState { + projects: Project[]; + currentProject: Project | null; + currentBeat: Beat | null; + currentLyrics: Lyrics | null; + currentLines: LyricLine[]; + currentMarkers: Marker[]; + isLoading: boolean; + error: string | null; +} + +function getBackendUrl(): string { + if (typeof window !== 'undefined') { + return ( + (window as unknown as { __PUBLIC_BACKEND_URL__: string }).__PUBLIC_BACKEND_URL__ || + 'http://localhost:3010' + ); + } + return 'http://localhost:3010'; +} + +function createProjectStore() { + let state = $state({ + projects: [], + currentProject: null, + currentBeat: null, + currentLyrics: null, + currentLines: [], + currentMarkers: [], + isLoading: false, + error: null, + }); + + async function fetchApi(path: string, options: RequestInit = {}): Promise { + const response = await fetch(`${getBackendUrl()}${path}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...authStore.getAuthHeaders(), + ...options.headers, + }, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Request failed' })); + throw new Error(error.message || 'Request failed'); + } + + return response.json(); + } + + return { + get projects() { + return state.projects; + }, + get currentProject() { + return state.currentProject; + }, + get currentBeat() { + return state.currentBeat; + }, + get currentLyrics() { + return state.currentLyrics; + }, + get currentLines() { + return state.currentLines; + }, + get currentMarkers() { + return state.currentMarkers; + }, + get isLoading() { + return state.isLoading; + }, + get error() { + return state.error; + }, + + async loadProjects() { + state.isLoading = true; + state.error = null; + try { + const data = await fetchApi<{ projects: Project[] }>('/projects'); + state.projects = data.projects; + } catch (e) { + state.error = e instanceof Error ? e.message : 'Failed to load projects'; + } + state.isLoading = false; + }, + + async loadProject(id: string) { + state.isLoading = true; + state.error = null; + try { + const data = await fetchApi<{ + project: Project & { beat: Beat | null; lyrics: Lyrics | null }; + }>(`/projects/${id}`); + state.currentProject = data.project; + state.currentBeat = data.project.beat; + state.currentLyrics = data.project.lyrics; + + // Load markers if beat exists + if (data.project.beat) { + const markersData = await fetchApi<{ markers: Marker[] }>( + `/markers/beat/${data.project.beat.id}` + ); + state.currentMarkers = markersData.markers; + } + + // Load lyrics lines if lyrics exists + if (data.project.lyrics) { + const lyricsData = await fetchApi<{ lyrics: { lines: LyricLine[] } | null }>( + `/lyrics/project/${id}` + ); + state.currentLines = lyricsData.lyrics?.lines || []; + } + } catch (e) { + state.error = e instanceof Error ? e.message : 'Failed to load project'; + } + state.isLoading = false; + }, + + async createProject(title: string, description?: string) { + const data = await fetchApi<{ project: Project }>('/projects', { + method: 'POST', + body: JSON.stringify({ title, description }), + }); + state.projects = [data.project, ...state.projects]; + return data.project; + }, + + async updateProject(id: string, updates: { title?: string; description?: string }) { + const data = await fetchApi<{ project: Project }>(`/projects/${id}`, { + method: 'PUT', + body: JSON.stringify(updates), + }); + state.projects = state.projects.map((p) => (p.id === id ? data.project : p)); + if (state.currentProject?.id === id) { + state.currentProject = data.project; + } + return data.project; + }, + + async deleteProject(id: string) { + await fetchApi(`/projects/${id}`, { method: 'DELETE' }); + state.projects = state.projects.filter((p) => p.id !== id); + if (state.currentProject?.id === id) { + state.currentProject = null; + state.currentBeat = null; + state.currentLyrics = null; + state.currentLines = []; + state.currentMarkers = []; + } + }, + + async uploadBeat(projectId: string, file: File) { + // Get upload URL + const uploadData = await fetchApi<{ beat: Beat; uploadUrl: string }>('/beats/upload', { + method: 'POST', + body: JSON.stringify({ projectId, filename: file.name }), + }); + + // Upload file to S3 + await fetch(uploadData.uploadUrl, { + method: 'PUT', + body: file, + headers: { 'Content-Type': file.type }, + }); + + state.currentBeat = uploadData.beat; + return uploadData.beat; + }, + + async updateBeatMetadata( + beatId: string, + metadata: { duration?: number; bpm?: number; bpmConfidence?: number; waveformData?: unknown } + ) { + const data = await fetchApi<{ beat: Beat }>(`/beats/${beatId}/metadata`, { + method: 'PUT', + body: JSON.stringify(metadata), + }); + state.currentBeat = data.beat; + return data.beat; + }, + + async getBeatDownloadUrl(beatId: string): Promise { + const data = await fetchApi<{ url: string }>(`/beats/${beatId}/download-url`); + return data.url; + }, + + async deleteBeat(beatId: string) { + await fetchApi(`/beats/${beatId}`, { method: 'DELETE' }); + state.currentBeat = null; + state.currentMarkers = []; + }, + + async updateLyrics(projectId: string, content: string) { + const data = await fetchApi<{ lyrics: Lyrics }>(`/lyrics/project/${projectId}`, { + method: 'POST', + body: JSON.stringify({ content }), + }); + state.currentLyrics = data.lyrics; + return data.lyrics; + }, + + async syncLines( + lyricsId: string, + lines: Array<{ lineNumber: number; text: string; startTime?: number; endTime?: number }> + ) { + const data = await fetchApi<{ lines: LyricLine[] }>(`/lyrics/${lyricsId}/sync`, { + method: 'POST', + body: JSON.stringify({ lines }), + }); + state.currentLines = data.lines; + return data.lines; + }, + + async updateLineTimestamp(lineId: string, startTime?: number, endTime?: number) { + const data = await fetchApi<{ line: LyricLine }>(`/lyrics/line/${lineId}/timestamp`, { + method: 'PUT', + body: JSON.stringify({ startTime, endTime }), + }); + state.currentLines = state.currentLines.map((l) => (l.id === lineId ? data.line : l)); + return data.line; + }, + + async createMarker(beatId: string, marker: Omit) { + const data = await fetchApi<{ marker: Marker }>('/markers', { + method: 'POST', + body: JSON.stringify({ beatId, ...marker }), + }); + state.currentMarkers = [...state.currentMarkers, data.marker].sort( + (a, b) => a.startTime - b.startTime + ); + return data.marker; + }, + + async updateMarker(markerId: string, updates: Partial) { + const data = await fetchApi<{ marker: Marker }>(`/markers/${markerId}`, { + method: 'PUT', + body: JSON.stringify(updates), + }); + state.currentMarkers = state.currentMarkers.map((m) => (m.id === markerId ? data.marker : m)); + return data.marker; + }, + + async deleteMarker(markerId: string) { + await fetchApi(`/markers/${markerId}`, { method: 'DELETE' }); + state.currentMarkers = state.currentMarkers.filter((m) => m.id !== markerId); + }, + + clearCurrent() { + state.currentProject = null; + state.currentBeat = null; + state.currentLyrics = null; + state.currentLines = []; + state.currentMarkers = []; + }, + }; +} + +export const projectStore = createProjectStore(); diff --git a/apps/lightwrite/apps/web/src/lib/utils/bpm-detector.ts b/apps/lightwrite/apps/web/src/lib/utils/bpm-detector.ts new file mode 100644 index 000000000..791dd448b --- /dev/null +++ b/apps/lightwrite/apps/web/src/lib/utils/bpm-detector.ts @@ -0,0 +1,234 @@ +/** + * BPM Detection using Web Audio API + * Uses peak detection algorithm for BPM estimation + * + * Note: For more accurate results, consider using essentia.js WASM module + * This implementation provides a lightweight fallback + */ + +interface BpmResult { + bpm: number; + confidence: number; +} + +/** + * Detect BPM from an audio buffer + */ +export async function detectBpm(audioBuffer: AudioBuffer): Promise { + // Get audio data from the first channel + const channelData = audioBuffer.getChannelData(0); + const sampleRate = audioBuffer.sampleRate; + + // Downsample for efficiency + const downsampleFactor = 4; + const downsampled = downsample(channelData, downsampleFactor); + const effectiveSampleRate = sampleRate / downsampleFactor; + + // Apply low-pass filter to focus on bass frequencies (kick drum) + const filtered = lowPassFilter(downsampled, effectiveSampleRate, 150); + + // Detect peaks + const peaks = detectPeaks(filtered, effectiveSampleRate); + + // Calculate intervals between peaks + const intervals = calculateIntervals(peaks, effectiveSampleRate); + + // Estimate BPM from intervals + const result = estimateBpm(intervals); + + return result; +} + +/** + * Detect BPM from a File object + */ +export async function detectBpmFromFile(file: File): Promise { + const arrayBuffer = await file.arrayBuffer(); + const audioContext = new AudioContext(); + const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); + const result = await detectBpm(audioBuffer); + await audioContext.close(); + return result; +} + +/** + * Detect BPM from a URL + */ +export async function detectBpmFromUrl(url: string): Promise { + const response = await fetch(url); + const arrayBuffer = await response.arrayBuffer(); + const audioContext = new AudioContext(); + const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); + const result = await detectBpm(audioBuffer); + await audioContext.close(); + return result; +} + +function downsample(data: Float32Array, factor: number): Float32Array { + const length = Math.floor(data.length / factor); + const result = new Float32Array(length); + for (let i = 0; i < length; i++) { + result[i] = data[i * factor]; + } + return result; +} + +function lowPassFilter(data: Float32Array, sampleRate: number, cutoff: number): Float32Array { + const rc = 1.0 / (cutoff * 2 * Math.PI); + const dt = 1.0 / sampleRate; + const alpha = dt / (rc + dt); + + const result = new Float32Array(data.length); + result[0] = data[0]; + + for (let i = 1; i < data.length; i++) { + result[i] = result[i - 1] + alpha * (data[i] - result[i - 1]); + } + + return result; +} + +function detectPeaks(data: Float32Array, sampleRate: number): number[] { + const peaks: number[] = []; + const minPeakDistance = Math.floor(sampleRate * 0.2); // Min 200ms between peaks (300 BPM max) + + // Calculate threshold as percentage of max amplitude + let maxAmplitude = 0; + for (let i = 0; i < data.length; i++) { + const abs = Math.abs(data[i]); + if (abs > maxAmplitude) maxAmplitude = abs; + } + const threshold = maxAmplitude * 0.5; + + let lastPeak = -minPeakDistance; + + for (let i = 1; i < data.length - 1; i++) { + if (i - lastPeak < minPeakDistance) continue; + + const current = Math.abs(data[i]); + const prev = Math.abs(data[i - 1]); + const next = Math.abs(data[i + 1]); + + if (current > threshold && current > prev && current > next) { + peaks.push(i); + lastPeak = i; + } + } + + return peaks; +} + +function calculateIntervals(peaks: number[], sampleRate: number): number[] { + const intervals: number[] = []; + + for (let i = 1; i < peaks.length; i++) { + const interval = (peaks[i] - peaks[i - 1]) / sampleRate; + // Filter to reasonable BPM range (60-200 BPM = 0.3-1.0 seconds) + if (interval >= 0.3 && interval <= 1.0) { + intervals.push(interval); + } + } + + return intervals; +} + +function estimateBpm(intervals: number[]): BpmResult { + if (intervals.length === 0) { + return { bpm: 120, confidence: 0 }; + } + + // Group intervals into buckets and find the most common + const bucketSize = 0.02; // 20ms buckets + const buckets: Map = new Map(); + + for (const interval of intervals) { + const bucket = Math.round(interval / bucketSize) * bucketSize; + if (!buckets.has(bucket)) { + buckets.set(bucket, []); + } + buckets.get(bucket)!.push(interval); + } + + // Find the bucket with most intervals + let maxCount = 0; + let bestBucket = 0.5; + let bestIntervals: number[] = []; + + for (const [bucket, bucketIntervals] of buckets) { + if (bucketIntervals.length > maxCount) { + maxCount = bucketIntervals.length; + bestBucket = bucket; + bestIntervals = bucketIntervals; + } + } + + // Calculate average interval from best bucket + const avgInterval = bestIntervals.reduce((a, b) => a + b, 0) / bestIntervals.length; + const bpm = Math.round(60 / avgInterval); + + // Calculate confidence based on how many intervals fell into the best bucket + const confidence = Math.min(1, (maxCount / intervals.length) * 2); + + // Ensure BPM is in reasonable range + let finalBpm = bpm; + if (finalBpm < 60) finalBpm *= 2; + if (finalBpm > 200) finalBpm /= 2; + + return { + bpm: Math.round(finalBpm), + confidence: Math.round(confidence * 100) / 100, + }; +} + +/** + * Snap a time value to the nearest beat based on BPM + */ +export function snapToBeat(time: number, bpm: number, offset: number = 0): number { + const beatDuration = 60 / bpm; + const adjustedTime = time - offset; + const nearestBeat = Math.round(adjustedTime / beatDuration) * beatDuration; + return nearestBeat + offset; +} + +/** + * Get beat times within a range + */ +export function getBeatTimes( + startTime: number, + endTime: number, + bpm: number, + offset: number = 0 +): number[] { + const beatDuration = 60 / bpm; + const beats: number[] = []; + + const firstBeat = Math.ceil((startTime - offset) / beatDuration) * beatDuration + offset; + + for (let beat = firstBeat; beat <= endTime; beat += beatDuration) { + beats.push(beat); + } + + return beats; +} + +/** + * Get bar (measure) times within a range (assuming 4/4 time) + */ +export function getBarTimes( + startTime: number, + endTime: number, + bpm: number, + offset: number = 0, + beatsPerBar: number = 4 +): number[] { + const barDuration = (60 / bpm) * beatsPerBar; + const bars: number[] = []; + + const firstBar = Math.ceil((startTime - offset) / barDuration) * barDuration + offset; + + for (let bar = firstBar; bar <= endTime; bar += barDuration) { + bars.push(bar); + } + + return bars; +} diff --git a/apps/lightwrite/apps/web/src/lib/utils/time-format.ts b/apps/lightwrite/apps/web/src/lib/utils/time-format.ts new file mode 100644 index 000000000..284f84f93 --- /dev/null +++ b/apps/lightwrite/apps/web/src/lib/utils/time-format.ts @@ -0,0 +1,44 @@ +/** + * Format time in seconds to MM:SS format + */ +export function formatTime(seconds: number): string { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; +} + +/** + * Format time in seconds to MM:SS.ms format + */ +export function formatTimeWithMs(seconds: number): string { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + const ms = Math.floor((seconds % 1) * 100); + return `${mins}:${secs.toString().padStart(2, '0')}.${ms.toString().padStart(2, '0')}`; +} + +/** + * Parse MM:SS or MM:SS.ms format to seconds + */ +export function parseTime(timeString: string): number | null { + const match = timeString.match(/^(\d+):(\d{2})(?:\.(\d{2}))?$/); + if (!match) return null; + + const mins = parseInt(match[1], 10); + const secs = parseInt(match[2], 10); + const ms = match[3] ? parseInt(match[3], 10) / 100 : 0; + + return mins * 60 + secs + ms; +} + +/** + * Format duration for display (e.g., "3:45") + */ +export function formatDuration(seconds: number): string { + if (seconds < 60) { + return `0:${Math.floor(seconds).toString().padStart(2, '0')}`; + } + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; +} diff --git a/apps/lightwrite/apps/web/src/routes/+layout.svelte b/apps/lightwrite/apps/web/src/routes/+layout.svelte new file mode 100644 index 000000000..4430f8bff --- /dev/null +++ b/apps/lightwrite/apps/web/src/routes/+layout.svelte @@ -0,0 +1,29 @@ + + +{#if loading} +
+
+
+

LightWrite

+
+
+{:else} +
+ {@render children()} +
+{/if} diff --git a/apps/lightwrite/apps/web/src/routes/+page.svelte b/apps/lightwrite/apps/web/src/routes/+page.svelte new file mode 100644 index 000000000..4fc4c25df --- /dev/null +++ b/apps/lightwrite/apps/web/src/routes/+page.svelte @@ -0,0 +1,335 @@ + + + + LightWrite - Beat & Lyrics Editor + + +
+ +
+
+

+ LightWrite +

+ +
+ {#if authStore.isAuthenticated} + + {authStore.user?.email} + + + {:else} + + Login + + {/if} +
+
+
+ + +
+ {#if !authStore.isAuthenticated} + +
+

Create Synced Lyrics for Your Beats

+

+ Upload your beats, add lyrics, sync timestamps, and export to LRC, SRT, or video formats. +

+ + + +
+
+
+ + + +
+

Waveform Editor

+

+ Visual waveform display with zoom, markers, and precise navigation. +

+
+ +
+
+ + + +
+

BPM Detection

+

+ Automatic tempo detection with snap-to-beat functionality. +

+
+ +
+
+ + + +
+

Multiple Exports

+

+ Export to LRC, SRT, JSON, or generate karaoke videos. +

+
+
+
+ {:else} + +
+

Your Projects

+ +
+ + {#if projectStore.isLoading} +
+
+
+ {:else if projectStore.projects.length === 0} +
+
+ + + +
+

No projects yet

+

Create your first project to get started

+ +
+ {:else} + + {/if} + {/if} +
+
+ + +{#if showCreateModal} +
(showCreateModal = false)} + role="dialog" + > +
e.stopPropagation()} + role="document" + > +

Create New Project

+
{ + e.preventDefault(); + handleCreateProject(); + }} + > +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+{/if} diff --git a/apps/lightwrite/apps/web/src/routes/editor/[id]/+page.svelte b/apps/lightwrite/apps/web/src/routes/editor/[id]/+page.svelte new file mode 100644 index 000000000..4accee458 --- /dev/null +++ b/apps/lightwrite/apps/web/src/routes/editor/[id]/+page.svelte @@ -0,0 +1,327 @@ + + + + {projectStore.currentProject?.title || 'Editor'} - LightWrite + + +
+ +
+
+
+ + + + + +
+

+ {projectStore.currentProject?.title || 'Loading...'} +

+ {#if projectStore.currentProject?.description} +

+ {projectStore.currentProject.description} +

+ {/if} +
+
+ +
+ +
+ + + {#if showExportMenu} +
+ + + +
+ {/if} +
+
+
+
+ + {#if projectStore.isLoading} +
+
+
+ {:else if projectStore.error} +
+
+

{projectStore.error}

+ Go back +
+
+ {:else} + +
+ +
+ {#if projectStore.currentBeat} +
+
+
+ + + + {projectStore.currentBeat.filename} +
+ +
+ + + + + + +
+ {:else} + + {/if} +
+ + +
+ +
+ +
+ + +
+ {#if editorStore.mode === 'preview'} + + {:else} +
+
+

Switch to Preview mode to see karaoke animation

+ +
+
+ {/if} +
+
+
+ {/if} +
+ + +{#if showExportMenu} + +{/if} diff --git a/apps/lightwrite/apps/web/src/routes/health/+server.ts b/apps/lightwrite/apps/web/src/routes/health/+server.ts new file mode 100644 index 000000000..367b3dba7 --- /dev/null +++ b/apps/lightwrite/apps/web/src/routes/health/+server.ts @@ -0,0 +1,5 @@ +import { json } from '@sveltejs/kit'; + +export function GET() { + return json({ status: 'ok', service: 'lightwrite-web' }); +} diff --git a/apps/lightwrite/apps/web/svelte.config.js b/apps/lightwrite/apps/web/svelte.config.js new file mode 100644 index 000000000..a7a917e4c --- /dev/null +++ b/apps/lightwrite/apps/web/svelte.config.js @@ -0,0 +1,14 @@ +import adapter from '@sveltejs/adapter-node'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter({ + out: 'build', + }), + }, +}; + +export default config; diff --git a/apps/lightwrite/apps/web/tsconfig.json b/apps/lightwrite/apps/web/tsconfig.json new file mode 100644 index 000000000..a8f10c8e3 --- /dev/null +++ b/apps/lightwrite/apps/web/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/apps/lightwrite/apps/web/vite.config.ts b/apps/lightwrite/apps/web/vite.config.ts new file mode 100644 index 000000000..03be88bf0 --- /dev/null +++ b/apps/lightwrite/apps/web/vite.config.ts @@ -0,0 +1,17 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; +import { MANACORE_SHARED_PACKAGES } from '@manacore/shared-vite-config'; + +export default defineConfig({ + plugins: [sveltekit()], + server: { + port: 5180, + strictPort: true, + }, + ssr: { + noExternal: [...MANACORE_SHARED_PACKAGES, '@lightwrite/shared'], + }, + optimizeDeps: { + exclude: [...MANACORE_SHARED_PACKAGES, '@lightwrite/shared'], + }, +}); diff --git a/apps/lightwrite/package.json b/apps/lightwrite/package.json new file mode 100644 index 000000000..7ae43041b --- /dev/null +++ b/apps/lightwrite/package.json @@ -0,0 +1,7 @@ +{ + "name": "lightwrite", + "private": true, + "scripts": { + "dev": "pnpm run --filter=@lightwrite/* --parallel dev" + } +} diff --git a/apps/lightwrite/packages/shared/package.json b/apps/lightwrite/packages/shared/package.json new file mode 100644 index 000000000..90936b203 --- /dev/null +++ b/apps/lightwrite/packages/shared/package.json @@ -0,0 +1,17 @@ +{ + "name": "@lightwrite/shared", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./types": "./src/types/index.ts" + }, + "scripts": { + "type-check": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^5.7.3" + } +} diff --git a/apps/lightwrite/packages/shared/src/index.ts b/apps/lightwrite/packages/shared/src/index.ts new file mode 100644 index 000000000..fcb073fef --- /dev/null +++ b/apps/lightwrite/packages/shared/src/index.ts @@ -0,0 +1 @@ +export * from './types'; diff --git a/apps/lightwrite/packages/shared/src/types/beat.ts b/apps/lightwrite/packages/shared/src/types/beat.ts new file mode 100644 index 000000000..9018b8a08 --- /dev/null +++ b/apps/lightwrite/packages/shared/src/types/beat.ts @@ -0,0 +1,34 @@ +export interface Beat { + id: string; + projectId: string; + storagePath: string; + filename?: string | null; + duration?: number | null; + bpm?: number | null; + bpmConfidence?: number | null; + waveformData?: WaveformData | null; + createdAt: Date; +} + +export interface WaveformData { + peaks: number[]; + sampleRate: number; + duration: number; +} + +export interface CreateBeatDto { + projectId: string; + filename: string; +} + +export interface UpdateBeatDto { + bpm?: number; + bpmConfidence?: number; + duration?: number; + waveformData?: WaveformData; +} + +export interface BeatUploadResponse { + beat: Beat; + uploadUrl: string; +} diff --git a/apps/lightwrite/packages/shared/src/types/export.ts b/apps/lightwrite/packages/shared/src/types/export.ts new file mode 100644 index 000000000..c01226899 --- /dev/null +++ b/apps/lightwrite/packages/shared/src/types/export.ts @@ -0,0 +1,57 @@ +export type ExportFormat = 'lrc' | 'srt' | 'json' | 'video'; + +export interface ExportOptions { + format: ExportFormat; + includeMarkers?: boolean; + videoOptions?: VideoExportOptions; +} + +export interface VideoExportOptions { + width: number; + height: number; + fps: number; + backgroundColor: string; + textColor: string; + highlightColor: string; + fontFamily: string; + fontSize: number; +} + +export interface LrcExportResult { + content: string; + filename: string; +} + +export interface SrtExportResult { + content: string; + filename: string; +} + +export interface JsonExportResult { + data: JsonExportData; + filename: string; +} + +export interface JsonExportData { + project: { + id: string; + title: string; + description?: string; + }; + beat: { + bpm?: number; + duration?: number; + }; + markers: Array<{ + type: string; + label?: string; + startTime: number; + endTime?: number; + }>; + lyrics: Array<{ + lineNumber: number; + text: string; + startTime?: number; + endTime?: number; + }>; +} diff --git a/apps/lightwrite/packages/shared/src/types/index.ts b/apps/lightwrite/packages/shared/src/types/index.ts new file mode 100644 index 000000000..ea26cf36b --- /dev/null +++ b/apps/lightwrite/packages/shared/src/types/index.ts @@ -0,0 +1,5 @@ +export * from './project'; +export * from './beat'; +export * from './marker'; +export * from './lyrics'; +export * from './export'; diff --git a/apps/lightwrite/packages/shared/src/types/lyrics.ts b/apps/lightwrite/packages/shared/src/types/lyrics.ts new file mode 100644 index 000000000..9bb1ee1a2 --- /dev/null +++ b/apps/lightwrite/packages/shared/src/types/lyrics.ts @@ -0,0 +1,55 @@ +export interface Lyrics { + id: string; + projectId: string; + content?: string | null; +} + +export interface LyricLine { + id: string; + lyricsId: string; + lineNumber: number; + text: string; + startTime?: number | null; + endTime?: number | null; +} + +export interface CreateLyricsDto { + projectId: string; + content?: string; +} + +export interface UpdateLyricsDto { + content?: string; +} + +export interface CreateLyricLineDto { + lyricsId: string; + lineNumber: number; + text: string; + startTime?: number; + endTime?: number; +} + +export interface UpdateLyricLineDto { + text?: string; + startTime?: number; + endTime?: number; +} + +export interface SyncedLyrics { + lines: SyncedLine[]; +} + +export interface SyncedLine { + lineNumber: number; + text: string; + startTime: number; + endTime?: number; + words?: SyncedWord[]; +} + +export interface SyncedWord { + word: string; + startTime: number; + endTime: number; +} diff --git a/apps/lightwrite/packages/shared/src/types/marker.ts b/apps/lightwrite/packages/shared/src/types/marker.ts new file mode 100644 index 000000000..54e3bb17c --- /dev/null +++ b/apps/lightwrite/packages/shared/src/types/marker.ts @@ -0,0 +1,49 @@ +export type MarkerType = + | 'verse' + | 'hook' + | 'bridge' + | 'intro' + | 'outro' + | 'drop' + | 'breakdown' + | 'custom'; + +export interface Marker { + id: string; + beatId: string; + type: MarkerType; + label?: string | null; + startTime: number; + endTime?: number | null; + color?: string | null; + sortOrder?: number | null; +} + +export interface CreateMarkerDto { + beatId: string; + type: MarkerType; + label?: string; + startTime: number; + endTime?: number; + color?: string; +} + +export interface UpdateMarkerDto { + type?: MarkerType; + label?: string; + startTime?: number; + endTime?: number; + color?: string; + sortOrder?: number; +} + +export const MARKER_COLORS: Record = { + verse: '#3B82F6', // blue + hook: '#EF4444', // red + bridge: '#8B5CF6', // purple + intro: '#22C55E', // green + outro: '#F97316', // orange + drop: '#EC4899', // pink + breakdown: '#14B8A6', // teal + custom: '#6B7280', // gray +}; diff --git a/apps/lightwrite/packages/shared/src/types/project.ts b/apps/lightwrite/packages/shared/src/types/project.ts new file mode 100644 index 000000000..aaac4e85b --- /dev/null +++ b/apps/lightwrite/packages/shared/src/types/project.ts @@ -0,0 +1,18 @@ +export interface Project { + id: string; + userId: string; + title: string; + description?: string | null; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateProjectDto { + title: string; + description?: string; +} + +export interface UpdateProjectDto { + title?: string; + description?: string; +} diff --git a/apps/lightwrite/packages/shared/tsconfig.json b/apps/lightwrite/packages/shared/tsconfig.json new file mode 100644 index 000000000..9976a4fcb --- /dev/null +++ b/apps/lightwrite/packages/shared/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "noEmit": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/shared-storage/src/factory.ts b/packages/shared-storage/src/factory.ts index b3e903b8a..d3fb622a7 100644 --- a/packages/shared-storage/src/factory.ts +++ b/packages/shared-storage/src/factory.ts @@ -157,3 +157,10 @@ export function createInventoryStorage(publicUrl?: string): StorageClient { publicUrl: publicUrl ?? process.env.INVENTORY_S3_PUBLIC_URL, }); } + +/** + * Create a storage client for the LightWrite project + */ +export function createLightWriteStorage(): StorageClient { + return createStorageClient({ name: BUCKETS.LIGHTWRITE }); +} diff --git a/packages/shared-storage/src/index.ts b/packages/shared-storage/src/index.ts index 4fae89495..f5d462861 100644 --- a/packages/shared-storage/src/index.ts +++ b/packages/shared-storage/src/index.ts @@ -16,6 +16,7 @@ export { createStorageStorage, createMailStorage, createInventoryStorage, + createLightWriteStorage, } from './factory'; // Utilities diff --git a/packages/shared-storage/src/types.ts b/packages/shared-storage/src/types.ts index 42c10eee0..9a95bb143 100644 --- a/packages/shared-storage/src/types.ts +++ b/packages/shared-storage/src/types.ts @@ -87,6 +87,7 @@ export const BUCKETS = { STORAGE: 'storage-storage', MAIL: 'mail-storage', INVENTORY: 'inventory-storage', + LIGHTWRITE: 'lightwrite-storage', } as const; export type BucketName = (typeof BUCKETS)[keyof typeof BUCKETS]; diff --git a/scripts/setup-databases.sh b/scripts/setup-databases.sh index d79db5ca5..b66b5d37a 100755 --- a/scripts/setup-databases.sh +++ b/scripts/setup-databases.sh @@ -81,6 +81,7 @@ ALL_DATABASES=( "nutriphi_bot" "questions" "skilltree" + "lightwrite" ) # Check if specific service requested @@ -190,9 +191,13 @@ setup_service() { create_db_if_not_exists "skilltree" push_schema "@skilltree/backend" "skilltree" ;; + lightwrite) + create_db_if_not_exists "lightwrite" + push_schema "@lightwrite/backend" "lightwrite" + ;; *) echo -e "${RED}Unknown service: $service${NC}" - echo "Available services: auth, chat, zitare, contacts, calendar, clock, todo, manadeck, mail, moodlit, picture, photos, finance, voxel-lava, figgos, planta, nutriphi, presi, storage, projectdoc, zitare_bot, todo_bot, nutriphi_bot, questions, skilltree" + echo "Available services: auth, chat, zitare, contacts, calendar, clock, todo, manadeck, mail, moodlit, picture, photos, finance, voxel-lava, figgos, planta, nutriphi, presi, storage, projectdoc, zitare_bot, todo_bot, nutriphi_bot, questions, skilltree, lightwrite" exit 1 ;; esac @@ -216,7 +221,7 @@ echo -e "\n${GREEN}Step 2: Pushing schemas${NC}" echo "--------------------------------------" # Push schemas for all known services -for service in auth chat zitare contacts calendar clock todo manadeck picture photos mail moodlit finance voxel-lava figgos planta nutriphi presi storage questions skilltree; do +for service in auth chat zitare contacts calendar clock todo manadeck picture photos mail moodlit finance voxel-lava figgos planta nutriphi presi storage questions skilltree lightwrite; do setup_service "$service" 2>/dev/null || true done diff --git a/services/mana-core-auth/src/config/env.validation.ts b/services/mana-core-auth/src/config/env.validation.ts index 62647724f..88f067f26 100644 --- a/services/mana-core-auth/src/config/env.validation.ts +++ b/services/mana-core-auth/src/config/env.validation.ts @@ -5,6 +5,10 @@ * Fails fast with clear error messages if configuration is invalid. */ +// Load .env file before validation runs +import * as dotenv from 'dotenv'; +dotenv.config(); + import { z } from 'zod'; // Schema for environment variables diff --git a/services/matrix-onboarding-bot/src/bot/matrix.service.ts b/services/matrix-onboarding-bot/src/bot/matrix.service.ts index c7aaf7a43..ae47d9a3e 100644 --- a/services/matrix-onboarding-bot/src/bot/matrix.service.ts +++ b/services/matrix-onboarding-bot/src/bot/matrix.service.ts @@ -203,9 +203,9 @@ export class MatrixService extends BaseMatrixService { const field = parts[0].toLowerCase(); const value = parts.slice(1).join(' '); - let fieldKey: 'displayName' | 'interests' | 'locale' | null = null; + let fieldKey: 'fullName' | 'interests' | 'locale' | null = null; if (field === 'name' || field === 'namen') { - fieldKey = 'displayName'; + fieldKey = 'fullName'; } else if (field === 'interests' || field === 'interessen') { fieldKey = 'interests'; } else if (field === 'language' || field === 'sprache' || field === 'lang') { diff --git a/services/matrix-onboarding-bot/src/config/configuration.ts b/services/matrix-onboarding-bot/src/config/configuration.ts index 577239694..6a9b58beb 100644 --- a/services/matrix-onboarding-bot/src/config/configuration.ts +++ b/services/matrix-onboarding-bot/src/config/configuration.ts @@ -16,72 +16,97 @@ export const HELP_TEXT = `**Onboarding Bot - Profil einrichten** **Befehle:** - \`!start\` - Onboarding starten/neustarten - \`!profile\` - Dein Profil anzeigen -- \`!edit name Max\` - Namen andern -- \`!edit interests KI, Musik\` - Interessen andern -- \`!edit language de\` - Sprache andern (de/en) -- \`!skip\` - Aktuelle Frage uberspringen +- \`!edit nickname Max\` - Spitznamen ändern +- \`!edit name Max Mustermann\` - Vollen Namen ändern +- \`!edit birthday 15.03\` - Geburtstag ändern +- \`!edit interests KI, Musik\` - Interessen ändern +- \`!edit goals Produktivität\` - Nutzungsziele ändern +- \`!edit language de\` - Sprache ändern (de/en) +- \`!skip\` - Aktuelle Frage überspringen - \`!help\` - Diese Hilfe anzeigen **Onboarding-Flow:** -1. Anzeigename eingeben -2. Interessen angeben (optional) -3. Sprache wahlen (de/en) -4. Profil bestatigen`; +1. Sprache wählen (de/en) +2. Spitzname eingeben +3. Vollständigen Namen eingeben +4. Geburtstag angeben +5. Interessen eingeben +6. Nutzungsziele angeben +7. Profil bestätigen + +Alle Fragen sind optional und können übersprungen werden.`; export const WELCOME_TEXT = `**Willkommen beim Onboarding!** Ich helfe dir, dein Profil einzurichten. Das dauert nur einen Moment. +Alle Fragen sind optional - sag einfach \`!skip\` zum Überspringen. -Wie mochtest du genannt werden?`; +Welche Sprache bevorzugst du? (\`de\` oder \`en\`)`; export const MESSAGES = { de: { welcome: - '**Willkommen beim Onboarding!**\n\nIch helfe dir, dein Profil einzurichten. Das dauert nur einen Moment.\n\nWie mochtest du genannt werden?', - askName: 'Wie mochtest du genannt werden?', - askInterests: - 'Hallo **{name}**! Was sind deine Interessen?\n(z.B. Programmierung, Musik, Gaming - durch Komma getrennt)\n\nSag `!skip` zum Uberspringen.', + '**Willkommen beim Onboarding!**\n\nIch helfe dir, dein Profil einzurichten. Das dauert nur einen Moment.\nAlle Fragen sind optional - sag einfach `!skip` zum Überspringen.\n\nWelche Sprache bevorzugst du? (`de` oder `en`)', askLanguage: - 'Welche Sprache bevorzugst du?\n\nAntworte mit `de` fur Deutsch oder `en` fur Englisch.', + 'Welche Sprache bevorzugst du?\n\nAntworte mit `de` für Deutsch oder `en` für English.\n\n(Optional - `!skip` zum Überspringen)', + askNickname: + 'Wie möchtest du genannt werden? (Spitzname)\n\nz.B. Max, Maxi, M...\n\n(Optional - `!skip` zum Überspringen)', + askFullName: + 'Wie heißt du vollständig? (Vor- und Nachname)\n\n(Optional - `!skip` zum Überspringen)', + askBirthDate: + 'Wann hast du Geburtstag?\n\nFormat: `TT.MM` oder `TT.MM.JJJJ`\nz.B. `15.03` oder `15.03.1990`\n\n(Optional - `!skip` zum Überspringen)', + askInterests: + 'Was sind deine Interessen?\n\n(z.B. Programmierung, Musik, Gaming - durch Komma getrennt)\n\n(Optional - `!skip` zum Überspringen)', + askUsageGoals: + 'Wofür möchtest du Mana nutzen?\n\nz.B. Produktivität, Kreativität, Lernen...\n\n(Optional - `!skip` zum Überspringen)', summary: - '**Dein Profil:**\n- Name: {name}\n- Interessen: {interests}\n- Sprache: {language}\n\nIst das korrekt? (ja/nein)', + '**Dein Profil:**\n- Spitzname: {nickname}\n- Voller Name: {fullName}\n- Geburtstag: {birthDate}\n- Interessen: {interests}\n- Nutzungsziele: {usageGoals}\n- Sprache: {language}\n\nIst das korrekt? (ja/nein)', completed: - 'Perfekt! Dein Profil ist eingerichtet. Du kannst es jederzeit mit `!profile` anzeigen oder mit `!edit` andern.', + 'Perfekt! Dein Profil ist eingerichtet.\n\nDu kannst es jederzeit mit `!profile` anzeigen oder mit `!edit` ändern.', cancelled: 'Onboarding abgebrochen. Starte jederzeit neu mit `!start`.', profileDisplay: - '**Dein Profil:**\n- Name: {name}\n- Interessen: {interests}\n- Sprache: {language}', + '**Dein Profil:**\n- Spitzname: {nickname}\n- Voller Name: {fullName}\n- Geburtstag: {birthDate}\n- Interessen: {interests}\n- Nutzungsziele: {usageGoals}\n- Sprache: {language}', noProfile: 'Du hast noch kein Profil eingerichtet. Starte mit `!start`.', updated: 'Profil aktualisiert!', - invalidLanguage: 'Bitte wahle `de` oder `en`.', - skipNotAllowed: 'Diese Frage kann nicht ubersprungen werden.', - skipped: 'Ubersprungen.', + invalidLanguage: 'Bitte wähle `de` oder `en`.', + invalidBirthDate: 'Bitte gib das Datum im Format `TT.MM` oder `TT.MM.JJJJ` ein.', + skipped: 'Übersprungen.', alreadyOnboarded: - 'Du hast das Onboarding bereits abgeschlossen. Nutze `!profile` zum Anzeigen oder `!edit` zum Andern.', - restartPrompt: 'Mochtest du das Onboarding neu starten? (ja/nein)', + 'Du hast das Onboarding bereits abgeschlossen. Nutze `!profile` zum Anzeigen oder `!edit` zum Ändern.', + restartPrompt: 'Möchtest du das Onboarding neu starten? (ja/nein)', loginRequired: 'Bitte melde dich zuerst an, um das Onboarding zu starten.', + skipNotAllowed: 'In diesem Schritt ist Überspringen nicht möglich.', }, en: { welcome: - "**Welcome to Onboarding!**\n\nI'll help you set up your profile. This will only take a moment.\n\nWhat would you like to be called?", - askName: 'What would you like to be called?', + "**Welcome to Onboarding!**\n\nI'll help you set up your profile. This will only take a moment.\nAll questions are optional - just say `!skip` to skip.\n\nWhich language do you prefer? (`de` or `en`)", + askLanguage: + 'Which language do you prefer?\n\nReply with `de` for German or `en` for English.\n\n(Optional - `!skip` to skip)', + askNickname: + 'What would you like to be called? (Nickname)\n\ne.g. Max, Maxi, M...\n\n(Optional - `!skip` to skip)', + askFullName: 'What is your full name? (First and last name)\n\n(Optional - `!skip` to skip)', + askBirthDate: + 'When is your birthday?\n\nFormat: `DD.MM` or `DD.MM.YYYY`\ne.g. `15.03` or `15.03.1990`\n\n(Optional - `!skip` to skip)', askInterests: - 'Hello **{name}**! What are your interests?\n(e.g. Programming, Music, Gaming - separated by commas)\n\nSay `!skip` to skip.', - askLanguage: 'Which language do you prefer?\n\nReply with `de` for German or `en` for English.', + 'What are your interests?\n\n(e.g. Programming, Music, Gaming - separated by commas)\n\n(Optional - `!skip` to skip)', + askUsageGoals: + 'What do you want to use Mana for?\n\ne.g. Productivity, Creativity, Learning...\n\n(Optional - `!skip` to skip)', summary: - '**Your Profile:**\n- Name: {name}\n- Interests: {interests}\n- Language: {language}\n\nIs this correct? (yes/no)', + '**Your Profile:**\n- Nickname: {nickname}\n- Full Name: {fullName}\n- Birthday: {birthDate}\n- Interests: {interests}\n- Usage Goals: {usageGoals}\n- Language: {language}\n\nIs this correct? (yes/no)', completed: - 'Perfect! Your profile is set up. You can view it anytime with `!profile` or change it with `!edit`.', + 'Perfect! Your profile is set up.\n\nYou can view it anytime with `!profile` or change it with `!edit`.', cancelled: 'Onboarding cancelled. Start again anytime with `!start`.', profileDisplay: - '**Your Profile:**\n- Name: {name}\n- Interests: {interests}\n- Language: {language}', + '**Your Profile:**\n- Nickname: {nickname}\n- Full Name: {fullName}\n- Birthday: {birthDate}\n- Interests: {interests}\n- Usage Goals: {usageGoals}\n- Language: {language}', noProfile: "You haven't set up a profile yet. Start with `!start`.", updated: 'Profile updated!', invalidLanguage: 'Please choose `de` or `en`.', - skipNotAllowed: 'This question cannot be skipped.', + invalidBirthDate: 'Please enter the date in format `DD.MM` or `DD.MM.YYYY`.', skipped: 'Skipped.', alreadyOnboarded: 'You have already completed onboarding. Use `!profile` to view or `!edit` to change.', restartPrompt: 'Would you like to restart onboarding? (yes/no)', loginRequired: 'Please log in first to start onboarding.', + skipNotAllowed: 'Skipping is not allowed at this step.', }, };