Add reusable UI components: ConfirmDialog, EditableSlider, Vite type declarations
- ConfirmDialog: modal confirm with Escape/backdrop dismiss, danger styling, i18n labels - EditableSlider: click-to-edit numeric slider with clamping and keyboard support - vite-env.d.ts: type declarations for .txt raw imports - Fixed missing title prop in EditableSlider interface from PR
This commit is contained in:
@@ -0,0 +1,90 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
import { useI18n } from '../context/I18nContext';
|
||||||
|
|
||||||
|
interface ConfirmDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
danger?: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||||
|
isOpen,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmLabel,
|
||||||
|
cancelLabel,
|
||||||
|
danger = true,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onCancel();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleEscape);
|
||||||
|
return () => document.removeEventListener('keydown', handleEscape);
|
||||||
|
}, [isOpen, onCancel]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm animate-in fade-in duration-150"
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
className="bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl border border-zinc-200 dark:border-white/10 w-full max-w-sm mx-4 p-6 animate-in zoom-in-95 fade-in duration-200"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3 mb-4">
|
||||||
|
{danger && (
|
||||||
|
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-red-500/10 flex items-center justify-center">
|
||||||
|
<AlertTriangle size={20} className="text-red-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-base font-semibold text-zinc-900 dark:text-white">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400 leading-relaxed">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 justify-end mt-6">
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-4 py-2 text-sm font-medium rounded-lg
|
||||||
|
text-zinc-700 dark:text-zinc-300
|
||||||
|
bg-zinc-100 dark:bg-zinc-800
|
||||||
|
hover:bg-zinc-200 dark:hover:bg-zinc-700
|
||||||
|
transition-colors"
|
||||||
|
>
|
||||||
|
{cancelLabel || t('cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors
|
||||||
|
${danger
|
||||||
|
? 'bg-red-600 hover:bg-red-700 text-white'
|
||||||
|
: 'bg-blue-600 hover:bg-blue-700 text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{confirmLabel || t('delete')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface EditableSliderProps {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
step: number;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
formatDisplay?: (value: number) => string;
|
||||||
|
helpText?: string;
|
||||||
|
title?: string;
|
||||||
|
autoLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditableSlider: React.FC<EditableSliderProps> = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
step,
|
||||||
|
onChange,
|
||||||
|
formatDisplay,
|
||||||
|
helpText,
|
||||||
|
title = '',
|
||||||
|
autoLabel = 'Auto',
|
||||||
|
}) => {
|
||||||
|
const [inputValue, setInputValue] = useState(value.toString());
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEditing) {
|
||||||
|
setInputValue(value.toString());
|
||||||
|
}
|
||||||
|
}, [value, isEditing]);
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setInputValue(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputBlur = () => {
|
||||||
|
setIsEditing(false);
|
||||||
|
const numValue = parseFloat(inputValue);
|
||||||
|
if (!isNaN(numValue)) {
|
||||||
|
const clampedValue = Math.max(min, Math.min(max, numValue));
|
||||||
|
onChange(clampedValue);
|
||||||
|
setInputValue(clampedValue.toString());
|
||||||
|
} else {
|
||||||
|
setInputValue(value.toString());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleInputBlur();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setInputValue(value.toString());
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayValue = formatDisplay ? formatDisplay(value) : (value === min && autoLabel ? autoLabel : value.toString());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400" title={title}>{label}</label>
|
||||||
|
{isEditing ? (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onBlur={handleInputBlur}
|
||||||
|
onKeyDown={handleInputKeyDown}
|
||||||
|
onFocus={() => setIsEditing(true)}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
autoFocus
|
||||||
|
className="text-xs font-mono text-zinc-900 dark:text-white bg-zinc-100 dark:bg-black/20 px-2 py-0.5 rounded w-20 text-right border border-pink-500 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
className="text-xs font-mono text-zinc-900 dark:text-white bg-zinc-100 dark:bg-black/20 px-2 py-0.5 rounded cursor-pointer hover:bg-zinc-200 dark:hover:bg-black/30 transition-colors"
|
||||||
|
>
|
||||||
|
{displayValue}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
className="w-full h-2 bg-zinc-200 dark:bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-pink-500"
|
||||||
|
/>
|
||||||
|
{helpText && (
|
||||||
|
<p className="text-[10px] text-zinc-500">{helpText}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Vendored
+12
@@ -0,0 +1,12 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
// Declare .txt file imports as raw text
|
||||||
|
declare module '*.txt' {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.txt?raw' {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user