From b42781ce3de29c1c073b659f8c28b8663cf578a1 Mon Sep 17 00:00:00 2001 From: amemya Date: Fri, 15 May 2026 18:46:52 +0900 Subject: [PATCH 1/4] feat: add success toast notification after saving image (#14) Closes #14 --- frontend/src/App.css | 46 ++++++++++++++++++++++++++++++++++++++++++++ frontend/src/App.tsx | 14 ++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/frontend/src/App.css b/frontend/src/App.css index 162009a..2eb238d 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -265,4 +265,50 @@ body { .input-group input:focus { border-color: var(--accent-color); background-color: #252525; +} + +/* Toast Notification */ +.toast-container { + position: absolute; + bottom: 2rem; + left: 50%; + transform: translateX(-50%); + z-index: 1000; + pointer-events: none; +} + +.toast { + background-color: var(--bg-topbar); + color: var(--text-primary); + padding: 0.8rem 1.5rem; + border-radius: 8px; + font-size: 0.9rem; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + border: 1px solid var(--border-color); + opacity: 0; + transform: translateY(10px); + animation: toast-fade-in-out 3s ease forwards; +} + +.toast.success { + border-left: 4px solid #2e7d32; +} + +@keyframes toast-fade-in-out { + 0% { + opacity: 0; + transform: translateY(10px); + } + 10% { + opacity: 1; + transform: translateY(0); + } + 90% { + opacity: 1; + transform: translateY(0); + } + 100% { + opacity: 0; + transform: translateY(-10px); + } } \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 579add8..7239554 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -30,6 +30,12 @@ function App() { const [filePath, setFilePath] = useState(""); const [isMac, setIsMac] = useState(false); const [sourceMimeType, setSourceMimeType] = useState(""); + const [toastMessage, setToastMessage] = useState(null); + + const showToast = (message: string) => { + setToastMessage(message); + setTimeout(() => setToastMessage(null), 3000); + }; useEffect(() => { Environment().then(env => { @@ -224,6 +230,8 @@ function App() { const errText = await response.text(); console.error("Save failed:", errText); alert("Failed to save image: " + errText); + } else { + showToast("Image saved successfully"); } } catch (err) { console.error("Failed to execute SaveImage:", err); @@ -338,6 +346,12 @@ function App() { )} + + {toastMessage && ( +
+
{toastMessage}
+
+ )} ); From e0466f1c6d2793784cee0bfd46d91531bc677975 Mon Sep 17 00:00:00 2001 From: amemya Date: Sat, 16 May 2026 01:11:04 +0900 Subject: [PATCH 2/4] fix: resolve toast timer races and improve accessibility - Use useRef to track toast timer and clear previous timers to prevent overlapping or racing toasts. - Reset state to null and use requestAnimationFrame before showing a new toast to force a React re-render and trigger the CSS animation. - Add aria-live="polite", aria-atomic="true", and role="status" to the toast container for screen reader accessibility. --- frontend/src/App.tsx | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7239554..5d3cb40 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -31,12 +31,31 @@ function App() { const [isMac, setIsMac] = useState(false); const [sourceMimeType, setSourceMimeType] = useState(""); const [toastMessage, setToastMessage] = useState(null); + const toastTimerRef = useRef(null); const showToast = (message: string) => { - setToastMessage(message); - setTimeout(() => setToastMessage(null), 3000); + if (toastTimerRef.current !== null) { + window.clearTimeout(toastTimerRef.current); + } + // Force a re-render by clearing the state first + setToastMessage(null); + requestAnimationFrame(() => { + setToastMessage(message); + toastTimerRef.current = window.setTimeout(() => { + setToastMessage(null); + toastTimerRef.current = null; + }, 3000); + }); }; + useEffect(() => { + return () => { + if (toastTimerRef.current !== null) { + window.clearTimeout(toastTimerRef.current); + } + }; + }, []); + useEffect(() => { Environment().then(env => { if (env.platform === 'darwin') { @@ -348,7 +367,7 @@ function App() { )} {toastMessage && ( -
+
{toastMessage}
)} From 5ae8e2cea3e8a585ffb035428c9c3a8d8d2a2062 Mon Sep 17 00:00:00 2001 From: amemya Date: Sat, 16 May 2026 01:17:44 +0900 Subject: [PATCH 3/4] fix: cancel previous requestAnimationFrame in showToast If showToast was called multiple times within the same animation frame, the previous requestAnimationFrame callback was not cancelled. This caused multiple callbacks to fire in the next frame, overwriting the toastTimerRef with a new timeout ID while leaking the older timeout, which would then prematurely clear the toast. This commit adds a toastRafRef to track and cancel any pending RAF callbacks, ensuring only the most recent showToast call schedules a timeout. --- frontend/src/App.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5d3cb40..6304de2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -32,19 +32,27 @@ function App() { const [sourceMimeType, setSourceMimeType] = useState(""); const [toastMessage, setToastMessage] = useState(null); const toastTimerRef = useRef(null); + const toastRafRef = useRef(null); const showToast = (message: string) => { if (toastTimerRef.current !== null) { window.clearTimeout(toastTimerRef.current); + toastTimerRef.current = null; } + if (toastRafRef.current !== null) { + window.cancelAnimationFrame(toastRafRef.current); + toastRafRef.current = null; + } + // Force a re-render by clearing the state first setToastMessage(null); - requestAnimationFrame(() => { + toastRafRef.current = requestAnimationFrame(() => { setToastMessage(message); toastTimerRef.current = window.setTimeout(() => { setToastMessage(null); toastTimerRef.current = null; }, 3000); + toastRafRef.current = null; }); }; @@ -53,6 +61,9 @@ function App() { if (toastTimerRef.current !== null) { window.clearTimeout(toastTimerRef.current); } + if (toastRafRef.current !== null) { + window.cancelAnimationFrame(toastRafRef.current); + } }; }, []); From 3b2f1378e426d550a87dcf2529a1a5aba02734a1 Mon Sep 17 00:00:00 2001 From: amemya Date: Sat, 16 May 2026 01:24:48 +0900 Subject: [PATCH 4/4] refactor: sync toast duration between JS and CSS Hardcoding the duration in both CSS (animation) and JS (setTimeout) creates a risk of timing drift and cutoff bugs if only one side is updated. This commit introduces a single TOAST_DURATION_MS constant in JS and passes it to CSS via the inline animationDuration style to ensure they are always perfectly synchronized. --- frontend/src/App.css | 2 +- frontend/src/App.tsx | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index 2eb238d..d661175 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -287,7 +287,7 @@ body { border: 1px solid var(--border-color); opacity: 0; transform: translateY(10px); - animation: toast-fade-in-out 3s ease forwards; + animation: toast-fade-in-out ease forwards; } .toast.success { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6304de2..cdfcfcb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,8 @@ interface ExifData { iso: string; } +const TOAST_DURATION_MS = 3000; + function App() { const canvasRef = useRef(null); const [imageLoaded, setImageLoaded] = useState(false); @@ -51,7 +53,7 @@ function App() { toastTimerRef.current = window.setTimeout(() => { setToastMessage(null); toastTimerRef.current = null; - }, 3000); + }, TOAST_DURATION_MS); toastRafRef.current = null; }); }; @@ -379,7 +381,7 @@ function App() { {toastMessage && (
-
{toastMessage}
+
{toastMessage}
)}