feat: base UI components, layout and global styles

This commit is contained in:
2026-02-22 20:00:38 +01:00
parent 7defeb87b3
commit bfe88b7ea8
3 changed files with 510 additions and 0 deletions

241
src/components/ui.tsx Normal file
View File

@@ -0,0 +1,241 @@
'use client';
import React, { useState, type ReactNode } from 'react';
// ─── Collapsible Section ────────────────────────────────────────────────────
interface CollapsibleSectionProps {
title: string;
icon?: ReactNode;
defaultOpen?: boolean;
badge?: ReactNode;
children: ReactNode;
}
export function CollapsibleSection({
title,
icon,
defaultOpen = true,
badge,
children,
}: CollapsibleSectionProps) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="card">
<button
className="card-header w-full"
onClick={() => setOpen(!open)}
aria-expanded={open}
>
<div className="flex items-center gap-2">
{icon && <span className="text-slate-400">{icon}</span>}
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200">{title}</h3>
{badge}
</div>
<svg
className={`w-4 h-4 text-slate-400 transition-transform duration-200 ${
open ? 'rotate-180' : ''
}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && <div className="card-body animate-fade-in">{children}</div>}
</div>
);
}
// ─── Number Input ───────────────────────────────────────────────────────────
interface NumberInputProps {
label: string;
value: number;
onChange: (v: number) => void;
suffix?: string;
prefix?: string;
min?: number;
max?: number;
step?: number;
hint?: string;
className?: string;
}
export function NumberInput({
label,
value,
onChange,
suffix,
prefix,
min,
max,
step = 1,
hint,
className,
}: NumberInputProps) {
return (
<div className={`form-field ${className ?? ''}`}>
<label className="form-label">{label}</label>
<div className="relative">
{prefix && (
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm text-slate-400 pointer-events-none">
{prefix}
</span>
)}
<input
type="number"
className={`form-input-sm ${prefix ? 'pl-7' : ''} ${suffix ? 'pr-10' : ''}`}
value={value}
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
min={min}
max={max}
step={step}
/>
{suffix && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-slate-400 pointer-events-none">
{suffix}
</span>
)}
</div>
{hint && <p className="text-xs text-slate-400 mt-0.5">{hint}</p>}
</div>
);
}
// ─── Rate Input (special for percentages) ───────────────────────────────────
interface RateInputProps {
label: string;
value: number; // stored as decimal (0.0325 for 3.25%)
onChange: (v: number) => void;
hint?: string;
className?: string;
}
export function RateInput({ label, value, onChange, hint, className }: RateInputProps) {
const displayValue = Math.round(value * 10000) / 100; // 0.0325 → 3.25
return (
<div className={`form-field ${className ?? ''}`}>
<label className="form-label">{label}</label>
<div className="relative">
<input
type="number"
className="form-input-sm pr-8"
value={displayValue}
onChange={(e) => onChange((parseFloat(e.target.value) || 0) / 100)}
step={0.01}
min={0}
max={100}
/>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-slate-400 pointer-events-none">
%
</span>
</div>
{hint && <p className="text-xs text-slate-400 mt-0.5">{hint}</p>}
</div>
);
}
// ─── Toggle ─────────────────────────────────────────────────────────────────
interface ToggleProps {
options: { value: string; label: string }[];
value: string;
onChange: (v: string) => void;
className?: string;
}
export function Toggle({ options, value, onChange, className }: ToggleProps) {
return (
<div className={`inline-flex rounded-lg bg-slate-100 dark:bg-slate-800 p-0.5 ${className ?? ''}`}>
{options.map((opt) => (
<button
key={opt.value}
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-all ${
value === opt.value
? 'bg-white dark:bg-slate-700 text-blue-700 dark:text-blue-400 shadow-sm'
: 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200'
}`}
onClick={() => onChange(opt.value)}
>
{opt.label}
</button>
))}
</div>
);
}
// ─── Tabs ───────────────────────────────────────────────────────────────────
interface TabsProps {
tabs: { id: string; label: string; icon?: ReactNode }[];
activeTab: string;
onTabChange: (id: string) => void;
}
export function Tabs({ tabs, activeTab, onTabChange }: TabsProps) {
return (
<div className="flex gap-1 p-1 bg-slate-100 dark:bg-slate-800/50 rounded-xl overflow-x-auto">
{tabs.map((tab) => (
<button
key={tab.id}
className={`tab-btn whitespace-nowrap flex items-center gap-1.5 ${
activeTab === tab.id ? 'tab-btn-active' : ''
}`}
onClick={() => onTabChange(tab.id)}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
);
}
// ─── Tooltip ────────────────────────────────────────────────────────────────
interface TooltipProps {
text: string;
children: ReactNode;
}
export function Tooltip({ text, children }: TooltipProps) {
return (
<span className="relative group">
{children}
<span
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-1.5
bg-slate-800 text-white text-xs rounded-lg whitespace-nowrap
opacity-0 invisible group-hover:opacity-100 group-hover:visible
transition-all duration-200 pointer-events-none z-50
after:content-[''] after:absolute after:top-full after:left-1/2
after:-translate-x-1/2 after:border-4 after:border-transparent
after:border-t-slate-800"
>
{text}
</span>
</span>
);
}
// ─── Empty State ────────────────────────────────────────────────────────────
interface EmptyStateProps {
title: string;
description: string;
action?: ReactNode;
}
export function EmptyState({ title, description, action }: EmptyStateProps) {
return (
<div className="text-center py-12 px-4">
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{title}</p>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">{description}</p>
{action && <div className="mt-4">{action}</div>}
</div>
);
}