| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384 |
- import { useState, useRef, useEffect, useCallback } from 'react';
- import { Tabs, TabsContent, TabsList, TabsTrigger } from './tabs';
- import { Button } from './button';
- import { Input } from './input';
- import { Label } from './label';
- import { Alert, AlertDescription } from './alert';
- import {
- Upload,
- Link as LinkIcon,
- X,
- Loader2,
- CheckCircle,
- AlertCircle,
- } from 'lucide-react';
- import {
- validateImageInput,
- validateImageUrlWithBase64,
- getImageDisplayName,
- fileToDataURL,
- type ImageValidationResult
- } from '../../lib/image-validation';
- export interface ImageInputProps {
- value?: File | string | null;
- onChange: (file: File | string | null) => void;
- onValidation?: (result: ImageValidationResult) => void;
- disabled?: boolean;
- className?: string;
- maxSize?: number; // in bytes, default 10MB
- accept?: string; // file accept attribute
- placeholder?: string;
- showPreview?: boolean;
- previewClassName?: string;
- }
- export interface ImageInputState {
- mode: 'file' | 'url';
- validation: ImageValidationResult | null;
- isValidating: boolean;
- error: string | null;
- previewUrl: string | null;
- }
- export function ImageInput({
- value,
- onChange,
- onValidation,
- disabled = false,
- className = '',
- maxSize = 10 * 1024 * 1024, // 10MB
- accept = 'image/*',
- placeholder = 'Enter image URL or select a file',
- showPreview = true,
- previewClassName = ''
- }: ImageInputProps) {
- const [state, setState] = useState<ImageInputState>({
- mode: 'file',
- validation: null,
- isValidating: false,
- error: null,
- previewUrl: null
- });
- const [urlInput, setUrlInput] = useState('');
- const fileInputRef = useRef<HTMLInputElement>(null);
- const validationTimeoutRef = useRef<NodeJS.Timeout | null>(null);
- const handleFileValidation = useCallback(async (file: File) => {
- setState(prev => ({ ...prev, isValidating: true, error: null }));
-
- const result = await validateImageInput(file);
-
- let previewUrl: string | null = null;
- if (result.isValid) {
- try {
- previewUrl = await fileToDataURL(file);
- } catch (error) {
- console.error('Failed to create preview URL:', error);
- }
- }
-
- setState(prev => ({
- ...prev,
- isValidating: false,
- validation: result,
- error: result.isValid ? null : (result.error || null),
- previewUrl
- }));
- onValidation?.(result);
- }, [onValidation]);
- const handleUrlValidation = useCallback(async (url: string | null) => {
- if (!url || !url.trim()) {
- setState(prev => ({
- ...prev,
- validation: null,
- error: null,
- previewUrl: null
- }));
- onValidation?.({ isValid: false, error: 'Please enter a URL' });
- return;
- }
- setState(prev => ({ ...prev, isValidating: true, error: null }));
-
- try {
- const result = await validateImageUrlWithBase64(url);
-
- // Use temporary URL for preview if available, otherwise fall back to base64 data or original URL
- const previewUrl = result.isValid ? (result.tempUrl || result.base64Data || url) : null;
-
- setState(prev => ({
- ...prev,
- isValidating: false,
- validation: result,
- error: result.isValid ? null : (result.error || null),
- previewUrl
- }));
- onValidation?.(result);
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : 'Failed to validate URL';
- setState(prev => ({
- ...prev,
- isValidating: false,
- validation: { isValid: false, error: errorMessage },
- error: errorMessage,
- previewUrl: null
- }));
- onValidation?.({ isValid: false, error: errorMessage });
- }
- }, [onValidation]);
- // Handle external value changes
- useEffect(() => {
- if (value === null) {
- setState(prev => ({
- ...prev,
- validation: null,
- error: null,
- previewUrl: null
- }));
- setUrlInput('');
- return;
- }
- if (value instanceof File) {
- // File mode
- setState(prev => ({
- ...prev,
- mode: 'file',
- validation: null,
- error: null,
- previewUrl: null
- }));
- setUrlInput('');
- // Validate file immediately
- handleFileValidation(value);
- } else {
- // URL mode - value is a string (not File, not null)
- // Don't set preview URL yet, wait for validation to complete
- setState(prev => ({
- ...prev,
- mode: 'url',
- validation: null,
- error: null,
- previewUrl: null
- }));
- // value should be a string here, but cast it to be safe
- setUrlInput(value || '');
- // Validate URL (with debounce)
- if (validationTimeoutRef.current) {
- clearTimeout(validationTimeoutRef.current);
- }
- validationTimeoutRef.current = setTimeout(() => {
- // Only validate if we have a non-empty string
- const urlValue = typeof value === 'string' ? value : null;
- if (urlValue && urlValue.trim()) {
- handleUrlValidation(urlValue);
- } else {
- handleUrlValidation(null);
- }
- }, 500);
- }
- }, [value, maxSize, handleFileValidation, handleUrlValidation]);
- // Cleanup timeout on unmount
- useEffect(() => {
- return () => {
- if (validationTimeoutRef.current) {
- clearTimeout(validationTimeoutRef.current);
- }
- };
- }, []);
- const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
- const file = event.target.files?.[0];
- if (file) {
- onChange(file);
- }
- };
- const handleUrlInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
- const url = event.target.value;
- setUrlInput(url);
- onChange(url || null);
- };
- const handleModeChange = (mode: 'file' | 'url') => {
- setState(prev => ({
- ...prev,
- mode,
- validation: null,
- error: null,
- previewUrl: null
- }));
- onChange(null);
- setUrlInput('');
- };
- const handleClear = () => {
- onChange(null);
- setUrlInput('');
- setState(prev => ({
- ...prev,
- validation: null,
- error: null,
- previewUrl: null
- }));
- };
- const isValid = state.validation?.isValid;
- const hasError = state.error && !isValid;
- const canPreview = isValid && state.previewUrl;
- const isCorsBlocked = state.validation?.isCorsBlocked;
- const hasBase64Data = !!state.validation?.base64Data;
- return (
- <div className={`image-input ${className}`}>
- <Tabs value={state.mode} onValueChange={(value: string) => handleModeChange(value as 'file' | 'url')}>
- <TabsList className="grid w-full grid-cols-2">
- <TabsTrigger value="file" disabled={disabled}>
- <Upload className="w-4 h-4 mr-2" />
- Upload File
- </TabsTrigger>
- <TabsTrigger value="url" disabled={disabled}>
- <LinkIcon className="w-4 h-4 mr-2" />
- From URL
- </TabsTrigger>
- </TabsList>
- <TabsContent value="file" className="space-y-4">
- <div className="space-y-2">
- <Label htmlFor="file-upload">Choose Image File</Label>
- <div className="flex gap-2">
- <Input
- ref={fileInputRef}
- id="file-upload"
- type="file"
- accept={accept}
- onChange={handleFileSelect}
- disabled={disabled}
- className="flex-1"
- />
- {value instanceof File && (
- <Button
- type="button"
- variant="outline"
- onClick={() => fileInputRef.current?.click()}
- disabled={disabled}
- >
- Browse
- </Button>
- )}
- </div>
- {typeof value === 'string' && value && (
- <p className="text-sm text-gray-500">
- Current: {getImageDisplayName(value)}
- </p>
- )}
- </div>
- </TabsContent>
- <TabsContent value="url" className="space-y-4">
- <div className="space-y-2">
- <Label htmlFor="url-input">Image URL</Label>
- <Input
- id="url-input"
- type="url"
- value={urlInput}
- onChange={handleUrlInputChange}
- placeholder={placeholder}
- disabled={disabled}
- className="flex-1"
- />
- <p className="text-xs text-gray-500">
- Enter a URL that ends with an image extension (.jpg, .png, .gif, etc.)
- </p>
- </div>
- </TabsContent>
- </Tabs>
- {/* Validation Status */}
- {state.isValidating && (
- <div className="flex items-center gap-2 text-sm text-gray-500">
- <Loader2 className="w-4 h-4 animate-spin" />
- Validating and downloading image...
- </div>
- )}
- {isValid && (
- <Alert className="mt-4">
- <CheckCircle className="h-4 w-4" />
- <AlertDescription>
- Image is valid and ready to use
- {state.validation?.filename && ` (${state.validation.filename})`}
- {hasBase64Data && (
- <span className="block mt-1 text-xs text-green-600">
- ✓ Image downloaded and cached for preview
- </span>
- )}
- {isCorsBlocked && (
- <span className="block mt-1 text-xs text-yellow-600">
- Note: Downloaded using fallback method due to CORS restrictions
- </span>
- )}
- </AlertDescription>
- </Alert>
- )}
- {hasError && (
- <Alert variant="destructive" className="mt-4">
- <AlertCircle className="h-4 w-4" />
- <AlertDescription>
- {state.error}
- {state.error?.includes('CORS') && (
- <span className="block mt-1 text-xs">
- Try using a different image URL or upload the file directly
- </span>
- )}
- </AlertDescription>
- </Alert>
- )}
- {/* Image Preview */}
- {showPreview && canPreview && (
- <div className={`mt-4 ${previewClassName}`}>
- <Label>Preview</Label>
- <div className="mt-2 border rounded-lg p-4 bg-gray-50">
- <div className="flex items-center justify-center h-48 bg-white rounded border">
- <img
- src={state.previewUrl || ''}
- alt="Image preview"
- className="max-w-full max-h-full object-contain"
- />
- </div>
- </div>
- </div>
- )}
- {/* Clear Button */}
- {value && (
- <div className="mt-4 flex justify-end">
- <Button
- type="button"
- variant="outline"
- onClick={handleClear}
- disabled={disabled}
- className="text-red-600 hover:text-red-700"
- >
- <X className="w-4 h-4 mr-2" />
- Clear Selection
- </Button>
- </div>
- )}
- </div>
- );
- }
- export default ImageInput;
|