From 26038c82438ed3d905dcbf92ce9a4e96209c0967 Mon Sep 17 00:00:00 2001 From: Adeel Raza Date: Wed, 5 Aug 2026 08:49:47 +0400 Subject: [PATCH] feat(demo): add image upload so users can try their own images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an "Upload image…" action to the demo sidebar backed by a hidden file input. The file is read client-side via FileReader.readAsDataURL and flows through the existing image prop / serialized reset chain — no server involved. Hardening from adversarial review: - MIME + 20 MB size gate (accept="image/*" is only a picker hint) - read token + abort so a slow read can't overwrite a newer pick - reader.error logged; input value cleared before the callback so re-selecting the same file always fires onChange Co-Authored-By: Claude --- demo/src/App.tsx | 37 +++++++++++++++++++++++++++++++++++++ demo/src/Sidebar.tsx | 21 ++++++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/demo/src/App.tsx b/demo/src/App.tsx index e658424..527f79c 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -14,6 +14,8 @@ const SAMPLE_IMAGES = [ 'https://picsum.photos/id/1040/1200/800', ]; +const MAX_UPLOAD_BYTES = 20 * 1024 * 1024; + const allToolsEnabled = () => Object.fromEntries(TOOL_NAMES.map((tool) => [tool, true])) as Record< ToolName, @@ -32,12 +34,46 @@ export default function App() { const [saved, setSaved] = useState(null); const [status, setStatus] = useState('Loading editor…'); + // Invalidates in-flight file reads so a slow read can never overwrite a + // newer image choice (upload or sample) after the fact. + const readTokenRef = useRef(0); + const readerRef = useRef(null); + const nextImage = () => { + readTokenRef.current++; + readerRef.current?.abort(); const index = SAMPLE_IMAGES.indexOf(image); setImage(SAMPLE_IMAGES[(index + 1) % SAMPLE_IMAGES.length]); setStatus('Image changed (reset)'); }; + const uploadImage = (file: File) => { + // accept="image/*" is only a picker hint — enforce type and size here. + if (!file.type.startsWith('image/')) { + setStatus(`"${file.name}" is not an image`); + return; + } + if (file.size > MAX_UPLOAD_BYTES) { + setStatus(`"${file.name}" is too large (max 20 MB)`); + return; + } + readerRef.current?.abort(); + const token = ++readTokenRef.current; + const reader = new FileReader(); + readerRef.current = reader; + reader.onload = () => { + if (token !== readTokenRef.current) return; + setImage(reader.result as string); + setStatus(`Loaded "${file.name}" (reset)`); + }; + reader.onerror = () => { + if (token !== readTokenRef.current) return; + console.error('[demo] file read failed', reader.error); + setStatus(`Could not read "${file.name}"`); + }; + reader.readAsDataURL(file); + }; + const checkChanges = () => { const editor = editorRef.current?.editor; if (!editor) return; @@ -82,6 +118,7 @@ export default function App() { tools={tools} onToolToggle={toggleTool} onChangeImage={nextImage} + onUploadImage={uploadImage} onCheckChanges={checkChanges} onSnapshot={snapshot} /> diff --git a/demo/src/Sidebar.tsx b/demo/src/Sidebar.tsx index 8e9624e..6babf7e 100644 --- a/demo/src/Sidebar.tsx +++ b/demo/src/Sidebar.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useRef } from 'react'; import type { UnlayerLocale } from '@unlayer/types'; @@ -31,16 +31,35 @@ interface SidebarProps { tools: Record; onToolToggle(tool: ToolName): void; onChangeImage(): void; + onUploadImage(file: File): void; onCheckChanges(): void; onSnapshot(): void; } export default function Sidebar(props: SidebarProps) { + const fileInputRef = useRef(null); + return (