image-input.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. import React, { useState, useRef, useEffect } from 'react';
  2. import { Tabs, TabsContent, TabsList, TabsTrigger } from './tabs';
  3. import { Button } from './button';
  4. import { Input } from './input';
  5. import { Label } from './label';
  6. import { Alert, AlertDescription } from './alert';
  7. import {
  8. Upload,
  9. Link as LinkIcon,
  10. X,
  11. Loader2,
  12. CheckCircle,
  13. AlertCircle,
  14. Image as ImageIcon,
  15. Info
  16. } from 'lucide-react';
  17. import {
  18. validateImageInput,
  19. validateImageUrlWithBase64,
  20. getImageDisplayName,
  21. fileToDataURL,
  22. type ImageValidationResult
  23. } from '../../lib/image-validation';
  24. export interface ImageInputProps {
  25. value?: File | string | null;
  26. onChange: (file: File | string | null) => void;
  27. onValidation?: (result: ImageValidationResult) => void;
  28. disabled?: boolean;
  29. className?: string;
  30. maxSize?: number; // in bytes, default 10MB
  31. accept?: string; // file accept attribute
  32. placeholder?: string;
  33. showPreview?: boolean;
  34. previewClassName?: string;
  35. }
  36. export interface ImageInputState {
  37. mode: 'file' | 'url';
  38. validation: ImageValidationResult | null;
  39. isValidating: boolean;
  40. error: string | null;
  41. previewUrl: string | null;
  42. }
  43. export function ImageInput({
  44. value,
  45. onChange,
  46. onValidation,
  47. disabled = false,
  48. className = '',
  49. maxSize = 10 * 1024 * 1024, // 10MB
  50. accept = 'image/*',
  51. placeholder = 'Enter image URL or select a file',
  52. showPreview = true,
  53. previewClassName = ''
  54. }: ImageInputProps) {
  55. const [state, setState] = useState<ImageInputState>({
  56. mode: 'file',
  57. validation: null,
  58. isValidating: false,
  59. error: null,
  60. previewUrl: null
  61. });
  62. const [urlInput, setUrlInput] = useState('');
  63. const fileInputRef = useRef<HTMLInputElement>(null);
  64. const validationTimeoutRef = useRef<NodeJS.Timeout | null>(null);
  65. // Handle external value changes
  66. useEffect(() => {
  67. if (value === null) {
  68. setState(prev => ({
  69. ...prev,
  70. validation: null,
  71. error: null,
  72. previewUrl: null
  73. }));
  74. setUrlInput('');
  75. return;
  76. }
  77. if (value instanceof File) {
  78. // File mode
  79. setState(prev => ({
  80. ...prev,
  81. mode: 'file',
  82. validation: null,
  83. error: null,
  84. previewUrl: null
  85. }));
  86. setUrlInput('');
  87. // Validate file immediately
  88. handleFileValidation(value);
  89. } else {
  90. // URL mode - value is a string (not File, not null)
  91. // Don't set preview URL yet, wait for validation to complete
  92. setState(prev => ({
  93. ...prev,
  94. mode: 'url',
  95. validation: null,
  96. error: null,
  97. previewUrl: null
  98. }));
  99. // value should be a string here, but cast it to be safe
  100. setUrlInput(value || '');
  101. // Validate URL (with debounce)
  102. if (validationTimeoutRef.current) {
  103. clearTimeout(validationTimeoutRef.current);
  104. }
  105. validationTimeoutRef.current = setTimeout(() => {
  106. // Only validate if we have a non-empty string
  107. const urlValue = typeof value === 'string' ? value : null;
  108. if (urlValue && urlValue.trim()) {
  109. handleUrlValidation(urlValue);
  110. } else {
  111. handleUrlValidation(null);
  112. }
  113. }, 500);
  114. }
  115. }, [value, maxSize]);
  116. // Cleanup timeout on unmount
  117. useEffect(() => {
  118. return () => {
  119. if (validationTimeoutRef.current) {
  120. clearTimeout(validationTimeoutRef.current);
  121. }
  122. };
  123. }, []);
  124. const handleFileValidation = async (file: File) => {
  125. setState(prev => ({ ...prev, isValidating: true, error: null }));
  126. const result = await validateImageInput(file);
  127. let previewUrl: string | null = null;
  128. if (result.isValid) {
  129. try {
  130. previewUrl = await fileToDataURL(file);
  131. } catch (error) {
  132. console.error('Failed to create preview URL:', error);
  133. }
  134. }
  135. setState(prev => ({
  136. ...prev,
  137. isValidating: false,
  138. validation: result,
  139. error: result.isValid ? null : (result.error || null),
  140. previewUrl
  141. }));
  142. onValidation?.(result);
  143. };
  144. const handleUrlValidation = async (url: string | null) => {
  145. if (!url || !url.trim()) {
  146. setState(prev => ({
  147. ...prev,
  148. validation: null,
  149. error: null,
  150. previewUrl: null
  151. }));
  152. onValidation?.({ isValid: false, error: 'Please enter a URL' });
  153. return;
  154. }
  155. setState(prev => ({ ...prev, isValidating: true, error: null }));
  156. try {
  157. const result = await validateImageUrlWithBase64(url);
  158. // Use base64 data for preview if available, otherwise use original URL
  159. const previewUrl = result.isValid ? (result.base64Data || url) : null;
  160. setState(prev => ({
  161. ...prev,
  162. isValidating: false,
  163. validation: result,
  164. error: result.isValid ? null : (result.error || null),
  165. previewUrl
  166. }));
  167. onValidation?.(result);
  168. } catch (error) {
  169. const errorMessage = error instanceof Error ? error.message : 'Failed to validate URL';
  170. setState(prev => ({
  171. ...prev,
  172. isValidating: false,
  173. validation: { isValid: false, error: errorMessage },
  174. error: errorMessage,
  175. previewUrl: null
  176. }));
  177. onValidation?.({ isValid: false, error: errorMessage });
  178. }
  179. };
  180. const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
  181. const file = event.target.files?.[0];
  182. if (file) {
  183. onChange(file);
  184. }
  185. };
  186. const handleUrlInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
  187. const url = event.target.value;
  188. setUrlInput(url);
  189. onChange(url || null);
  190. };
  191. const handleModeChange = (mode: 'file' | 'url') => {
  192. setState(prev => ({
  193. ...prev,
  194. mode,
  195. validation: null,
  196. error: null,
  197. previewUrl: null
  198. }));
  199. onChange(null);
  200. setUrlInput('');
  201. };
  202. const handleClear = () => {
  203. onChange(null);
  204. setUrlInput('');
  205. setState(prev => ({
  206. ...prev,
  207. validation: null,
  208. error: null,
  209. previewUrl: null
  210. }));
  211. };
  212. const isValid = state.validation?.isValid;
  213. const hasError = state.error && !isValid;
  214. const canPreview = isValid && state.previewUrl;
  215. const isCorsBlocked = state.validation?.isCorsBlocked;
  216. const hasBase64Data = !!state.validation?.base64Data;
  217. return (
  218. <div className={`image-input ${className}`}>
  219. <Tabs value={state.mode} onValueChange={(value) => handleModeChange(value as 'file' | 'url')}>
  220. <TabsList className="grid w-full grid-cols-2">
  221. <TabsTrigger value="file" disabled={disabled}>
  222. <Upload className="w-4 h-4 mr-2" />
  223. Upload File
  224. </TabsTrigger>
  225. <TabsTrigger value="url" disabled={disabled}>
  226. <LinkIcon className="w-4 h-4 mr-2" />
  227. From URL
  228. </TabsTrigger>
  229. </TabsList>
  230. <TabsContent value="file" className="space-y-4">
  231. <div className="space-y-2">
  232. <Label htmlFor="file-upload">Choose Image File</Label>
  233. <div className="flex gap-2">
  234. <Input
  235. ref={fileInputRef}
  236. id="file-upload"
  237. type="file"
  238. accept={accept}
  239. onChange={handleFileSelect}
  240. disabled={disabled}
  241. className="flex-1"
  242. />
  243. {value instanceof File && (
  244. <Button
  245. type="button"
  246. variant="outline"
  247. onClick={() => fileInputRef.current?.click()}
  248. disabled={disabled}
  249. >
  250. Browse
  251. </Button>
  252. )}
  253. </div>
  254. {typeof value === 'string' && value && (
  255. <p className="text-sm text-gray-500">
  256. Current: {getImageDisplayName(value)}
  257. </p>
  258. )}
  259. </div>
  260. </TabsContent>
  261. <TabsContent value="url" className="space-y-4">
  262. <div className="space-y-2">
  263. <Label htmlFor="url-input">Image URL</Label>
  264. <Input
  265. id="url-input"
  266. type="url"
  267. value={urlInput}
  268. onChange={handleUrlInputChange}
  269. placeholder={placeholder}
  270. disabled={disabled}
  271. className="flex-1"
  272. />
  273. <p className="text-xs text-gray-500">
  274. Enter a URL that ends with an image extension (.jpg, .png, .gif, etc.)
  275. </p>
  276. </div>
  277. </TabsContent>
  278. </Tabs>
  279. {/* Validation Status */}
  280. {state.isValidating && (
  281. <div className="flex items-center gap-2 text-sm text-gray-500">
  282. <Loader2 className="w-4 h-4 animate-spin" />
  283. Validating and downloading image...
  284. </div>
  285. )}
  286. {isValid && (
  287. <Alert className="mt-4">
  288. <CheckCircle className="h-4 w-4" />
  289. <AlertDescription>
  290. Image is valid and ready to use
  291. {state.validation?.filename && ` (${state.validation.filename})`}
  292. {hasBase64Data && (
  293. <span className="block mt-1 text-xs text-green-600">
  294. ✓ Image downloaded and cached for preview
  295. </span>
  296. )}
  297. {isCorsBlocked && (
  298. <span className="block mt-1 text-xs text-yellow-600">
  299. Note: Downloaded using fallback method due to CORS restrictions
  300. </span>
  301. )}
  302. </AlertDescription>
  303. </Alert>
  304. )}
  305. {hasError && (
  306. <Alert variant="destructive" className="mt-4">
  307. <AlertCircle className="h-4 w-4" />
  308. <AlertDescription>
  309. {state.error}
  310. {state.error?.includes('CORS') && (
  311. <span className="block mt-1 text-xs">
  312. Try using a different image URL or upload the file directly
  313. </span>
  314. )}
  315. </AlertDescription>
  316. </Alert>
  317. )}
  318. {/* Image Preview */}
  319. {showPreview && canPreview && (
  320. <div className={`mt-4 ${previewClassName}`}>
  321. <Label>Preview</Label>
  322. <div className="mt-2 border rounded-lg p-4 bg-gray-50">
  323. <div className="flex items-center justify-center h-48 bg-white rounded border">
  324. <img
  325. src={state.previewUrl || ''}
  326. alt="Image preview"
  327. className="max-w-full max-h-full object-contain"
  328. />
  329. </div>
  330. </div>
  331. </div>
  332. )}
  333. {/* Clear Button */}
  334. {value && (
  335. <div className="mt-4 flex justify-end">
  336. <Button
  337. type="button"
  338. variant="outline"
  339. onClick={handleClear}
  340. disabled={disabled}
  341. className="text-red-600 hover:text-red-700"
  342. >
  343. <X className="w-4 h-4 mr-2" />
  344. Clear Selection
  345. </Button>
  346. </div>
  347. )}
  348. </div>
  349. );
  350. }
  351. export default ImageInput;