From dc747bb73ca4b1afb382d7e3c309131224e4a2f2 Mon Sep 17 00:00:00 2001 From: Andre Avindra Date: Fri, 5 Dec 2025 11:01:07 +0700 Subject: [PATCH 1/5] feat: Persist markdown text to local storage Automatically save the user's work in the editor to the browser's local storage to prevent data loss on refresh. Features: - Auto-save markdown text to `localStorage` - Restore text on page load - Prevents data loss on refresh Technical implementation: - Hydration-safe storage initialization using `isLoaded` state - `useEffect` hook to load from 'airdeck-markdown' on mount - `useEffect` hook to sync state changes to storage - Prevents overwriting storage with initial empty state Components: - PresentationContext.tsx: Added persistence logic --- app/contexts/PresentationContext.tsx | 30 ++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/app/contexts/PresentationContext.tsx b/app/contexts/PresentationContext.tsx index f28148c..b45ca01 100644 --- a/app/contexts/PresentationContext.tsx +++ b/app/contexts/PresentationContext.tsx @@ -4,6 +4,7 @@ import { useState, useCallback, useMemo, + useEffect, } from 'react'; import type { ReactNode } from 'react'; @@ -18,15 +19,15 @@ interface PresentationContextType { totalSlides: number; } -const PresentationContext = createContext< - PresentationContextType | undefined ->(undefined); +const PresentationContext = createContext( + undefined +); export function usePresentationContext() { const context = useContext(PresentationContext); if (!context) { throw new Error( - 'usePresentationContext must be used within a PresentationProvider', + 'usePresentationContext must be used within a PresentationProvider' ); } return context; @@ -43,6 +44,23 @@ export function PresentationProvider({ }: PresentationProviderProps) { const [markdownText, setMarkdownText] = useState(initialMarkdown); const [currentSlide, setCurrentSlide] = useState(0); + const [isLoaded, setIsLoaded] = useState(false); + + // Load from local storage on mount + useEffect(() => { + const saved = localStorage.getItem('airdeck-markdown'); + if (saved) { + setMarkdownText(saved); + } + setIsLoaded(true); + }, []); + + // Save to local storage when markdownText changes, but only after initial load + useEffect(() => { + if (isLoaded) { + localStorage.setItem('airdeck-markdown', markdownText); + } + }, [markdownText, isLoaded]); const slides = useMemo( () => @@ -50,7 +68,7 @@ export function PresentationProvider({ .split(/\n---\n/) .map((slide) => slide.trim()) .filter((slide) => slide.length > 0), - [markdownText], + [markdownText] ); const totalSlides = slides.length; @@ -69,7 +87,7 @@ export function PresentationProvider({ setCurrentSlide(index); } }, - [totalSlides], + [totalSlides] ); return ( From 5cba84455b45c6895647aac78dea479e17eac7d1 Mon Sep 17 00:00:00 2001 From: Andre Avindra Date: Fri, 19 Dec 2025 20:28:44 +0700 Subject: [PATCH 2/5] fix(presentation-context): make PresentationContext SSR-safe and cross-platform - Use lazy initialization to prevent SSR hydration errors - Add STORAGE_KEY constant for better maintainability - Add isLoaded flag to prevent race conditions on mount - Add try-catch for localStorage error handling - Fix regex to support Windows (\r\n) and Unix (\n) line endings - Add auto-reset for currentSlide when out of bounds Fixes hydration errors when using SSR (ssr: true in react-router.config.ts) --- app/contexts/PresentationContext.tsx | 31 +++++++++++++++++++--------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/app/contexts/PresentationContext.tsx b/app/contexts/PresentationContext.tsx index b45ca01..183a977 100644 --- a/app/contexts/PresentationContext.tsx +++ b/app/contexts/PresentationContext.tsx @@ -33,6 +33,8 @@ export function usePresentationContext() { return context; } +const STORAGE_KEY = 'airdeck-markdown'; + interface PresentationProviderProps { children: ReactNode; initialMarkdown?: string; @@ -42,30 +44,33 @@ export function PresentationProvider({ children, initialMarkdown = '', }: PresentationProviderProps) { - const [markdownText, setMarkdownText] = useState(initialMarkdown); + const [markdownText, setMarkdownText] = useState(() => { + if (typeof window !== 'undefined') { + return localStorage.getItem(STORAGE_KEY) || initialMarkdown; + } + return initialMarkdown; + }); const [currentSlide, setCurrentSlide] = useState(0); const [isLoaded, setIsLoaded] = useState(false); - // Load from local storage on mount useEffect(() => { - const saved = localStorage.getItem('airdeck-markdown'); - if (saved) { - setMarkdownText(saved); - } setIsLoaded(true); }, []); - // Save to local storage when markdownText changes, but only after initial load useEffect(() => { - if (isLoaded) { - localStorage.setItem('airdeck-markdown', markdownText); + if (isLoaded && typeof window !== 'undefined') { + try { + localStorage.setItem(STORAGE_KEY, markdownText); + } catch (error) { + console.error('Failed to save to localStorage:', error); + } } }, [markdownText, isLoaded]); const slides = useMemo( () => markdownText - .split(/\n---\n/) + .split(/\r?\n---\r?\n/) .map((slide) => slide.trim()) .filter((slide) => slide.length > 0), [markdownText] @@ -73,6 +78,12 @@ export function PresentationProvider({ const totalSlides = slides.length; + useEffect(() => { + if (currentSlide >= totalSlides && totalSlides > 0) { + setCurrentSlide(totalSlides - 1); + } + }, [currentSlide, totalSlides]); + const nextSlide = useCallback(() => { setCurrentSlide((prev) => Math.min(prev + 1, totalSlides - 1)); }, [totalSlides]); From 8d9720d20f20409dc029f8330722a96e22fd9171 Mon Sep 17 00:00:00 2001 From: Andre Avindra Date: Wed, 31 Dec 2025 18:54:44 +0700 Subject: [PATCH 3/5] refactor(presentation-context): optimize slide navigation and remove redundant useEffect - Replace manual slide index synchronization with derived safe index logic. - Remove redundant useEffect for out-of-bounds slide handling. - Wrap Context value in useMemo to prevent unnecessary consumer re-renders. - Add canNext and canPrev helper booleans for UI state management. --- app/contexts/PresentationContext.tsx | 51 ++++++++++++++++++---------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/app/contexts/PresentationContext.tsx b/app/contexts/PresentationContext.tsx index 183a977..3b06087 100644 --- a/app/contexts/PresentationContext.tsx +++ b/app/contexts/PresentationContext.tsx @@ -17,6 +17,8 @@ interface PresentationContextType { prevSlide: () => void; goToSlide: (index: number) => void; totalSlides: number; + canNext: boolean; + canPrev: boolean; } const PresentationContext = createContext( @@ -78,11 +80,11 @@ export function PresentationProvider({ const totalSlides = slides.length; - useEffect(() => { - if (currentSlide >= totalSlides && totalSlides > 0) { - setCurrentSlide(totalSlides - 1); - } - }, [currentSlide, totalSlides]); + const safeCurrentSlide = + totalSlides > 0 ? Math.min(currentSlide, totalSlides - 1) : 0; + + const canNext = safeCurrentSlide < totalSlides - 1; + const canPrev = safeCurrentSlide > 0; const nextSlide = useCallback(() => { setCurrentSlide((prev) => Math.min(prev + 1, totalSlides - 1)); @@ -101,19 +103,34 @@ export function PresentationProvider({ [totalSlides] ); + const value = useMemo( + () => ({ + markdownText, + setMarkdownText, + slides, + currentSlide: safeCurrentSlide, + nextSlide, + prevSlide, + goToSlide, + totalSlides, + canNext, + canPrev, + }), + [ + markdownText, + slides, + safeCurrentSlide, + nextSlide, + prevSlide, + goToSlide, + totalSlides, + canNext, + canPrev, + ] + ); + return ( - + {children} ); From 3e7525494a103c182590c423557a7c417720266b Mon Sep 17 00:00:00 2001 From: Andre Avindra Date: Wed, 31 Dec 2025 19:17:14 +0700 Subject: [PATCH 4/5] feat(hooks): add useLocalStorage custom hook Add a reusable React custom hook for persisting state in localStorage. This hook: - Supports TypeScript generics - Reads initial value from localStorage on mount - Persists state changes to localStorage - Supports functional updates like useState - Safely handles JSON parsing and storage errors Purpose: - Reduce duplicated localStorage logic - Simplify persistent state management in React applications --- app/hooks/useLocalStorage.ts | 39 ++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 app/hooks/useLocalStorage.ts diff --git a/app/hooks/useLocalStorage.ts b/app/hooks/useLocalStorage.ts new file mode 100644 index 0000000..d3bdac3 --- /dev/null +++ b/app/hooks/useLocalStorage.ts @@ -0,0 +1,39 @@ +import { useState, useEffect, useCallback } from 'react'; + +const useLocalStorage = ( + key: string, + defaultValue: T +): [T, (value: T | ((val: T) => T)) => void] => { + const [value, setValue] = useState(defaultValue); + + useEffect(() => { + try { + const item = window.localStorage.getItem(key); + if (item) { + setValue(JSON.parse(item)); + } + } catch (error) { + console.warn(`Error reading localStorage key "${key}":`, error); + } + }, [key]); + + const setStoredValue = useCallback( + (valueOrFn: T | ((val: T) => T)) => { + try { + const newValue = + valueOrFn instanceof Function ? valueOrFn(value) : valueOrFn; + setValue(newValue); + if (typeof window !== 'undefined') { + window.localStorage.setItem(key, JSON.stringify(newValue)); + } + } catch (error) { + console.error(error); + } + }, + [key, value] + ); + + return [value, setStoredValue]; +}; + +export default useLocalStorage; From cd42f19d711540e65b4d6c6c0b6c2ffb3aaca65d Mon Sep 17 00:00:00 2001 From: Andre Avindra Date: Wed, 31 Dec 2025 19:18:30 +0700 Subject: [PATCH 5/5] refactor(presentation-context): replace manual localStorage logic with useLocalStorage hook Refactor PresentationProvider to use the shared useLocalStorage hook for managing markdown persistence. Changes: - Remove manual localStorage initialization and side effects - Replace markdownText state with useLocalStorage - Simplify component logic and reduce boilerplate - Improve consistency and reusability of persistence logic No behavior change intended. --- app/contexts/PresentationContext.tsx | 37 +++++++++------------------- 1 file changed, 11 insertions(+), 26 deletions(-) diff --git a/app/contexts/PresentationContext.tsx b/app/contexts/PresentationContext.tsx index 3b06087..db9d020 100644 --- a/app/contexts/PresentationContext.tsx +++ b/app/contexts/PresentationContext.tsx @@ -1,12 +1,14 @@ +import type { ReactNode } from 'react'; import { createContext, - useContext, - useState, useCallback, - useMemo, + useContext, useEffect, + useMemo, + useState, } from 'react'; -import type { ReactNode } from 'react'; + +import useLocalStorage from '~/hooks/useLocalStorage'; interface PresentationContextType { markdownText: string; @@ -46,28 +48,12 @@ export function PresentationProvider({ children, initialMarkdown = '', }: PresentationProviderProps) { - const [markdownText, setMarkdownText] = useState(() => { - if (typeof window !== 'undefined') { - return localStorage.getItem(STORAGE_KEY) || initialMarkdown; - } - return initialMarkdown; - }); - const [currentSlide, setCurrentSlide] = useState(0); - const [isLoaded, setIsLoaded] = useState(false); - - useEffect(() => { - setIsLoaded(true); - }, []); + const [markdownText, setMarkdownText] = useLocalStorage( + STORAGE_KEY, + initialMarkdown + ); - useEffect(() => { - if (isLoaded && typeof window !== 'undefined') { - try { - localStorage.setItem(STORAGE_KEY, markdownText); - } catch (error) { - console.error('Failed to save to localStorage:', error); - } - } - }, [markdownText, isLoaded]); + const [currentSlide, setCurrentSlide] = useState(0); const slides = useMemo( () => @@ -82,7 +68,6 @@ export function PresentationProvider({ const safeCurrentSlide = totalSlides > 0 ? Math.min(currentSlide, totalSlides - 1) : 0; - const canNext = safeCurrentSlide < totalSlides - 1; const canPrev = safeCurrentSlide > 0;