migrate to nextjs
This commit is contained in:
46
src/components/ui/Button.tsx
Normal file
46
src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { forwardRef, ButtonHTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { LoadingSpinner } from './LoadingSpinner';
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{ className, variant = 'primary', size = 'md', isLoading, children, disabled, ...props },
|
||||
ref
|
||||
) => {
|
||||
const baseClasses =
|
||||
'inline-flex items-center justify-center font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
|
||||
secondary: 'bg-gray-600 text-white hover:bg-gray-700 focus:ring-gray-500',
|
||||
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
|
||||
ghost: 'text-gray-300 hover:text-white hover:bg-gray-700 focus:ring-gray-500'
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-3 py-1 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base'
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(baseClasses, variants[variant], sizes[size], className)}
|
||||
disabled={disabled || isLoading}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{isLoading && <LoadingSpinner size="sm" className="mr-2" />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
29
src/components/ui/Input.tsx
Normal file
29
src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { forwardRef, InputHTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, label, error, ...props }, ref) => {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{label && <label className="block text-sm font-medium text-gray-300">{label}</label>}
|
||||
<input
|
||||
className={cn(
|
||||
'block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white placeholder-gray-400 focus:border-transparent focus:ring-2 focus:ring-blue-500 focus:outline-none',
|
||||
error && 'border-red-500 focus:ring-red-500',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
24
src/components/ui/LoadingSpinner.tsx
Normal file
24
src/components/ui/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
className?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export function LoadingSpinner({ className, size = 'md' }: LoadingSpinnerProps) {
|
||||
const sizeClasses = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-6 w-6',
|
||||
lg: 'h-8 w-8'
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'animate-spin rounded-full border-2 border-gray-300 border-t-transparent',
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
54
src/components/ui/Modal.tsx
Normal file
54
src/components/ui/Modal.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode, useEffect } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Modal({ isOpen, onClose, title, children, className }: ModalProps) {
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="bg-opacity-50 absolute inset-0 bg-black" onClick={onClose} />
|
||||
<div
|
||||
className={cn(
|
||||
'relative mx-4 w-full max-w-md rounded-lg bg-gray-800 p-6 shadow-lg',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-white">{title}</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-white">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
src/components/ui/Pagination.tsx
Normal file
73
src/components/ui/Pagination.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
|
||||
const getVisiblePages = () => {
|
||||
const delta = 2;
|
||||
const range = [];
|
||||
|
||||
for (
|
||||
let i = Math.max(2, currentPage - delta);
|
||||
i <= Math.min(totalPages - 1, currentPage + delta);
|
||||
i++
|
||||
) {
|
||||
range.push(i);
|
||||
}
|
||||
|
||||
if (currentPage - delta > 2) {
|
||||
range.unshift('...');
|
||||
}
|
||||
if (currentPage + delta < totalPages - 1) {
|
||||
range.push('...');
|
||||
}
|
||||
|
||||
range.unshift(1);
|
||||
if (totalPages !== 1) {
|
||||
range.push(totalPages);
|
||||
}
|
||||
|
||||
return range;
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="flex items-center justify-center space-x-2">
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="rounded-md bg-gray-700 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-gray-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
|
||||
<div className="flex space-x-1">
|
||||
{getVisiblePages().map((page, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => (typeof page === 'number' ? onPageChange(page) : undefined)}
|
||||
disabled={typeof page === 'string'}
|
||||
className={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
||||
page === currentPage
|
||||
? 'bg-blue-600 text-white'
|
||||
: page === '...'
|
||||
? 'cursor-default text-gray-400'
|
||||
: 'bg-gray-700 text-white hover:bg-gray-600'
|
||||
} `}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="rounded-md bg-gray-700 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-gray-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
12
src/components/ui/RefreshButton.tsx
Normal file
12
src/components/ui/RefreshButton.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
'use client';
|
||||
|
||||
export default function RefreshButton() {
|
||||
return (
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="rounded-md bg-gray-700 px-3 py-1 text-sm hover:bg-gray-600"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
);
|
||||
}
|
||||
36
src/components/ui/Select.tsx
Normal file
36
src/components/ui/Select.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { forwardRef, SelectHTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
options: { value: string | number; label: string }[];
|
||||
}
|
||||
|
||||
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ className, label, error, options, ...props }, ref) => {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{label && <label className="block text-sm font-medium text-gray-300">{label}</label>}
|
||||
<select
|
||||
className={cn(
|
||||
'block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white focus:border-transparent focus:ring-2 focus:ring-blue-500 focus:outline-none',
|
||||
error && 'border-red-500 focus:ring-red-500',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Select.displayName = 'Select';
|
||||
49
src/components/ui/Toast.tsx
Normal file
49
src/components/ui/Toast.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ToastProps {
|
||||
message: string;
|
||||
type: 'success' | 'error' | 'info';
|
||||
duration?: number;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function Toast({ message, type, duration = 5000, onClose }: ToastProps) {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
onClose?.();
|
||||
}, duration);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [duration, onClose]);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('fixed top-4 right-4 z-50 max-w-md rounded-md p-4 shadow-lg', {
|
||||
'bg-green-600 text-white': type === 'success',
|
||||
'bg-red-600 text-white': type === 'error',
|
||||
'bg-blue-600 text-white': type === 'info'
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm">{message}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsVisible(false);
|
||||
onClose?.();
|
||||
}}
|
||||
className="ml-4 text-white hover:opacity-80"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user