feat: implement Figlet, Pastel, and Unit tools with a unified layout
- Add Figlet text converter with font selection and history - Add Pastel color palette generator and manipulation suite - Add comprehensive Units converter with category-based logic - Introduce AppShell with Sidebar and Header for navigation - Modernize theme system with CSS variables and new animations - Update project configuration and dependencies
This commit is contained in:
35
components/ui/Badge.tsx
Normal file
35
components/ui/Badge.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: 'default' | 'success' | 'warning' | 'destructive' | 'outline';
|
||||
}
|
||||
|
||||
const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
|
||||
({ className, variant = 'default', ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
'bg-primary text-primary-foreground': variant === 'default',
|
||||
'bg-green-500 text-white': variant === 'success',
|
||||
'bg-yellow-500 text-white': variant === 'warning',
|
||||
'bg-destructive text-destructive-foreground': variant === 'destructive',
|
||||
'border border-input': variant === 'outline',
|
||||
},
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Badge.displayName = 'Badge';
|
||||
|
||||
export { Badge };
|
||||
45
components/ui/Button.tsx
Normal file
45
components/ui/Button.tsx
Normal 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' | 'outline' | 'ghost' | 'destructive' | 'secondary';
|
||||
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 rounded-xl font-semibold',
|
||||
'transition-all duration-300',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ring-offset-background',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
'active:scale-[0.98]',
|
||||
{
|
||||
'bg-primary text-primary-foreground shadow-[0_0_20px_rgba(139,92,246,0.3)] hover:bg-primary/90 hover:shadow-[0_0_25px_rgba(139,92,246,0.5)] hover:-translate-y-0.5':
|
||||
variant === 'default',
|
||||
'glass bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 text-foreground':
|
||||
variant === 'outline',
|
||||
'hover:bg-accent hover:text-accent-foreground': variant === 'ghost',
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-lg hover:shadow-destructive/20':
|
||||
variant === 'destructive',
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80':
|
||||
variant === 'secondary',
|
||||
'h-10 px-5 py-2 text-sm': size === 'default',
|
||||
'h-9 px-4 text-xs': size === 'sm',
|
||||
'h-12 px-8 text-base': size === 'lg',
|
||||
'h-10 w-10': size === 'icon',
|
||||
},
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button };
|
||||
50
components/ui/Card.tsx
Normal file
50
components/ui/Card.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
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('glass rounded-2xl text-card-foreground shadow-xl transition-all duration-300', 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<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div 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 };
|
||||
231
components/ui/CommandPalette.tsx
Normal file
231
components/ui/CommandPalette.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Command, Hash, Clock, Star, Moon, Sun } from 'lucide-react';
|
||||
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||
import {
|
||||
getAllMeasures,
|
||||
formatMeasureName,
|
||||
getCategoryColor,
|
||||
getCategoryColorHex,
|
||||
type Measure,
|
||||
} from '@/lib/units/units';
|
||||
import { getHistory, getFavorites } from '@/lib/units/storage';
|
||||
import { cn } from '@/lib/units/utils';
|
||||
|
||||
interface CommandPaletteProps {
|
||||
onSelectMeasure: (measure: Measure) => void;
|
||||
onSelectUnit: (unit: string, measure: Measure) => void;
|
||||
}
|
||||
|
||||
export default function CommandPalette({
|
||||
onSelectMeasure,
|
||||
onSelectUnit,
|
||||
}: CommandPaletteProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const { theme, setTheme } = useTheme();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Commands
|
||||
const commands: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
icon: any;
|
||||
action: () => void;
|
||||
keywords: string[];
|
||||
color?: string;
|
||||
}> = [
|
||||
{
|
||||
id: 'theme-light',
|
||||
label: 'Switch to Light Mode',
|
||||
icon: Sun,
|
||||
action: () => setTheme('light'),
|
||||
keywords: ['theme', 'light', 'mode'],
|
||||
},
|
||||
{
|
||||
id: 'theme-dark',
|
||||
label: 'Switch to Dark Mode',
|
||||
icon: Moon,
|
||||
action: () => setTheme('dark'),
|
||||
keywords: ['theme', 'dark', 'mode'],
|
||||
},
|
||||
{
|
||||
id: 'theme-system',
|
||||
label: 'Use System Theme',
|
||||
icon: Command,
|
||||
action: () => setTheme('system'),
|
||||
keywords: ['theme', 'system', 'auto'],
|
||||
},
|
||||
];
|
||||
|
||||
// Add measure commands
|
||||
const measures = getAllMeasures();
|
||||
const measureCommands = measures.map(measure => ({
|
||||
id: `measure-${measure}`,
|
||||
label: `Convert ${formatMeasureName(measure)}`,
|
||||
icon: Hash,
|
||||
action: () => onSelectMeasure(measure),
|
||||
keywords: ['convert', measure, formatMeasureName(measure).toLowerCase()],
|
||||
color: getCategoryColorHex(measure),
|
||||
}));
|
||||
|
||||
const allCommands = [...commands, ...measureCommands];
|
||||
|
||||
// Filter commands
|
||||
const filteredCommands = query
|
||||
? allCommands.filter(cmd =>
|
||||
cmd.keywords.some(kw => kw.toLowerCase().includes(query.toLowerCase())) ||
|
||||
cmd.label.toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
: allCommands;
|
||||
|
||||
// Keyboard shortcut to open
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
setIsOpen(prev => !prev);
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
// Focus input when opened
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
inputRef.current?.focus();
|
||||
setQuery('');
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev =>
|
||||
prev < filteredCommands.length - 1 ? prev + 1 : prev
|
||||
);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => (prev > 0 ? prev - 1 : prev));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const command = filteredCommands[selectedIndex];
|
||||
if (command) {
|
||||
command.action();
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, selectedIndex, filteredCommands]);
|
||||
|
||||
// Reset selected index when query changes
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [query]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Command Palette */}
|
||||
<div className="fixed left-1/2 top-1/4 -translate-x-1/2 w-full max-w-2xl z-50 animate-scale-in">
|
||||
<div className="bg-popover border rounded-lg shadow-2xl overflow-hidden">
|
||||
{/* Search Input */}
|
||||
<div className="flex items-center border-b px-4">
|
||||
<Command className="h-5 w-5 text-muted-foreground" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Type a command or search..."
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
className="flex-1 bg-transparent py-4 px-4 outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
<kbd className="hidden sm:inline-block px-2 py-1 text-xs bg-muted rounded">
|
||||
ESC
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
{/* Commands List */}
|
||||
<div className="max-h-96 overflow-y-auto p-2">
|
||||
{filteredCommands.length === 0 ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
No commands found
|
||||
</div>
|
||||
) : (
|
||||
filteredCommands.map((command, index) => {
|
||||
const Icon = command.icon;
|
||||
return (
|
||||
<button
|
||||
key={command.id}
|
||||
onClick={() => {
|
||||
command.action();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-4 py-3 rounded-md transition-colors text-left',
|
||||
index === selectedIndex
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
{command.color ? (
|
||||
<div
|
||||
className="w-5 h-5 rounded flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: command.color,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Icon className="h-5 w-5 flex-shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="flex-1">{command.label}</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t px-4 py-2 text-xs text-muted-foreground flex items-center gap-4">
|
||||
<div className="flex items-center gap-1">
|
||||
<kbd className="px-1.5 py-0.5 bg-muted rounded">↑</kbd>
|
||||
<kbd className="px-1.5 py-0.5 bg-muted rounded">↓</kbd>
|
||||
<span>Navigate</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<kbd className="px-1.5 py-0.5 bg-muted rounded">Enter</kbd>
|
||||
<span>Select</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<kbd className="px-1.5 py-0.5 bg-muted rounded">ESC</kbd>
|
||||
<span>Close</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
36
components/ui/EmptyState.tsx
Normal file
36
components/ui/EmptyState.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
export interface EmptyStateProps {
|
||||
icon?: LucideIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center justify-center py-12 px-4 text-center', className)}>
|
||||
{Icon && (
|
||||
<div className="mb-4 rounded-full bg-muted p-3">
|
||||
<Icon className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<h3 className="mb-2 text-sm font-semibold">{title}</h3>
|
||||
{description && (
|
||||
<p className="mb-4 text-sm text-muted-foreground max-w-sm">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
components/ui/Input.tsx
Normal file
28
components/ui/Input.tsx
Normal 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-xl border border-white/10 bg-white/5 px-4 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-primary/50 focus-visible:border-primary/50',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50 transition-all duration-200',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
102
components/ui/KeyboardShortcutsHelp.tsx
Normal file
102
components/ui/KeyboardShortcutsHelp.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Card } from './Card';
|
||||
import { Button } from './Button';
|
||||
import { X, Keyboard } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
export interface Shortcut {
|
||||
key: string;
|
||||
description: string;
|
||||
modifier?: 'ctrl' | 'shift';
|
||||
}
|
||||
|
||||
const shortcuts: Shortcut[] = [
|
||||
{ key: '/', description: 'Focus font search' },
|
||||
{ key: 'Esc', description: 'Clear search / Close dialog' },
|
||||
{ key: 'D', description: 'Toggle dark/light mode', modifier: 'ctrl' },
|
||||
{ key: '?', description: 'Show this help dialog', modifier: 'shift' },
|
||||
];
|
||||
|
||||
export function KeyboardShortcutsHelp() {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === '?' && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
setIsOpen(true);
|
||||
}
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsOpen(true)}
|
||||
title="Keyboard shortcuts (Shift + ?)"
|
||||
className="fixed bottom-4 right-4"
|
||||
>
|
||||
<Keyboard className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<Card className="max-w-md w-full">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Keyboard className="h-5 w-5" />
|
||||
Keyboard Shortcuts
|
||||
</h2>
|
||||
<Button variant="ghost" size="icon" onClick={() => setIsOpen(false)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase mb-2">Navigation</h3>
|
||||
{shortcuts.map((shortcut, i) => (
|
||||
<div key={i} className="flex items-center justify-between py-2 border-b last:border-0">
|
||||
<span className="text-sm text-muted-foreground">{shortcut.description}</span>
|
||||
<div className="flex gap-1">
|
||||
{shortcut.modifier && (
|
||||
<kbd className="px-2 py-1 text-xs font-semibold bg-muted rounded">
|
||||
{shortcut.modifier === 'ctrl' ? '⌘/Ctrl' : 'Shift'}
|
||||
</kbd>
|
||||
)}
|
||||
<kbd className="px-2 py-1 text-xs font-semibold bg-muted rounded">
|
||||
{shortcut.key}
|
||||
</kbd>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase mb-2">Tips</h3>
|
||||
<ul className="text-xs text-muted-foreground space-y-1">
|
||||
<li>• Click the Shuffle button for random fonts</li>
|
||||
<li>• Use the heart icon to favorite fonts</li>
|
||||
<li>• Filter by All, Favorites, or Recent</li>
|
||||
<li>• Text alignment and size controls in Preview</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
components/ui/Select.tsx
Normal file
39
components/ui/Select.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ className, label, children, ...props }, ref) => {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{label && (
|
||||
<label htmlFor={props.id} className="text-sm font-medium">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2',
|
||||
'text-sm ring-offset-background',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:border-primary/50',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50 transition-all duration-200',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Select.displayName = 'Select';
|
||||
|
||||
export { Select };
|
||||
32
components/ui/Separator.tsx
Normal file
32
components/ui/Separator.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
decorative?: boolean;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{ className, orientation = 'horizontal', decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role={decorative ? undefined : 'separator'}
|
||||
aria-orientation={decorative ? undefined : orientation}
|
||||
className={cn(
|
||||
'shrink-0 bg-border',
|
||||
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Separator.displayName = 'Separator';
|
||||
|
||||
export { Separator };
|
||||
14
components/ui/Skeleton.tsx
Normal file
14
components/ui/Skeleton.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
export interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export function Skeleton({ className, ...props }: SkeletonProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn('animate-pulse rounded-md bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
65
components/ui/Slider.tsx
Normal file
65
components/ui/Slider.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
export interface SliderProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
||||
label?: string;
|
||||
showValue?: boolean;
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
const Slider = React.forwardRef<HTMLInputElement, SliderProps>(
|
||||
({ className, label, showValue = true, suffix = '', ...props }, ref) => {
|
||||
const [value, setValue] = React.useState(props.value || props.defaultValue || 0);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(e.target.value);
|
||||
props.onChange?.(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{(label || showValue) && (
|
||||
<div className="flex items-center justify-between">
|
||||
{label && (
|
||||
<label htmlFor={props.id} className="text-sm font-medium">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
{showValue && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{value}
|
||||
{suffix}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="range"
|
||||
className={cn(
|
||||
'w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer',
|
||||
'[&::-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-all',
|
||||
'[&::-webkit-slider-thumb]:hover:scale-110',
|
||||
'[&::-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-all',
|
||||
'[&::-moz-range-thumb]:hover:scale-110',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Slider.displayName = 'Slider';
|
||||
|
||||
export { Slider };
|
||||
90
components/ui/Toast.tsx
Normal file
90
components/ui/Toast.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { X, CheckCircle2, AlertCircle, Info } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'info';
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
}
|
||||
|
||||
interface ToastContextType {
|
||||
toasts: Toast[];
|
||||
addToast: (message: string, type?: ToastType) => 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: ToastType = 'success') => {
|
||||
const id = Math.random().toString(36).substring(7);
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
|
||||
// Auto remove after 3 seconds
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, 3000);
|
||||
}, []);
|
||||
|
||||
const removeToast = React.useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toasts, addToast, removeToast }}>
|
||||
{children}
|
||||
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 pointer-events-none">
|
||||
{toasts.map((toast) => (
|
||||
<ToastItem key={toast.id} toast={toast} onClose={() => removeToast(toast.id)} />
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
|
||||
const Icon = toast.type === 'success' ? CheckCircle2 : toast.type === 'error' ? AlertCircle : Info;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg pointer-events-auto',
|
||||
'animate-in slide-in-from-right-full duration-300',
|
||||
'min-w-[300px] max-w-[400px]',
|
||||
{
|
||||
'bg-green-50 text-green-900 border border-green-200 dark:bg-green-900/20 dark:text-green-100 dark:border-green-800':
|
||||
toast.type === 'success',
|
||||
'bg-red-50 text-red-900 border border-red-200 dark:bg-red-900/20 dark:text-red-100 dark:border-red-800':
|
||||
toast.type === 'error',
|
||||
'bg-blue-50 text-blue-900 border border-blue-200 dark:bg-blue-900/20 dark:text-blue-100 dark:border-blue-800':
|
||||
toast.type === 'info',
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5 flex-shrink-0" />
|
||||
<p className="text-sm font-medium flex-1">{toast.message}</p>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-shrink-0 opacity-70 hover:opacity-100 transition-opacity"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const context = React.useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within ToastProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
Reference in New Issue
Block a user