migrate to nextjs

This commit is contained in:
Fran Jurmanović
2025-08-27 20:38:19 +02:00
parent 965e13a0bf
commit b269144ee7
120 changed files with 10393 additions and 8526 deletions

View 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';

View 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';

View 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
)}
/>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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';

View 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>
);
}