/** * Image Tools Page - Upload, filter, and download images * Supports chaining multiple filters with dynamic parameter schemas */ const ImageToolsPage = () => { const { Box, Typography, Card, CardContent, Button, Select, MenuItem, FormControl, InputLabel, Slider, CircularProgress, Alert, Chip, Paper, Checkbox, FormControlLabel } = MaterialUI; // State const [availableFilters, setAvailableFilters] = React.useState([]); const [filtersLoading, setFiltersLoading] = React.useState(true); const [selectedFile, setSelectedFile] = React.useState(null); const [previewUrl, setPreviewUrl] = React.useState(null); const [resultUrl, setResultUrl] = React.useState(null); const [selectedFilter, setSelectedFilter] = React.useState(''); const [pendingParams, setPendingParams] = React.useState({}); const [filterChain, setFilterChain] = React.useState([]); const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState(null); // Fetch filter definitions from API on mount React.useEffect(() => { const fetchFilters = async () => { try { const response = await fetch('/image/filters'); if (!response.ok) throw new Error('Failed to fetch filters'); const filters = await response.json(); setAvailableFilters(filters); if (filters.length > 0) { setSelectedFilter(filters[0].id); setPendingParams(getDefaultParams(filters[0])); } } catch (err) { setError('Failed to load filters: ' + err.message); } finally { setFiltersLoading(false); } }; fetchFilters(); }, []); // Get current filter config from dropdown const currentFilterConfig = availableFilters.find(f => f.id === selectedFilter); // Get default params for a filter const getDefaultParams = (filterDef) => { const defaults = {}; (filterDef?.parameters || []).forEach(p => { defaults[p.id] = p.default; }); return defaults; }; // Check if a parameter should be visible based on showWhen condition const isParamVisible = (param, currentParams) => { if (!param.showWhen) return true; const { param: dependsOn, values } = param.showWhen; return values.includes(currentParams[dependsOn]); }; // Handle filter selection change const handleFilterChange = (newFilterId) => { setSelectedFilter(newFilterId); const filterDef = availableFilters.find(f => f.id === newFilterId); setPendingParams(getDefaultParams(filterDef)); }; // Handle parameter value change const handleParamChange = (paramId, value) => { setPendingParams(prev => ({ ...prev, [paramId]: value })); }; // Generate unique ID for filter chain items const generateUid = () => Date.now().toString(36) + Math.random().toString(36).substr(2); // Add filter to chain const handleAddFilter = () => { if (!currentFilterConfig) return; setFilterChain(prev => [...prev, { id: currentFilterConfig.id, name: currentFilterConfig.name, params: { ...pendingParams }, uid: generateUid() }]); }; // Remove filter from chain by uid const handleRemoveFilter = (uid) => { setFilterChain(prev => prev.filter(f => f.uid !== uid)); }; // Format filter label for display in chain const formatFilterLabel = (filter) => { const filterDef = availableFilters.find(f => f.id === filter.id); if (!filterDef || !filterDef.parameters || filterDef.parameters.length === 0) { return filter.name; } // Show key param values const paramValues = filterDef.parameters .filter(p => isParamVisible(p, filter.params)) .map(p => { const val = filter.params[p.id]; if (p.type === 'checkbox') return val ? p.label : null; if (p.type === 'slider') return `${p.label}: ${Number(val).toFixed(1)}`; return `${val}`; }) .filter(Boolean) .slice(0, 2); // Show max 2 params return paramValues.length > 0 ? `${filter.name} (${paramValues.join(', ')})` : filter.name; }; // Render parameter input based on schema const renderParamInput = (param) => { if (!isParamVisible(param, pendingParams)) return null; const value = pendingParams[param.id]; switch (param.type) { case 'slider': return ( {param.label}: {Number(value).toFixed(param.step < 1 ? 1 : 0)} handleParamChange(param.id, v)} min={param.min} max={param.max} step={param.step} size="small" /> ); case 'dropdown': return ( {param.label} ); case 'checkbox': return ( handleParamChange(param.id, e.target.checked)} size="small" /> } label={param.label} /> ); default: return null; } }; // Handle file selection const handleFileSelect = (event) => { const file = event.target.files[0]; if (!file) return; // Validate file type const allowedTypes = ['image/png', 'image/jpeg', 'image/webp']; if (!allowedTypes.includes(file.type)) { setError('Please select a PNG, JPEG, or WebP image'); return; } setSelectedFile(file); setError(null); setResultUrl(null); // Revoke old preview URL to free memory if (previewUrl) { URL.revokeObjectURL(previewUrl); } // Create new preview URL const url = URL.createObjectURL(file); setPreviewUrl(url); }; // Handle drag and drop const handleDrop = (event) => { event.preventDefault(); const file = event.dataTransfer.files[0]; if (file) { handleFileSelect({ target: { files: [file] } }); } }; const handleDragOver = (event) => { event.preventDefault(); }; // Apply all filters in chain const handleApplyFilters = async () => { if (!selectedFile) { setError('Please select an image first'); return; } if (filterChain.length === 0) { setError('Please add at least one filter to the chain'); return; } setLoading(true); setError(null); try { const formData = new FormData(); formData.append('file', selectedFile); formData.append('filters', JSON.stringify(filterChain.map(f => ({ filter: f.id, params: f.params })))); const response = await fetch('/image/apply-filters', { method: 'POST', body: formData }); if (!response.ok) { const text = await response.text(); throw new Error(text || 'Failed to process image'); } // Get the processed image as a blob const blob = await response.blob(); // Revoke old result URL if (resultUrl) { URL.revokeObjectURL(resultUrl); } // Create URL for the result const url = URL.createObjectURL(blob); setResultUrl(url); } catch (err) { setError(err.message); } finally { setLoading(false); } }; // Download result const handleDownload = () => { if (!resultUrl) return; const filterNames = filterChain.map(f => f.id).join('-'); const link = document.createElement('a'); link.href = resultUrl; link.download = `filtered-${filterNames}-${selectedFile?.name || 'image.png'}`; document.body.appendChild(link); link.click(); document.body.removeChild(link); }; // Cleanup URLs on unmount React.useEffect(() => { return () => { if (previewUrl) URL.revokeObjectURL(previewUrl); if (resultUrl) URL.revokeObjectURL(resultUrl); }; }, []); return ( Image Tools Upload an image, build a filter chain, and download the result. {error && ( setError(null)}> {error} )} {/* Upload Area */} 1. Select Image document.getElementById('file-input').click()} > {selectedFile ? selectedFile.name : 'Click or drag an image here'} Supports PNG, JPEG, WebP {/* Filter Selection */} 2. Build Filter Chain {filtersLoading ? ( Loading filters... ) : ( <> {/* Add filter controls */} Filter {/* Dynamic parameter inputs */} {currentFilterConfig?.parameters?.map(param => renderParamInput(param))} )} {/* Filter chain display */} {filterChain.length > 0 && ( Filter Chain ({filterChain.length} filter{filterChain.length !== 1 ? 's' : ''}): {filterChain.map((filter, index) => ( {index > 0 && ( )} handleRemoveFilter(filter.uid)} color="primary" variant="outlined" /> ))} )} {/* Apply button */} {/* Preview and Result */} {/* Original Preview */} {previewUrl && ( Original )} {/* Processed Result */} {resultUrl && ( Result )} ); }; // Expose globally for app.jsx window.ImageToolsPage = ImageToolsPage;