Files
pdf/frontend/src/components/ui.tsx
T

190 lines
6.5 KiB
TypeScript

import React, { useEffect, useRef, useState } from 'react';
import { XIcon } from './icons';
import { CustomButton } from './custom/CustomButton';
interface PopoverProps {
trigger: (open: boolean) => React.ReactNode;
children: React.ReactNode;
align?: 'left' | 'right';
width?: number;
}
export const Popover: React.FC<PopoverProps> = ({ trigger, children, align = 'left', width }) => {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false);
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [open]);
return (
<div className="relative" ref={ref}>
<div onClick={() => setOpen((o) => !o)}>{trigger(open)}</div>
{open && (
<div
style={{ width }}
className={`absolute top-[calc(100%+6px)] z-50 rounded-[12px] border border-[#ebedf0] bg-[#ffffff] p-2.5 shadow-[0_12px_32px_rgba(16,24,40,0.16)] ${
align === 'right' ? 'right-0' : 'left-0'
}`}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
)}
</div>
);
};
interface ColorSwatchesProps {
value: string;
onChange: (color: string) => void;
palette?: string[];
}
const DEFAULT_PALETTE = ['#facc15', '#fb923c', '#f87171', '#f472b6', '#a78bfa', '#60a5fa', '#34d399', '#1f2937', '#ffffff'];
export const ColorSwatches: React.FC<ColorSwatchesProps> = ({ value, onChange, palette = DEFAULT_PALETTE }) => (
<div className="flex items-center gap-1.5">
{palette.map((c) => (
<CustomButton
variant="unstyled"
key={c}
title={c}
onClick={() => onChange(c)}
className={`h-5 w-5 rounded-full border transition-transform hover:scale-110 ${
value.toLowerCase() === c.toLowerCase() ? 'ring-2 ring-[#2563eb] ring-offset-1' : 'border-[#dadde2]'
}`}
style={{ background: c }}
/>
))}
<label className="relative h-5 w-5 cursor-pointer overflow-hidden rounded-full border border-[#dadde2]"
title="Custom color"
style={{ background: 'conic-gradient(from 0deg, #f87171, #facc15, #34d399, #60a5fa, #a78bfa, #f87171)' }}>
<input type="color" value={value} onChange={(e) => onChange(e.target.value)} className="absolute inset-0 cursor-pointer opacity-0" />
</label>
</div>
);
interface SliderProps {
value: number;
min: number;
max: number;
step?: number;
onChange: (v: number) => void;
label?: string;
suffix?: string;
width?: number;
}
export const Slider: React.FC<SliderProps> = ({ value, min, max, step = 1, onChange, label, suffix = '', width = 110 }) => (
<div className="flex items-center gap-2">
{label && <span className="text-[11px] font-medium text-[#5b6573]">{label}</span>}
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(parseFloat(e.target.value))}
style={{ width }}
className="accent-[#2563eb]"
/>
<span className="w-9 text-right text-[11px] font-semibold tabular-nums text-[#18212e]">{value}{suffix}</span>
</div>
);
interface EmptyStateProps {
icon?: React.ReactNode;
title: string;
hint?: string;
badge?: string;
}
export const EmptyState: React.FC<EmptyStateProps> = ({ icon, title, hint, badge }) => (
<div className="flex flex-col items-center justify-center gap-2.5 px-6 py-14 text-center">
{icon && (
<div className="mb-1 flex h-14 w-14 items-center justify-center rounded-2xl bg-[#edeff2] text-[#98a1ad]">
{icon}
</div>
)}
{badge && (
<span className="rounded-full bg-[#eef4ff] px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-[#2563eb]">
{badge}
</span>
)}
<p className="text-[13.5px] font-semibold text-[#18212e]">{title}</p>
{hint && <p className="max-w-[210px] text-[11.5px] leading-relaxed text-[#98a1ad]">{hint}</p>}
</div>
);
interface ModalProps {
open: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
width?: number;
footer?: React.ReactNode;
}
export const Modal: React.FC<ModalProps> = ({ open, onClose, title, children, width = 460, footer }) => {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/35 p-4" onMouseDown={onClose}>
<div
style={{ width }}
className="max-h-[88vh] overflow-hidden rounded-[12px] border border-[#ebedf0] bg-[#ffffff] shadow-[0_12px_32px_rgba(16,24,40,0.16)]"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-[#ebedf0] px-6 py-4">
<h2 className="text-[15px] font-bold text-[#18212e]">{title}</h2>
<CustomButton variant="icon" label="Close" size={30} onClick={onClose}><XIcon size={18} /></CustomButton>
</div>
<div className="overflow-y-auto p-6">{children}</div>
{footer && <div className="flex justify-end gap-2.5 border-t border-[#ebedf0] bg-[#f6f7f9] px-6 py-4">{footer}</div>}
</div>
</div>
);
};
export interface ConfirmOptions {
title: string;
message: string;
confirmLabel?: string;
danger?: boolean;
}
interface ConfirmDialogProps {
state: (ConfirmOptions & { onConfirm: () => void }) | null;
onClose: () => void;
}
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({ state, onClose }) => (
<Modal
open={!!state}
onClose={onClose}
title={state?.title ?? ''}
width={420}
footer={
state && (
<>
<CustomButton variant="outline" onClick={onClose}>Cancel</CustomButton>
<CustomButton
variant={state.danger ? 'danger' : 'primary'}
onClick={() => { state.onConfirm(); onClose(); }}
>
{state.confirmLabel ?? 'Confirm'}
</CustomButton>
</>
)
}
>
<p className="text-[13px] leading-relaxed text-[#5b6573]">{state?.message}</p>
</Modal>
);