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:
2025-11-17 15:23:00 +01:00
parent 88749dafae
commit 591f726899
20 changed files with 5475 additions and 0 deletions

44
.dockerignore Normal file
View 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
View 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
View 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
View 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
View 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
View 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>
);
}

View 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
View 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
View 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 };

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

41
tsconfig.json Normal file
View 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"
]
}