first commit
This commit is contained in:
18
frontend/src/components/ui/Badge.tsx
Normal file
18
frontend/src/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
const variantStyles: Record<string, string> = {
|
||||
auto: 'bg-blue-500/15 text-blue-400 border-blue-500/20',
|
||||
manual: 'bg-violet-500/15 text-violet-400 border-violet-500/20',
|
||||
default: 'bg-white/[0.06] text-gray-400 border-white/[0.08]',
|
||||
};
|
||||
|
||||
interface BadgeProps {
|
||||
label: string;
|
||||
variant?: 'auto' | 'manual' | 'default';
|
||||
}
|
||||
|
||||
export function Badge({ label, variant = 'default' }: BadgeProps) {
|
||||
return (
|
||||
<span className={`inline-block rounded-full border px-2.5 py-0.5 text-xs font-medium backdrop-blur-sm ${variantStyles[variant]}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
35
frontend/src/components/ui/ConfirmDialog.tsx
Normal file
35
frontend/src/components/ui/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({ open, title, message, onConfirm, onCancel }: ConfirmDialogProps) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center animate-fade-in">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onCancel} />
|
||||
<div className="relative z-10 w-full max-w-md glass p-6 shadow-2xl animate-slide-up">
|
||||
<h2 className="text-lg font-semibold text-gray-100">{title}</h2>
|
||||
<p className="mt-2 text-sm text-gray-400">{message}</p>
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="rounded-lg border border-white/[0.1] bg-white/[0.04] px-4 py-2 text-sm text-gray-300 hover:bg-white/[0.08] transition-all duration-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="rounded-lg bg-gradient-to-r from-red-600 to-red-500 px-4 py-2 text-sm text-white hover:from-red-500 hover:to-red-400 transition-all duration-200"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
frontend/src/components/ui/ScoreCard.tsx
Normal file
89
frontend/src/components/ui/ScoreCard.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
interface ScoreCardProps {
|
||||
compositeScore: number | null;
|
||||
dimensions: { dimension: string; score: number }[];
|
||||
}
|
||||
|
||||
function scoreColor(score: number): string {
|
||||
if (score > 70) return 'text-emerald-400';
|
||||
if (score >= 40) return 'text-amber-400';
|
||||
return 'text-red-400';
|
||||
}
|
||||
|
||||
function ringGradient(score: number): string {
|
||||
if (score > 70) return '#10b981';
|
||||
if (score >= 40) return '#f59e0b';
|
||||
return '#ef4444';
|
||||
}
|
||||
|
||||
function barGradient(score: number): string {
|
||||
if (score > 70) return 'from-emerald-500 to-emerald-400';
|
||||
if (score >= 40) return 'from-amber-500 to-amber-400';
|
||||
return 'from-red-500 to-red-400';
|
||||
}
|
||||
|
||||
function ScoreRing({ score }: { score: number }) {
|
||||
const radius = 36;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const clamped = Math.max(0, Math.min(100, score));
|
||||
const offset = circumference - (clamped / 100) * circumference;
|
||||
const color = ringGradient(score);
|
||||
|
||||
return (
|
||||
<div className="relative inline-flex items-center justify-center">
|
||||
<svg width="88" height="88" className="-rotate-90">
|
||||
<circle cx="44" cy="44" r={radius} fill="none" strokeWidth="6" className="stroke-white/[0.06]" />
|
||||
<circle
|
||||
cx="44" cy="44" r={radius} fill="none" strokeWidth="6" strokeLinecap="round"
|
||||
strokeDasharray={circumference} strokeDashoffset={offset}
|
||||
stroke={color}
|
||||
style={{ filter: `drop-shadow(0 0 8px ${color}40)`, transition: 'all 0.6s ease' }}
|
||||
/>
|
||||
</svg>
|
||||
<span className={`absolute text-lg font-bold ${scoreColor(score)}`}>
|
||||
{Math.round(score)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScoreCard({ compositeScore, dimensions }: ScoreCardProps) {
|
||||
return (
|
||||
<div className="glass p-5">
|
||||
<div className="flex items-center gap-4">
|
||||
{compositeScore !== null ? (
|
||||
<ScoreRing score={compositeScore} />
|
||||
) : (
|
||||
<div className="flex h-[88px] w-[88px] items-center justify-center text-sm text-gray-500">N/A</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider">Composite Score</p>
|
||||
<p className={`text-2xl font-bold ${compositeScore !== null ? scoreColor(compositeScore) : 'text-gray-500'}`}>
|
||||
{compositeScore !== null ? Math.round(compositeScore) : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dimensions.length > 0 && (
|
||||
<div className="mt-5 space-y-2.5">
|
||||
<p className="text-[10px] font-medium uppercase tracking-widest text-gray-500">Dimensions</p>
|
||||
{dimensions.map((d) => (
|
||||
<div key={d.dimension} className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-300 capitalize">{d.dimension}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1.5 w-20 rounded-full bg-white/[0.06] overflow-hidden">
|
||||
<div
|
||||
className={`h-1.5 rounded-full bg-gradient-to-r ${barGradient(d.score)} transition-all duration-500`}
|
||||
style={{ width: `${Math.max(0, Math.min(100, d.score))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`w-8 text-right font-medium text-xs ${scoreColor(d.score)}`}>
|
||||
{Math.round(d.score)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
frontend/src/components/ui/Skeleton.tsx
Normal file
23
frontend/src/components/ui/Skeleton.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
const pulse = 'animate-pulse rounded-lg bg-white/[0.05]';
|
||||
|
||||
export function SkeletonLine({ className = '' }: { className?: string }) {
|
||||
return <div className={`${pulse} h-4 w-full ${className}`} />;
|
||||
}
|
||||
|
||||
export function SkeletonCard({ className = '' }: { className?: string }) {
|
||||
return <div className={`${pulse} h-32 w-full ${className}`} />;
|
||||
}
|
||||
|
||||
export function SkeletonTable({ rows = 5, cols = 4, className = '' }: { rows?: number; cols?: number; className?: string }) {
|
||||
return (
|
||||
<div className={`space-y-2 ${className}`}>
|
||||
{Array.from({ length: rows }, (_, r) => (
|
||||
<div key={r} className="flex gap-4">
|
||||
{Array.from({ length: cols }, (_, c) => (
|
||||
<div key={c} className={`${pulse} h-6 flex-1`} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
frontend/src/components/ui/Toast.tsx
Normal file
87
frontend/src/components/ui/Toast.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
type ToastType = 'success' | 'error' | 'info';
|
||||
|
||||
interface Toast {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
addToast: (type: ToastType, message: string) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
const MAX_VISIBLE = 3;
|
||||
const AUTO_DISMISS_MS = 8000;
|
||||
|
||||
const typeStyles: Record<ToastType, string> = {
|
||||
error: 'border-red-500/30 bg-red-500/10 text-red-300',
|
||||
success: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300',
|
||||
info: 'border-blue-500/30 bg-blue-500/10 text-blue-300',
|
||||
};
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const nextId = useRef(0);
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
const addToast = useCallback((type: ToastType, message: string) => {
|
||||
const id = String(nextId.current++);
|
||||
setToasts((prev) => {
|
||||
const next = [...prev, { id, type, message }];
|
||||
return next.length > MAX_VISIBLE ? next.slice(next.length - MAX_VISIBLE) : next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ addToast }}>
|
||||
{children}
|
||||
{createPortal(
|
||||
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 w-96">
|
||||
{toasts.map((toast) => (
|
||||
<ToastItem key={toast.id} toast={toast} onDismiss={removeToast} />
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: (id: string) => void }) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => onDismiss(toast.id), AUTO_DISMISS_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [toast.id, onDismiss]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className={`glass px-4 py-3 shadow-2xl animate-slide-up ${typeStyles[toast.type]}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm">{toast.message}</p>
|
||||
<button
|
||||
onClick={() => onDismiss(toast.id)}
|
||||
className="shrink-0 text-gray-400 hover:text-gray-200 transition-colors duration-200"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast(): ToastContextValue {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) throw new Error('useToast must be used within a ToastProvider');
|
||||
return ctx;
|
||||
}
|
||||
Reference in New Issue
Block a user