feat(lightwrite): add UX improvements and beat library

Phase 1-5 implementation:
- Keyboard shortcuts (Space, arrows, M, L, +/-)
- Dark/Light mode with theme store
- Loop regions for practice sections
- Mobile responsive view with tabs
- Beat library with preview and use functionality

Backend:
- Add library_beats schema
- Add library beat endpoints (GET list, GET download-url, POST use)

Frontend:
- BeatLibrary component with preview player
- BeatUploader tabs (Upload/Library)
- Theme-aware waveform colors
- Compact mobile playback controls

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-02-16 12:14:13 +01:00
parent b5d7524c77
commit 6e7b671a73
15 changed files with 998 additions and 209 deletions

View file

@ -11,14 +11,50 @@ import {
} from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { BeatService } from './beat.service';
import { CreateBeatUploadDto, UpdateBeatMetadataDto } from './dto/beat.dto';
import { CreateBeatUploadDto, UpdateBeatMetadataDto, UseLibraryBeatDto } from './dto/beat.dto';
@Controller('beats')
@UseGuards(JwtAuthGuard)
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 };
}
// ==================== User Beats (Protected) ====================
@Get('project/:projectId')
@UseGuards(JwtAuthGuard)
async findByProject(
@CurrentUser() user: CurrentUserData,
@Param('projectId', ParseUUIDPipe) projectId: string
@ -29,6 +65,7 @@ export class BeatController {
}
@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);
@ -37,6 +74,7 @@ export class BeatController {
}
@Get(':id/download-url')
@UseGuards(JwtAuthGuard)
async getDownloadUrl(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string
@ -46,12 +84,14 @@ export class BeatController {
}
@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,
@ -62,6 +102,7 @@ export class BeatController {
}
@Delete(':id')
@UseGuards(JwtAuthGuard)
async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
await this.beatService.delete(id, user.userId);
return { success: true };

View file

@ -2,8 +2,8 @@ import { Injectable, Inject, NotFoundException, BadRequestException } from '@nes
import { eq, and } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
import { beats, projects, markers } from '../db/schema';
import type { Beat, Marker } from '../db/schema';
import { beats, projects, markers, libraryBeats } from '../db/schema';
import type { Beat, Marker, LibraryBeat } from '../db/schema';
import {
createLightWriteStorage,
generateUserFileKey,
@ -127,4 +127,56 @@ export class BeatService {
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;
}
}

View file

@ -10,6 +10,12 @@ export class CreateBeatUploadDto {
filename!: string;
}
export class UseLibraryBeatDto {
@IsUUID()
@IsNotEmpty()
projectId!: string;
}
export class UpdateBeatMetadataDto {
@IsNumber()
@IsOptional()

View file

@ -2,3 +2,4 @@ export * from './projects.schema';
export * from './beats.schema';
export * from './markers.schema';
export * from './lyrics.schema';
export * from './library-beats.schema';

View file

@ -0,0 +1,24 @@
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;

View file

@ -119,3 +119,50 @@
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;
}
}

View file

@ -0,0 +1,225 @@
<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>

View file

@ -2,6 +2,7 @@
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;
@ -10,6 +11,9 @@
let { projectId, onUploadComplete }: Props = $props();
type Tab = 'upload' | 'library';
let activeTab = $state<Tab>('upload');
let isUploading = $state(false);
let isDetectingBpm = $state(false);
let uploadProgress = $state(0);
@ -90,59 +94,17 @@
}
</script>
<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">
<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"
@ -150,14 +112,112 @@
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}
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>
{#if errorMessage}
<p class="text-red-500 mt-4 text-sm">{errorMessage}</p>
<!-- 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>
{:else}
<BeatLibrary {projectId} onSelectBeat={onUploadComplete} />
{/if}
</div>

View file

@ -7,9 +7,10 @@
interface Props {
onMarkerClick?: (markerId: string) => void;
onSeek?: (time: number) => void;
onToggleLoop?: (markerId: string) => void;
}
let { onMarkerClick, onSeek }: Props = $props();
let { onMarkerClick, onSeek, onToggleLoop }: Props = $props();
let containerRef: HTMLDivElement;
@ -165,6 +166,24 @@
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"

View file

@ -9,9 +9,10 @@
onSeek?: (time: number) => void;
onZoomIn?: () => void;
onZoomOut?: () => void;
compact?: boolean;
}
let { onPlay, onPause, onSeek, onZoomIn, onZoomOut }: Props = $props();
let { onPlay, onPause, onSeek, onZoomIn, onZoomOut, compact = false }: Props = $props();
function handlePlayPause() {
if (audioStore.isPlaying) {
@ -38,113 +39,166 @@
}
</script>
<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>
{#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-3 bg-primary text-white rounded-full hover:bg-primary-hover transition-colors"
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-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<svg class="w-5 h-5" 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">
<svg class="w-5 h-5" 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
<!-- Time display -->
<div class="text-xs font-mono text-foreground-secondary shrink-0">
{formatTime(audioStore.currentTime)}
</div>
{/if}
</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}

View file

@ -5,6 +5,7 @@
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;
@ -19,16 +20,26 @@
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: '#6B7280',
progressColor: '#3B82F6',
cursorColor: '#EF4444',
waveColor: colors.waveColor,
progressColor: colors.progressColor,
cursorColor: colors.cursorColor,
cursorWidth: 2,
height: 128,
normalize: true,
@ -104,6 +115,19 @@
}
});
// 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;
@ -166,6 +190,38 @@
});
}
}
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}>

