Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added src/types/Images/.gitkeep
Empty file.
30 changes: 30 additions & 0 deletions src/types/Images/Images.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
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)

// --- TYPE EXPORTS ---
export type ImagesConfig = z.infer<typeof configSchema>
export type ImagesAnswer = z.infer<typeof answerSchema>
194 changes: 194 additions & 0 deletions src/types/Images/ImagesInput.component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import React, { useRef } from 'react'
import { BaseResponseAreaProps } from '../base-props.type'
import { CONSTRAINTS, ImagesConfig, ImagesAnswer } from './Images.schema'

type ImagesInputProps = Omit<BaseResponseAreaProps, 'config' | 'handleChange'> & {
config?: ImagesConfig

handleChange: (answer: ImagesAnswer) => void
}

// --- INPUT COMPONENT ---

export const ImagesInputComponent: React.FC<ImagesInputProps> = ({
config,
answer,
handleChange,
typesafeErrorMessage,
}) => {
const inputRef = useRef<HTMLInputElement>(null)
const typedAnswer = (Array.isArray(answer) ? answer : []) as ImagesAnswer
const cfg = config || {
maxImages: CONSTRAINTS.maxImages.default,
allowedTypes: CONSTRAINTS.allowedTypes.default,
maxSizeMb: CONSTRAINTS.maxSizeMb.default,
resizeMaxSide: CONSTRAINTS.resizeMaxSide.default,
}
const { maxImages, allowedTypes, maxSizeMb } = cfg

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: ImagesAnswer = []
const resizeMaxSide = cfg.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<string>((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 = [...typedAnswer, ...arr].slice(0, maxImages || CONSTRAINTS.maxImages.default)
handleChange(newAnswer)
}

const handleRemove = (idx: number) => {
const newAnswer = [...typedAnswer]
newAnswer.splice(idx, 1)
handleChange(newAnswer)
}

const handleCommentChange = (comment: string, idx: number) => {
const newAnswer = [...typedAnswer]
const item = newAnswer[idx]
if (item) {
newAnswer[idx] = { ...item, comment } as ImagesAnswer[number]
}
handleChange(newAnswer)
}

return (
<div>
<input
ref={inputRef}
type="file"
accept={allowedTypes?.join(',') || 'image/*'}
multiple
style={{ display: 'none' }}
onChange={e => handleFiles(e.target.files)}
data-testid="images-input"
/>
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={typedAnswer.length >= (maxImages || CONSTRAINTS.maxImages.default)}
style={{
backgroundColor: '#0099c4',
color: '#ffffff',
border: 'none',
borderRadius: '4px',
padding: '8px 16px',
fontSize: '0.875rem',
fontWeight: 500,
cursor: 'pointer',
boxShadow: '0px 3px 1px -2px rgba(145, 158, 171, 0.2), 0px 2px 2px 0px rgba(145, 158, 171, 0.14), 0px 1px 5px 0px rgba(145, 158, 171, 0.12)',
transition: 'background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms',
}}
>
Add images
</button>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 8 }}>
{typedAnswer.map((img, idx) => (
<div key={idx} style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ position: 'relative' }}>
<img
src={img.data}
alt={img.name}
style={{ width: 100, height: 100, objectFit: 'cover', borderRadius: 8, border: '1px solid #ccc' }}
/>
<button
type="button"
onClick={() => handleRemove(idx)}
style={{
position: 'absolute', top: 2, right: 2, background: '#fff', border: 'none', borderRadius: '50%', cursor: 'pointer'
}}
aria-label="Delete photo"
>✕</button>
</div>
<textarea
value={img.comment || ''}
onChange={e => handleCommentChange(e.target.value, idx)}
placeholder="Add a comment (optional)"
style={{ width: 100, height: 40, resize: 'none', borderRadius: 4, border: '1px solid #ccc', padding: 4, fontSize: 12 }}
aria-label={`Comment for ${img.name}`}
/>
</div>
))}
</div>
{typesafeErrorMessage && (
<div style={{ color: 'red', marginTop: 4 }}>{typesafeErrorMessage}</div>
)}
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
Maximum {maxImages || CONSTRAINTS.maxImages.default} photos, types: {(allowedTypes || []).join(', ')}, max {maxSizeMb || CONSTRAINTS.maxSizeMb.default} MB/file
</div>
</div>
)
}
120 changes: 120 additions & 0 deletions src/types/Images/ImagesWizard.component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import React from 'react'
import { BaseResponseAreaWizardProps } from '../base-props.type'
import { CONSTRAINTS, ImagesConfig, ImagesAnswer } from './Images.schema'
import { ImagesInputComponent } from './ImagesInput.component'

