feat: initialize Convert UI - browser-based file conversion app

- Add Next.js 16 with Turbopack and React 19
- Add Tailwind CSS 4 with OKLCH color system
- Implement FFmpeg.wasm for video/audio conversion
- Implement ImageMagick WASM for image conversion
- Add file upload with drag-and-drop
- Add format selector with fuzzy search
- Add conversion preview and download
- Add conversion history with localStorage
- Add dark/light theme support
- Support 22+ file formats across video, audio, and images

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-17 10:44:49 +01:00
commit 1771ca42eb
32 changed files with 7098 additions and 0 deletions

45
components/ui/Button.tsx Normal file
View File

@@ -0,0 +1,45 @@
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';
children?: React.ReactNode;
}
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 ring-offset-background 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 };

57
components/ui/Card.tsx Normal file
View File

@@ -0,0 +1,57 @@
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 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, children, ...props }, ref) => (
<h3
ref={ref}
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
{...props}
>
{children}
</h3>
)
);
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 };

28
components/ui/Input.tsx Normal file
View File

@@ -0,0 +1,28 @@
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background',
'file:border-0 file:bg-transparent file:text-sm file:font-medium',
'placeholder:text-muted-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = 'Input';
export { Input };

View File

@@ -0,0 +1,33 @@
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
export interface ProgressProps {
value: number;
max?: number;
className?: string;
showLabel?: boolean;
}
const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
({ value, max = 100, className, showLabel = false }, ref) => {
const percentage = Math.min(Math.max((value / max) * 100, 0), 100);
return (
<div ref={ref} className={cn('w-full', className)}>
<div className="relative h-4 w-full overflow-hidden rounded-full bg-secondary">
<div
className="h-full bg-primary transition-all duration-300 ease-in-out"
style={{ width: `${percentage}%` }}
/>
</div>
{showLabel && (
<div className="mt-1 text-xs text-muted-foreground text-right">{Math.round(percentage)}%</div>
)}
</div>
);
}
);
Progress.displayName = 'Progress';
export { Progress };

View File

@@ -0,0 +1,16 @@
import { cn } from '@/lib/utils/cn';
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('animate-shimmer rounded-md bg-muted', className)}
style={{
backgroundImage: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.1), transparent)',
backgroundSize: '200% 100%',
}}
{...props}
/>
);
}
export { Skeleton };

92
components/ui/Toast.tsx Normal file
View File

@@ -0,0 +1,92 @@
'use client';
import * as React from 'react';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
export interface Toast {
id: string;
message: string;
type?: 'success' | 'error' | 'info';
duration?: number;
}
interface ToastContextType {
toasts: Toast[];
addToast: (message: string, type?: Toast['type'], duration?: number) => void;
removeToast: (id: string) => void;
}
const ToastContext = React.createContext<ToastContextType | undefined>(undefined);
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = React.useState<Toast[]>([]);
const addToast = React.useCallback((message: string, type: Toast['type'] = 'info', duration = 3000) => {
const id = Math.random().toString(36).substring(7);
const toast: Toast = { id, message, type, duration };
setToasts((prev) => [...prev, toast]);
if (duration > 0) {
setTimeout(() => {
removeToast(id);
}, duration);
}
}, []);
const removeToast = React.useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
return (
<ToastContext.Provider value={{ toasts, addToast, removeToast }}>
{children}
<ToastContainer toasts={toasts} onRemove={removeToast} />
</ToastContext.Provider>
);
}
export function useToast() {
const context = React.useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within ToastProvider');
}
return context;
}
interface ToastContainerProps {
toasts: Toast[];
onRemove: (id: string) => void;
}
function ToastContainer({ toasts, onRemove }: ToastContainerProps) {
if (toasts.length === 0) return null;
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 w-full max-w-sm pointer-events-none">
{toasts.map((toast) => (
<div
key={toast.id}
className={cn(
'flex items-center gap-3 rounded-lg border p-4 shadow-lg animate-slideInFromRight pointer-events-auto',
{
'bg-success text-success-foreground border-success': toast.type === 'success',
'bg-destructive text-destructive-foreground border-destructive': toast.type === 'error',
'bg-card text-card-foreground border-border': toast.type === 'info',
}
)}
>
<p className="flex-1 text-sm font-medium">{toast.message}</p>
<button
onClick={() => onRemove(toast.id)}
className="rounded-sm opacity-70 hover:opacity-100 transition-opacity"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</button>
</div>
))}
</div>
);
}