Refactor UI
This commit is contained in:
parent
ce4c0cc08c
commit
34a4d0ba86
32 changed files with 1726 additions and 845 deletions
89
frontend/src/components/ui/CheckboxList.tsx
Normal file
89
frontend/src/components/ui/CheckboxList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
frontend/src/components/ui/EmptyState.tsx
Normal file
31
frontend/src/components/ui/EmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
frontend/src/components/ui/IconButton.tsx
Normal file
22
frontend/src/components/ui/IconButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
frontend/src/components/ui/Icons.tsx
Normal file
92
frontend/src/components/ui/Icons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
frontend/src/components/ui/PaneHeader.tsx
Normal file
35
frontend/src/components/ui/PaneHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
frontend/src/components/ui/SearchInput.tsx
Normal file
23
frontend/src/components/ui/SearchInput.tsx
Normal 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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
24
frontend/src/components/ui/SelectionButtons.tsx
Normal file
24
frontend/src/components/ui/SelectionButtons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
frontend/src/components/ui/TabButton.tsx
Normal file
22
frontend/src/components/ui/TabButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue