app.tsx
1 import { createRoot } from 'react-dom/client' 2 import { useState, useEffect } from 'react' 3 import { BrowserRouter, Routes, Route, Link, useLocation, useSearchParams } from 'react-router-dom' 4 import { ErrorBoundary, getErrorMessage } from 'react-error-boundary' 5 6 import { LS_RADIXES, AppContext, getCharsLS, sanitizeInput, serializeRadixes, unserializeRadixes } from './common.ts' 7 import { type Radix, createRadixes, createRadix, num2str, str2num, allowedCharaters } from './radixes.ts' 8 9 import Header from './components/header.tsx' 10 import Show from './components/show.tsx' 11 import Add from './components/add.tsx' 12 import Multiply from './components/multiply.tsx' 13 import Convert from './components/convert.tsx' 14 15 import './app.css' 16 17 18 export type UpdateRadixes = (radixes: Radix[]) => void 19 export type UpdateValue = (value: bigint, radix?: Radix) => void 20 21 const BIG_INT_0 = 0n 22 23 createRoot(document.querySelector('#root')!).render( 24 <BrowserRouter basename={import.meta.env.BASE_URL}> 25 <App/> 26 </BrowserRouter> 27 ) 28 29 function App() { 30 const [ error, setError ] = useState<unknown>() 31 const updateError = (error: unknown) => { setError(error); setTimeout(() => { setError(undefined) }, 30_000) } 32 const { radixes, enabledRadixes, updateRadixes, value, updateValue } = useStore(updateError) 33 const { pathname, search } = useLocation() 34 35 useEffect(() => { 36 const keyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') setError(undefined) } 37 document.addEventListener('keydown', keyDown) 38 return () => { document.removeEventListener('keydown', keyDown) } 39 }, []) 40 41 return ( 42 <ErrorBoundary onError={updateError} FallbackComponent={ErrorToast}> 43 <AppContext value={{ updateError }}> 44 { getErrorMessage(error) && <ErrorToast error={error}/> } 45 <Header radixes={radixes} updateRadixes={updateRadixes}/> 46 <nav className="tabs tabs-bordered justify-center mb-4 z-10"> 47 <Link className={`tab ${pathname === '/' ? 'tab-active' : ''}`} to={`/${search}`}>Show</Link> 48 <Link className={`tab ${pathname.includes('add') ? 'tab-active' : ''}`} to={`add${search}`}>Add</Link> 49 <Link className={`tab ${pathname.includes('multiply') ? 'tab-active' : ''}`} to={`multiply${search}`}>Multiply</Link> 50 <Link className={`tab ${pathname.includes('convert') ? 'tab-active' : ''}`} to={`convert${search}`}>Convert</Link> 51 </nav> 52 <Routes> 53 <Route path="/" element={<Show radixes={enabledRadixes}/>}/> 54 <Route path="add" element={<Add radixes={enabledRadixes}/>}/> 55 <Route path="multiply" element={<Multiply radixes={enabledRadixes}/>}/> 56 <Route path="convert" element={<Convert radixes={enabledRadixes} value={value} updateValue={updateValue}/>}/> 57 </Routes> 58 </AppContext> 59 </ErrorBoundary> 60 ) 61 } 62 63 function ErrorToast({ error }: { error: unknown }) { 64 return ( 65 <div className="toast toast-top toast-center z-50"> 66 <div className="alert alert-error"> 67 <pre>{ getErrorMessage(error) ?? 'Unknown error' }</pre> 68 </div> 69 </div> 70 ) 71 } 72 73 function useStore(updateError: (error: unknown) => void) { 74 const [ radixes, setRadixes ] = useState(getRadixesLS(updateError) ?? createRadixes(getCharsLS())) 75 const [ enabledRadixes, setEnabledRadixes ] = useState(radixes.filter(r => r.enabled)) 76 const [ searchParams, setSearchParams ] = useSearchParams() 77 const [ radix, setRadix ] = useState(createRadix(10)) 78 const [ value, setValue ] = useState(BIG_INT_0) 79 80 const updateRadixes: UpdateRadixes = radixes => { 81 setRadixes(radixes) 82 83 const enabledRadixes = radixes.filter(v => v.enabled) 84 setEnabledRadixes(enabledRadixes) 85 86 searchParams.delete('r') 87 enabledRadixes.forEach(r => { searchParams.append('r', r.name) }) 88 setSearchParams(searchParams) 89 90 localStorage.setItem(LS_RADIXES, serializeRadixes(radixes)) 91 } 92 93 const updateValue: UpdateValue = (value, r = radix) => { 94 if (value === BIG_INT_0) { 95 searchParams.delete('radix') 96 searchParams.delete('value') 97 } else { 98 if (r.name === '10') { 99 searchParams.delete('radix') 100 } else { 101 searchParams.set('radix', r.name) 102 } 103 searchParams.set('value', num2str(value, r)) 104 } 105 setValue(value) 106 setRadix(r) 107 setSearchParams(searchParams) 108 } 109 110 useEffect(() => { 111 if (searchParams.has('clear-settings')) localStorage.clear() 112 113 if (searchParams.has('r')) { 114 const searchRadixes = searchParams.getAll('r') 115 radixes.forEach(r => { r.enabled = searchRadixes.includes(r.name) }) 116 updateRadixes(radixes) 117 setEnabledRadixes(radixes.filter(r => r.enabled)) 118 } else { 119 enabledRadixes.forEach(r => { searchParams.append('r', r.name) }) 120 setSearchParams(searchParams) 121 } 122 123 let r: Radix | undefined = radix 124 125 const sRadix = searchParams.get('radix') 126 if (sRadix) { 127 r = radixes.find(r => r.name === sRadix) 128 if (r == undefined) { 129 updateError(new Error(`Unknown radix "${sRadix}" in the URL`)) 130 return 131 } 132 setRadix(r) 133 } 134 135 const sValue = searchParams.get('value') 136 if (sValue) { 137 const [ value, rest ] = sanitizeInput(sValue, r) 138 setValue(str2num(value, r)) 139 if (rest) updateError(new Error(`Non-Base characters "${rest}" for radix "${r.name}" has been filtered out. ${allowedCharaters(r)}`)) 140 } 141 }, []) 142 143 return { radixes, enabledRadixes, updateRadixes, value, updateValue } 144 } 145 146 function getRadixesLS(updateError: (error: unknown) => void): Radix[] | undefined { 147 const item = localStorage.getItem(LS_RADIXES) 148 if (item == undefined) return 149 150 try { 151 return unserializeRadixes(item) 152 } catch (error) { 153 updateError(error) 154 localStorage.removeItem(LS_RADIXES) 155 } 156 }