feat: initialize Next.js 16 project with Tailwind CSS 4 and Docker support
Phase 1 Implementation: - Set up Next.js 16 with React 19, TypeScript 5, and Turbopack - Configure Tailwind CSS 4 with OKLCH color system - Implement dark/light theme support - Create core UI components: Button, Card, Slider, Progress, Toast - Add ThemeToggle component for theme switching - Set up project directory structure for audio editor - Create storage utilities for settings management - Add Dockerfile with multi-stage build (Node + nginx) - Configure nginx for static file serving with caching - Add docker-compose.yml for easy deployment - Configure static export mode for production Tech Stack: - Next.js 16 with Turbopack - React 19 - TypeScript 5 - Tailwind CSS 4 - pnpm 9.0.0 - nginx 1.27 (for Docker deployment) Build verified and working ✓ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
44
.dockerignore
Normal file
44
.dockerignore
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
pnpm-debug.log
|
||||||
|
|
||||||
|
# Next.js
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
README.md
|
||||||
|
PLAN.md
|
||||||
|
*.md
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.log
|
||||||
53
.gitignore
vendored
Normal file
53
.gitignore
vendored
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
.env
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
Thumbs.db
|
||||||
41
Dockerfile
Normal file
41
Dockerfile
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Multi-stage build for Next.js static export
|
||||||
|
|
||||||
|
# Stage 1: Build the application
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
|
# Install pnpm
|
||||||
|
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package.json pnpm-lock.yaml* ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy application files
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the Next.js application (static export)
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# Stage 2: Production server with nginx
|
||||||
|
FROM nginx:1.27-alpine AS runner
|
||||||
|
|
||||||
|
# Copy custom nginx configuration
|
||||||
|
COPY nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|
||||||
|
# Copy built static files from builder stage
|
||||||
|
COPY --from=builder /app/out /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Expose port 80
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD wget --quiet --tries=1 --spider http://localhost/ || exit 1
|
||||||
|
|
||||||
|
# Start nginx
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
311
app/globals.css
Normal file
311
app/globals.css
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* Source directives - scan components for Tailwind classes */
|
||||||
|
@source "../components/editor/*.{js,ts,jsx,tsx}";
|
||||||
|
@source "../components/effects/*.{js,ts,jsx,tsx}";
|
||||||
|
@source "../components/tracks/*.{js,ts,jsx,tsx}";
|
||||||
|
@source "../components/automation/*.{js,ts,jsx,tsx}";
|
||||||
|
@source "../components/analysis/*.{js,ts,jsx,tsx}";
|
||||||
|
@source "../components/recording/*.{js,ts,jsx,tsx}";
|
||||||
|
@source "../components/export/*.{js,ts,jsx,tsx}";
|
||||||
|
@source "../components/project/*.{js,ts,jsx,tsx}";
|
||||||
|
@source "../components/layout/*.{js,ts,jsx,tsx}";
|
||||||
|
@source "../components/ui/*.{js,ts,jsx,tsx}";
|
||||||
|
@source "*.{js,ts,jsx,tsx}";
|
||||||
|
|
||||||
|
/* Custom dark mode variant */
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
/* CSS Variables for theming */
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
/* Light mode colors using OKLCH */
|
||||||
|
--background: oklch(100% 0 0);
|
||||||
|
--foreground: oklch(9.8% 0.038 285.8);
|
||||||
|
|
||||||
|
--card: oklch(100% 0 0);
|
||||||
|
--card-foreground: oklch(9.8% 0.038 285.8);
|
||||||
|
|
||||||
|
--popover: oklch(100% 0 0);
|
||||||
|
--popover-foreground: oklch(9.8% 0.038 285.8);
|
||||||
|
|
||||||
|
--primary: oklch(22.4% 0.053 285.8);
|
||||||
|
--primary-foreground: oklch(98% 0 0);
|
||||||
|
|
||||||
|
--secondary: oklch(96.1% 0 0);
|
||||||
|
--secondary-foreground: oklch(13.8% 0.038 285.8);
|
||||||
|
|
||||||
|
--muted: oklch(96.1% 0 0);
|
||||||
|
--muted-foreground: oklch(45.1% 0.015 285.9);
|
||||||
|
|
||||||
|
--accent: oklch(96.1% 0 0);
|
||||||
|
--accent-foreground: oklch(13.8% 0.038 285.8);
|
||||||
|
|
||||||
|
--destructive: oklch(60.2% 0.168 29.2);
|
||||||
|
--destructive-foreground: oklch(98% 0 0);
|
||||||
|
|
||||||
|
--border: oklch(89.8% 0 0);
|
||||||
|
--input: oklch(89.8% 0 0);
|
||||||
|
--ring: oklch(22.4% 0.053 285.8);
|
||||||
|
|
||||||
|
--radius: 0.5rem;
|
||||||
|
|
||||||
|
--success: oklch(60% 0.15 145);
|
||||||
|
--success-foreground: oklch(98% 0 0);
|
||||||
|
|
||||||
|
--warning: oklch(75% 0.15 85);
|
||||||
|
--warning-foreground: oklch(20% 0 0);
|
||||||
|
|
||||||
|
--info: oklch(65% 0.15 240);
|
||||||
|
--info-foreground: oklch(98% 0 0);
|
||||||
|
|
||||||
|
/* Audio-specific colors */
|
||||||
|
--waveform: oklch(50% 0.1 240);
|
||||||
|
--waveform-progress: oklch(60% 0.15 145);
|
||||||
|
--waveform-selection: oklch(65% 0.15 240);
|
||||||
|
--waveform-bg: oklch(98% 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
/* Dark mode colors using OKLCH */
|
||||||
|
--background: oklch(9.8% 0.038 285.8);
|
||||||
|
--foreground: oklch(98% 0 0);
|
||||||
|
|
||||||
|
--card: oklch(9.8% 0.038 285.8);
|
||||||
|
--card-foreground: oklch(98% 0 0);
|
||||||
|
|
||||||
|
--popover: oklch(9.8% 0.038 285.8);
|
||||||
|
--popover-foreground: oklch(98% 0 0);
|
||||||
|
|
||||||
|
--primary: oklch(98% 0 0);
|
||||||
|
--primary-foreground: oklch(13.8% 0.038 285.8);
|
||||||
|
|
||||||
|
--secondary: oklch(17.7% 0.038 285.8);
|
||||||
|
--secondary-foreground: oklch(98% 0 0);
|
||||||
|
|
||||||
|
--muted: oklch(17.7% 0.038 285.8);
|
||||||
|
--muted-foreground: oklch(63.9% 0.012 285.9);
|
||||||
|
|
||||||
|
--accent: oklch(17.7% 0.038 285.8);
|
||||||
|
--accent-foreground: oklch(98% 0 0);
|
||||||
|
|
||||||
|
--destructive: oklch(50% 0.2 29.2);
|
||||||
|
--destructive-foreground: oklch(98% 0 0);
|
||||||
|
|
||||||
|
--border: oklch(17.7% 0.038 285.8);
|
||||||
|
--input: oklch(17.7% 0.038 285.8);
|
||||||
|
--ring: oklch(83.1% 0.012 285.9);
|
||||||
|
|
||||||
|
--success: oklch(55% 0.15 145);
|
||||||
|
--success-foreground: oklch(98% 0 0);
|
||||||
|
|
||||||
|
--warning: oklch(70% 0.15 85);
|
||||||
|
--warning-foreground: oklch(20% 0 0);
|
||||||
|
|
||||||
|
--info: oklch(60% 0.15 240);
|
||||||
|
--info-foreground: oklch(98% 0 0);
|
||||||
|
|
||||||
|
/* Audio-specific colors */
|
||||||
|
--waveform: oklch(70% 0.15 240);
|
||||||
|
--waveform-progress: oklch(65% 0.15 145);
|
||||||
|
--waveform-selection: oklch(70% 0.15 240);
|
||||||
|
--waveform-bg: oklch(12% 0.038 285.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme inline - map CSS variables to Tailwind colors */
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-success: var(--success);
|
||||||
|
--color-success-foreground: var(--success-foreground);
|
||||||
|
--color-warning: var(--warning);
|
||||||
|
--color-warning-foreground: var(--warning-foreground);
|
||||||
|
--color-info: var(--info);
|
||||||
|
--color-info-foreground: var(--info-foreground);
|
||||||
|
--color-waveform: var(--waveform);
|
||||||
|
--color-waveform-progress: var(--waveform-progress);
|
||||||
|
--color-waveform-selection: var(--waveform-selection);
|
||||||
|
--color-waveform-bg: var(--waveform-bg);
|
||||||
|
|
||||||
|
--radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Global styles */
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
font-feature-settings: "rlig" 1, "calt" 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom animations */
|
||||||
|
@layer utilities {
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInFromRight {
|
||||||
|
from {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
transform: translateY(-10px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
transform: translateY(10px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scaleIn {
|
||||||
|
from {
|
||||||
|
transform: scale(0.95);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulseSubtle {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: -1000px 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 1000px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes progress {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fadeIn {
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slideInFromRight {
|
||||||
|
animation: slideInFromRight 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slideDown {
|
||||||
|
animation: slideDown 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slideUp {
|
||||||
|
animation: slideUp 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-scaleIn {
|
||||||
|
animation: scaleIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-pulseSubtle {
|
||||||
|
animation: pulseSubtle 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-shimmer {
|
||||||
|
animation: shimmer 2s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-progress {
|
||||||
|
animation: progress 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
@layer utilities {
|
||||||
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-track {
|
||||||
|
@apply bg-muted;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
@apply bg-muted-foreground/30;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
@apply bg-muted-foreground/50;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/layout.tsx
Normal file
40
app/layout.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import './globals.css';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Audio UI - Browser Audio Editor',
|
||||||
|
description: 'Professional audio editing in your browser. Multi-track editing, effects, recording, and more.',
|
||||||
|
keywords: ['audio editor', 'waveform editor', 'web audio', 'daw', 'music production', 'audio effects'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="en" suppressHydrationWarning>
|
||||||
|
<head>
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
(function() {
|
||||||
|
try {
|
||||||
|
const theme = localStorage.getItem('theme');
|
||||||
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
const shouldBeDark = theme === 'dark' || (!theme && prefersDark);
|
||||||
|
if (shouldBeDark) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body className="min-h-screen antialiased">
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
171
app/page.tsx
Normal file
171
app/page.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Music, Settings } from 'lucide-react';
|
||||||
|
import { ThemeToggle } from '@/components/layout/ThemeToggle';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||||
|
import { ToastProvider } from '@/components/ui/Toast';
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="border-b border-border">
|
||||||
|
<div className="container mx-auto px-3 sm:px-4 py-3 sm:py-4 flex items-center justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1 flex items-center gap-3">
|
||||||
|
<Music className="h-6 w-6 text-primary" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl sm:text-2xl font-bold text-foreground">Audio UI</h1>
|
||||||
|
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||||
|
Professional audio editing in your browser
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title="Settings"
|
||||||
|
>
|
||||||
|
<Settings className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<main className="container mx-auto px-3 sm:px-4 py-6 sm:py-8 md:py-16">
|
||||||
|
<div className="max-w-4xl mx-auto space-y-8">
|
||||||
|
{/* Welcome Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Welcome to Audio UI</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
A sophisticated browser-only audio editor built with Next.js 16 and Web Audio API
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This project is currently in development. The following features are planned:
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-2 text-sm">
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-primary">•</span>
|
||||||
|
<span>Multi-track audio editing with professional mixer</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-primary">•</span>
|
||||||
|
<span>Advanced effects: EQ, compression, reverb, delay, and more</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-primary">•</span>
|
||||||
|
<span>Waveform visualization with zoom and scroll</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-primary">•</span>
|
||||||
|
<span>Audio recording from microphone</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-primary">•</span>
|
||||||
|
<span>Automation lanes for parameters</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-primary">•</span>
|
||||||
|
<span>Export to WAV, MP3, OGG, and FLAC</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-primary">•</span>
|
||||||
|
<span>Project save/load with IndexedDB</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Tech Stack Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Technology Stack</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Built with modern web technologies
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold mb-2">Frontend</h4>
|
||||||
|
<ul className="space-y-1 text-muted-foreground">
|
||||||
|
<li>• Next.js 16 with React 19</li>
|
||||||
|
<li>• TypeScript 5</li>
|
||||||
|
<li>• Tailwind CSS 4</li>
|
||||||
|
<li>• Lucide React Icons</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold mb-2">Audio</h4>
|
||||||
|
<ul className="space-y-1 text-muted-foreground">
|
||||||
|
<li>• Web Audio API</li>
|
||||||
|
<li>• Canvas API</li>
|
||||||
|
<li>• MediaRecorder API</li>
|
||||||
|
<li>• AudioWorklets</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Privacy Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Privacy First</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Your audio never leaves your device
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
All audio processing happens locally in your browser using the Web Audio API.
|
||||||
|
No files are uploaded to any server. Your projects are saved in your browser's
|
||||||
|
IndexedDB storage, giving you complete control over your data.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="border-t border-border mt-8 sm:mt-12 md:mt-16">
|
||||||
|
<div className="container mx-auto px-3 sm:px-4 py-6 text-center text-xs sm:text-sm text-muted-foreground">
|
||||||
|
<p>
|
||||||
|
Powered by{' '}
|
||||||
|
<a
|
||||||
|
href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Web Audio API
|
||||||
|
</a>
|
||||||
|
{' '}and{' '}
|
||||||
|
<a
|
||||||
|
href="https://nextjs.org"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Next.js 16
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p className="mt-2">
|
||||||
|
All audio processing happens locally in your browser. No files are uploaded.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
components/layout/ThemeToggle.tsx
Normal file
43
components/layout/ThemeToggle.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Moon, Sun } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const [theme, setTheme] = React.useState<'light' | 'dark'>('light');
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
// Get initial theme
|
||||||
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
|
setTheme(isDark ? 'dark' : 'light');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
const newTheme = theme === 'light' ? 'dark' : 'light';
|
||||||
|
setTheme(newTheme);
|
||||||
|
|
||||||
|
if (newTheme === 'dark') {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
localStorage.setItem('theme', 'dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
localStorage.setItem('theme', 'light');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
||||||
|
>
|
||||||
|
{theme === 'light' ? (
|
||||||
|
<Moon className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<Sun className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
components/ui/Button.tsx
Normal file
47
components/ui/Button.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
|
||||||
|
size?: 'default' | 'sm' | 'lg' | 'icon';
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant = 'default', size = 'default', ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
|
'disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
{
|
||||||
|
'bg-primary text-primary-foreground hover:bg-primary/90':
|
||||||
|
variant === 'default',
|
||||||
|
'bg-destructive text-destructive-foreground hover:bg-destructive/90':
|
||||||
|
variant === 'destructive',
|
||||||
|
'border border-input bg-background hover:bg-accent hover:text-accent-foreground':
|
||||||
|
variant === 'outline',
|
||||||
|
'bg-secondary text-secondary-foreground hover:bg-secondary/80':
|
||||||
|
variant === 'secondary',
|
||||||
|
'hover:bg-accent hover:text-accent-foreground': variant === 'ghost',
|
||||||
|
'text-primary underline-offset-4 hover:underline': variant === 'link',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'h-10 px-4 py-2': size === 'default',
|
||||||
|
'h-9 rounded-md px-3': size === 'sm',
|
||||||
|
'h-11 rounded-md px-8': size === 'lg',
|
||||||
|
'h-10 w-10': size === 'icon',
|
||||||
|
},
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
export { Button };
|
||||||
78
components/ui/Card.tsx
Normal file
78
components/ui/Card.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border border-border bg-card text-card-foreground shadow-sm',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Card.displayName = 'Card';
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardHeader.displayName = 'CardHeader';
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'text-2xl font-semibold leading-none tracking-tight',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardTitle.displayName = 'CardTitle';
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardDescription.displayName = 'CardDescription';
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||||
|
));
|
||||||
|
CardContent.displayName = 'CardContent';
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex items-center p-6 pt-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardFooter.displayName = 'CardFooter';
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||||
58
components/ui/Progress.tsx
Normal file
58
components/ui/Progress.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
|
export interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
value?: number;
|
||||||
|
max?: number;
|
||||||
|
showValue?: boolean;
|
||||||
|
variant?: 'default' | 'success' | 'warning' | 'destructive';
|
||||||
|
}
|
||||||
|
|
||||||
|
const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
value = 0,
|
||||||
|
max = 100,
|
||||||
|
showValue = false,
|
||||||
|
variant = 'default',
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const percentage = Math.min(100, Math.max(0, (value / max) * 100));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn('w-full', className)} {...props}>
|
||||||
|
{showValue && (
|
||||||
|
<div className="flex justify-between mb-1">
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
Progress
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{Math.round(percentage)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-full transition-all duration-300 ease-in-out',
|
||||||
|
{
|
||||||
|
'bg-primary': variant === 'default',
|
||||||
|
'bg-success': variant === 'success',
|
||||||
|
'bg-warning': variant === 'warning',
|
||||||
|
'bg-destructive': variant === 'destructive',
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Progress.displayName = 'Progress';
|
||||||
|
|
||||||
|
export { Progress };
|
||||||
81
components/ui/Slider.tsx
Normal file
81
components/ui/Slider.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
|
export interface SliderProps
|
||||||
|
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'> {
|
||||||
|
value?: number;
|
||||||
|
onChange?: (value: number) => void;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
label?: string;
|
||||||
|
showValue?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Slider = React.forwardRef<HTMLInputElement, SliderProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
value = 0,
|
||||||
|
onChange,
|
||||||
|
min = 0,
|
||||||
|
max = 100,
|
||||||
|
step = 1,
|
||||||
|
label,
|
||||||
|
showValue = false,
|
||||||
|
disabled,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onChange?.(parseFloat(e.target.value));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('w-full', className)}>
|
||||||
|
{(label || showValue) && (
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
{label && (
|
||||||
|
<label className="text-sm font-medium text-foreground">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{showValue && (
|
||||||
|
<span className="text-sm text-muted-foreground">{value}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
'w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||||
|
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
|
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4',
|
||||||
|
'[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary',
|
||||||
|
'[&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:transition-colors',
|
||||||
|
'[&::-webkit-slider-thumb]:hover:bg-primary/90',
|
||||||
|
'[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full',
|
||||||
|
'[&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:cursor-pointer',
|
||||||
|
'[&::-moz-range-thumb]:transition-colors [&::-moz-range-thumb]:hover:bg-primary/90'
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Slider.displayName = 'Slider';
|
||||||
|
|
||||||
|
export { Slider };
|
||||||
134
components/ui/Toast.tsx
Normal file
134
components/ui/Toast.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
variant?: 'default' | 'success' | 'error' | 'warning' | 'info';
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastContextType {
|
||||||
|
toasts: Toast[];
|
||||||
|
addToast: (toast: Omit<Toast, 'id'>) => void;
|
||||||
|
removeToast: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = React.createContext<ToastContextType | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const context = React.useContext(ToastContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useToast must be used within ToastProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [toasts, setToasts] = React.useState<Toast[]>([]);
|
||||||
|
|
||||||
|
const addToast = React.useCallback((toast: Omit<Toast, 'id'>) => {
|
||||||
|
const id = Math.random().toString(36).substring(2, 9);
|
||||||
|
const newToast: Toast = { id, ...toast };
|
||||||
|
|
||||||
|
setToasts((prev) => [...prev, newToast]);
|
||||||
|
|
||||||
|
// Auto remove after duration
|
||||||
|
const duration = toast.duration ?? 5000;
|
||||||
|
if (duration > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
removeToast(id);
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeToast = React.useCallback((id: string) => {
|
||||||
|
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={{ toasts, addToast, removeToast }}>
|
||||||
|
{children}
|
||||||
|
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToastContainer({
|
||||||
|
toasts,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
toasts: Toast[];
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
if (toasts.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-md w-full pointer-events-none">
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<ToastItem key={toast.id} toast={toast} onRemove={onRemove} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToastItem({
|
||||||
|
toast,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
toast: Toast;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const variant = toast.variant ?? 'default';
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
default: Info,
|
||||||
|
success: CheckCircle,
|
||||||
|
error: AlertCircle,
|
||||||
|
warning: AlertTriangle,
|
||||||
|
info: Info,
|
||||||
|
};
|
||||||
|
|
||||||
|
const Icon = icons[variant];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-start gap-3 rounded-lg border p-4 shadow-lg pointer-events-auto',
|
||||||
|
'animate-slideInFromRight',
|
||||||
|
{
|
||||||
|
'bg-card border-border': variant === 'default',
|
||||||
|
'bg-success/10 border-success text-success-foreground':
|
||||||
|
variant === 'success',
|
||||||
|
'bg-destructive/10 border-destructive text-destructive-foreground':
|
||||||
|
variant === 'error',
|
||||||
|
'bg-warning/10 border-warning text-warning-foreground':
|
||||||
|
variant === 'warning',
|
||||||
|
'bg-info/10 border-info text-info-foreground': variant === 'info',
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{toast.title && (
|
||||||
|
<div className="font-semibold text-sm">{toast.title}</div>
|
||||||
|
)}
|
||||||
|
{toast.description && (
|
||||||
|
<div className="text-sm opacity-90 mt-1">{toast.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onRemove(toast.id)}
|
||||||
|
className="flex-shrink-0 rounded-md p-1 hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
docker-compose.yml
Normal file
27
docker-compose.yml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
version: '3.9'
|
||||||
|
|
||||||
|
services:
|
||||||
|
audio-ui:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: audio-ui
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:80"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
labels:
|
||||||
|
- "com.docker.compose.project=audio-ui"
|
||||||
|
networks:
|
||||||
|
- audio-ui-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
audio-ui-network:
|
||||||
|
driver: bridge
|
||||||
72
lib/storage/settings.ts
Normal file
72
lib/storage/settings.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
export interface AppSettings {
|
||||||
|
theme: 'light' | 'dark' | 'system';
|
||||||
|
audioBufferSize: number;
|
||||||
|
sampleRate: number;
|
||||||
|
autoSave: boolean;
|
||||||
|
autoSaveInterval: number; // in seconds
|
||||||
|
snapToGrid: boolean;
|
||||||
|
gridResolution: number; // in ms
|
||||||
|
waveformColor: string;
|
||||||
|
showSpectrogram: boolean;
|
||||||
|
maxHistorySize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS: AppSettings = {
|
||||||
|
theme: 'system',
|
||||||
|
audioBufferSize: 4096,
|
||||||
|
sampleRate: 44100,
|
||||||
|
autoSave: true,
|
||||||
|
autoSaveInterval: 60,
|
||||||
|
snapToGrid: false,
|
||||||
|
gridResolution: 100,
|
||||||
|
waveformColor: '#3b82f6',
|
||||||
|
showSpectrogram: false,
|
||||||
|
maxHistorySize: 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SETTINGS_KEY = 'audio-ui-settings';
|
||||||
|
|
||||||
|
export function getSettings(): AppSettings {
|
||||||
|
if (typeof window === 'undefined') return DEFAULT_SETTINGS;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(SETTINGS_KEY);
|
||||||
|
if (!stored) return DEFAULT_SETTINGS;
|
||||||
|
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
return { ...DEFAULT_SETTINGS, ...parsed };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load settings:', error);
|
||||||
|
return DEFAULT_SETTINGS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSettings(settings: Partial<AppSettings>): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const current = getSettings();
|
||||||
|
const updated = { ...current, ...settings };
|
||||||
|
localStorage.setItem(SETTINGS_KEY, JSON.stringify(updated));
|
||||||
|
|
||||||
|
// Dispatch custom event for settings updates
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('settingsUpdated', { detail: updated })
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save settings:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetSettings(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(SETTINGS_KEY);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('settingsUpdated', { detail: DEFAULT_SETTINGS })
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to reset settings:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
lib/utils/cn.ts
Normal file
11
lib/utils/cn.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function to merge Tailwind CSS classes with clsx
|
||||||
|
* @param inputs - Class values to merge
|
||||||
|
* @returns Merged class string
|
||||||
|
*/
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
28
next.config.ts
Normal file
28
next.config.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { NextConfig } from 'next';
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: 'export',
|
||||||
|
reactStrictMode: true,
|
||||||
|
// Turbopack configuration (Next.js 16+)
|
||||||
|
turbopack: {},
|
||||||
|
// Webpack fallback for older Next.js versions
|
||||||
|
webpack: (config) => {
|
||||||
|
// Required for Web Audio API and WASM modules
|
||||||
|
config.resolve.fallback = {
|
||||||
|
...config.resolve.fallback,
|
||||||
|
fs: false,
|
||||||
|
path: false,
|
||||||
|
crypto: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Enable WASM
|
||||||
|
config.experiments = {
|
||||||
|
...config.experiments,
|
||||||
|
asyncWebAssembly: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
94
nginx.conf
Normal file
94
nginx.conf
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
user nginx;
|
||||||
|
worker_processes auto;
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" '
|
||||||
|
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||||
|
access_log /var/log/nginx/access.log main;
|
||||||
|
|
||||||
|
# Performance
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
tcp_nodelay on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
types_hash_max_size 2048;
|
||||||
|
client_max_body_size 100M;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_types text/plain text/css text/xml text/javascript
|
||||||
|
application/json application/javascript application/xml+rss
|
||||||
|
application/rss+xml font/truetype font/opentype
|
||||||
|
application/vnd.ms-fontobject image/svg+xml;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||||
|
|
||||||
|
# Server block
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Enable CORS for Web Audio API (if needed)
|
||||||
|
add_header Access-Control-Allow-Origin "*" always;
|
||||||
|
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
|
||||||
|
add_header Access-Control-Allow-Headers "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range" always;
|
||||||
|
|
||||||
|
# Handle SPA routing
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Don't cache HTML files
|
||||||
|
location ~* \.html$ {
|
||||||
|
expires -1;
|
||||||
|
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Handle WebAssembly files (if needed in the future)
|
||||||
|
location ~* \.wasm$ {
|
||||||
|
types { application/wasm wasm; }
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check endpoint
|
||||||
|
location /health {
|
||||||
|
access_log off;
|
||||||
|
return 200 "healthy\n";
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Deny access to hidden files
|
||||||
|
location ~ /\. {
|
||||||
|
deny all;
|
||||||
|
access_log off;
|
||||||
|
log_not_found off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
31
package.json
Normal file
31
package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "audio-ui",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev --turbopack",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lamejs": "^1.2.1",
|
||||||
|
"lucide-react": "^0.553.0",
|
||||||
|
"next": "^16.0.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"tailwind-merge": "^3.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.1.17",
|
||||||
|
"@types/node": "^22",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "^16.0.0",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@9.0.0"
|
||||||
|
}
|
||||||
4070
pnpm-lock.yaml
generated
Normal file
4070
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
tsconfig.json
Normal file
41
tsconfig.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user