type ImagesWizardProps = BaseResponseAreaWizardProps & {
config?: ImagesConfig
answer?: ImagesAnswer
}

export const ImagesWizardComponent: React.FC<ImagesWizardProps> = ({
handleChange,
setAllowSave,
config,
answer,
}) => {
const [maxImages, setMaxImages] = React.useState(config?.maxImages ?? CONSTRAINTS.maxImages.default)
const [maxSizeMb, setMaxSizeMb] = React.useState(config?.maxSizeMb ?? CONSTRAINTS.maxSizeMb.default)
const [allowedTypes, setAllowedTypes] = React.useState(config?.allowedTypes ?? CONSTRAINTS.allowedTypes.default)
const [resizeMaxSide, setResizeMaxSide] = React.useState(config?.resizeMaxSide ?? CONSTRAINTS.resizeMaxSide.default)
const [currentAnswer, setCurrentAnswer] = React.useState(answer ?? [])

const currentConfig = { maxImages, allowedTypes, maxSizeMb, resizeMaxSide }

React.useEffect(() => {
handleChange({
responseType: 'IMAGES',
config: currentConfig,
answer: currentAnswer,
})
setAllowSave(true)
}, [maxImages, allowedTypes, maxSizeMb, resizeMaxSide, currentAnswer])

return (
<div>

<h4>Configuration</h4>

<label>
Maximum number of photos:
<input
type="number"
min={CONSTRAINTS.maxImages.min}
max={CONSTRAINTS.maxImages.max}
value={maxImages}
onChange={e => {
const value = Number(e.target.value)
if (value >= CONSTRAINTS.maxImages.min && value <= CONSTRAINTS.maxImages.max) {
setMaxImages(value)
}
}}
style={{ marginLeft: 8, width: 60 }}
/>
</label>
<br />
<label>
Maximum file size (MB):
<input
type="number"
min={CONSTRAINTS.maxSizeMb.min}
max={CONSTRAINTS.maxSizeMb.max}
value={maxSizeMb}
onChange={e => {
const value = Number(e.target.value)
if (value >= CONSTRAINTS.maxSizeMb.min && value <= CONSTRAINTS.maxSizeMb.max) {
setMaxSizeMb(value)
}
}}
style={{ marginLeft: 8, width: 60 }}
/>
</label>
<br />
<label>
Resize max side (pixels, 0 = no resize):
<input
type="number"
min={CONSTRAINTS.resizeMaxSide.min}
max={CONSTRAINTS.resizeMaxSide.max}
value={resizeMaxSide}
onChange={e => {
const value = Number(e.target.value)
if (value >= CONSTRAINTS.resizeMaxSide.min && value <= CONSTRAINTS.resizeMaxSide.max) {
setResizeMaxSide(value)
}
}}
style={{ marginLeft: 8, width: 80 }}
/>
</label>
<br />
<label style={{ display: 'flex', alignItems: 'center', marginTop: 2 }} >
Allowed types:
<select
multiple
value={allowedTypes}
onChange={e =>
setAllowedTypes(
Array.from(e.target.selectedOptions).map(opt => opt.value)
)
}
style={{ marginLeft: 8, minWidth: 100, height: 60 }}
>
{CONSTRAINTS.allowedTypes.default.map(type => (
<option key={type} value={type}>
{(type.split('/')[1] ?? type).toUpperCase()}
</option>
))}
</select>
</label>

<h4>Data (optional)</h4>
<div style={{ marginTop: 0 }}>
<ImagesInputComponent
config={currentConfig}
answer={currentAnswer}
handleChange={setCurrentAnswer}
/>
</div>
</div>
)
}
Loading