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:
92
components/ui/Toast.tsx
Normal file
92
components/ui/Toast.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user