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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user