whisper-money/resources/js/components/accounts/custom-bank-form.tsx

172 lines
5.7 KiB
TypeScript

import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { __ } from '@/utils/i18n';
import { ImagePlus, X } from 'lucide-react';
import { useCallback, useRef, useState } from 'react';
const MAX_FILE_SIZE = 500 * 1024; // 500KB
const MAX_DIMENSIONS = 500;
export interface CustomBankData {
name: string;
logo: File | null;
logoPreview: string | null;
}
interface CustomBankFormProps {
defaultName?: string;
onCancel: () => void;
onChange: (data: CustomBankData) => void;
value: CustomBankData;
}
export function CustomBankForm({
defaultName = '',
onCancel,
onChange,
value,
}: CustomBankFormProps) {
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const validateImage = useCallback((file: File): Promise<string | null> => {
return new Promise((resolve) => {
if (file.size > MAX_FILE_SIZE) {
resolve(
`File size must be less than ${MAX_FILE_SIZE / 1024}KB`,
);
return;
}
const img = new Image();
const objectUrl = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(objectUrl);
if (img.width !== img.height) {
resolve('Image must be square (same width and height)');
return;
}
if (img.width > MAX_DIMENSIONS) {
resolve(
`Image dimensions must be ${MAX_DIMENSIONS}x${MAX_DIMENSIONS} pixels or smaller`,
);
return;
}
resolve(null);
};
img.onerror = () => {
URL.revokeObjectURL(objectUrl);
resolve('Invalid image file');
};
img.src = objectUrl;
});
}, []);
const handleFileChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
setError(null);
if (!file) {
onChange({ ...value, logo: null, logoPreview: null });
return;
}
const validationError = await validateImage(file);
if (validationError) {
setError(validationError);
e.target.value = '';
return;
}
const preview = URL.createObjectURL(file);
onChange({ ...value, logo: file, logoPreview: preview });
},
[onChange, validateImage, value],
);
const handleNameChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onChange({ ...value, name: e.target.value });
},
[onChange, value],
);
return (
<>
<Button
type="button"
variant="link"
size="sm"
onClick={onCancel}
className="float-right -mr-2 ml-auto cursor-pointer"
>
<X className="size-4" />
</Button>
<div className="mt-1 space-y-2 rounded-sm border border-border p-3">
<div className="space-y-2">
<Label htmlFor="bank_name">{__('Bank name')}</Label>
<Input
id="bank_name"
placeholder={__('Bank name')}
defaultValue={defaultName}
value={value.name}
className="mt-1"
onChange={handleNameChange}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="bank_logo">{__('Logo')}</Label>
<div className="mt-1 flex items-center gap-2">
<Input
ref={fileInputRef}
type="file"
id="bank_logo"
accept="image/png,image/jpeg,image/webp"
onChange={handleFileChange}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="flex aspect-square h-9 w-9 items-center justify-center rounded-lg border border-muted-foreground/25 transition-colors hover:border-muted-foreground/50"
>
{value.logoPreview ? (
<img
src={value.logoPreview}
alt={__('Bank logo preview')}
className="size-5 rounded-full object-contain"
/>
) : (
<ImagePlus className="size-4 text-muted-foreground" />
)}
</button>
</div>
<div className="text-xs text-muted-foreground">
<p>
{__('Square images only (max')}
{MAX_DIMENSIONS}x{MAX_DIMENSIONS}
{__('px) /')}
{MAX_FILE_SIZE / 1024}
{__('KB max.')}
</p>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
</div>
</>
);
}