Skip to content

Commit 480ae0a

Browse files
authored
feat(convert/bases): improve Bases page UX and refact some function
1 parent 4189507 commit 480ae0a

File tree

4 files changed

+159
-77
lines changed

4 files changed

+159
-77
lines changed

src/app/converter/bases/_components/select-base.tsx

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
SelectValue
77
} from '~/shared/components/select'
88
import { Label } from '~/shared/components/label'
9-
import { BASE_LABELS, Base } from '../_lib/convert-bases'
9+
import { Base, baseList, baseActions } from '../_lib/convert-bases'
1010

1111
interface SelectBaseProps {
1212
label: string
@@ -15,25 +15,32 @@ interface SelectBaseProps {
1515
}
1616

1717
export function SelectBase({ label, value, onChange }: SelectBaseProps) {
18-
function handleSelectBase(value: string): void {
18+
function handleSelectBase(value: string) {
1919
onChange(value as Base)
2020
}
2121

2222
return (
23-
<>
24-
<Label>{label}</Label>
23+
<div className="space-y-1">
24+
<Label htmlFor={label} className="font-normal">
25+
{label}
26+
</Label>
2527
<Select value={value} onValueChange={handleSelectBase}>
26-
<SelectTrigger>
28+
<SelectTrigger id={label} className="font-medium">
2729
<SelectValue placeholder={label} />
2830
</SelectTrigger>
2931
<SelectContent>
30-
{BASE_LABELS.map((base, i) => (
31-
<SelectItem value={base.type} key={i}>
32-
{base.label}
32+
{baseList.map((base, i) => (
33+
<SelectItem value={base} key={i}>
34+
<span className="flex gap-2">
35+
<span>{baseActions[base].dictionary.label}</span>
36+
<span className="text-muted-foreground">
37+
{baseActions[base].dictionary.example}
38+
</span>
39+
</span>
3340
</SelectItem>
3441
))}
3542
</SelectContent>
3643
</Select>
37-
</>
44+
</div>
3845
)
3946
}
Lines changed: 60 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,76 @@
1-
export type Base = 'BIN' | 'HEX' | 'DEC'
1+
import { parseBigInt } from './parse-big-int'
22

3-
const onlyBinaryRegex = /^[01]+$/
4-
const onlyHexRegex = /^(0x)?[0-9A-Fa-f]+$/
5-
const onlyDecimalRegex = /^[0-9]+$/
3+
export const baseList = ['BIN', 'HEX', 'DEC'] as const
4+
export type Base = (typeof baseList)[number]
65

7-
export const BASE_LABELS: Array<{ type: Base; label: string }> = [
8-
{ type: 'BIN', label: 'Binary' },
9-
{ type: 'HEX', label: 'Hex' },
10-
{ type: 'DEC', label: 'Decimal' }
11-
]
12-
13-
function convertBaseToRadix(base: Base): number {
14-
if (base === 'BIN') return 2
15-
if (base === 'DEC') return 10
16-
if (base === 'HEX') return 16
17-
18-
return 10
19-
}
20-
21-
export function convertBases(input: string, from: Base, to: Base): string {
22-
if (from === 'BIN' && !onlyBinaryRegex.test(input)) {
23-
throw new Error('The input value must be a binary.')
6+
type BaseActions = {
7+
[key in Base]: {
8+
dictionary: {
9+
label: string
10+
example: string
11+
error: string
12+
}
13+
radix: bigint
14+
validate: (input: string) => boolean
2415
}
25-
26-
if (from === 'HEX' && !onlyHexRegex.test(input)) {
27-
throw new Error('The input value must be a hex.')
16+
}
17+
export const baseActions: BaseActions = {
18+
BIN: {
19+
dictionary: {
20+
label: 'Binary',
21+
example: '01011',
22+
error: 'The input value must be a binary (01011)'
23+
},
24+
radix: 2n,
25+
validate: (input: string) => /^[01]+$/.test(input)
26+
},
27+
DEC: {
28+
dictionary: {
29+
label: 'Decimal',
30+
example: '123',
31+
error: 'The input value must be a decimal (123)'
32+
},
33+
radix: 10n,
34+
validate: (input: string) => /^[0-9]+$/.test(input)
35+
},
36+
HEX: {
37+
dictionary: {
38+
label: 'Hex',
39+
example: '0x0A',
40+
error: 'The input value must be a hexadecimal (0x0A)'
41+
},
42+
radix: 16n,
43+
validate: (input: string) => /^(0x)?[0-9A-Fa-f]+$/.test(input)
2844
}
45+
}
2946

30-
if (from === 'DEC' && !onlyDecimalRegex.test(input)) {
31-
throw new Error('The input value must be a decimal.')
47+
export function convertBases(
48+
input: string,
49+
from: Base,
50+
to: Base
51+
): string | undefined {
52+
if (
53+
input.length < 1 ||
54+
(from === 'BIN' && !baseActions.BIN.validate(input)) ||
55+
(from === 'HEX' && !baseActions.HEX.validate(input)) ||
56+
(from === 'DEC' && !baseActions.DEC.validate(input))
57+
) {
58+
return undefined
3259
}
3360

3461
if (from === to) return input
3562

36-
let converted = parseInt(input, convertBaseToRadix(from))
37-
.toString(convertBaseToRadix(to))
38-
.toUpperCase()
63+
let converted = (
64+
parseBigInt(input, baseActions[from].radix) as Number
65+
).toString(Number(baseActions[to].radix))
3966

4067
if (from === 'HEX' && to === 'BIN') {
4168
converted = converted.padStart(8, '0')
4269
}
4370

71+
if (to === 'DEC') {
72+
converted = Number(converted).toLocaleString().replace(/\./g, '')
73+
}
74+
4475
return converted
4576
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// https://es.discourse.group/t/feature-request-parseint-for-bigint-convert-any-string-of-any-radix/150/8
2+
export function parseBigInt(str: string, base = 36n) {
3+
str = str.toUpperCase()
4+
5+
const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'
6+
7+
return Array.prototype.reduce.call(
8+
str,
9+
(acc: unknown, digit: string) => {
10+
const pos = BigInt(alphabet.indexOf(digit))
11+
return (acc as bigint) * base + pos
12+
},
13+
0n
14+
)
15+
}

src/app/converter/bases/page.tsx

Lines changed: 68 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,101 @@
11
'use client'
22

33
import { ChangeEvent, useState } from 'react'
4+
import { ArrowRightLeft } from 'lucide-react'
5+
46
import { Textarea } from '~/shared/components/textarea'
7+
import { CopyButton } from '~/shared/components/copy-button'
8+
import { Label } from '~/shared/components/label'
59
import { Button } from '~/shared/components/button'
6-
import { Binary, Cpu } from 'lucide-react'
7-
import { convertBases, Base } from './_lib/convert-bases'
10+
11+
import { convertBases, Base, baseActions } from './_lib/convert-bases'
812
import { SelectBase } from './_components/select-base'
9-
import { CopyButton } from '~/shared/components/copy-button'
10-
import { toast } from 'sonner'
1113

1214
export default function Page() {
13-
const [text, setText] = useState('')
14-
const [from, setFrom] = useState<Base>('BIN')
15-
const [to, setTo] = useState<Base>('BIN')
16-
const [output, setOutput] = useState('')
15+
const [input, setInput] = useState('')
16+
const [target, setTarget] = useState<{ from: Base; to: Base }>({
17+
from: 'BIN',
18+
to: 'HEX'
19+
})
20+
21+
function handleInput(e: ChangeEvent<HTMLTextAreaElement>) {
22+
// const value = e.target.value.replace(/[^\w\s]/gi, '').replace(/\s/g, '')
23+
const value = e.target.value
1724

18-
function handleInputText(e: ChangeEvent<HTMLTextAreaElement>): void {
19-
setText(e.target.value.replace(/[^\w\s]/gi, '').replace(/\s/g, ''))
25+
setInput(value)
2026
}
2127

22-
function handleConvertButton(): void {
23-
try {
24-
let converted = convertBases(text, from, to)
25-
setOutput(converted)
26-
} catch (e: any) {
27-
toast.error(e.message)
28+
const { from, to } = target
29+
const output = convertBases(input, from, to) ?? ''
30+
const inputIsValid = input ? baseActions[from].validate(input) : true
31+
32+
function handleSetTarget(value: Base, selectedTarget: 'from' | 'to') {
33+
if (selectedTarget === 'from') {
34+
const swap = value === target.to ? target.from : target.to
35+
36+
setTarget({ from: value, to: swap })
37+
} else if (selectedTarget === 'to') {
38+
const swap = value === target.from ? target.to : target.from
39+
40+
setTarget({ to: value, from: swap })
2841
}
2942
}
3043

44+
const swapTargets = () =>
45+
setTarget(curr => ({ from: curr.to, to: curr.from }))
46+
3147
return (
3248
<div className="space-y-12">
33-
<div className="space-y-5">
49+
<div className="relative space-y-1">
50+
<Label htmlFor="input">Input</Label>
3451
<Textarea
35-
value={text}
36-
onChange={handleInputText}
37-
placeholder="Text to be converted here..."
38-
className="min-h-32 text-lg"
52+
id="input"
53+
value={input}
54+
onChange={handleInput}
55+
placeholder="Data to be converted here..."
56+
data-valid={inputIsValid}
57+
className="min-h-32 text-lg data-[valid='false']:border-red-500"
3958
/>
59+
<span
60+
data-valid={inputIsValid}
61+
className="text-red-500 text-sm absolute bottom-1 left-1 transition-opacity opacity-0 data-[valid='false']:opacity-100"
62+
>
63+
{baseActions[from].dictionary.error}
64+
</span>
4065
</div>
41-
<div className="flex gap-2 flex-col lg:flex-row">
42-
<div className="w-full lg:w-1/2">
43-
<SelectBase label="From" value={from} onChange={setFrom} />
44-
</div>
45-
<div className="w-full lg:w-1/2">
46-
<SelectBase label="To" value={to} onChange={setTo} />
66+
<div className="flex gap-2 flex-col items-end lg:flex-row">
67+
<div className="flex-1">
68+
<SelectBase
69+
label="From"
70+
value={from}
71+
onChange={value => handleSetTarget(value, 'from')}
72+
/>
4773
</div>
48-
<div className="flex items-end justify-end">
49-
<Button
50-
className="space-x-2"
51-
disabled={text.length < 1}
52-
onClick={handleConvertButton}
53-
>
54-
<Binary size="1em" />
55-
<span>Convert</span>
56-
</Button>
74+
<Button onClick={swapTargets} className="text-xl" variant="ghost">
75+
<ArrowRightLeft size="1em" strokeWidth="1.25px" />
76+
</Button>
77+
<div className="flex-1">
78+
<SelectBase
79+
label="To"
80+
value={to}
81+
onChange={value => handleSetTarget(value, 'to')}
82+
/>
5783
</div>
5884
</div>
59-
<div>
85+
<div className="space-y-1">
86+
<Label htmlFor="output">Output</Label>
6087
<Textarea
88+
id="output"
6189
value={output}
62-
disabled={true}
90+
disabled={!output}
6391
placeholder="Converted value here..."
6492
className="min-h-32 text-lg"
6593
/>
6694
</div>
6795
<div className="flex justify-center flex-wrap gap-5">
6896
<CopyButton
69-
text={output}
97+
text={output || ''}
98+
disabled={!output}
7099
toastMessage="Converted text copied to clipboard!"
71100
variant="secondary"
72101
/>

0 commit comments

Comments
 (0)