/ src / components / header.tsx
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  }