From 31af40a3cf59b4ff39981b79d2bfe8d837a30058 Mon Sep 17 00:00:00 2001 From: Ernest Torz Date: Mon, 24 Nov 2025 11:13:34 +0100 Subject: [PATCH 1/9] upload ImagesInput --- src/types/Sandbox/Images.schema.ts | 26 +++ src/types/Sandbox/ImagesInput.component.tsx | 178 +++++++++++++++++++ src/types/Sandbox/ImagesWizard.component.tsx | 115 ++++++++++++ src/types/Sandbox/SandboxInput.component.tsx | 40 ----- src/types/Sandbox/index.ts | 44 +++-- 5 files changed, 345 insertions(+), 58 deletions(-) create mode 100644 src/types/Sandbox/Images.schema.ts create mode 100644 src/types/Sandbox/ImagesInput.component.tsx create mode 100644 src/types/Sandbox/ImagesWizard.component.tsx delete mode 100644 src/types/Sandbox/SandboxInput.component.tsx diff --git a/src/types/Sandbox/Images.schema.ts b/src/types/Sandbox/Images.schema.ts new file mode 100644 index 0000000..5804b80 --- /dev/null +++ b/src/types/Sandbox/Images.schema.ts @@ -0,0 +1,26 @@ +import { z } from 'zod' + +export const CONSTRAINTS = { + maxImages: { min: 1, max: 10, default: 3 }, + maxSizeMb: { min: 1, max: 20, default: 10 }, + allowedTypes: { default: ['image/jpg', 'image/jpeg', 'image/png'] }, + resizeMaxSide: { min: 0, max: 4096, default: 0 }, // 0 means no resize +} + +// --- SCHEMAS --- +export const configSchema = z.object({ + maxImages: z.number().min(CONSTRAINTS.maxImages.min).max(CONSTRAINTS.maxImages.max).default(CONSTRAINTS.maxImages.default), + allowedTypes: z.array(z.string()).default(CONSTRAINTS.allowedTypes.default), + maxSizeMb: z.number().min(CONSTRAINTS.maxSizeMb.min).max(CONSTRAINTS.maxSizeMb.max).default(CONSTRAINTS.maxSizeMb.default), + resizeMaxSide: z.number().min(CONSTRAINTS.resizeMaxSide.min).max(CONSTRAINTS.resizeMaxSide.max).default(CONSTRAINTS.resizeMaxSide.default), +}) + +export const answerSchema = z.array( + z.object({ + data: z.string(), // base64 + name: z.string(), + type: z.string(), + size: z.number(), + comment: z.string().optional(), + }) +).max(CONSTRAINTS.maxImages.max) \ No newline at end of file diff --git a/src/types/Sandbox/ImagesInput.component.tsx b/src/types/Sandbox/ImagesInput.component.tsx new file mode 100644 index 0000000..ececc51 --- /dev/null +++ b/src/types/Sandbox/ImagesInput.component.tsx @@ -0,0 +1,178 @@ +import React, { useRef } from 'react' +import { BaseResponseAreaProps } from '../base-props.type' +import { CONSTRAINTS } from './Images.schema' + +// --- INPUT COMPONENT --- + +export const ImagesInputComponent: React.FC = ({ + config, + answer = [], + handleChange, + typesafeErrorMessage, +}) => { + const inputRef = useRef(null) + const { maxImages, allowedTypes, maxSizeMb } = config as any || {} + + const resizeImage = (file: File, maxSide: number): Promise<{ data: string; size: number }> => { + return new Promise((resolve, reject) => { + const img = new Image() + img.onload = () => { + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + if (!ctx) { + reject(new Error('Canvas context not available')) + return + } + + let { width, height } = img + if (maxSide > 0 && Math.max(width, height) > maxSide) { + const ratio = maxSide / Math.max(width, height) + width = Math.round(width * ratio) + height = Math.round(height * ratio) + } + + canvas.width = width + canvas.height = height + ctx.drawImage(img, 0, 0, width, height) + canvas.toBlob((blob) => { + if (blob) { + const reader = new FileReader() + reader.onload = () => resolve({ data: reader.result as string, size: blob.size }) + reader.onerror = reject + reader.readAsDataURL(blob) + } else { + reject(new Error('Failed to create blob')) + } + }, file.type) + } + img.onerror = reject + img.src = URL.createObjectURL(file) + }) + } + + const handleFiles = async (files: FileList | null) => { + if (!files) return + const arr: any[] = [] + const resizeMaxSide = (config as any)?.resizeMaxSide || 0 + for (let i = 0; i < files.length; i++) { + const file = files[i] + if (!file) continue; + if ( + allowedTypes && + !allowedTypes.includes(file.type) + ) { + alert(`Unsupported file type: ${file?.type}`) + continue + } + // Read file as base64, resize if needed + let data: string + let size: number + if (resizeMaxSide > 0) { + const resized = await resizeImage(file, resizeMaxSide) + data = resized.data + size = resized.size + } else { + data = await new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result as string) + reader.onerror = reject + reader.readAsDataURL(file) + }) + size = file.size + } + if (size > (maxSizeMb || CONSTRAINTS.maxSizeMb.default) * 1024 * 1024) { + alert(`File ${file?.name} exceeds the limit ${maxSizeMb} MB`) + continue + } + + arr.push({ + data, // base64 + name: file.name, + type: file.type, + size, + comment: '', + }) + } + const newAnswer = [...(answer as any[]), ...arr].slice(0, maxImages || CONSTRAINTS.maxImages.default) + handleChange(newAnswer) + } + + const handleRemove = (idx: number) => { + const newAnswer = [...(answer as any[])] + newAnswer.splice(idx, 1) + handleChange(newAnswer) + } + + const handleCommentChange = (comment: string, idx: number) => { + const newAnswer = [...(answer as any[])] + newAnswer[idx] = { ...newAnswer[idx], comment } + handleChange(newAnswer) + } + + return ( +
+ handleFiles(e.target.files)} + data-testid="images-input" + /> + +
+ {(answer as any[]).map((img, idx) => ( +
+
+ {img.name} + +
+