Refactor UI

This commit is contained in:
Andras Schmelczer 2026-02-04 22:27:56 +00:00
parent ce4c0cc08c
commit 34a4d0ba86
32 changed files with 1726 additions and 845 deletions

View file

@ -0,0 +1,89 @@
import { useCallback } from 'react';
interface CheckboxListProps {
items: string[];
selected: string[] | Set<string>;
onChange: (selected: string[]) => void;
className?: string;
}
export function CheckboxList({ items, selected, onChange, className = '' }: CheckboxListProps) {
const selectedSet = selected instanceof Set ? selected : new Set(selected);
const handleToggle = useCallback(
(item: string) => {
const newSelected = selectedSet.has(item)
? [...selectedSet].filter((v) => v !== item)
: [...selectedSet, item];
onChange(newSelected);
},
[selectedSet, onChange]
);
return (
<div className={`space-y-0.5 ${className}`}>
{items.map((item) => (
<label
key={item}
className="flex items-center gap-1.5 text-sm cursor-pointer dark:text-warm-300"
>
<input
type="checkbox"
checked={selectedSet.has(item)}
onChange={() => handleToggle(item)}
className="rounded accent-teal-600"
/>
{item}
</label>
))}
</div>
);
}
interface CheckboxListWithSetProps {
items: string[];
selected: Set<string>;
onChange: (selected: Set<string>) => void;
className?: string;
itemClassName?: string;
}
export function CheckboxListWithSet({
items,
selected,
onChange,
className = '',
itemClassName = '',
}: CheckboxListWithSetProps) {
const handleToggle = useCallback(
(item: string) => {
const newSet = new Set(selected);
if (newSet.has(item)) {
newSet.delete(item);
} else {
newSet.add(item);
}
onChange(newSet);
},
[selected, onChange]
);
return (
<div className={className}>
{items.map((item) => (
<label
key={item}
className={`flex items-center gap-2 cursor-pointer dark:text-warm-300 ${itemClassName}`}
>
<input
type="checkbox"
checked={selected.has(item)}
onChange={() => handleToggle(item)}
className="rounded accent-teal-600"
/>
<span className="text-sm flex-1">{item}</span>
</label>
))}
</div>
);
}

View file

@ -0,0 +1,31 @@
import type { ReactNode } from 'react';
interface EmptyStateProps {
icon?: ReactNode;
title: string;
description?: string;
className?: string;
}
export function EmptyState({ icon, title, description, className = '' }: EmptyStateProps) {
return (
<div
className={`flex flex-col items-center justify-center py-8 text-center ${className}`}
>
{icon && <div className="mb-2">{icon}</div>}
<span className="text-sm font-medium text-warm-400 dark:text-warm-500">{title}</span>
{description && (
<span className="text-xs text-warm-400 dark:text-warm-500 mt-1">{description}</span>
)}
</div>
);
}
// Centered message variant for panes
export function PaneEmptyState({ message }: { message: string }) {
return (
<div className="flex items-center justify-center h-full text-warm-500 dark:text-warm-400 px-4 text-center text-sm">
{message}
</div>
);
}

View file

@ -0,0 +1,22 @@
import type { ReactNode, MouseEvent } from 'react';
interface IconButtonProps {
onClick: (e: MouseEvent<HTMLButtonElement>) => void;
title?: string;
children: ReactNode;
active?: boolean;
className?: string;
}
export function IconButton({ onClick, title, children, active, className }: IconButtonProps) {
const baseClasses = 'p-0.5 rounded';
const colorClasses = active
? 'text-teal-600 dark:text-teal-400'
: 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300';
return (
<button onClick={onClick} title={title} className={`${baseClasses} ${colorClasses} ${className || ''}`}>
{children}
</button>
);
}

View file

@ -0,0 +1,92 @@
// Shared icon components with consistent sizing and styling
interface IconProps {
className?: string;
}
export function CloseIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
);
}
export function InfoIcon({ className = 'w-3.5 h-3.5' }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<circle cx="12" cy="12" r="10" />
<path strokeLinecap="round" d="M12 16v-4m0-4h.01" />
</svg>
);
}
export function EyeIcon({ filled, className = 'w-3.5 h-3.5' }: IconProps & { filled: boolean }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill={filled ? 'currentColor' : 'none'}
stroke="currentColor"
strokeWidth={2}
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
}
export function PlusIcon({ className = 'w-3.5 h-3.5' }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14m-7-7h14" />
</svg>
);
}
export function ChevronIcon({
direction,
className = 'w-4 h-4',
}: IconProps & { direction: 'left' | 'right' | 'up' | 'down' }) {
const paths: Record<string, string> = {
left: 'M15 19l-7-7 7-7',
right: 'M9 5l7 7-7 7',
up: 'M18 15l-6-6-6 6',
down: 'M6 9l6 6 6-6',
};
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d={paths[direction]} />
</svg>
);
}
export function FilterIcon({ className = 'w-8 h-8' }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z"
/>
</svg>
);
}
export function LightbulbIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
/>
</svg>
);
}

View file

@ -0,0 +1,35 @@
import type { ReactNode } from 'react';
import { CloseIcon, InfoIcon } from './Icons';
import { IconButton } from './IconButton';
interface PaneHeaderProps {
title: string;
subtitle?: ReactNode;
onClose?: () => void;
onInfoClick?: () => void;
children?: ReactNode;
}
export function PaneHeader({ title, subtitle, onClose, onInfoClick, children }: PaneHeaderProps) {
return (
<div className="p-3 border-b border-warm-200 dark:border-navy-700">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold dark:text-warm-100">{title}</h2>
{onInfoClick && (
<IconButton onClick={onInfoClick} title="Data source info">
<InfoIcon />
</IconButton>
)}
</div>
{onClose && (
<IconButton onClick={onClose} title="Close">
<CloseIcon />
</IconButton>
)}
</div>
{subtitle && <div className="text-sm text-warm-600 dark:text-warm-400 mt-1">{subtitle}</div>}
{children}
</div>
);
}

View file

@ -0,0 +1,23 @@
interface SearchInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
}
export function SearchInput({
value,
onChange,
placeholder = 'Search...',
className = '',
}: SearchInputProps) {
return (
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={`w-full px-2 py-1 text-sm border rounded bg-white dark:bg-navy-800 dark:text-warm-200 border-warm-200 dark:border-navy-700 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-1 focus:ring-teal-400 ${className}`}
/>
);
}

View file

@ -0,0 +1,24 @@
interface SelectionButtonsProps {
onSelectAll: () => void;
onSelectNone: () => void;
className?: string;
}
export function SelectionButtons({ onSelectAll, onSelectNone, className = '' }: SelectionButtonsProps) {
return (
<div className={`flex gap-2 text-sm ${className}`}>
<button
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline"
onClick={onSelectAll}
>
All
</button>
<button
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline"
onClick={onSelectNone}
>
None
</button>
</div>
);
}

View file

@ -0,0 +1,22 @@
interface TabButtonProps {
label: string;
count?: number;
isActive: boolean;
onClick: () => void;
}
export function TabButton({ label, count, isActive, onClick }: TabButtonProps) {
return (
<button
className={`flex-1 p-3 ${
isActive
? 'border-b-2 border-teal-500 font-semibold dark:text-warm-100'
: 'text-warm-600 dark:text-warm-400'
}`}
onClick={onClick}
>
{label}
{count !== undefined && count > 0 && ` (${count})`}
</button>
);
}