first commit
Some checks failed
Deploy / lint (push) Failing after 7s
Deploy / test (push) Has been skipped
Deploy / deploy (push) Has been skipped

This commit is contained in:
Dennis Thiessen
2026-02-20 17:31:01 +01:00
commit 61ab24490d
160 changed files with 17034 additions and 0 deletions

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

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

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

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

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