mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23:21:08 +02:00
chore: remove old lightwrite directory (renamed to mukke)
The lightwrite app was renamed to mukke in commit 7a56699d.
This removes the leftover tracked files from the old location.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0e496f7a00
commit
313db7646d
88 changed files with 0 additions and 6766 deletions
|
|
@ -1,165 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
# 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
|
||||
|
||||
# STT (Speech-to-Text)
|
||||
MANA_STT_URL=http://localhost:3020
|
||||
# MANA_STT_API_KEY= # Optional, only if mana-stt requires auth
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy root workspace files
|
||||
COPY pnpm-workspace.yaml ./
|
||||
COPY package.json ./
|
||||
COPY pnpm-lock.yaml ./
|
||||
|
||||
# Copy shared packages
|
||||
COPY packages/shared-drizzle-config ./packages/shared-drizzle-config
|
||||
COPY packages/shared-errors ./packages/shared-errors
|
||||
COPY packages/shared-nestjs-auth ./packages/shared-nestjs-auth
|
||||
COPY packages/shared-nestjs-health ./packages/shared-nestjs-health
|
||||
COPY packages/shared-nestjs-setup ./packages/shared-nestjs-setup
|
||||
COPY packages/shared-storage ./packages/shared-storage
|
||||
COPY packages/shared-tsconfig ./packages/shared-tsconfig
|
||||
|
||||
# Copy lightwrite packages
|
||||
COPY apps/lightwrite/packages ./apps/lightwrite/packages
|
||||
COPY apps/lightwrite/apps/backend ./apps/lightwrite/apps/backend
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
# Build shared packages
|
||||
WORKDIR /app/packages/shared-errors
|
||||
RUN pnpm build
|
||||
|
||||
WORKDIR /app/packages/shared-nestjs-auth
|
||||
RUN pnpm build
|
||||
|
||||
WORKDIR /app/packages/shared-nestjs-health
|
||||
RUN pnpm build
|
||||
|
||||
WORKDIR /app/packages/shared-storage
|
||||
RUN pnpm build
|
||||
|
||||
WORKDIR /app/packages/shared-nestjs-setup
|
||||
RUN pnpm build
|
||||
|
||||
# Build the backend
|
||||
WORKDIR /app/apps/lightwrite/apps/backend
|
||||
RUN pnpm build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
# Install pnpm and postgresql-client
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate \
|
||||
&& apk add --no-cache postgresql-client
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy everything from builder
|
||||
COPY --from=builder /app/pnpm-workspace.yaml ./
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/pnpm-lock.yaml ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/packages ./packages
|
||||
COPY --from=builder /app/apps/lightwrite ./apps/lightwrite
|
||||
|
||||
# Copy entrypoint script
|
||||
COPY apps/lightwrite/apps/backend/docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
WORKDIR /app/apps/lightwrite/apps/backend
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3010
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3010/health || exit 1
|
||||
|
||||
# Run entrypoint script
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD ["node", "dist/main.js"]
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "=========================================="
|
||||
echo " LightWrite Backend Startup"
|
||||
echo "=========================================="
|
||||
echo "Environment: ${NODE_ENV:-development}"
|
||||
echo "Port: ${PORT:-3010}"
|
||||
|
||||
# Wait for database to be ready
|
||||
if [ -n "$DATABASE_URL" ]; then
|
||||
echo "Waiting for database..."
|
||||
|
||||
# Extract host and port from DATABASE_URL
|
||||
DB_HOST=$(echo $DATABASE_URL | sed -n 's/.*@\([^:]*\):.*/\1/p')
|
||||
DB_PORT=$(echo $DATABASE_URL | sed -n 's/.*:\([0-9]*\)\/.*/\1/p')
|
||||
|
||||
# Default port if not found
|
||||
DB_PORT=${DB_PORT:-5432}
|
||||
|
||||
# Wait for database to accept connections
|
||||
max_attempts=30
|
||||
attempt=1
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
if pg_isready -h "$DB_HOST" -p "$DB_PORT" > /dev/null 2>&1; then
|
||||
echo "Database is ready!"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for database... (attempt $attempt/$max_attempts)"
|
||||
sleep 2
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
if [ $attempt -gt $max_attempts ]; then
|
||||
echo "Warning: Could not connect to database after $max_attempts attempts"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Push database schema (safe for production - only adds missing tables/columns)
|
||||
if [ "$RUN_DB_PUSH" = "true" ]; then
|
||||
echo "Pushing database schema..."
|
||||
npx drizzle-kit push --force || echo "Warning: db:push failed, continuing anyway..."
|
||||
fi
|
||||
|
||||
echo "Starting application..."
|
||||
exec "$@"
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import { createDrizzleConfig } from '@manacore/shared-drizzle-config';
|
||||
|
||||
export default createDrizzleConfig({
|
||||
dbName: 'lightwrite',
|
||||
additionalEnvVars: ['LIGHTWRITE_DATABASE_URL'],
|
||||
});
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
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 { SttModule } from './stt/stt.module';
|
||||
import { HealthModule } from '@manacore/shared-nestjs-health';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: '.env',
|
||||
}),
|
||||
DatabaseModule,
|
||||
ProjectModule,
|
||||
BeatModule,
|
||||
MarkerModule,
|
||||
LyricsModule,
|
||||
ExportModule,
|
||||
SttModule,
|
||||
HealthModule.forRoot({ serviceName: 'lightwrite-backend' }),
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
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, UseLibraryBeatDto } from './dto/beat.dto';
|
||||
|
||||
@Controller('beats')
|
||||
export class BeatController {
|
||||
constructor(private readonly beatService: BeatService) {}
|
||||
|
||||
// ==================== Library Beats (Public) ====================
|
||||
|
||||
@Get('library')
|
||||
async getLibraryBeats() {
|
||||
const beats = await this.beatService.getLibraryBeats();
|
||||
return { beats };
|
||||
}
|
||||
|
||||
@Get('library/:id')
|
||||
async getLibraryBeat(@Param('id', ParseUUIDPipe) id: string) {
|
||||
const beat = await this.beatService.getLibraryBeatById(id);
|
||||
if (!beat) {
|
||||
return { beat: null };
|
||||
}
|
||||
return { beat };
|
||||
}
|
||||
|
||||
@Get('library/:id/download-url')
|
||||
async getLibraryBeatDownloadUrl(@Param('id', ParseUUIDPipe) id: string) {
|
||||
const url = await this.beatService.getLibraryBeatDownloadUrl(id);
|
||||
return { url };
|
||||
}
|
||||
|
||||
@Post('library/:id/use')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async useLibraryBeat(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UseLibraryBeatDto
|
||||
) {
|
||||
const beat = await this.beatService.useLibraryBeat(id, dto.projectId, user.userId);
|
||||
return { beat };
|
||||
}
|
||||
|
||||
// ==================== STT Transcription ====================
|
||||
|
||||
@Get('stt/available')
|
||||
async getSttAvailability() {
|
||||
const available = await this.beatService.isSttAvailable();
|
||||
return { available };
|
||||
}
|
||||
|
||||
// ==================== User Beats (Protected) ====================
|
||||
|
||||
@Get('project/:projectId')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
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')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
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')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async getDownloadUrl(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string
|
||||
) {
|
||||
const url = await this.beatService.getDownloadUrl(id, user.userId);
|
||||
return { url };
|
||||
}
|
||||
|
||||
@Post('upload')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
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')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
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')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
await this.beatService.delete(id, user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post(':id/transcribe')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async transcribeBeat(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string
|
||||
) {
|
||||
const result = await this.beatService.transcribeBeat(id, user.userId);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { BeatController } from './beat.controller';
|
||||
import { BeatService } from './beat.service';
|
||||
import { SttModule } from '../stt/stt.module';
|
||||
import { LyricsModule } from '../lyrics/lyrics.module';
|
||||
|
||||
@Module({
|
||||
imports: [SttModule, forwardRef(() => LyricsModule)],
|
||||
controllers: [BeatController],
|
||||
providers: [BeatService],
|
||||
exports: [BeatService],
|
||||
})
|
||||
export class BeatModule {}
|
||||
|
|
@ -1,267 +0,0 @@
|
|||
import { Injectable, Inject, NotFoundException, BadRequestException, Logger } 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, libraryBeats } from '../db/schema';
|
||||
import type { Beat, Marker, LibraryBeat } from '../db/schema';
|
||||
import {
|
||||
createLightWriteStorage,
|
||||
generateUserFileKey,
|
||||
getContentType,
|
||||
type StorageClient,
|
||||
} from '@manacore/shared-storage';
|
||||
import { SttService } from '../stt/stt.service';
|
||||
import { LyricsService } from '../lyrics/lyrics.service';
|
||||
|
||||
@Injectable()
|
||||
export class BeatService {
|
||||
private readonly logger = new Logger(BeatService.name);
|
||||
private storage: StorageClient;
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION) private db: Database,
|
||||
private sttService: SttService,
|
||||
private lyricsService: LyricsService
|
||||
) {
|
||||
this.storage = createLightWriteStorage();
|
||||
}
|
||||
|
||||
async findByProjectId(projectId: string): Promise<Beat | null> {
|
||||
const [beat] = await this.db.select().from(beats).where(eq(beats.projectId, projectId));
|
||||
return beat || null;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Beat | null> {
|
||||
const [beat] = await this.db.select().from(beats).where(eq(beats.id, id));
|
||||
return beat || null;
|
||||
}
|
||||
|
||||
async findByIdOrThrow(id: string): Promise<Beat> {
|
||||
const beat = await this.findById(id);
|
||||
if (!beat) {
|
||||
throw new NotFoundException('Beat not found');
|
||||
}
|
||||
return beat;
|
||||
}
|
||||
|
||||
async verifyProjectOwnership(projectId: string, userId: string): Promise<void> {
|
||||
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<Beat> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<Marker[]> {
|
||||
return this.db.select().from(markers).where(eq(markers.beatId, beatId));
|
||||
}
|
||||
|
||||
// ==================== Library Beats ====================
|
||||
|
||||
async getLibraryBeats(): Promise<LibraryBeat[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(libraryBeats)
|
||||
.where(eq(libraryBeats.isActive, true))
|
||||
.orderBy(libraryBeats.title);
|
||||
}
|
||||
|
||||
async getLibraryBeatById(id: string): Promise<LibraryBeat | null> {
|
||||
const [beat] = await this.db.select().from(libraryBeats).where(eq(libraryBeats.id, id));
|
||||
return beat || null;
|
||||
}
|
||||
|
||||
async getLibraryBeatDownloadUrl(id: string): Promise<string> {
|
||||
const beat = await this.getLibraryBeatById(id);
|
||||
if (!beat) {
|
||||
throw new NotFoundException('Library beat not found');
|
||||
}
|
||||
return this.storage.getDownloadUrl(beat.storagePath, { expiresIn: 3600 });
|
||||
}
|
||||
|
||||
async useLibraryBeat(libraryBeatId: string, projectId: string, userId: string): Promise<Beat> {
|
||||
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 libraryBeat = await this.getLibraryBeatById(libraryBeatId);
|
||||
if (!libraryBeat) {
|
||||
throw new NotFoundException('Library beat not found');
|
||||
}
|
||||
|
||||
// Create beat record referencing the same storage path
|
||||
const [beat] = await this.db
|
||||
.insert(beats)
|
||||
.values({
|
||||
projectId,
|
||||
storagePath: libraryBeat.storagePath,
|
||||
filename: `${libraryBeat.title}${libraryBeat.artist ? ` - ${libraryBeat.artist}` : ''}.mp3`,
|
||||
duration: libraryBeat.duration,
|
||||
bpm: libraryBeat.bpm,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return beat;
|
||||
}
|
||||
|
||||
// ==================== STT Transcription ====================
|
||||
|
||||
/**
|
||||
* Check if STT service is available
|
||||
*/
|
||||
async isSttAvailable(): Promise<boolean> {
|
||||
return this.sttService.isAvailable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Transcribe beat audio and save lyrics to the project
|
||||
*/
|
||||
async transcribeBeat(
|
||||
beatId: string,
|
||||
userId: string
|
||||
): Promise<{ beat: Beat; lyrics: string | null }> {
|
||||
const beat = await this.findByIdOrThrow(beatId);
|
||||
await this.verifyProjectOwnership(beat.projectId, userId);
|
||||
|
||||
// Set status to pending
|
||||
await this.db
|
||||
.update(beats)
|
||||
.set({
|
||||
transcriptionStatus: 'pending',
|
||||
transcriptionError: null,
|
||||
})
|
||||
.where(eq(beats.id, beatId));
|
||||
|
||||
try {
|
||||
this.logger.log(`Starting transcription for beat ${beatId}`);
|
||||
|
||||
// Download audio from storage
|
||||
const audioBuffer = await this.storage.download(beat.storagePath);
|
||||
|
||||
// Call STT service
|
||||
const result = await this.sttService.transcribe(audioBuffer, beat.filename || 'audio.mp3');
|
||||
|
||||
// Save transcribed text as lyrics
|
||||
const lyricsRecord = await this.lyricsService.createOrUpdate(
|
||||
beat.projectId,
|
||||
userId,
|
||||
result.text
|
||||
);
|
||||
|
||||
// Update beat status to completed
|
||||
const [updatedBeat] = await this.db
|
||||
.update(beats)
|
||||
.set({
|
||||
transcriptionStatus: 'completed',
|
||||
transcribedAt: new Date(),
|
||||
transcriptionError: null,
|
||||
})
|
||||
.where(eq(beats.id, beatId))
|
||||
.returning();
|
||||
|
||||
this.logger.log(`Transcription completed for beat ${beatId}: ${result.text.length} chars`);
|
||||
|
||||
return {
|
||||
beat: updatedBeat,
|
||||
lyrics: lyricsRecord.content,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
this.logger.error(`Transcription failed for beat ${beatId}: ${errorMessage}`);
|
||||
|
||||
// Update beat status to failed
|
||||
await this.db
|
||||
.update(beats)
|
||||
.set({
|
||||
transcriptionStatus: 'failed',
|
||||
transcriptionError: errorMessage,
|
||||
})
|
||||
.where(eq(beats.id, beatId));
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import { IsString, IsNotEmpty, IsUUID, IsNumber, IsOptional, IsObject } from 'class-validator';
|
||||
|
||||
export class CreateBeatUploadDto {
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
projectId!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
filename!: string;
|
||||
}
|
||||
|
||||
export class UseLibraryBeatDto {
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
projectId!: 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;
|
||||
};
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
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<typeof postgres> | null = null;
|
||||
let db: ReturnType<typeof drizzle> | 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<typeof getDb>;
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
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<string>('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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
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);
|
||||
});
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
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'),
|
||||
// STT Transcription fields
|
||||
transcriptionStatus: varchar('transcription_status', { length: 50 }).default('none'), // 'none' | 'pending' | 'completed' | 'failed'
|
||||
transcriptionError: text('transcription_error'),
|
||||
transcribedAt: timestamp('transcribed_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type Beat = typeof beats.$inferSelect;
|
||||
export type NewBeat = typeof beats.$inferInsert;
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
export * from './projects.schema';
|
||||
export * from './beats.schema';
|
||||
export * from './markers.schema';
|
||||
export * from './lyrics.schema';
|
||||
export * from './library-beats.schema';
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import { pgTable, uuid, text, timestamp, varchar, real, boolean } from 'drizzle-orm/pg-core';
|
||||
|
||||
/**
|
||||
* Library beats are free beats available to all users.
|
||||
* They are pre-uploaded by admins and can be used in any project.
|
||||
*/
|
||||
export const libraryBeats = pgTable('library_beats', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
title: varchar('title', { length: 255 }).notNull(),
|
||||
artist: varchar('artist', { length: 255 }),
|
||||
genre: varchar('genre', { length: 100 }),
|
||||
bpm: real('bpm'),
|
||||
duration: real('duration'),
|
||||
storagePath: text('storage_path').notNull(),
|
||||
previewUrl: text('preview_url'),
|
||||
license: varchar('license', { length: 100 }).default('free'),
|
||||
isActive: boolean('is_active').default(true),
|
||||
tags: text('tags').array(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type LibraryBeat = typeof libraryBeats.$inferSelect;
|
||||
export type NewLibraryBeat = typeof libraryBeats.$inferInsert;
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
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);
|
||||
});
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
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 {}
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
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 {}
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
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<void> {
|
||||
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<Lyrics | null> {
|
||||
const [lyricsRecord] = await this.db
|
||||
.select()
|
||||
.from(lyrics)
|
||||
.where(eq(lyrics.projectId, projectId));
|
||||
return lyricsRecord || null;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Lyrics | null> {
|
||||
const [lyricsRecord] = await this.db.select().from(lyrics).where(eq(lyrics.id, id));
|
||||
return lyricsRecord || null;
|
||||
}
|
||||
|
||||
async findByIdOrThrow(id: string): Promise<Lyrics> {
|
||||
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<Lyrics> {
|
||||
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<LyricLine[]> {
|
||||
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<LyricLine[]> {
|
||||
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<LyricLine> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import { bootstrapApp } from '@manacore/shared-nestjs-setup';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
bootstrapApp(AppModule, {
|
||||
defaultPort: 3010,
|
||||
serviceName: 'LightWrite',
|
||||
additionalCorsOrigins: ['http://localhost:5180'],
|
||||
});
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
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[];
|
||||
}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
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 {}
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
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<void> {
|
||||
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<Marker[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(markers)
|
||||
.where(eq(markers.beatId, beatId))
|
||||
.orderBy(asc(markers.startTime));
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Marker | null> {
|
||||
const [marker] = await this.db.select().from(markers).where(eq(markers.id, id));
|
||||
return marker || null;
|
||||
}
|
||||
|
||||
async findByIdOrThrow(id: string): Promise<Marker> {
|
||||
const marker = await this.findById(id);
|
||||
if (!marker) {
|
||||
throw new NotFoundException('Marker not found');
|
||||
}
|
||||
return marker;
|
||||
}
|
||||
|
||||
async create(data: NewMarker): Promise<Marker> {
|
||||
const [marker] = await this.db.insert(markers).values(data).returning();
|
||||
return marker;
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
userId: string,
|
||||
data: Partial<Pick<Marker, 'type' | 'label' | 'startTime' | 'endTime' | 'color' | 'sortOrder'>>
|
||||
): Promise<Marker> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await this.verifyBeatOwnership(beatId, userId);
|
||||
await this.db.delete(markers).where(eq(markers.beatId, beatId));
|
||||
}
|
||||
|
||||
async bulkCreate(
|
||||
beatId: string,
|
||||
userId: string,
|
||||
items: Omit<NewMarker, 'beatId'>[]
|
||||
): Promise<Marker[]> {
|
||||
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<Pick<Marker, 'startTime' | 'endTime' | 'sortOrder'>>;
|
||||
}>
|
||||
): Promise<Marker[]> {
|
||||
const results: Marker[] = [];
|
||||
for (const update of updates) {
|
||||
const marker = await this.update(update.id, userId, update.data);
|
||||
results.push(marker);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
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 {}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
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<Project[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(eq(projects.userId, userId))
|
||||
.orderBy(desc(projects.updatedAt));
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string): Promise<Project | null> {
|
||||
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<Project> {
|
||||
const project = await this.findById(id, userId);
|
||||
if (!project) {
|
||||
throw new NotFoundException('Project not found');
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
async create(data: NewProject): Promise<Project> {
|
||||
const [project] = await this.db.insert(projects).values(data).returning();
|
||||
return project;
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
userId: string,
|
||||
data: Partial<Pick<Project, 'title' | 'description'>>
|
||||
): Promise<Project> {
|
||||
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<void> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { SttService } from './stt.service';
|
||||
|
||||
@Module({
|
||||
providers: [SttService],
|
||||
exports: [SttService],
|
||||
})
|
||||
export class SttModule {}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface TranscriptionResult {
|
||||
text: string;
|
||||
language: string | null;
|
||||
model: string;
|
||||
latencyMs: number | null;
|
||||
durationSeconds: number | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SttService {
|
||||
private readonly logger = new Logger(SttService.name);
|
||||
private readonly sttUrl: string;
|
||||
private readonly apiKey: string | undefined;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.sttUrl = this.configService.get('MANA_STT_URL') || 'http://localhost:3020';
|
||||
this.apiKey = this.configService.get('MANA_STT_API_KEY');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if mana-stt service is available
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.sttUrl}/health`, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
this.logger.warn(`STT service not available: ${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transcribe audio buffer using Whisper via mana-stt
|
||||
*/
|
||||
async transcribe(
|
||||
audioBuffer: Buffer,
|
||||
filename: string,
|
||||
language?: string
|
||||
): Promise<TranscriptionResult> {
|
||||
this.logger.log(`Starting transcription for ${filename} (${audioBuffer.length} bytes)`);
|
||||
|
||||
const formData = new FormData();
|
||||
// Convert Buffer to Uint8Array for Blob compatibility
|
||||
const uint8Array = new Uint8Array(audioBuffer);
|
||||
formData.append('file', new Blob([uint8Array]), filename);
|
||||
|
||||
if (language) {
|
||||
formData.append('language', language);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (this.apiKey) {
|
||||
headers['X-API-Key'] = this.apiKey;
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.sttUrl}/transcribe`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(120000), // 2 minute timeout
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`STT transcription failed: ${response.status} - ${error}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
this.logger.log(
|
||||
`Transcription complete: ${result.text?.length || 0} chars, language: ${result.language}, model: ${result.model}`
|
||||
);
|
||||
|
||||
return {
|
||||
text: result.text,
|
||||
language: result.language || null,
|
||||
model: result.model,
|
||||
latencyMs: result.latency_ms || null,
|
||||
durationSeconds: result.duration_seconds || null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
{
|
||||
"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"]
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import { defineConfig } from 'astro/config';
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
|
||||
export default defineConfig({
|
||||
site: 'https://lightwrite.app',
|
||||
integrations: [sitemap()],
|
||||
});
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
---
|
||||
interface Props {
|
||||
title: string;
|
||||
}
|
||||
|
||||
const { title } = Astro.props;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="description"
|
||||
content="LightWrite - Create synchronized lyrics for your beats with precision timing and beautiful karaoke exports."
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>{title}</title>
|
||||
<style is:global>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
'Open Sans',
|
||||
'Helvetica Neue',
|
||||
sans-serif;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,212 +0,0 @@
|
|||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
---
|
||||
|
||||
<Layout title="LightWrite - Beat & Lyrics Editor">
|
||||
<main class="min-h-screen bg-gradient-to-b from-gray-900 to-black text-white">
|
||||
<!-- Hero Section -->
|
||||
<section class="relative overflow-hidden">
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-blue-600/20 to-purple-600/20"></div>
|
||||
<div class="max-w-6xl mx-auto px-4 py-24 relative">
|
||||
<div class="text-center">
|
||||
<h1 class="text-5xl md:text-7xl font-bold mb-6">
|
||||
<span class="text-blue-400">Light</span>Write
|
||||
</h1>
|
||||
<p class="text-xl md:text-2xl text-gray-300 mb-8 max-w-2xl mx-auto">
|
||||
Create synchronized lyrics for your beats with precision timing and beautiful karaoke
|
||||
exports.
|
||||
</p>
|
||||
<div class="flex items-center justify-center gap-4">
|
||||
<a
|
||||
href="https://app.lightwrite.app"
|
||||
class="px-8 py-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-semibold text-lg"
|
||||
>
|
||||
Get Started Free
|
||||
</a>
|
||||
<a
|
||||
href="#features"
|
||||
class="px-8 py-4 border border-gray-600 text-white rounded-lg hover:bg-white/10 transition-colors font-semibold text-lg"
|
||||
>
|
||||
Learn More
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Features Section -->
|
||||
<section id="features" class="py-24 bg-gray-900/50">
|
||||
<div class="max-w-6xl mx-auto px-4">
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-center mb-16">
|
||||
Everything You Need for Lyric Sync
|
||||
</h2>
|
||||
|
||||
<div class="grid md:grid-cols-3 gap-8">
|
||||
<div class="p-8 bg-gray-800/50 rounded-xl border border-gray-700">
|
||||
<div class="w-14 h-14 bg-blue-600/20 rounded-lg flex items-center justify-center mb-6">
|
||||
<svg
|
||||
class="w-7 h-7 text-blue-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold mb-3">Waveform Editor</h3>
|
||||
<p class="text-gray-400">
|
||||
Visualize your audio with an interactive waveform. Zoom, scroll, and navigate with
|
||||
precision.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-8 bg-gray-800/50 rounded-xl border border-gray-700">
|
||||
<div
|
||||
class="w-14 h-14 bg-purple-600/20 rounded-lg flex items-center justify-center mb-6"
|
||||
>
|
||||
<svg
|
||||
class="w-7 h-7 text-purple-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold mb-3">BPM Detection</h3>
|
||||
<p class="text-gray-400">
|
||||
Automatic tempo detection helps you sync lyrics to the beat with snap-to-beat
|
||||
functionality.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-8 bg-gray-800/50 rounded-xl border border-gray-700">
|
||||
<div class="w-14 h-14 bg-green-600/20 rounded-lg flex items-center justify-center mb-6">
|
||||
<svg
|
||||
class="w-7 h-7 text-green-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold mb-3">Part Markers</h3>
|
||||
<p class="text-gray-400">
|
||||
Mark verses, hooks, bridges, and more. Organize your song structure visually.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-8 bg-gray-800/50 rounded-xl border border-gray-700">
|
||||
<div class="w-14 h-14 bg-red-600/20 rounded-lg flex items-center justify-center mb-6">
|
||||
<svg
|
||||
class="w-7 h-7 text-red-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold mb-3">Live Sync Recording</h3>
|
||||
<p class="text-gray-400">
|
||||
Record timestamps in real-time as the song plays. Just tap to sync each line.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-8 bg-gray-800/50 rounded-xl border border-gray-700">
|
||||
<div
|
||||
class="w-14 h-14 bg-yellow-600/20 rounded-lg flex items-center justify-center mb-6"
|
||||
>
|
||||
<svg
|
||||
class="w-7 h-7 text-yellow-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold mb-3">Karaoke Preview</h3>
|
||||
<p class="text-gray-400">
|
||||
Preview your synced lyrics in real-time with smooth karaoke-style highlighting.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-8 bg-gray-800/50 rounded-xl border border-gray-700">
|
||||
<div class="w-14 h-14 bg-cyan-600/20 rounded-lg flex items-center justify-center mb-6">
|
||||
<svg
|
||||
class="w-7 h-7 text-cyan-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold mb-3">Multiple Exports</h3>
|
||||
<p class="text-gray-400">
|
||||
Export to LRC, SRT, JSON, or generate karaoke videos for social media.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA Section -->
|
||||
<section class="py-24">
|
||||
<div class="max-w-4xl mx-auto px-4 text-center">
|
||||
<h2 class="text-3xl md:text-4xl font-bold mb-6">Ready to Create?</h2>
|
||||
<p class="text-xl text-gray-400 mb-8">
|
||||
Start syncing your lyrics today. Free to use, no credit card required.
|
||||
</p>
|
||||
<a
|
||||
href="https://app.lightwrite.app"
|
||||
class="inline-block px-8 py-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-semibold text-lg"
|
||||
>
|
||||
Start Creating
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="py-8 border-t border-gray-800">
|
||||
<div class="max-w-6xl mx-auto px-4 text-center text-gray-500">
|
||||
<p>© {new Date().getFullYear()} LightWrite. Part of the ManaCore ecosystem.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
</Layout>
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"strictNullChecks": true
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Build arguments for SvelteKit static env vars
|
||||
ARG PUBLIC_MANA_CORE_AUTH_URL=http://mana-auth:3001
|
||||
ARG PUBLIC_BACKEND_URL=http://lightwrite-backend:3010
|
||||
|
||||
# Set as environment variables for build
|
||||
ENV PUBLIC_MANA_CORE_AUTH_URL=$PUBLIC_MANA_CORE_AUTH_URL
|
||||
ENV PUBLIC_BACKEND_URL=$PUBLIC_BACKEND_URL
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy root workspace files
|
||||
COPY pnpm-workspace.yaml ./
|
||||
COPY package.json ./
|
||||
COPY pnpm-lock.yaml ./
|
||||
|
||||
# Copy shared packages needed by lightwrite web
|
||||
COPY packages/shared-api-client ./packages/shared-api-client
|
||||
COPY packages/shared-auth ./packages/shared-auth
|
||||
COPY packages/shared-auth-ui ./packages/shared-auth-ui
|
||||
COPY packages/shared-branding ./packages/shared-branding
|
||||
COPY packages/shared-config ./packages/shared-config
|
||||
COPY packages/shared-i18n ./packages/shared-i18n
|
||||
COPY packages/shared-icons ./packages/shared-icons
|
||||
COPY packages/shared-pwa ./packages/shared-pwa
|
||||
COPY packages/shared-stores ./packages/shared-stores
|
||||
COPY packages/shared-tailwind ./packages/shared-tailwind
|
||||
COPY packages/shared-theme ./packages/shared-theme
|
||||
COPY packages/shared-theme-ui ./packages/shared-theme-ui
|
||||
COPY packages/shared-types ./packages/shared-types
|
||||
COPY packages/shared-ui ./packages/shared-ui
|
||||
COPY packages/shared-utils ./packages/shared-utils
|
||||
COPY packages/shared-vite-config ./packages/shared-vite-config
|
||||
|
||||
# Copy lightwrite shared package
|
||||
COPY apps/lightwrite/packages ./apps/lightwrite/packages
|
||||
|
||||
# Copy lightwrite web
|
||||
COPY apps/lightwrite/apps/web ./apps/lightwrite/apps/web
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Build shared packages that need building
|
||||
WORKDIR /app/packages/shared-vite-config
|
||||
RUN pnpm build
|
||||
|
||||
WORKDIR /app/packages/shared-auth
|
||||
RUN pnpm build || true
|
||||
|
||||
# Build the web app
|
||||
WORKDIR /app/apps/lightwrite/apps/web
|
||||
RUN pnpm exec svelte-kit sync
|
||||
RUN pnpm build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
# Keep same directory structure as builder so pnpm symlinks resolve correctly
|
||||
WORKDIR /app/apps/lightwrite/apps/web
|
||||
|
||||
# Copy the pnpm store that symlinks point to
|
||||
COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm
|
||||
|
||||
# Copy the app's node_modules
|
||||
COPY --from=builder /app/apps/lightwrite/apps/web/node_modules ./node_modules
|
||||
|
||||
# Copy built application
|
||||
COPY --from=builder /app/apps/lightwrite/apps/web/build ./build
|
||||
COPY --from=builder /app/apps/lightwrite/apps/web/package.json ./
|
||||
|
||||
# Copy entrypoint script
|
||||
COPY apps/lightwrite/apps/web/docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
|
||||
# Expose port
|
||||
EXPOSE 5180
|
||||
|
||||
# Set environment variables
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=5180
|
||||
ENV HOST=0.0.0.0
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:5180/health || exit 1
|
||||
|
||||
# Run the app
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
CMD ["node", "build"]
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# This script injects runtime environment variables into the SvelteKit build
|
||||
# SvelteKit builds env vars at build time, but we need to inject them at runtime
|
||||
# for Docker deployments where the container runs in different environments
|
||||
|
||||
echo "Starting LightWrite Web with runtime configuration..."
|
||||
echo "PUBLIC_MANA_CORE_AUTH_URL_CLIENT: ${PUBLIC_MANA_CORE_AUTH_URL_CLIENT:-not set}"
|
||||
echo "PUBLIC_BACKEND_URL_CLIENT: ${PUBLIC_BACKEND_URL_CLIENT:-not set}"
|
||||
|
||||
# Execute the main command
|
||||
exec "$@"
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
{
|
||||
"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-pwa": "workspace:*",
|
||||
"@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",
|
||||
"@vite-pwa/sveltekit": "^1.1.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-i18n": "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"
|
||||
}
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
@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%;
|
||||
}
|
||||
|
||||
/* Mobile responsive waveform */
|
||||
@media (max-width: 767px) {
|
||||
.waveform-container {
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.timeline-ruler {
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.timeline-marker span {
|
||||
font-size: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Touch-friendly range inputs */
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-runnable-track {
|
||||
background: hsl(var(--color-surface-hover));
|
||||
border-radius: 4px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: hsl(var(--color-primary));
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
border-radius: 50%;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
margin-top: -6px;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#3B82F6" />
|
||||
<meta name="description" content="LightWrite - Beat & Lyrics Editor" />
|
||||
<title>LightWrite</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
/**
|
||||
* Server Hooks for SvelteKit
|
||||
* - Injects runtime environment variables for client-side use
|
||||
* - Auth is handled client-side via Mana Core Auth
|
||||
*/
|
||||
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
|
||||
// Get client-side URLs from environment (Docker runtime)
|
||||
const PUBLIC_MANA_CORE_AUTH_URL_CLIENT =
|
||||
process.env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || process.env.PUBLIC_MANA_CORE_AUTH_URL || '';
|
||||
const PUBLIC_BACKEND_URL_CLIENT =
|
||||
process.env.PUBLIC_BACKEND_URL_CLIENT || process.env.PUBLIC_BACKEND_URL || '';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
return resolve(event, {
|
||||
transformPageChunk: ({ html }) => {
|
||||
// Inject runtime environment variables into the HTML
|
||||
// These will be available on window.__PUBLIC_*__ for client-side code
|
||||
const envScript = `<script>
|
||||
window.__PUBLIC_MANA_CORE_AUTH_URL__ = "${PUBLIC_MANA_CORE_AUTH_URL_CLIENT}";
|
||||
window.__PUBLIC_BACKEND_URL__ = "${PUBLIC_BACKEND_URL_CLIENT}";
|
||||
</script>`;
|
||||
return html.replace('<head>', `<head>${envScript}`);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -1,225 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
interface LibraryBeat {
|
||||
id: string;
|
||||
title: string;
|
||||
artist: string | null;
|
||||
genre: string | null;
|
||||
bpm: number | null;
|
||||
duration: number | null;
|
||||
tags: string[] | null;
|
||||
license: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
onSelectBeat?: () => void;
|
||||
}
|
||||
|
||||
let { projectId, onSelectBeat }: Props = $props();
|
||||
|
||||
let beats = $state<LibraryBeat[]>([]);
|
||||
let isLoading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let isUsing = $state<string | null>(null);
|
||||
let previewingBeat = $state<string | null>(null);
|
||||
let audioElement: HTMLAudioElement | null = null;
|
||||
|
||||
const backendUrl =
|
||||
(typeof window !== 'undefined' &&
|
||||
(window as unknown as { __PUBLIC_BACKEND_URL__: string }).__PUBLIC_BACKEND_URL__) ||
|
||||
'http://localhost:3010';
|
||||
|
||||
onMount(async () => {
|
||||
await loadLibraryBeats();
|
||||
});
|
||||
|
||||
async function loadLibraryBeats() {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${backendUrl}/beats/library`);
|
||||
if (!response.ok) throw new Error('Failed to load library');
|
||||
const data = await response.json();
|
||||
beats = data.beats;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to load beat library';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUseBeat(beatId: string) {
|
||||
isUsing = beatId;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${backendUrl}/beats/library/${beatId}/use`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authStore.getAuthHeaders(),
|
||||
},
|
||||
body: JSON.stringify({ projectId }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.message || 'Failed to use beat');
|
||||
}
|
||||
|
||||
onSelectBeat?.();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to add beat to project';
|
||||
} finally {
|
||||
isUsing = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePreview(beatId: string) {
|
||||
if (previewingBeat === beatId) {
|
||||
// Stop preview
|
||||
audioElement?.pause();
|
||||
audioElement = null;
|
||||
previewingBeat = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop any existing preview
|
||||
audioElement?.pause();
|
||||
previewingBeat = beatId;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${backendUrl}/beats/library/${beatId}/download-url`);
|
||||
if (!response.ok) throw new Error('Failed to get preview URL');
|
||||
const data = await response.json();
|
||||
|
||||
audioElement = new Audio(data.url);
|
||||
audioElement.play();
|
||||
audioElement.onended = () => {
|
||||
previewingBeat = null;
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Preview error:', err);
|
||||
previewingBeat = null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number | null): string {
|
||||
if (!seconds) return '--:--';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
{#if isLoading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div
|
||||
class="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="text-center py-8">
|
||||
<p class="text-red-500 mb-4">{error}</p>
|
||||
<button onclick={loadLibraryBeats} class="px-4 py-2 text-primary hover:underline">
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
{:else if beats.length === 0}
|
||||
<div class="text-center py-12 text-foreground-secondary">
|
||||
<div class="w-16 h-16 mx-auto mb-4 opacity-50">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="w-full h-full">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p>No beats available in the library yet.</p>
|
||||
<p class="text-sm mt-2">Upload your own beat instead.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-3">
|
||||
{#each beats as beat}
|
||||
<div
|
||||
class="flex items-center gap-4 p-4 bg-surface rounded-lg hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<!-- Preview button -->
|
||||
<button
|
||||
onclick={() => togglePreview(beat.id)}
|
||||
class="w-10 h-10 flex items-center justify-center rounded-full bg-primary/10 text-primary hover:bg-primary/20 transition-colors shrink-0"
|
||||
aria-label={previewingBeat === beat.id ? 'Stop preview' : 'Play preview'}
|
||||
>
|
||||
{#if previewingBeat === beat.id}
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Beat info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-medium truncate">{beat.title}</h3>
|
||||
<div class="flex items-center gap-3 text-sm text-foreground-secondary">
|
||||
{#if beat.artist}
|
||||
<span>{beat.artist}</span>
|
||||
{/if}
|
||||
{#if beat.genre}
|
||||
<span class="px-2 py-0.5 bg-surface-active rounded-full text-xs">
|
||||
{beat.genre}
|
||||
</span>
|
||||
{/if}
|
||||
{#if beat.bpm}
|
||||
<span>{beat.bpm} BPM</span>
|
||||
{/if}
|
||||
<span>{formatDuration(beat.duration)}</span>
|
||||
</div>
|
||||
{#if beat.tags && beat.tags.length > 0}
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
{#each beat.tags.slice(0, 3) as tag}
|
||||
<span class="px-1.5 py-0.5 bg-primary/10 text-primary rounded text-xs">
|
||||
{tag}
|
||||
</span>
|
||||
{/each}
|
||||
{#if beat.tags.length > 3}
|
||||
<span class="text-xs text-foreground-secondary">
|
||||
+{beat.tags.length - 3} more
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Use button -->
|
||||
<button
|
||||
onclick={() => handleUseBeat(beat.id)}
|
||||
disabled={isUsing !== null}
|
||||
class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover disabled:opacity-50 transition-colors shrink-0 text-sm"
|
||||
>
|
||||
{#if isUsing === beat.id}
|
||||
<span class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
Adding...
|
||||
</span>
|
||||
{:else}
|
||||
Use Beat
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,298 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { projectStore } from '$lib/stores/project.svelte';
|
||||
import { audioStore } from '$lib/stores/audio.svelte';
|
||||
import { detectBpmFromFile } from '$lib/utils/bpm-detector';
|
||||
import BeatLibrary from './BeatLibrary.svelte';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
onUploadComplete?: () => void;
|
||||
onLyricsUpdate?: (lyrics: string) => void;
|
||||
}
|
||||
|
||||
let { projectId, onUploadComplete, onLyricsUpdate }: Props = $props();
|
||||
|
||||
type Tab = 'upload' | 'library';
|
||||
let activeTab = $state<Tab>('upload');
|
||||
|
||||
let isUploading = $state(false);
|
||||
let isDetectingBpm = $state(false);
|
||||
let isTranscribing = $state(false);
|
||||
let uploadProgress = $state(0);
|
||||
let errorMessage = $state<string | null>(null);
|
||||
let transcriptionError = $state<string | null>(null);
|
||||
let currentBeatId = $state<string | null>(null);
|
||||
let fileInputRef: HTMLInputElement;
|
||||
|
||||
const acceptedTypes = ['audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/mp3', 'audio/x-wav'];
|
||||
const acceptedExtensions = '.mp3,.wav,.ogg';
|
||||
|
||||
async function startTranscription(beatId: string) {
|
||||
isTranscribing = true;
|
||||
transcriptionError = null;
|
||||
currentBeatId = beatId;
|
||||
|
||||
try {
|
||||
const result = await projectStore.transcribeBeat(beatId);
|
||||
if (result.lyrics) {
|
||||
onLyricsUpdate?.(result.lyrics);
|
||||
}
|
||||
} catch (err) {
|
||||
transcriptionError = err instanceof Error ? err.message : 'Transcription failed';
|
||||
} finally {
|
||||
isTranscribing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function retryTranscription() {
|
||||
if (currentBeatId) {
|
||||
await startTranscription(currentBeatId);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file type
|
||||
if (!acceptedTypes.includes(file.type) && !file.name.match(/\.(mp3|wav|ogg)$/i)) {
|
||||
errorMessage = 'Please select an audio file (MP3, WAV, or OGG)';
|
||||
return;
|
||||
}
|
||||
|
||||
errorMessage = null;
|
||||
transcriptionError = null;
|
||||
isUploading = true;
|
||||
uploadProgress = 0;
|
||||
|
||||
try {
|
||||
// Upload the file
|
||||
uploadProgress = 30;
|
||||
const beat = await projectStore.uploadBeat(projectId, file);
|
||||
currentBeatId = beat.id;
|
||||
uploadProgress = 60;
|
||||
|
||||
// Detect BPM
|
||||
isDetectingBpm = true;
|
||||
const bpmResult = await detectBpmFromFile(file);
|
||||
uploadProgress = 80;
|
||||
|
||||
// Get audio duration
|
||||
const audioContext = new AudioContext();
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||
const duration = audioBuffer.duration;
|
||||
await audioContext.close();
|
||||
|
||||
// Update beat metadata
|
||||
await projectStore.updateBeatMetadata(beat.id, {
|
||||
duration,
|
||||
bpm: bpmResult.bpm,
|
||||
bpmConfidence: bpmResult.confidence,
|
||||
});
|
||||
|
||||
audioStore.setBpm(bpmResult.bpm);
|
||||
uploadProgress = 100;
|
||||
|
||||
onUploadComplete?.();
|
||||
|
||||
// Auto-start transcription
|
||||
startTranscription(beat.id);
|
||||
} catch (err) {
|
||||
errorMessage = err instanceof Error ? err.message : 'Failed to upload beat';
|
||||
} finally {
|
||||
isUploading = false;
|
||||
isDetectingBpm = false;
|
||||
// Reset file input
|
||||
if (fileInputRef) fileInputRef.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer!.dropEffect = 'copy';
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer?.files[0];
|
||||
if (file && fileInputRef) {
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(file);
|
||||
fileInputRef.files = dt.files;
|
||||
handleFileSelect({ target: fileInputRef } as unknown as Event);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Tab Switcher -->
|
||||
<div class="flex border-b border-border">
|
||||
<button
|
||||
onclick={() => (activeTab = 'upload')}
|
||||
class="flex-1 py-3 px-4 text-sm font-medium transition-colors relative {activeTab === 'upload'
|
||||
? 'text-primary'
|
||||
: 'text-foreground-secondary hover:text-foreground'}"
|
||||
>
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
Upload
|
||||
</span>
|
||||
{#if activeTab === 'upload'}
|
||||
<div class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary"></div>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (activeTab = 'library')}
|
||||
class="flex-1 py-3 px-4 text-sm font-medium transition-colors relative {activeTab ===
|
||||
'library'
|
||||
? 'text-primary'
|
||||
: 'text-foreground-secondary hover:text-foreground'}"
|
||||
>
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
|
||||
/>
|
||||
</svg>
|
||||
Library
|
||||
</span>
|
||||
{#if activeTab === 'library'}
|
||||
<div class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary"></div>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
{#if activeTab === 'upload'}
|
||||
<div
|
||||
class="border-2 border-dashed border-border rounded-lg p-8 text-center transition-colors hover:border-primary"
|
||||
ondragover={handleDragOver}
|
||||
ondrop={handleDrop}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<input
|
||||
bind:this={fileInputRef}
|
||||
type="file"
|
||||
accept={acceptedExtensions}
|
||||
onchange={handleFileSelect}
|
||||
class="hidden"
|
||||
id="beat-upload"
|
||||
/>
|
||||
|
||||
{#if isUploading}
|
||||
<div class="space-y-4">
|
||||
<div class="w-16 h-16 mx-auto">
|
||||
{#if isDetectingBpm}
|
||||
<svg
|
||||
class="w-full h-full text-primary animate-pulse"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<div
|
||||
class="w-full h-full border-4 border-primary border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-foreground-secondary">
|
||||
{isDetectingBpm ? 'Detecting BPM...' : 'Uploading...'}
|
||||
</p>
|
||||
<div class="w-full max-w-xs mx-auto h-2 bg-surface-hover rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-primary transition-all duration-300"
|
||||
style="width: {uploadProgress}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<label for="beat-upload" class="cursor-pointer block">
|
||||
<div class="w-16 h-16 mx-auto mb-4 text-foreground-secondary">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="w-full h-full">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-lg font-medium mb-2">Upload a Beat</p>
|
||||
<p class="text-foreground-secondary text-sm">
|
||||
Drag & drop or click to select an audio file
|
||||
</p>
|
||||
<p class="text-foreground-secondary text-xs mt-2">Supported formats: MP3, WAV, OGG</p>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
{#if errorMessage}
|
||||
<p class="text-red-500 mt-4 text-sm">{errorMessage}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Transcription Status -->
|
||||
{#if isTranscribing}
|
||||
<div
|
||||
class="flex items-center gap-3 p-4 bg-surface-hover rounded-lg border border-border animate-pulse"
|
||||
>
|
||||
<div
|
||||
class="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium">Transcribing lyrics...</p>
|
||||
<p class="text-xs text-foreground-secondary">
|
||||
Analyzing audio to extract lyrics automatically
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if transcriptionError}
|
||||
<div class="flex items-center gap-3 p-4 bg-red-500/10 rounded-lg border border-red-500/30">
|
||||
<svg
|
||||
class="w-5 h-5 text-red-500 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-red-500">Transcription failed</p>
|
||||
<p class="text-xs text-foreground-secondary">{transcriptionError}</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={retryTranscription}
|
||||
class="px-3 py-1.5 text-sm font-medium bg-red-500 text-white rounded-md hover:bg-red-600 transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<BeatLibrary {projectId} onSelectBeat={onUploadComplete} />
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { projectStore } from '$lib/stores/project.svelte';
|
||||
import { audioStore } from '$lib/stores/audio.svelte';
|
||||
|
||||
interface Props {
|
||||
fontSize?: number;
|
||||
showPastLines?: number;
|
||||
showFutureLines?: number;
|
||||
}
|
||||
|
||||
let { fontSize = 24, showPastLines = 1, showFutureLines = 2 }: Props = $props();
|
||||
|
||||
// Find the current line index based on playback time
|
||||
let currentLineIndex = $derived.by(() => {
|
||||
const currentTime = audioStore.currentTime;
|
||||
const lines = projectStore.currentLines;
|
||||
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i];
|
||||
if (
|
||||
line.startTime !== null &&
|
||||
line.startTime !== undefined &&
|
||||
currentTime >= line.startTime
|
||||
) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Get visible lines
|
||||
let visibleLines = $derived.by(() => {
|
||||
const lines = projectStore.currentLines;
|
||||
const start = Math.max(0, currentLineIndex - showPastLines);
|
||||
const end = Math.min(lines.length, currentLineIndex + showFutureLines + 1);
|
||||
return lines.slice(start, end).map((line, idx) => ({
|
||||
...line,
|
||||
relativeIndex: start + idx - currentLineIndex,
|
||||
}));
|
||||
});
|
||||
|
||||
// Calculate progress within current line
|
||||
let lineProgress = $derived.by(() => {
|
||||
const lines = projectStore.currentLines;
|
||||
const currentLine = lines[currentLineIndex];
|
||||
if (!currentLine || currentLine.startTime === null || currentLine.startTime === undefined) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const endTime =
|
||||
currentLine.endTime || (lines[currentLineIndex + 1]?.startTime ?? audioStore.duration);
|
||||
const lineDuration = endTime - currentLine.startTime;
|
||||
const elapsed = audioStore.currentTime - currentLine.startTime;
|
||||
|
||||
return Math.min(1, Math.max(0, elapsed / lineDuration));
|
||||
});
|
||||
|
||||
function getLineOpacity(relativeIndex: number): number {
|
||||
if (relativeIndex === 0) return 1;
|
||||
if (relativeIndex < 0) return 0.3;
|
||||
return 0.5;
|
||||
}
|
||||
|
||||
function getLineScale(relativeIndex: number): number {
|
||||
if (relativeIndex === 0) return 1;
|
||||
return 0.9;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-col items-center justify-center h-full p-8 bg-gradient-to-b from-surface to-background"
|
||||
style="font-size: {fontSize}px"
|
||||
>
|
||||
{#each visibleLines as line}
|
||||
<div
|
||||
class="karaoke-line text-center transition-all duration-300 py-2"
|
||||
style="
|
||||
opacity: {getLineOpacity(line.relativeIndex)};
|
||||
transform: scale({getLineScale(line.relativeIndex)});
|
||||
"
|
||||
>
|
||||
{#if line.relativeIndex === 0}
|
||||
<!-- Current line with progress highlight -->
|
||||
<div class="relative inline-block">
|
||||
<!-- Background text -->
|
||||
<span class="text-foreground-secondary">{line.text}</span>
|
||||
|
||||
<!-- Highlighted text (clips based on progress) -->
|
||||
<span
|
||||
class="absolute inset-0 text-primary overflow-hidden whitespace-nowrap"
|
||||
style="width: {lineProgress * 100}%"
|
||||
>
|
||||
{line.text}
|
||||
</span>
|
||||
</div>
|
||||
{:else if line.relativeIndex < 0}
|
||||
<!-- Past line -->
|
||||
<span class="text-foreground-secondary">{line.text}</span>
|
||||
{:else}
|
||||
<!-- Future line -->
|
||||
<span class="text-foreground-secondary/50">{line.text}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if projectStore.currentLines.length === 0}
|
||||
<p class="text-foreground-secondary text-center">No synced lyrics to preview.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,214 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { projectStore } from '$lib/stores/project.svelte';
|
||||
import { audioStore } from '$lib/stores/audio.svelte';
|
||||
import { editorStore } from '$lib/stores/editor.svelte';
|
||||
import { formatTimeWithMs } from '$lib/utils/time-format';
|
||||
import type { LyricLine } from '@lightwrite/shared';
|
||||
|
||||
interface Props {
|
||||
onLineClick?: (lineIndex: number, line: LyricLine) => void;
|
||||
onSyncLine?: (lineIndex: number) => void;
|
||||
}
|
||||
|
||||
let { onLineClick, onSyncLine }: Props = $props();
|
||||
|
||||
let textContent = $state('');
|
||||
let isSaving = $state(false);
|
||||
|
||||
// Initialize text content from lyrics
|
||||
$effect(() => {
|
||||
if (projectStore.currentLyrics?.content) {
|
||||
textContent = projectStore.currentLyrics.content;
|
||||
}
|
||||
});
|
||||
|
||||
// Find the currently active line based on playback time
|
||||
let activeLineIndex = $derived.by(() => {
|
||||
if (!audioStore.isPlaying) return null;
|
||||
|
||||
const currentTime = audioStore.currentTime;
|
||||
const lines = projectStore.currentLines;
|
||||
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i];
|
||||
if (
|
||||
line.startTime !== null &&
|
||||
line.startTime !== undefined &&
|
||||
currentTime >= line.startTime
|
||||
) {
|
||||
// Check if we're past the end time
|
||||
if (line.endTime && currentTime > line.endTime) {
|
||||
// Check if next line has started
|
||||
const nextLine = lines[i + 1];
|
||||
if (
|
||||
nextLine?.startTime !== null &&
|
||||
nextLine?.startTime !== undefined &&
|
||||
currentTime >= nextLine.startTime
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
async function handleSave() {
|
||||
if (!projectStore.currentProject) return;
|
||||
|
||||
isSaving = true;
|
||||
try {
|
||||
// Save lyrics content
|
||||
const lyrics = await projectStore.updateLyrics(projectStore.currentProject.id, textContent);
|
||||
|
||||
// Parse and sync lines
|
||||
const lines = textContent
|
||||
.split('\n')
|
||||
.map((text, index) => {
|
||||
// Find existing line to preserve timestamp
|
||||
const existingLine = projectStore.currentLines.find((l) => l.lineNumber === index);
|
||||
return {
|
||||
lineNumber: index,
|
||||
text: text.trim(),
|
||||
startTime: existingLine?.startTime ?? undefined,
|
||||
endTime: existingLine?.endTime ?? undefined,
|
||||
};
|
||||
})
|
||||
.filter((l) => l.text.length > 0);
|
||||
|
||||
await projectStore.syncLines(lyrics.id, lines);
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleLineClick(index: number) {
|
||||
const line = projectStore.currentLines[index];
|
||||
if (line) {
|
||||
editorStore.selectLine(index);
|
||||
onLineClick?.(index, line);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSyncClick(index: number, e: Event) {
|
||||
e.stopPropagation();
|
||||
onSyncLine?.(index);
|
||||
}
|
||||
|
||||
async function handleTimestampUpdate(lineId: string) {
|
||||
const currentTime = audioStore.currentTime;
|
||||
await projectStore.updateLineTimestamp(lineId, currentTime);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center justify-between p-3 border-b border-border">
|
||||
<h3 class="font-medium">Lyrics</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={() => editorStore.setRecordingTimestamps(!editorStore.isRecordingTimestamps)}
|
||||
class="px-3 py-1 text-sm rounded-lg transition-colors {editorStore.isRecordingTimestamps
|
||||
? 'bg-red-500 text-white'
|
||||
: 'bg-surface-hover hover:bg-surface-active'}"
|
||||
>
|
||||
{editorStore.isRecordingTimestamps ? 'Stop Recording' : 'Record Timestamps'}
|
||||
</button>
|
||||
<button
|
||||
onclick={handleSave}
|
||||
disabled={isSaving}
|
||||
class="px-3 py-1 text-sm bg-primary text-white rounded-lg hover:bg-primary-hover disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mode toggle -->
|
||||
<div class="flex p-2 gap-2">
|
||||
<button
|
||||
onclick={() => editorStore.setMode('edit')}
|
||||
class="flex-1 px-3 py-2 text-sm rounded-lg transition-colors {editorStore.mode === 'edit'
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-surface-hover'}"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editorStore.setMode('preview')}
|
||||
class="flex-1 px-3 py-2 text-sm rounded-lg transition-colors {editorStore.mode === 'preview'
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-surface-hover'}"
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-auto p-4">
|
||||
{#if editorStore.mode === 'edit'}
|
||||
<!-- Edit mode: textarea -->
|
||||
<textarea
|
||||
bind:value={textContent}
|
||||
class="w-full h-full p-4 bg-surface rounded-lg border border-border focus:border-primary focus:outline-none resize-none lyrics-editor"
|
||||
placeholder="Enter your lyrics here... Each line will be synced to a timestamp."
|
||||
></textarea>
|
||||
{:else}
|
||||
<!-- Preview mode: synced lines -->
|
||||
<div class="space-y-1">
|
||||
{#each projectStore.currentLines as line, index}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => 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'
|
||||
: ''}"
|
||||
>
|
||||
<!-- Timestamp -->
|
||||
<span class="text-xs font-mono text-foreground-secondary min-w-[60px]">
|
||||
{line.startTime !== null && line.startTime !== undefined
|
||||
? formatTimeWithMs(line.startTime)
|
||||
: '--:--'}
|
||||
</span>
|
||||
|
||||
<!-- Line text -->
|
||||
<span class="flex-1">{line.text}</span>
|
||||
|
||||
<!-- Sync button -->
|
||||
{#if editorStore.isRecordingTimestamps}
|
||||
<button
|
||||
onclick={(e) => handleSyncClick(index, e)}
|
||||
class="px-2 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600"
|
||||
>
|
||||
Sync
|
||||
</button>
|
||||
{:else if line.startTime === null || line.startTime === undefined}
|
||||
<button
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleTimestampUpdate(line.id);
|
||||
}}
|
||||
class="px-2 py-1 text-xs bg-surface-hover text-foreground-secondary rounded hover:bg-surface-active"
|
||||
>
|
||||
Set Time
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if projectStore.currentLines.length === 0}
|
||||
<p class="text-center text-foreground-secondary py-8">
|
||||
No lyrics yet. Switch to Edit mode to add lyrics.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,204 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { projectStore } from '$lib/stores/project.svelte';
|
||||
import { audioStore } from '$lib/stores/audio.svelte';
|
||||
import { editorStore } from '$lib/stores/editor.svelte';
|
||||
import { MARKER_COLORS, type MarkerType } from '@lightwrite/shared';
|
||||
|
||||
interface Props {
|
||||
onMarkerClick?: (markerId: string) => void;
|
||||
onSeek?: (time: number) => void;
|
||||
onToggleLoop?: (markerId: string) => void;
|
||||
}
|
||||
|
||||
let { onMarkerClick, onSeek, onToggleLoop }: Props = $props();
|
||||
|
||||
let containerRef: HTMLDivElement;
|
||||
|
||||
const markerTypes: MarkerType[] = [
|
||||
'intro',
|
||||
'verse',
|
||||
'hook',
|
||||
'bridge',
|
||||
'drop',
|
||||
'breakdown',
|
||||
'outro',
|
||||
'custom',
|
||||
];
|
||||
|
||||
function handleTimelineClick(e: MouseEvent) {
|
||||
if (!containerRef || !audioStore.duration) return;
|
||||
|
||||
const rect = containerRef.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const time = (x / rect.width) * audioStore.duration;
|
||||
onSeek?.(time);
|
||||
}
|
||||
|
||||
function getMarkerPosition(time: number): number {
|
||||
if (!audioStore.duration) return 0;
|
||||
return (time / audioStore.duration) * 100;
|
||||
}
|
||||
|
||||
function getMarkerWidth(startTime: number, endTime: number | null | undefined): number {
|
||||
if (!audioStore.duration || !endTime) return 0.5;
|
||||
return ((endTime - startTime) / audioStore.duration) * 100;
|
||||
}
|
||||
|
||||
async function handleAddMarker() {
|
||||
const beatId = projectStore.currentBeat?.id;
|
||||
if (!beatId) return;
|
||||
|
||||
await projectStore.createMarker(beatId, {
|
||||
type: editorStore.markerTypeToCreate,
|
||||
startTime: audioStore.currentTime,
|
||||
endTime: audioStore.currentTime + 4, // Default 4 seconds
|
||||
color: MARKER_COLORS[editorStore.markerTypeToCreate],
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDeleteMarker(markerId: string) {
|
||||
await projectStore.deleteMarker(markerId);
|
||||
if (editorStore.selectedMarkerId === markerId) {
|
||||
editorStore.selectMarker(null);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2 p-4 bg-surface rounded-lg">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium">Markers</span>
|
||||
|
||||
<!-- Marker type selector -->
|
||||
<select
|
||||
value={editorStore.markerTypeToCreate}
|
||||
onchange={(e) =>
|
||||
editorStore.setMarkerTypeToCreate((e.target as HTMLSelectElement).value as MarkerType)}
|
||||
class="px-2 py-1 text-sm bg-surface-hover rounded border border-border"
|
||||
>
|
||||
{#each markerTypes as type}
|
||||
<option value={type}>{type.charAt(0).toUpperCase() + type.slice(1)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<button
|
||||
onclick={handleAddMarker}
|
||||
disabled={!projectStore.currentBeat}
|
||||
class="px-3 py-1 text-sm bg-primary text-white rounded hover:bg-primary-hover disabled:opacity-50"
|
||||
>
|
||||
Add Marker
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="flex items-center gap-3 text-xs">
|
||||
{#each markerTypes.slice(0, 5) as type}
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="w-3 h-3 rounded" style="background-color: {MARKER_COLORS[type]}"></span>
|
||||
<span>{type}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline ruler -->
|
||||
<div
|
||||
bind:this={containerRef}
|
||||
class="timeline-ruler relative cursor-pointer"
|
||||
onclick={handleTimelineClick}
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax={audioStore.duration}
|
||||
aria-valuenow={audioStore.currentTime}
|
||||
>
|
||||
<!-- Markers -->
|
||||
{#each projectStore.currentMarkers as marker}
|
||||
<button
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMarkerClick?.(marker.id);
|
||||
}}
|
||||
class="timeline-marker {editorStore.selectedMarkerId === marker.id
|
||||
? 'ring-2 ring-white'
|
||||
: ''}"
|
||||
style="
|
||||
left: {getMarkerPosition(marker.startTime)}%;
|
||||
width: {getMarkerWidth(marker.startTime, marker.endTime)}%;
|
||||
background-color: {marker.color || MARKER_COLORS[marker.type as MarkerType]};
|
||||
"
|
||||
title="{marker.type}{marker.label ? `: ${marker.label}` : ''}"
|
||||
>
|
||||
<span class="text-[10px] text-white truncate px-1">
|
||||
{marker.label || marker.type}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<!-- Playhead -->
|
||||
{#if audioStore.duration > 0}
|
||||
<div class="playhead" style="left: {getMarkerPosition(audioStore.currentTime)}%"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Selected marker info -->
|
||||
{#if editorStore.selectedMarkerId}
|
||||
{@const selectedMarker = projectStore.currentMarkers.find(
|
||||
(m) => m.id === editorStore.selectedMarkerId
|
||||
)}
|
||||
{#if selectedMarker}
|
||||
<div class="flex items-center justify-between p-2 bg-surface-hover rounded text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-4 h-4 rounded"
|
||||
style="background-color: {selectedMarker.color ||
|
||||
MARKER_COLORS[selectedMarker.type as MarkerType]}"
|
||||
></span>
|
||||
<span class="font-medium">{selectedMarker.type}</span>
|
||||
{#if selectedMarker.label}
|
||||
<span class="text-foreground-secondary">- {selectedMarker.label}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-foreground-secondary">
|
||||
{selectedMarker.startTime.toFixed(2)}s - {(
|
||||
selectedMarker.endTime || selectedMarker.startTime
|
||||
).toFixed(2)}s
|
||||
</span>
|
||||
<button
|
||||
onclick={() => onToggleLoop?.(selectedMarker.id)}
|
||||
class="p-1 rounded transition-colors {editorStore.loopRegionId === selectedMarker.id
|
||||
? 'text-primary bg-primary/10'
|
||||
: 'text-foreground-secondary hover:bg-surface-active'}"
|
||||
title={editorStore.loopRegionId === selectedMarker.id
|
||||
? 'Stop Loop (L)'
|
||||
: 'Loop Region (L)'}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleDeleteMarker(selectedMarker.id)}
|
||||
class="p-1 text-red-500 hover:bg-red-500/10 rounded"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,204 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { audioStore } from '$lib/stores/audio.svelte';
|
||||
import { editorStore } from '$lib/stores/editor.svelte';
|
||||
import { formatTime } from '$lib/utils/time-format';
|
||||
|
||||
interface Props {
|
||||
onPlay?: () => void;
|
||||
onPause?: () => void;
|
||||
onSeek?: (time: number) => void;
|
||||
onZoomIn?: () => void;
|
||||
onZoomOut?: () => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
let { onPlay, onPause, onSeek, onZoomIn, onZoomOut, compact = false }: Props = $props();
|
||||
|
||||
function handlePlayPause() {
|
||||
if (audioStore.isPlaying) {
|
||||
onPause?.();
|
||||
} else {
|
||||
onPlay?.();
|
||||
}
|
||||
}
|
||||
|
||||
function handleSeek(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const time = parseFloat(input.value);
|
||||
onSeek?.(time);
|
||||
}
|
||||
|
||||
function skipBackward() {
|
||||
const newTime = Math.max(0, audioStore.currentTime - 5);
|
||||
onSeek?.(newTime);
|
||||
}
|
||||
|
||||
function skipForward() {
|
||||
const newTime = Math.min(audioStore.duration, audioStore.currentTime + 5);
|
||||
onSeek?.(newTime);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if compact}
|
||||
<!-- Compact mobile layout -->
|
||||
<div class="flex items-center gap-2 p-2 bg-surface rounded-lg">
|
||||
<!-- Play/Pause button -->
|
||||
<button
|
||||
onclick={handlePlayPause}
|
||||
class="p-2 bg-primary text-white rounded-full hover:bg-primary-hover transition-colors shrink-0"
|
||||
aria-label={audioStore.isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{#if audioStore.isPlaying}
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Time display -->
|
||||
<div class="text-xs font-mono text-foreground-secondary shrink-0">
|
||||
{formatTime(audioStore.currentTime)}
|
||||
</div>
|
||||
|
||||
<!-- Seek slider -->
|
||||
<div class="flex-1">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={audioStore.duration || 100}
|
||||
step="0.1"
|
||||
value={audioStore.currentTime}
|
||||
oninput={handleSeek}
|
||||
class="w-full h-1.5 bg-surface-hover rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Duration -->
|
||||
<div class="text-xs font-mono text-foreground-secondary shrink-0">
|
||||
{formatTime(audioStore.duration)}
|
||||
</div>
|
||||
|
||||
<!-- BPM display (compact) -->
|
||||
{#if audioStore.bpm}
|
||||
<div class="px-2 py-0.5 bg-primary/10 text-primary rounded-full text-xs font-medium shrink-0">
|
||||
{audioStore.bpm}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Desktop layout -->
|
||||
<div class="flex items-center gap-4 p-4 bg-surface rounded-lg">
|
||||
<!-- Time display -->
|
||||
<div class="text-sm font-mono text-foreground-secondary min-w-[100px]">
|
||||
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
|
||||
</div>
|
||||
|
||||
<!-- Playback controls -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={skipBackward}
|
||||
class="p-2 rounded-lg hover:bg-surface-hover transition-colors"
|
||||
aria-label="Skip backward 5 seconds"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12.066 11.2a1 1 0 000 1.6l5.334 4A1 1 0 0019 16V8a1 1 0 00-1.6-.8l-5.334 4zM4.066 11.2a1 1 0 000 1.6l5.334 4A1 1 0 0011 16V8a1 1 0 00-1.6-.8l-5.334 4z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={handlePlayPause}
|
||||
class="p-3 bg-primary text-white rounded-full hover:bg-primary-hover transition-colors"
|
||||
aria-label={audioStore.isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{#if audioStore.isPlaying}
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={skipForward}
|
||||
class="p-2 rounded-lg hover:bg-surface-hover transition-colors"
|
||||
aria-label="Skip forward 5 seconds"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11.933 12.8a1 1 0 000-1.6L6.6 7.2A1 1 0 005 8v8a1 1 0 001.6.8l5.333-4zM19.933 12.8a1 1 0 000-1.6l-5.333-4A1 1 0 0013 8v8a1 1 0 001.6.8l5.333-4z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Seek slider -->
|
||||
<div class="flex-1">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={audioStore.duration || 100}
|
||||
step="0.1"
|
||||
value={audioStore.currentTime}
|
||||
oninput={handleSeek}
|
||||
class="w-full h-2 bg-surface-hover rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Zoom controls -->
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
onclick={onZoomOut}
|
||||
class="p-2 rounded-lg hover:bg-surface-hover transition-colors"
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM13 10H7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="text-xs text-foreground-secondary min-w-[40px] text-center">
|
||||
{Math.round(editorStore.zoom * 100)}%
|
||||
</span>
|
||||
<button
|
||||
onclick={onZoomIn}
|
||||
class="p-2 rounded-lg hover:bg-surface-hover transition-colors"
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v6m3-3H7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- BPM display -->
|
||||
{#if audioStore.bpm}
|
||||
<div class="px-3 py-1 bg-primary/10 text-primary rounded-full text-sm font-medium">
|
||||
{audioStore.bpm} BPM
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -1,235 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import WaveSurfer from 'wavesurfer.js';
|
||||
import RegionsPlugin, { type Region } from 'wavesurfer.js/dist/plugins/regions.js';
|
||||
import { audioStore } from '$lib/stores/audio.svelte';
|
||||
import { projectStore } from '$lib/stores/project.svelte';
|
||||
import { editorStore } from '$lib/stores/editor.svelte';
|
||||
import { theme } from '$lib/stores/theme.svelte';
|
||||
import { MARKER_COLORS, type Marker, type MarkerType } from '@lightwrite/shared';
|
||||
|
||||
let containerRef: HTMLDivElement;
|
||||
let wavesurfer: WaveSurfer | null = null;
|
||||
let regionsPlugin: RegionsPlugin | null = null;
|
||||
|
||||
interface Props {
|
||||
audioUrl: string | null;
|
||||
onTimeUpdate?: (time: number) => void;
|
||||
onSeek?: (time: number) => void;
|
||||
}
|
||||
|
||||
let { audioUrl, onTimeUpdate, onSeek }: Props = $props();
|
||||
|
||||
// Get theme-aware colors
|
||||
function getWaveformColors() {
|
||||
return {
|
||||
waveColor: theme.isDark ? '#9CA3AF' : '#6B7280',
|
||||
progressColor: theme.isDark ? '#60A5FA' : '#3B82F6',
|
||||
cursorColor: '#EF4444',
|
||||
};
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!containerRef) return;
|
||||
|
||||
regionsPlugin = RegionsPlugin.create();
|
||||
const colors = getWaveformColors();
|
||||
|
||||
wavesurfer = WaveSurfer.create({
|
||||
container: containerRef,
|
||||
waveColor: colors.waveColor,
|
||||
progressColor: colors.progressColor,
|
||||
cursorColor: colors.cursorColor,
|
||||
cursorWidth: 2,
|
||||
height: 128,
|
||||
normalize: true,
|
||||
plugins: [regionsPlugin],
|
||||
});
|
||||
|
||||
wavesurfer.on('ready', () => {
|
||||
audioStore.setDuration(wavesurfer!.getDuration());
|
||||
audioStore.setLoaded(true);
|
||||
syncRegionsFromMarkers();
|
||||
});
|
||||
|
||||
wavesurfer.on('play', () => audioStore.setPlaying(true));
|
||||
wavesurfer.on('pause', () => audioStore.setPlaying(false));
|
||||
|
||||
wavesurfer.on('timeupdate', (time) => {
|
||||
audioStore.setCurrentTime(time);
|
||||
onTimeUpdate?.(time);
|
||||
});
|
||||
|
||||
wavesurfer.on('seeking', (time) => {
|
||||
audioStore.setCurrentTime(time);
|
||||
onSeek?.(time);
|
||||
});
|
||||
|
||||
// Region events
|
||||
regionsPlugin.on('region-created', (region: Region) => {
|
||||
// Skip if this is a sync operation
|
||||
if (region.id.startsWith('marker-')) return;
|
||||
|
||||
const beatId = projectStore.currentBeat?.id;
|
||||
if (!beatId) return;
|
||||
|
||||
// Create marker from region
|
||||
projectStore.createMarker(beatId, {
|
||||
type: editorStore.markerTypeToCreate,
|
||||
startTime: region.start,
|
||||
endTime: region.end,
|
||||
color: MARKER_COLORS[editorStore.markerTypeToCreate],
|
||||
});
|
||||
});
|
||||
|
||||
regionsPlugin.on('region-updated', (region: Region) => {
|
||||
const markerId = region.id.replace('marker-', '');
|
||||
const marker = projectStore.currentMarkers.find((m) => m.id === markerId);
|
||||
if (marker) {
|
||||
projectStore.updateMarker(markerId, {
|
||||
startTime: region.start,
|
||||
endTime: region.end,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
regionsPlugin.on('region-clicked', (region: Region, e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const markerId = region.id.replace('marker-', '');
|
||||
editorStore.selectMarker(markerId);
|
||||
});
|
||||
|
||||
if (audioUrl) {
|
||||
wavesurfer.load(audioUrl);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
wavesurfer?.destroy();
|
||||
});
|
||||
|
||||
// Watch for audio URL changes
|
||||
$effect(() => {
|
||||
if (wavesurfer && audioUrl) {
|
||||
wavesurfer.load(audioUrl);
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for theme changes and update waveform colors
|
||||
$effect(() => {
|
||||
// Reference isDark to track changes
|
||||
const isDark = theme.isDark;
|
||||
if (wavesurfer) {
|
||||
const colors = getWaveformColors();
|
||||
wavesurfer.setOptions({
|
||||
waveColor: colors.waveColor,
|
||||
progressColor: colors.progressColor,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for marker changes and sync regions
|
||||
$effect(() => {
|
||||
const markers = projectStore.currentMarkers;
|
||||
if (markers && regionsPlugin && audioStore.isLoaded) {
|
||||
syncRegionsFromMarkers();
|
||||
}
|
||||
});
|
||||
|
||||
function syncRegionsFromMarkers() {
|
||||
if (!regionsPlugin) return;
|
||||
|
||||
// Clear existing regions
|
||||
regionsPlugin.clearRegions();
|
||||
|
||||
// Add regions for each marker
|
||||
for (const marker of projectStore.currentMarkers) {
|
||||
regionsPlugin.addRegion({
|
||||
id: `marker-${marker.id}`,
|
||||
start: marker.startTime,
|
||||
end: marker.endTime || marker.startTime + 1,
|
||||
color: `${marker.color || MARKER_COLORS[marker.type as MarkerType]}40`,
|
||||
drag: true,
|
||||
resize: true,
|
||||
content: marker.label || marker.type,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function play() {
|
||||
wavesurfer?.play();
|
||||
}
|
||||
|
||||
export function pause() {
|
||||
wavesurfer?.pause();
|
||||
}
|
||||
|
||||
export function playPause() {
|
||||
wavesurfer?.playPause();
|
||||
}
|
||||
|
||||
export function seekTo(time: number) {
|
||||
if (wavesurfer) {
|
||||
wavesurfer.seekTo(time / wavesurfer.getDuration());
|
||||
}
|
||||
}
|
||||
|
||||
export function zoom(level: number) {
|
||||
wavesurfer?.zoom(level * 100);
|
||||
}
|
||||
|
||||
export function addRegion(start: number, end: number, type: MarkerType) {
|
||||
if (regionsPlugin) {
|
||||
regionsPlugin.addRegion({
|
||||
start,
|
||||
end,
|
||||
color: `${MARKER_COLORS[type]}40`,
|
||||
drag: true,
|
||||
resize: true,
|
||||
content: type,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleLoop(markerId: string) {
|
||||
if (!regionsPlugin || !wavesurfer) return;
|
||||
|
||||
const region = regionsPlugin.getRegions().find((r) => r.id === `marker-${markerId}`);
|
||||
if (!region) return;
|
||||
|
||||
if (editorStore.loopRegionId === markerId) {
|
||||
// Disable loop
|
||||
editorStore.setLoopRegion(null);
|
||||
} else {
|
||||
// Enable loop and start playback from region start
|
||||
editorStore.setLoopRegion(markerId);
|
||||
wavesurfer.setTime(region.start);
|
||||
wavesurfer.play();
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for loop region and handle looping
|
||||
$effect(() => {
|
||||
if (!wavesurfer || !regionsPlugin || !editorStore.loopRegionId) return;
|
||||
|
||||
const region = regionsPlugin
|
||||
.getRegions()
|
||||
.find((r) => r.id === `marker-${editorStore.loopRegionId}`);
|
||||
if (!region) return;
|
||||
|
||||
// Check if we've reached the end of the loop region
|
||||
if (audioStore.currentTime >= region.end && editorStore.isLooping) {
|
||||
wavesurfer.setTime(region.start);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="waveform-container" bind:this={containerRef}>
|
||||
{#if !audioStore.isLoaded && audioUrl}
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<div
|
||||
class="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
interface AudioState {
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
isLoaded: boolean;
|
||||
bpm: number | null;
|
||||
audioUrl: string | null;
|
||||
}
|
||||
|
||||
function createAudioStore() {
|
||||
let state = $state<AudioState>({
|
||||
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();
|
||||
|
|
@ -1,212 +0,0 @@
|
|||
/**
|
||||
* 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<typeof initializeWebAuth>['authService'] | null = null;
|
||||
let _tokenManager: ReturnType<typeof initializeWebAuth>['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<UserData | null>(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, 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 resendVerificationEmail(email: string) {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server' };
|
||||
}
|
||||
|
||||
try {
|
||||
const sourceAppUrl = browser ? window.location.origin : undefined;
|
||||
const result = await authService.resendVerificationEmail(email, sourceAppUrl);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Failed to resend verification email' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
|
||||
async getValidToken(): Promise<string | null> {
|
||||
const tokenManager = getTokenManager();
|
||||
if (!tokenManager) {
|
||||
return null;
|
||||
}
|
||||
return await tokenManager.getValidToken();
|
||||
},
|
||||
|
||||
getAuthHeaders(): Record<string, string> {
|
||||
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 {};
|
||||
},
|
||||
};
|
||||
|
|
@ -1,160 +0,0 @@
|
|||
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;
|
||||
loopRegionId: string | null;
|
||||
isLooping: boolean;
|
||||
}
|
||||
|
||||
function createEditorStore() {
|
||||
let state = $state<EditorState>({
|
||||
mode: 'edit',
|
||||
syncMode: 'line',
|
||||
selectedMarkerId: null,
|
||||
selectedLineIndex: null,
|
||||
isRecordingTimestamps: false,
|
||||
zoom: 1,
|
||||
scrollPosition: 0,
|
||||
markerTypeToCreate: 'verse',
|
||||
snapToBeat: true,
|
||||
showWaveform: true,
|
||||
showMarkers: true,
|
||||
showLyrics: true,
|
||||
loopRegionId: null,
|
||||
isLooping: false,
|
||||
});
|
||||
|
||||
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;
|
||||
},
|
||||
get loopRegionId() {
|
||||
return state.loopRegionId;
|
||||
},
|
||||
get isLooping() {
|
||||
return state.isLooping;
|
||||
},
|
||||
|
||||
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;
|
||||
},
|
||||
|
||||
setLoopRegion(markerId: string | null) {
|
||||
state.loopRegionId = markerId;
|
||||
state.isLooping = markerId !== null;
|
||||
},
|
||||
|
||||
reset() {
|
||||
state.mode = 'edit';
|
||||
state.syncMode = 'line';
|
||||
state.selectedMarkerId = null;
|
||||
state.selectedLineIndex = null;
|
||||
state.isRecordingTimestamps = false;
|
||||
state.zoom = 1;
|
||||
state.scrollPosition = 0;
|
||||
state.loopRegionId = null;
|
||||
state.isLooping = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const editorStore = createEditorStore();
|
||||
|
|
@ -1,286 +0,0 @@
|
|||
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 {
|
||||
let baseUrl = 'http://localhost:3010';
|
||||
if (typeof window !== 'undefined') {
|
||||
baseUrl =
|
||||
(window as unknown as { __PUBLIC_BACKEND_URL__: string }).__PUBLIC_BACKEND_URL__ ||
|
||||
'http://localhost:3010';
|
||||
}
|
||||
// Ensure API prefix is included
|
||||
return baseUrl.endsWith('/api/v1') ? baseUrl : `${baseUrl}/api/v1`;
|
||||
}
|
||||
|
||||
function createProjectStore() {
|
||||
let state = $state<ProjectState>({
|
||||
projects: [],
|
||||
currentProject: null,
|
||||
currentBeat: null,
|
||||
currentLyrics: null,
|
||||
currentLines: [],
|
||||
currentMarkers: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
async function fetchApi<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
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<string> {
|
||||
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 checkSttAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const data = await fetchApi<{ available: boolean }>('/beats/stt/available');
|
||||
return data.available;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async transcribeBeat(beatId: string): Promise<{ beat: Beat; lyrics: string | null }> {
|
||||
const data = await fetchApi<{ beat: Beat; lyrics: string | null }>(
|
||||
`/beats/${beatId}/transcribe`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
state.currentBeat = data.beat;
|
||||
if (data.lyrics) {
|
||||
state.currentLyrics = { ...state.currentLyrics!, content: data.lyrics };
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
||||
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<Marker, 'id' | 'beatId'>) {
|
||||
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<Marker>) {
|
||||
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();
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import { createThemeStore, type HSLValue, type ThemeVariant } from '@manacore/shared-theme';
|
||||
|
||||
/**
|
||||
* LightWrite theme store
|
||||
*
|
||||
* Uses blue primary color matching the waveform progress color
|
||||
*/
|
||||
export const theme = createThemeStore({
|
||||
appId: 'lightwrite',
|
||||
defaultVariant: 'ocean' as ThemeVariant,
|
||||
primaryColor: {
|
||||
light: '217 91% 60%' as HSLValue, // Blue #3b82f6
|
||||
dark: '217 91% 60%' as HSLValue,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,234 +0,0 @@
|
|||
/**
|
||||
* 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<BpmResult> {
|
||||
// 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<BpmResult> {
|
||||
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<BpmResult> {
|
||||
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<number, number[]> = 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;
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
/**
|
||||
* 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')}`;
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
<script lang="ts">
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { browser } from '$app/environment';
|
||||
import { LoginPage } from '@manacore/shared-auth-ui';
|
||||
import { getLoginTranslations } from '@manacore/shared-i18n';
|
||||
import { LightWriteLogo } from '@manacore/shared-branding';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
// Get redirect URL from query params or sessionStorage
|
||||
const redirectTo = $derived.by(() => {
|
||||
const queryRedirect = $page.url.searchParams.get('redirectTo');
|
||||
if (queryRedirect) return queryRedirect;
|
||||
|
||||
if (browser) {
|
||||
const sessionRedirect = sessionStorage.getItem('auth-return-url');
|
||||
if (sessionRedirect) {
|
||||
sessionStorage.removeItem('auth-return-url');
|
||||
return sessionRedirect;
|
||||
}
|
||||
}
|
||||
|
||||
return '/';
|
||||
});
|
||||
|
||||
// Use English translations
|
||||
const translations = getLoginTranslations('en');
|
||||
|
||||
// Read verification status from query params
|
||||
const verified = $derived($page.url.searchParams.get('verified') === 'true');
|
||||
const initialEmail = $derived($page.url.searchParams.get('email') || '');
|
||||
|
||||
async function handleSignIn(email: string, password: string) {
|
||||
return authStore.signIn(email, password);
|
||||
}
|
||||
|
||||
async function handleResendVerification(email: string) {
|
||||
// Implement if needed
|
||||
return { success: true };
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Login - LightWrite</title>
|
||||
</svelte:head>
|
||||
|
||||
<LoginPage
|
||||
appName="LightWrite"
|
||||
logo={LightWriteLogo}
|
||||
primaryColor="#f97316"
|
||||
onSignIn={handleSignIn}
|
||||
onResendVerification={handleResendVerification}
|
||||
{goto}
|
||||
enableGoogle={false}
|
||||
enableApple={false}
|
||||
successRedirect={redirectTo}
|
||||
registerPath="/register"
|
||||
forgotPasswordPath="/forgot-password"
|
||||
lightBackground="#fff7ed"
|
||||
darkBackground="#1c1917"
|
||||
{translations}
|
||||
{verified}
|
||||
{initialEmail}
|
||||
/>
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { browser } from '$app/environment';
|
||||
import { RegisterPage } from '@manacore/shared-auth-ui';
|
||||
import { getRegisterTranslations } from '@manacore/shared-i18n';
|
||||
import { LightWriteLogo } from '@manacore/shared-branding';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
// Get redirect URL from sessionStorage
|
||||
const redirectTo = $derived.by(() => {
|
||||
if (browser) {
|
||||
const sessionRedirect = sessionStorage.getItem('auth-return-url');
|
||||
if (sessionRedirect) {
|
||||
sessionStorage.removeItem('auth-return-url');
|
||||
return sessionRedirect;
|
||||
}
|
||||
}
|
||||
return '/';
|
||||
});
|
||||
|
||||
// Use English translations
|
||||
const translations = getRegisterTranslations('en');
|
||||
|
||||
async function handleSignUp(email: string, password: string) {
|
||||
return authStore.signUp(email, password);
|
||||
}
|
||||
|
||||
async function handleResendVerification(email: string) {
|
||||
// Implement if needed
|
||||
return { success: true };
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Register - LightWrite</title>
|
||||
</svelte:head>
|
||||
|
||||
<RegisterPage
|
||||
appName="LightWrite"
|
||||
logo={LightWriteLogo}
|
||||
primaryColor="#f97316"
|
||||
onSignUp={handleSignUp}
|
||||
onResendVerification={handleResendVerification}
|
||||
{goto}
|
||||
successRedirect={redirectTo}
|
||||
loginPath="/login"
|
||||
lightBackground="#fff7ed"
|
||||
darkBackground="#1c1917"
|
||||
{translations}
|
||||
/>
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { theme } from '$lib/stores/theme.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let loading = $state(true);
|
||||
let cleanupTheme: (() => void) | undefined;
|
||||
|
||||
onMount(async () => {
|
||||
// Initialize theme first to prevent flash
|
||||
cleanupTheme = theme.initialize();
|
||||
await authStore.initialize();
|
||||
loading = false;
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
cleanupTheme?.();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="min-h-screen bg-background flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4"
|
||||
></div>
|
||||
<p class="text-foreground-secondary">LightWrite</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="min-h-screen bg-background text-foreground">
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -1,335 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { projectStore } from '$lib/stores/project.svelte';
|
||||
|
||||
let showCreateModal = $state(false);
|
||||
let newProjectTitle = $state('');
|
||||
let newProjectDescription = $state('');
|
||||
let isCreating = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
if (authStore.isAuthenticated) {
|
||||
await projectStore.loadProjects();
|
||||
}
|
||||
});
|
||||
|
||||
async function handleCreateProject() {
|
||||
if (!newProjectTitle.trim()) return;
|
||||
|
||||
isCreating = true;
|
||||
try {
|
||||
const project = await projectStore.createProject(newProjectTitle, newProjectDescription);
|
||||
showCreateModal = false;
|
||||
newProjectTitle = '';
|
||||
newProjectDescription = '';
|
||||
goto(`/editor/${project.id}`);
|
||||
} finally {
|
||||
isCreating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteProject(id: string, e: Event) {
|
||||
e.stopPropagation();
|
||||
if (confirm('Are you sure you want to delete this project?')) {
|
||||
await projectStore.deleteProject(id);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(date: Date | string): string {
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>LightWrite - Beat & Lyrics Editor</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen">
|
||||
<!-- Header -->
|
||||
<header class="border-b border-border">
|
||||
<div class="max-w-6xl mx-auto px-4 py-4 flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold">
|
||||
<span class="text-primary">Light</span>Write
|
||||
</h1>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
{#if authStore.isAuthenticated}
|
||||
<span class="text-foreground-secondary text-sm">
|
||||
{authStore.user?.email}
|
||||
</span>
|
||||
<button
|
||||
onclick={() => authStore.signOut()}
|
||||
class="px-4 py-2 text-sm text-foreground-secondary hover:text-foreground transition-colors"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
{:else}
|
||||
<a
|
||||
href="/login"
|
||||
class="px-4 py-2 text-sm bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors"
|
||||
>
|
||||
Login
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="max-w-6xl mx-auto px-4 py-8">
|
||||
{#if !authStore.isAuthenticated}
|
||||
<!-- Landing content for non-authenticated users -->
|
||||
<div class="text-center py-16">
|
||||
<h2 class="text-4xl font-bold mb-4">Create Synced Lyrics for Your Beats</h2>
|
||||
<p class="text-xl text-foreground-secondary mb-8 max-w-2xl mx-auto">
|
||||
Upload your beats, add lyrics, sync timestamps, and export to LRC, SRT, or video formats.
|
||||
</p>
|
||||
<div class="flex items-center justify-center gap-4">
|
||||
<a
|
||||
href="/login"
|
||||
class="px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors font-medium"
|
||||
>
|
||||
Get Started
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
<div class="grid md:grid-cols-3 gap-8 mt-16">
|
||||
<div class="p-6 bg-surface rounded-lg">
|
||||
<div
|
||||
class="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center mb-4 mx-auto"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6 text-primary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="font-semibold mb-2">Waveform Editor</h3>
|
||||
<p class="text-foreground-secondary text-sm">
|
||||
Visual waveform display with zoom, markers, and precise navigation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6 bg-surface rounded-lg">
|
||||
<div
|
||||
class="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center mb-4 mx-auto"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6 text-primary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="font-semibold mb-2">BPM Detection</h3>
|
||||
<p class="text-foreground-secondary text-sm">
|
||||
Automatic tempo detection with snap-to-beat functionality.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6 bg-surface rounded-lg">
|
||||
<div
|
||||
class="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center mb-4 mx-auto"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6 text-primary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="font-semibold mb-2">Multiple Exports</h3>
|
||||
<p class="text-foreground-secondary text-sm">
|
||||
Export to LRC, SRT, JSON, or generate karaoke videos.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Projects list for authenticated users -->
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h2 class="text-2xl font-bold">Your Projects</h2>
|
||||
<button
|
||||
onclick={() => (showCreateModal = true)}
|
||||
class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
New Project
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if projectStore.isLoading}
|
||||
<div class="flex items-center justify-center py-16">
|
||||
<div
|
||||
class="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
</div>
|
||||
{:else if projectStore.projects.length === 0}
|
||||
<div class="text-center py-16">
|
||||
<div
|
||||
class="w-16 h-16 bg-surface rounded-full flex items-center justify-center mx-auto mb-4"
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 text-foreground-secondary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium mb-2">No projects yet</h3>
|
||||
<p class="text-foreground-secondary mb-4">Create your first project to get started</p>
|
||||
<button
|
||||
onclick={() => (showCreateModal = true)}
|
||||
class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors"
|
||||
>
|
||||
Create Project
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{#each projectStore.projects as project}
|
||||
<a
|
||||
href="/editor/{project.id}"
|
||||
class="block p-4 bg-surface rounded-lg hover:bg-surface-hover transition-colors group"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<h3 class="font-medium group-hover:text-primary transition-colors">
|
||||
{project.title}
|
||||
</h3>
|
||||
<button
|
||||
onclick={(e) => handleDeleteProject(project.id, e)}
|
||||
class="p-1 text-foreground-secondary hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{#if project.description}
|
||||
<p class="text-sm text-foreground-secondary mt-1 line-clamp-2">
|
||||
{project.description}
|
||||
</p>
|
||||
{/if}
|
||||
<p class="text-xs text-foreground-secondary mt-2">
|
||||
Updated {formatDate(project.updatedAt)}
|
||||
</p>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Create Project Modal -->
|
||||
{#if showCreateModal}
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
onclick={() => (showCreateModal = false)}
|
||||
role="dialog"
|
||||
>
|
||||
<div
|
||||
class="bg-surface rounded-lg p-6 w-full max-w-md"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
role="document"
|
||||
>
|
||||
<h2 class="text-xl font-bold mb-4">Create New Project</h2>
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleCreateProject();
|
||||
}}
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium mb-1">Title</label>
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
bind:value={newProjectTitle}
|
||||
class="w-full px-3 py-2 bg-background border border-border rounded-lg focus:border-primary focus:outline-none"
|
||||
placeholder="My New Track"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium mb-1"
|
||||
>Description (optional)</label
|
||||
>
|
||||
<textarea
|
||||
id="description"
|
||||
bind:value={newProjectDescription}
|
||||
class="w-full px-3 py-2 bg-background border border-border rounded-lg focus:border-primary focus:outline-none resize-none"
|
||||
rows="3"
|
||||
placeholder="Add a description..."
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showCreateModal = false)}
|
||||
class="px-4 py-2 text-foreground-secondary hover:text-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isCreating || !newProjectTitle.trim()}
|
||||
class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isCreating ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -1,491 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { projectStore } from '$lib/stores/project.svelte';
|
||||
import { audioStore } from '$lib/stores/audio.svelte';
|
||||
import { editorStore } from '$lib/stores/editor.svelte';
|
||||
import { MARKER_COLORS } from '@lightwrite/shared';
|
||||
import WaveformEditor from '$lib/components/WaveformEditor.svelte';
|
||||
import PlaybackControls from '$lib/components/PlaybackControls.svelte';
|
||||
import LyricsEditor from '$lib/components/LyricsEditor.svelte';
|
||||
import MarkerTimeline from '$lib/components/MarkerTimeline.svelte';
|
||||
import KaraokePreview from '$lib/components/KaraokePreview.svelte';
|
||||
import BeatUploader from '$lib/components/BeatUploader.svelte';
|
||||
import { ThemeToggle } from '@manacore/shared-theme-ui';
|
||||
import { theme } from '$lib/stores/theme.svelte';
|
||||
|
||||
let waveformEditor: WaveformEditor;
|
||||
let showExportMenu = $state(false);
|
||||
let isExporting = $state(false);
|
||||
|
||||
// Mobile responsive state
|
||||
let isMobile = $state(false);
|
||||
let mobileTab: 'lyrics' | 'preview' = $state('lyrics');
|
||||
|
||||
// Listen for resize events
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const checkMobile = () => {
|
||||
isMobile = window.innerWidth < 768;
|
||||
};
|
||||
checkMobile();
|
||||
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
});
|
||||
|
||||
// Keyboard shortcuts handler
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
// Ignore if typing in input fields
|
||||
if (
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
(e.target instanceof HTMLElement && e.target.isContentEditable)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore if no audio loaded
|
||||
if (!audioStore.isLoaded) return;
|
||||
|
||||
switch (e.code) {
|
||||
case 'Space':
|
||||
e.preventDefault();
|
||||
waveformEditor?.playPause();
|
||||
break;
|
||||
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
const skipBack = e.shiftKey ? 1 : 5;
|
||||
waveformEditor?.seekTo(Math.max(0, audioStore.currentTime - skipBack));
|
||||
break;
|
||||
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
const skipForward = e.shiftKey ? 1 : 5;
|
||||
waveformEditor?.seekTo(Math.min(audioStore.duration, audioStore.currentTime + skipForward));
|
||||
break;
|
||||
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
waveformEditor?.seekTo(0);
|
||||
break;
|
||||
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
waveformEditor?.seekTo(audioStore.duration);
|
||||
break;
|
||||
|
||||
case 'KeyM':
|
||||
e.preventDefault();
|
||||
if (projectStore.currentBeat) {
|
||||
projectStore.createMarker(projectStore.currentBeat.id, {
|
||||
type: editorStore.markerTypeToCreate,
|
||||
startTime: audioStore.currentTime,
|
||||
endTime: audioStore.currentTime + 4,
|
||||
color: MARKER_COLORS[editorStore.markerTypeToCreate],
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'KeyL':
|
||||
e.preventDefault();
|
||||
if (editorStore.selectedMarkerId) {
|
||||
waveformEditor?.toggleLoop(editorStore.selectedMarkerId);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
editorStore.selectMarker(null);
|
||||
editorStore.selectLine(null);
|
||||
editorStore.setLoopRegion(null);
|
||||
break;
|
||||
|
||||
case 'Equal':
|
||||
case 'NumpadAdd':
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
handleZoomIn();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Minus':
|
||||
case 'NumpadSubtract':
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
handleZoomOut();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const id = $page.params.id;
|
||||
if (id && authStore.isAuthenticated) {
|
||||
loadProject(id);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadProject(id: string) {
|
||||
await projectStore.loadProject(id);
|
||||
|
||||
// Load audio URL if beat exists
|
||||
if (projectStore.currentBeat) {
|
||||
const url = await projectStore.getBeatDownloadUrl(projectStore.currentBeat.id);
|
||||
audioStore.setAudioUrl(url);
|
||||
audioStore.setBpm(projectStore.currentBeat.bpm ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!authStore.isAuthenticated) {
|
||||
goto('/');
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
projectStore.clearCurrent();
|
||||
audioStore.reset();
|
||||
editorStore.reset();
|
||||
});
|
||||
|
||||
function handlePlay() {
|
||||
waveformEditor?.play();
|
||||
}
|
||||
|
||||
function handlePause() {
|
||||
waveformEditor?.pause();
|
||||
}
|
||||
|
||||
function handleSeek(time: number) {
|
||||
waveformEditor?.seekTo(time);
|
||||
}
|
||||
|
||||
function handleZoomIn() {
|
||||
editorStore.zoomIn();
|
||||
waveformEditor?.zoom(editorStore.zoom);
|
||||
}
|
||||
|
||||
function handleZoomOut() {
|
||||
editorStore.zoomOut();
|
||||
waveformEditor?.zoom(editorStore.zoom);
|
||||
}
|
||||
|
||||
function handleMarkerClick(markerId: string) {
|
||||
editorStore.selectMarker(markerId);
|
||||
const marker = projectStore.currentMarkers.find((m) => m.id === markerId);
|
||||
if (marker) {
|
||||
handleSeek(marker.startTime);
|
||||
}
|
||||
}
|
||||
|
||||
function handleLineClick(lineIndex: number, line: { startTime?: number | null }) {
|
||||
editorStore.selectLine(lineIndex);
|
||||
if (line.startTime !== null && line.startTime !== undefined) {
|
||||
handleSeek(line.startTime);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSyncLine(lineIndex: number) {
|
||||
const line = projectStore.currentLines[lineIndex];
|
||||
if (line) {
|
||||
await projectStore.updateLineTimestamp(line.id, audioStore.currentTime);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBeatUploadComplete() {
|
||||
const id = $page.params.id;
|
||||
if (id) {
|
||||
await loadProject(id);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport(format: 'lrc' | 'srt' | 'json') {
|
||||
if (!projectStore.currentProject) return;
|
||||
|
||||
isExporting = true;
|
||||
showExportMenu = false;
|
||||
|
||||
try {
|
||||
const backendUrl =
|
||||
(typeof window !== 'undefined' &&
|
||||
(window as unknown as { __PUBLIC_BACKEND_URL__: string }).__PUBLIC_BACKEND_URL__) ||
|
||||
'http://localhost:3010';
|
||||
const response = await fetch(
|
||||
`${backendUrl}/export/${projectStore.currentProject.id}?format=${format}`,
|
||||
{
|
||||
headers: authStore.getAuthHeaders(),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) throw new Error('Export failed');
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download =
|
||||
response.headers.get('Content-Disposition')?.split('filename="')[1]?.replace('"', '') ||
|
||||
`export.${format}`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error('Export error:', err);
|
||||
alert('Failed to export. Please try again.');
|
||||
} finally {
|
||||
isExporting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteBeat() {
|
||||
if (!projectStore.currentBeat) return;
|
||||
if (!confirm('Are you sure you want to delete this beat?')) return;
|
||||
|
||||
await projectStore.deleteBeat(projectStore.currentBeat.id);
|
||||
audioStore.reset();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{projectStore.currentProject?.title || 'Editor'} - LightWrite</title>
|
||||
</svelte:head>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div class="h-screen flex flex-col">
|
||||
<!-- Header -->
|
||||
<header class="border-b border-border bg-surface shrink-0">
|
||||
<div class="px-3 md:px-4 py-2 md:py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 md:gap-4 min-w-0">
|
||||
<a
|
||||
href="/"
|
||||
class="text-foreground-secondary hover:text-foreground transition-colors shrink-0"
|
||||
>
|
||||
<svg class="w-5 h-5 md:w-6 md:h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<div class="min-w-0">
|
||||
<h1 class="font-semibold text-sm md:text-base truncate">
|
||||
{projectStore.currentProject?.title || 'Loading...'}
|
||||
</h1>
|
||||
{#if projectStore.currentProject?.description && !isMobile}
|
||||
<p class="text-sm text-foreground-secondary truncate">
|
||||
{projectStore.currentProject.description}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 md:gap-3 shrink-0">
|
||||
<!-- Theme toggle -->
|
||||
<ThemeToggle {theme} showTooltip size={isMobile ? 16 : 20} />
|
||||
|
||||
<!-- Export dropdown -->
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={() => (showExportMenu = !showExportMenu)}
|
||||
disabled={isExporting || !projectStore.currentLyrics}
|
||||
class="px-2 md:px-4 py-1.5 md:py-2 bg-surface-hover hover:bg-surface-active rounded-lg transition-colors flex items-center gap-1 md:gap-2 disabled:opacity-50 text-sm"
|
||||
>
|
||||
{#if isExporting}
|
||||
<div
|
||||
class="w-4 h-4 border-2 border-foreground border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
{:else}
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
<span class="hidden sm:inline">Export</span>
|
||||
</button>
|
||||
|
||||
{#if showExportMenu}
|
||||
<div
|
||||
class="absolute right-0 top-full mt-1 bg-surface border border-border rounded-lg shadow-lg py-1 min-w-[150px] z-10"
|
||||
>
|
||||
<button
|
||||
onclick={() => handleExport('lrc')}
|
||||
class="w-full px-4 py-2 text-left hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
LRC (Lyrics)
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleExport('srt')}
|
||||
class="w-full px-4 py-2 text-left hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
SRT (Subtitles)
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleExport('json')}
|
||||
class="w-full px-4 py-2 text-left hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
JSON
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if projectStore.isLoading}
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div
|
||||
class="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
</div>
|
||||
{:else if projectStore.error}
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<p class="text-red-500 mb-4">{projectStore.error}</p>
|
||||
<a href="/" class="text-primary hover:underline">Go back</a>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Main editor layout -->
|
||||
<div class="flex-1 flex flex-col min-h-0">
|
||||
<!-- Waveform section -->
|
||||
<div class="shrink-0 p-2 md:p-4 border-b border-border">
|
||||
{#if projectStore.currentBeat}
|
||||
<div class="space-y-2 md:space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div
|
||||
class="flex items-center gap-2 text-xs md:text-sm text-foreground-secondary truncate"
|
||||
>
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
|
||||
/>
|
||||
</svg>
|
||||
<span class="truncate">{projectStore.currentBeat.filename}</span>
|
||||
</div>
|
||||
<button
|
||||
onclick={handleDeleteBeat}
|
||||
class="text-xs md:text-sm text-red-500 hover:text-red-600 shrink-0"
|
||||
>
|
||||
{isMobile ? 'Remove' : 'Remove Beat'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<WaveformEditor
|
||||
bind:this={waveformEditor}
|
||||
audioUrl={audioStore.audioUrl}
|
||||
onSeek={handleSeek}
|
||||
/>
|
||||
|
||||
{#if !isMobile}
|
||||
<MarkerTimeline
|
||||
onMarkerClick={handleMarkerClick}
|
||||
onSeek={handleSeek}
|
||||
onToggleLoop={(markerId) => waveformEditor?.toggleLoop(markerId)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<PlaybackControls
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
onSeek={handleSeek}
|
||||
onZoomIn={handleZoomIn}
|
||||
onZoomOut={handleZoomOut}
|
||||
compact={isMobile}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<BeatUploader
|
||||
projectId={projectStore.currentProject?.id || ''}
|
||||
onUploadComplete={handleBeatUploadComplete}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Lyrics and preview section -->
|
||||
{#if isMobile}
|
||||
<!-- Mobile: Tab-based layout -->
|
||||
<div class="flex-1 flex flex-col min-h-0">
|
||||
<!-- Tab bar -->
|
||||
<div class="flex border-b border-border bg-surface shrink-0">
|
||||
<button
|
||||
onclick={() => (mobileTab = 'lyrics')}
|
||||
class="flex-1 px-4 py-2 text-sm font-medium transition-colors {mobileTab === 'lyrics'
|
||||
? 'text-primary border-b-2 border-primary'
|
||||
: 'text-foreground-secondary'}"
|
||||
>
|
||||
Lyrics
|
||||
</button>
|
||||
<button
|
||||
onclick={() => {
|
||||
mobileTab = 'preview';
|
||||
editorStore.setMode('preview');
|
||||
}}
|
||||
class="flex-1 px-4 py-2 text-sm font-medium transition-colors {mobileTab === 'preview'
|
||||
? 'text-primary border-b-2 border-primary'
|
||||
: 'text-foreground-secondary'}"
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab content -->
|
||||
<div class="flex-1 overflow-hidden">
|
||||
{#if mobileTab === 'lyrics'}
|
||||
<LyricsEditor onLineClick={handleLineClick} onSyncLine={handleSyncLine} />
|
||||
{:else}
|
||||
<KaraokePreview />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Desktop: Side-by-side layout -->
|
||||
<div class="flex-1 flex min-h-0">
|
||||
<!-- Lyrics editor -->
|
||||
<div class="w-1/2 border-r border-border overflow-hidden">
|
||||
<LyricsEditor onLineClick={handleLineClick} onSyncLine={handleSyncLine} />
|
||||
</div>
|
||||
|
||||
<!-- Karaoke preview -->
|
||||
<div class="w-1/2 overflow-hidden">
|
||||
{#if editorStore.mode === 'preview'}
|
||||
<KaraokePreview />
|
||||
{:else}
|
||||
<div class="h-full flex items-center justify-center text-foreground-secondary">
|
||||
<div class="text-center">
|
||||
<p>Switch to Preview mode to see karaoke animation</p>
|
||||
<button
|
||||
onclick={() => editorStore.setMode('preview')}
|
||||
class="mt-4 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover"
|
||||
>
|
||||
Preview Mode
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Click outside handler for export menu -->
|
||||
{#if showExportMenu}
|
||||
<button class="fixed inset-0 z-0" onclick={() => (showExportMenu = false)} aria-label="Close menu"
|
||||
></button>
|
||||
{/if}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { json } from '@sveltejs/kit';
|
||||
|
||||
export function GET() {
|
||||
return json({ status: 'ok', service: 'lightwrite-web' });
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let isOnline = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
isOnline = navigator.onLine;
|
||||
|
||||
const handleOnline = () => {
|
||||
isOnline = true;
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const handleOffline = () => {
|
||||
isOnline = false;
|
||||
};
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Offline - LightWrite</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen bg-slate-900 flex items-center justify-center px-4">
|
||||
<div class="text-center max-w-md">
|
||||
<div class="mb-8">
|
||||
<svg class="w-24 h-24 mx-auto text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a5 5 0 01-.354-7.072L8.95 7.636m1.414 5.657L7.535 16.12m8.485 0a5 5 0 01-7.07 0" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3l18 18" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 class="text-2xl font-bold text-white mb-4">
|
||||
{isOnline ? 'Verbindung wiederhergestellt!' : 'Du bist offline'}
|
||||
</h1>
|
||||
|
||||
<p class="text-slate-400 mb-8">
|
||||
{#if isOnline}
|
||||
Du wirst gleich weitergeleitet...
|
||||
{:else}
|
||||
LightWrite benötigt eine Internetverbindung für Audio.
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
{#if !isOnline}
|
||||
<div class="space-y-4">
|
||||
<a href="/" class="inline-flex items-center justify-center px-6 py-3 bg-orange-600 hover:bg-orange-700 text-white font-medium rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
Zur Startseite
|
||||
</a>
|
||||
|
||||
<button onclick={() => window.location.reload()} class="block w-full px-6 py-3 text-slate-400 hover:text-white transition-colors">
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center text-green-400">
|
||||
<svg class="w-5 h-5 mr-2 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Weiterleitung...
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
|
||||
import { createPWAConfig } from '@manacore/shared-pwa';
|
||||
import { MANACORE_SHARED_PACKAGES } from '@manacore/shared-vite-config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
sveltekit(),
|
||||
SvelteKitPWA(
|
||||
createPWAConfig({
|
||||
name: 'LightWrite - Audio Editor',
|
||||
shortName: 'LightWrite',
|
||||
description: 'Beat und Lyrics Editor',
|
||||
themeColor: '#f97316',
|
||||
})
|
||||
),
|
||||
],
|
||||
server: {
|
||||
port: 5180,
|
||||
strictPort: true,
|
||||
},
|
||||
ssr: {
|
||||
noExternal: [...MANACORE_SHARED_PACKAGES, '@lightwrite/shared'],
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: [...MANACORE_SHARED_PACKAGES, '@lightwrite/shared'],
|
||||
},
|
||||
});
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"name": "lightwrite",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "pnpm run --filter=@lightwrite/* --parallel dev"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export * from './types';
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
export type TranscriptionStatus = 'none' | 'pending' | 'completed' | 'failed';
|
||||
|
||||
export interface Beat {
|
||||
id: string;
|
||||
projectId: string;
|
||||
storagePath: string;
|
||||
filename?: string | null;
|
||||
duration?: number | null;
|
||||
bpm?: number | null;
|
||||
bpmConfidence?: number | null;
|
||||
waveformData?: WaveformData | null;
|
||||
// STT Transcription fields
|
||||
transcriptionStatus?: TranscriptionStatus | null;
|
||||
transcriptionError?: string | null;
|
||||
transcribedAt?: Date | 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;
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
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;
|
||||
}>;
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
export * from './project';
|
||||
export * from './beat';
|
||||
export * from './marker';
|
||||
export * from './lyrics';
|
||||
export * from './export';
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
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<MarkerType, string> = {
|
||||
verse: '#3B82F6', // blue
|
||||
hook: '#EF4444', // red
|
||||
bridge: '#8B5CF6', // purple
|
||||
intro: '#22C55E', // green
|
||||
outro: '#F97316', // orange
|
||||
drop: '#EC4899', // pink
|
||||
breakdown: '#14B8A6', // teal
|
||||
custom: '#6B7280', // gray
|
||||
};
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"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"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue