npm install zod
import { z } from 'zod';
const exportSchema = z.object({
filename: z.string()
.min(1, 'Filename is required')
.max(100, 'Filename too long')
.regex(
/^[a-zA-Z0-9_\-. ]+$/,
'Only letters, numbers, dashes, ' +
'underscores, dots'
),
format: z.enum(['png', 'jpeg', 'webp']),
quality: z.number()
.min(1).max(100).optional(),
width: z.number().min(1).max(10000),
height: z.number().min(1).max(10000),
maintainAspectRatio: z.boolean(),
});
function ExportDialog({ image, onExport, onClose }) {
const [formData, setFormData] = useState({
filename: image.name.replace(/\.[^.]+$/, ''),
format: 'png',
quality: 90,
width: image.width,
height: image.height,
maintainAspectRatio: true,
});
const [errors, setErrors] = useState({});
const validate = (data) => {
const result = exportSchema.safeParse(data);
if (result.success) {
setErrors({});
return true;
}
const fieldErrors = {};
result.error.issues.forEach(issue => {
fieldErrors[issue.path[0]] = issue.message;
});
setErrors(fieldErrors);
return false;
};
const handleChange = (field, value) => {
const updated = { ...formData, [field]: value };
// Aspect ratio lock
if (field === 'width'
&& formData.maintainAspectRatio) {
updated.height = Math.round(
value * (image.height / image.width)
);
}
if (field === 'height'
&& formData.maintainAspectRatio) {
updated.width = Math.round(
value * (image.width / image.height)
);
}
setFormData(updated);
validate(updated);
};
const handleSubmit = () => {
if (validate(formData)) onExport(formData);
};
return (
<div className="...modal classes...">
<h2>Export Image</h2>
<label htmlFor="filename">Filename</label>
<input
id="filename"
value={formData.filename}
onChange={(e) =>
handleChange('filename', e.target.value)}
className={errors.filename
? 'border-red-500' : ''}
/>
{errors.filename &&
<p className="text-red-400 text-xs">
{errors.filename}</p>}
<label htmlFor="format">Format</label>
<select id="format"
value={formData.format}
onChange={e =>
handleChange('format', e.target.value)}>
<option value="png">
PNG (lossless)</option>
<option value="jpeg">
JPEG (smaller file)</option>
<option value="webp">
WebP (modern)</option>
</select>
{formData.format === 'jpeg' && (<>
<label>
Quality: {formData.quality}%
</label>
<input type="range" min={1} max={100}
value={formData.quality}
onChange={e => handleChange(
'quality', parseInt(e.target.value)
)} />
</>)}
<button onClick={handleSubmit}
disabled={
Object.keys(errors).length > 0
}>
Export
</button>
</div>
);
}
| Input | Expected |
|---|---|
| Empty filename | "Filename is required" |
| Special chars: file<>name | "Only letters, numbers..." |
| Width = 0 | Number must be ≥ 1 |
| Width = 99999 | Number must be ≤ 10000 |
| JPEG quality = 0 | Number must be ≥ 1 |
| All valid | Export button enabled |
git switch -c feature/PIXELCRAFT-041-export-dialog
git add src/
git commit -m "Add export dialog with Zod validation (PIXELCRAFT-041)"
git push origin feature/PIXELCRAFT-041-export-dialog
# PR → Review → Merge → Close ticket ✅
Schema validation is "specify the shape of valid data, reject everything else."
"Never trust user input" is the first commandment of security and reliability.