header.tsx
1 import { type ReactEventHandler, type ChangeEventHandler, type KeyboardEventHandler, useContext, useState, useRef, useMemo } from 'react' 2 import { getErrorMessage } from 'react-error-boundary' 3 import TextareaAutosize from 'react-textarea-autosize' 4 // import themeObject from 'daisyui/theme/object.js' 5 6 import { type Radix, createRadixes, createRadix, defaultChars } from '#/radixes.ts' 7 import type { UpdateRadixes } from '#/app.tsx' 8 import { AppContext, getCharsLS, LS_CHARS, serializeRadixes, unserializeRadixes } from '#/common.ts' 9 10 11 export const LS_THEME = 'theme' 12 type ToggleRadixes = (radix: 'all' | 'odd' | 'even' | Radix['system'] | Radix, enabled: boolean) => void 13 // const themes = Object.keys(themeObject).toSorted() 14 15 export default function Header({ radixes, updateRadixes }: { 16 radixes: Radix[], 17 updateRadixes: UpdateRadixes, 18 }) { 19 const { updateError } = useContext(AppContext) 20 const [ settingsExpanded, setSettingsExpanded ] = useState(false) 21 const [ theme, setTheme ] = useState(getThemeLS) 22 const [ allChars, setAllChars ] = useState(getCharsLS() ?? defaultChars) 23 const [ inputRadix, setInputRadix ] = useState<Radix>() 24 const [ inputChars, setInputChars ] = useState(allChars) 25 const [ inputCharsError, setInputCharsError ] = useState<string>() 26 const formRef = useRef<HTMLFormElement>(null) 27 const fileInputRef = useRef<HTMLInputElement>(null) 28 29 const radixesSystems = useMemo(() => [ ...new Set(radixes.map(r => r.system)) ], [ radixes ]) 30 const groupedRadixes = useMemo(() => Object.values(Object.groupBy(radixes, r => r.system)), [ radixes ]) 31 const toggleRadixes = useMemo(() => createToggleRadixes(radixes, updateRadixes), [ radixes, updateRadixes ]) 32 const toggleSettings = () => { setSettingsExpanded(!settingsExpanded) } 33 34 const updateTheme = (theme: string) => { 35 document.documentElement.setAttribute('data-theme', theme) 36 setTheme(theme) 37 setThemeLS(theme) 38 } 39 40 const clearSettings = () => { 41 localStorage.clear() 42 const radixes = createRadixes() 43 updateRadixes(radixes) 44 } 45 46 const downloadSettings = () => { 47 downloadContent(serializeRadixes(radixes), 'settings.json') 48 } 49 50 const uploadSettings: ChangeEventHandler<HTMLInputElement, HTMLInputElement> = async e => { 51 const file = e.currentTarget.files?.[0] 52 e.target.value = '' 53 if (!file) return 54 55 try { 56 const content = await file.text() 57 if (typeof content !== 'string') return updateError(new Error('File content is not a text')) 58 updateRadixes(unserializeRadixes(content)) 59 } catch (error) { 60 updateError(error) 61 } 62 } 63 64 const updateInputRadixAndChars = (radix?: string) => { 65 let r: Radix | undefined 66 let chars: string 67 68 if (radix == undefined || radix === 'All') { 69 chars = allChars 70 } else { 71 r = radixes.find(r => r.name === radix) 72 if (r) { 73 ({ chars } = r) 74 } else { 75 return updateError(new Error(`Radix ${radix} not found`)) 76 } 77 } 78 setInputRadix(r) 79 setInputChars(chars) 80 } 81 82 const inputCharsSubmit: ReactEventHandler<HTMLFormElement> = e => { 83 e.preventDefault() 84 setInputCharsError(undefined) 85 86 let rs: Radix[] 87 let error: string | undefined 88 if (inputRadix) { // specific radix 89 const { chars, isAllChars } = (e.type === 'submit') ? { chars: inputChars, isAllChars: false } : { chars: allChars, isAllChars: true } 90 rs = [ ...radixes ] 91 try { 92 const i = rs.findIndex(r => r.name === inputRadix.name) 93 const r = rs[i] 94 rs[i] = createRadix(Number(r.radix), r.system, chars, r.enabled, r.name, isAllChars) 95 setInputRadix(rs[i]) 96 setInputChars(rs[i].chars) 97 } catch (e) { 98 error = getErrorMessage(e) 99 } 100 } else { // all radixes 101 const chars = (e.type === 'submit') ? inputChars : defaultChars 102 try { 103 rs = radixes.map(r => createRadix(Number(r.radix), r.system, chars, r.enabled, r.name)) 104 setInputChars(chars) 105 setAllChars(chars) 106 setCharsLS(chars) 107 } catch (e) { 108 error = getErrorMessage(e) 109 rs = radixes 110 } 111 } 112 if (error) { setInputCharsError(error) } else { updateRadixes(rs) } 113 } 114 115 const handleInputCharsKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = e => { 116 if (e.key === 'Enter') { 117 e.preventDefault() 118 formRef.current?.requestSubmit() 119 } else if (e.key === 'Escape') { 120 updateInputRadixAndChars(inputRadix?.name) 121 setInputCharsError(undefined) 122 e.currentTarget.blur() 123 } 124 } 125 126 return ( 127 <header className="p-2"> 128 <div className="navbar bg-base-100 p-0"> 129 <div className="navbar-start"> 130 <button className="text-left text-4xl tracking-wide pl-2" type="button" onClick={toggleSettings} tabIndex={-1}> 131 <span style={{ color: 'hsl(0 80% 40%)' }}>R</span> 132 <span style={{ color: 'hsl(36 80% 40%)' }}>a</span> 133 <span style={{ color: 'hsl(72 80% 40%)' }}>d</span> 134 <span style={{ color: 'hsl(108 80% 40%)' }}>i</span> 135 <span style={{ color: 'hsl(144 80% 40%)' }}>x</span> 136 <span style={{ color: 'hsl(180 80% 40%)' }}>V</span> 137 <span style={{ color: 'hsl(216 80% 40%)' }}>e</span> 138 <span style={{ color: 'hsl(252 80% 40%)' }}>r</span> 139 <span style={{ color: 'hsl(288 80% 40%)' }}>s</span> 140 <span style={{ color: 'hsl(324 80% 40%)' }}>e</span> 141 </button> 142 </div> 143 <menu className="navbar-end menu menu-horizontal p-0 z-10"> 144 <li> 145 <button 146 className={`menu-dropdown-toggle ${settingsExpanded ? 'menu-dropdown-show' : ''}`} 147 type="button" 148 onClick={toggleSettings} 149 tabIndex={0} 150 >Settings</button> 151 </li> 152 {/* <li> 153 <details className="dropdown dropdown-end"> 154 <summary>Themes</summary> 155 <menu className="dropdown-content rounded-field bg-base-100 shadow-sm p-2 mt-0">{ themes.map(t => 156 <li key={t}> 157 <button className={t === theme ? 'menu-active' : undefined} onClick={() => { updateTheme(t) }} tabIndex={0}>{capitalize(t)}</button> 158 </li>)} 159 </menu> 160 </details> 161 </li> */} 162 {/* <li className="dropdown dropdown-end"> 163 <div 164 role="button" 165 className="menu-dropdown-toggle menu-dropdown-toggle-active" 166 tabIndex={0} 167 >Themes</div> 168 <menu className="dropdown-content menu-vertical items-stretch rounded-field bg-base-100 shadow-sm p-2 mt-1">{ themes.map(t => 169 <li key={t}> 170 <a className={`${t === theme ? 'menu-active' : undefined}`} onClick={() => updateTheme(t)} tabIndex={0}>{capitalize(t)}</a> 171 </li>)} 172 </menu> 173 </li> 174 <li> 175 <button 176 className="menu-dropdown-toggle peer-open:menu-dropdown-show [anchor-name:--dp-1] px-4 mr-4" 177 popoverTarget="popover" 178 tabIndex={0} 179 > 180 Themes 181 </button> 182 <menu 183 className="peer dropdown menu-vertical items-stretch rounded-field bg-base-100 shadow-sm [position-anchor:--dp-1] [position-area:bottom_span-left]" 184 id="popover" 185 popover="auto" 186 >{ themes.map(t => 187 <li key={t}> 188 <a className={t === theme ? 'menu-active' : undefined} onClick={() => updateTheme(t)} tabIndex={0}>{capitalize(t)}</a> 189 </li>)} 190 </menu> 191 </li> */} 192 </menu> 193 </div> 194 <div className={`collapse ${settingsExpanded ? 'collapse-open' : 'collapse-close'}`}> 195 <div className="collapse-content px-0"> 196 <div className="card-actions flex-row-reverse grow m-1"> 197 <span className="join"> 198 <button className="btn btn-xs btn-outline btn-success join-item" type="button" onClick={downloadSettings}> 199 Download settings 200 </button> 201 <button className="btn btn-xs btn-outline btn-warning join-item" onClick={() => fileInputRef.current?.click()} type="button"> 202 <input type="file" accept="application/json" onChange={uploadSettings} ref={fileInputRef} style={{ display: 'none' }}/> 203 Upload settings 204 </button> 205 <button className="btn btn-xs btn-outline btn-error join-item" type="button" onClick={clearSettings}> 206 Clear settings 207 </button> 208 </span> 209 <div className="flex flex-wrap grow justify-center gap-2 m-1"> 210 <RadixesSelect who="all" toggleRadixes={toggleRadixes}/> 211 <RadixesSelect who="odd" toggleRadixes={toggleRadixes}/> 212 <RadixesSelect who="even" toggleRadixes={toggleRadixes}/> 213 </div> 214 </div> 215 <div className="card flex-row flex-wrap xl:flex-nowrap justify-center m-1">{ radixesSystems.map(rs => 216 <RadixSelect who={rs} radixes={radixes} toggleRadixes={toggleRadixes} key={rs}/>)} 217 </div> 218 <div className="flex flex-col justify-center items-center m-1"> 219 <div className="card card-border gap-2 p-2"> 220 <form className="flex flex-col xl:flex-row justify-center items-center h-fit gap-1" onReset={inputCharsSubmit} onSubmit={inputCharsSubmit} ref={formRef}> 221 <select 222 className="select select-sm rounded-md bg-base-100 w-fit pl-2 pr-10 mr-1" 223 name="radix" 224 onChange={e => { updateInputRadixAndChars(e.target.value) }} 225 > 226 <option>All</option> { groupedRadixes.map(rgs => 227 <optgroup label={rgs[0].system} key={rgs[0].system} className="font-bold">{ rgs.map(r => 228 <option value={r.name} key={r.name}>{r.name}</option>)} 229 </optgroup>)} 230 </select> 231 <div className={inputCharsError ? 'tooltip tooltip-error tooltip-open' : undefined} data-tip={inputCharsError}> 232 <TextareaAutosize 233 className="supports-[field-sizing:content]:field-sizing-content mi n-w-24 max-w-[calc(100vw-5.5ch)] xl:max-w-[calc(100vw-ch)] block resize-none bg-base-100 rounded-lg font-mono leading-8 p-0 px-2" 234 name="chars" 235 rows={1} 236 cols={70} 237 value={inputChars} 238 onChange={e => { setInputChars(e.target.value) }} 239 onKeyDown={handleInputCharsKeyDown} 240 /> 241 </div> 242 <span className="join flex flex-row justify-center"> 243 <button className="join-item btn btn-sm btn-outline btn-success" type="reset">Reset</button> 244 <button className="join-item btn btn-sm btn-outline btn-error" type="submit">Set</button> 245 </span> 246 </form>{ inputRadix && 247 <div className="flex flex-row flex-wrap justify-center text-center text-xs">{ inputRadix.values.entries().map(([ k, v ]) => 248 <span key={k} className="font-mono p-1"> 249 {k}:{Number(v)} 250 </span>) } 251 </div>} 252 </div> 253 </div> 254 </div> 255 </div> 256 </header> 257 ) 258 } 259 260 const RadixesSelect = ({ who, toggleRadixes }: { who: 'all' | 'odd' | 'even', toggleRadixes: ToggleRadixes }) => 261 <span className="join"> 262 <button 263 className="btn btn-xs btn-outline btn-success join-item" 264 type="button" 265 onClick={() => { toggleRadixes(who, true) }} 266 > 267 Add 268 </button> 269 <button className="btn btn-xs btn-outline btn-neutral join-item pointer-events-none cursor-default" type="button">{ capitalize(who) }</button> 270 <button 271 className="btn btn-xs btn-outline btn-error join-item" 272 type="button" 273 onClick={() => { toggleRadixes(who, false) }} 274 > 275 Remove 276 </button> 277 </span> 278 279 const RadixSelect = ({ who, radixes, toggleRadixes }: { who: Radix['system'], radixes: Radix[], toggleRadixes: ToggleRadixes }) => 280 <div className="flex flex-col items-center min-[690px]:max-w-1/2 xl:max-w-1/4"> 281 <div className="card card-border p-1 m-1"> 282 <div className="flex justify-between items-center gap-2"> 283 <button 284 className="btn btn-xs btn-outline btn-success m-1" 285 type="button" 286 onClick={() => { toggleRadixes(who, true) }} 287 > 288 Add 289 </button> 290 <div className="card-title">{ capitalize(who) }</div> 291 <button 292 className="btn btn-xs btn-outline btn-error m-1" 293 type="button" 294 onClick={() => { toggleRadixes(who, false) }} 295 > 296 Remove 297 </button> 298 </div> 299 <div className="card-actions justify-center">{ radixes.filter(r => r.system === who).map(radix => 300 <button 301 className={`btn btn-xs btn-outline btn-neutral ${radix.enabled ? 'btn-active' : ''} w-12 m-1 p-0`} 302 type="button" 303 key={radix.name} 304 onClick={() => { toggleRadixes(radix, !radix.enabled) }} 305 > 306 { radix.name } 307 </button>) } 308 </div> 309 </div> 310 </div> 311 312 const createToggleRadixes: (radixes: Radix[], updateRadixes: UpdateRadixes) => ToggleRadixes = (radixes, updateRadixes) => (radix, enabled) => { 313 const rs = [ ...radixes ] 314 switch (radix) { 315 case 'all': 316 rs.forEach(r => { r.enabled = enabled }) 317 break 318 case 'odd': 319 rs.forEach(r => { if ((r.radix & 1n) === 1n) r.enabled = enabled }) 320 break 321 case 'even': 322 rs.forEach(r => { if ((r.radix & 1n) === 0n) r.enabled = enabled }) 323 break 324 case 'standard': 325 case 'bijective': 326 case 'balanced': 327 case 'clock': 328 case 'sum': 329 case 'balsum': 330 rs.forEach(r => { if (r.system === radix) r.enabled = enabled }) 331 break 332 default: 333 radix.enabled = enabled 334 } 335 updateRadixes(rs) 336 } 337 338 function downloadContent(content: string, fileName = 'settings.json', mimeType = 'application/json') { 339 const url = URL.createObjectURL(new Blob([ content ], { type: mimeType })) 340 const link = document.createElement('a') 341 342 document.body.append(link) 343 344 link.href = url 345 link.download = fileName 346 link.click() 347 348 link.remove() 349 350 URL.revokeObjectURL(url) 351 } 352 353 function capitalize(string: string) { 354 return string.charAt(0).toUpperCase() + string.slice(1) 355 } 356 357 function getThemeLS(): string | undefined { 358 return localStorage.getItem(LS_THEME) ?? undefined 359 } 360 361 function setThemeLS(theme: string): void { 362 localStorage.setItem(LS_THEME, theme) 363 } 364 365 function setCharsLS(chars?: string): void { 366 if (chars) { 367 localStorage.setItem(LS_CHARS, chars) 368 } else { 369 localStorage.removeItem(LS_CHARS) 370 } 371 }