View file

@ -16,6 +16,8 @@ interface EditorState {
showWaveform: boolean;
showMarkers: boolean;
showLyrics: boolean;
loopRegionId: string | null;
isLooping: boolean;
}
function createEditorStore() {
@ -32,6 +34,8 @@ function createEditorStore() {
showWaveform: true,
showMarkers: true,
showLyrics: true,
loopRegionId: null,
isLooping: false,
});
return {
@ -71,6 +75,12 @@ function createEditorStore() {
get showLyrics() {
return state.showLyrics;
},
get loopRegionId() {
return state.loopRegionId;
},
get isLooping() {
return state.isLooping;
},
setMode(mode: EditorMode) {
state.mode = mode;
@ -128,6 +138,11 @@ function createEditorStore() {
state.showLyrics = !state.showLyrics;
},
setLoopRegion(markerId: string | null) {
state.loopRegionId = markerId;
state.isLooping = markerId !== null;
},
reset() {
state.mode = 'edit';
state.syncMode = 'line';
@ -136,6 +151,8 @@ function createEditorStore() {
state.isRecordingTimestamps = false;
state.zoom = 1;
state.scrollPosition = 0;
state.loopRegionId = null;
state.isLooping = false;
},
};
}

View file

@ -0,0 +1,15 @@
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,
},
});

View file

@ -1,16 +1,24 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
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}

View file

@ -6,17 +6,123 @@
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) {
@ -148,13 +254,18 @@
<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-4 py-3 flex items-center justify-between">
<div class="flex items-center gap-4">
<a href="/" class="text-foreground-secondary hover:text-foreground transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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"
@ -163,25 +274,28 @@
/>
</svg>
</a>
<div>
<h1 class="font-semibold">
<div class="min-w-0">
<h1 class="font-semibold text-sm md:text-base truncate">
{projectStore.currentProject?.title || 'Loading...'}
</h1>
{#if projectStore.currentProject?.description}
<p class="text-sm text-foreground-secondary">
{#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-3">
<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-4 py-2 bg-surface-hover hover:bg-surface-active rounded-lg transition-colors flex items-center gap-2 disabled:opacity-50"
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
@ -197,7 +311,7 @@
/>
</svg>
{/if}
Export
<span class="hidden sm:inline">Export</span>
</button>
{#if showExportMenu}
@ -246,12 +360,14 @@
<!-- Main editor layout -->
<div class="flex-1 flex flex-col min-h-0">
<!-- Waveform section -->
<div class="shrink-0 p-4 border-b border-border">
<div class="shrink-0 p-2 md:p-4 border-b border-border">
{#if projectStore.currentBeat}
<div class="space-y-4">
<div class="space-y-2 md:space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 text-sm text-foreground-secondary">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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"
@ -259,10 +375,13 @@
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>
{projectStore.currentBeat.filename}
<span class="truncate">{projectStore.currentBeat.filename}</span>
</div>
<button onclick={handleDeleteBeat} class="text-sm text-red-500 hover:text-red-600">
Remove Beat
<button
onclick={handleDeleteBeat}
class="text-xs md:text-sm text-red-500 hover:text-red-600 shrink-0"
>
{isMobile ? 'Remove' : 'Remove Beat'}
</button>
</div>
@ -272,7 +391,13 @@
onSeek={handleSeek}
/>
<MarkerTimeline onMarkerClick={handleMarkerClick} onSeek={handleSeek} />
{#if !isMobile}
<MarkerTimeline
onMarkerClick={handleMarkerClick}
onSeek={handleSeek}
onToggleLoop={(markerId) => waveformEditor?.toggleLoop(markerId)}
/>
{/if}
<PlaybackControls
onPlay={handlePlay}
@ -280,6 +405,7 @@
onSeek={handleSeek}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
compact={isMobile}
/>
</div>
{:else}
@ -291,31 +417,69 @@
</div>
<!-- Lyrics and preview section -->
<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>
{#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>
<!-- 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}
<!-- Tab content -->
<div class="flex-1 overflow-hidden">
{#if mobileTab === 'lyrics'}
<LyricsEditor onLineClick={handleLineClick} onSyncLine={handleSyncLine} />
{:else}
<KaraokePreview />
{/if}
</div>
</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>