Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions frontend/components/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export { Button, buttonVariants } from './ui/button';
export type { ButtonProps } from './ui/button';
export { Input } from './ui/input';
export type { InputProps } from './ui/input';
export { Card } from './ui/card';
export type { CardProps } from './ui/card';
export { Badge, badgeVariants } from './ui/badge';
export type { BadgeProps } from './ui/badge';
export { Modal } from './ui/modal';
export { ToastProvider, useToast } from './ui/toast';
33 changes: 33 additions & 0 deletions frontend/components/ui/badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { HTMLAttributes, forwardRef } from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';

const badgeVariants = cva(
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
{
variants: {
variant: {
default: 'bg-gray-100 text-gray-800',
success: 'bg-green-100 text-green-800',
warning: 'bg-yellow-100 text-yellow-800',
danger: 'bg-red-100 text-red-800',
info: 'bg-blue-100 text-blue-800',
},
},
defaultVariants: { variant: 'default' },
}
);

interface BadgeProps
extends HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof badgeVariants> {}

const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
({ className, variant, ...props }, ref) => (
<span ref={ref} className={cn(badgeVariants({ variant }), className)} {...props} />
)
);
Badge.displayName = 'Badge';

export { Badge, badgeVariants };
export type { BadgeProps };
49 changes: 49 additions & 0 deletions frontend/components/ui/button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { ButtonHTMLAttributes, forwardRef } from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';

const buttonVariants = cva(
'inline-flex items-center justify-center rounded-lg font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus-visible:ring-blue-500',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 focus-visible:ring-gray-400',
outline: 'border border-gray-300 bg-white hover:bg-gray-50 focus-visible:ring-gray-400',
ghost: 'hover:bg-gray-100 text-gray-700 focus-visible:ring-gray-400',
danger: 'bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500',
},
size: {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4 text-sm',
lg: 'h-12 px-6 text-base',
},
},
defaultVariants: { variant: 'primary', size: 'md' },
}
);

interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
loading?: boolean;
}

const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, loading, disabled, children, ...props }, ref) => (
<button
ref={ref}
className={cn(buttonVariants({ variant, size, className }))}
disabled={disabled || loading}
{...props}
>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{children}
</button>
)
);
Button.displayName = 'Button';

export { Button, buttonVariants };
export type { ButtonProps };
32 changes: 32 additions & 0 deletions frontend/components/ui/card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { HTMLAttributes, forwardRef } from 'react';
import { cn } from '@/lib/utils';

interface CardProps extends HTMLAttributes<HTMLDivElement> {
title?: string;
subtitle?: string;
}

const Card = forwardRef<HTMLDivElement, CardProps>(
({ className, title, subtitle, children, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-xl border border-gray-200 bg-white shadow-sm',
className
)}
{...props}
>
{(title || subtitle) && (
<div className="border-b border-gray-100 px-6 py-4">
{title && <h3 className="text-lg font-semibold text-gray-900">{title}</h3>}
{subtitle && <p className="mt-1 text-sm text-gray-500">{subtitle}</p>}
</div>
)}
{children && <div className="px-6 py-4">{children}</div>}
</div>
)
);
Card.displayName = 'Card';

export { Card };
export type { CardProps };
57 changes: 57 additions & 0 deletions frontend/components/ui/input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { InputHTMLAttributes, forwardRef, useId } from 'react';
import { cn } from '@/lib/utils';

interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
helperText?: string;
}

const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, label, error, helperText, id: externalId, ...props }, ref) => {
const generatedId = useId();
const id = externalId || generatedId;
const errorId = `${id}-error`;
const helperId = `${id}-helper`;

return (
<div className="flex flex-col gap-1">
{label && (
<label htmlFor={id} className="text-sm font-medium text-gray-700">
{label}
</label>
)}
<input
ref={ref}
id={id}
className={cn(
'h-10 rounded-lg border px-3 text-sm transition-colors',
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500',
'placeholder:text-gray-400',
error
? 'border-red-500 focus:ring-red-500 focus:border-red-500'
: 'border-gray-300',
className
)}
aria-invalid={!!error}
aria-describedby={error ? errorId : helperText ? helperId : undefined}
{...props}
/>
{error && (
<p id={errorId} className="text-sm text-red-600" role="alert">
{error}
</p>
)}
{helperText && !error && (
<p id={helperId} className="text-sm text-gray-500">
{helperText}
</p>
)}
</div>
);
}
);
Input.displayName = 'Input';

export { Input };
export type { InputProps };
75 changes: 75 additions & 0 deletions frontend/components/ui/modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
'use client';

import { useEffect, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';

interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
size?: 'sm' | 'md' | 'lg';
}

export function Modal({ isOpen, onClose, title, children, size = 'md' }: ModalProps) {
const overlayRef = useRef<HTMLDivElement>(null);

const handleEscape = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
},
[onClose]
);

useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleEscape);
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleEscape);
document.body.style.overflow = '';
};
}, [isOpen, handleEscape]);

if (!isOpen) return null;

return createPortal(
<div
ref={overlayRef}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 transition-opacity"
onClick={(e) => e.target === overlayRef.current && onClose()}
>
<div
className={cn(
'relative rounded-xl bg-white shadow-xl transition-all',
size === 'sm' && 'w-full max-w-sm',
size === 'md' && 'w-full max-w-lg',
size === 'lg' && 'w-full max-w-2xl'
)}
role="dialog"
aria-modal="true"
aria-labelledby={title ? 'modal-title' : undefined}
>
<div className="flex items-center justify-between border-b px-6 py-4">
{title && (
<h2 id="modal-title" className="text-lg font-semibold text-gray-900">
{title}
</h2>
)}
<button
onClick={onClose}
className="ml-auto rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
aria-label="Close"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="px-6 py-4">{children}</div>
</div>
</div>,
document.body
);
}
81 changes: 81 additions & 0 deletions frontend/components/ui/toast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
'use client';

import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
import { X, CheckCircle2, AlertCircle, AlertTriangle, Info } from 'lucide-react';
import { cn } from '@/lib/utils';

type ToastType = 'success' | 'error' | 'warning' | 'info';

interface Toast {
id: number;
message: string;
type: ToastType;
}

interface ToastContextValue {
toast: (message: string, type?: ToastType) => void;
}

const ToastContext = createContext<ToastContextValue | undefined>(undefined);

const icons: Record<ToastType, ReactNode> = {
success: <CheckCircle2 className="h-5 w-5 text-green-500" />,
error: <AlertCircle className="h-5 w-5 text-red-500" />,
warning: <AlertTriangle className="h-5 w-5 text-yellow-500" />,
info: <Info className="h-5 w-5 text-blue-500" />,
};

const bgStyles: Record<ToastType, string> = {
success: 'border-green-200 bg-green-50',
error: 'border-red-200 bg-red-50',
warning: 'border-yellow-200 bg-yellow-50',
info: 'border-blue-200 bg-blue-50',
};

export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);

const addToast = useCallback((message: string, type: ToastType = 'info') => {
const id = Date.now();
setToasts((prev) => [...prev, { id, message, type }]);
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 4000);
}, []);

const removeToast = useCallback((id: number) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);

return (
<ToastContext.Provider value={{ toast: addToast }}>
{children}
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
{toasts.map((t) => (
<div
key={t.id}
className={cn(
'flex items-center gap-3 rounded-lg border px-4 py-3 shadow-lg animate-in slide-in-from-right',
bgStyles[t.type]
)}
>
{icons[t.type]}
<p className="text-sm text-gray-800">{t.message}</p>
<button
onClick={() => removeToast(t.id)}
className="ml-auto rounded p-1 text-gray-400 hover:text-gray-600"
>
<X className="h-4 w-4" />
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
}

export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used within ToastProvider');
return ctx;
}
20 changes: 20 additions & 0 deletions frontend/lib/auth-middleware.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
'use client';

import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/lib/auth-store';

export default function AuthGuard({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token);
const router = useRouter();

useEffect(() => {
if (!token) {
router.replace('/login');
}
}, [token, router]);

if (!token) return null;

return <>{children}</>;
}
Loading
Loading