diff --git a/app/components/pages/utilities/roman-converter.tsx b/app/components/pages/utilities/roman-converter.tsx new file mode 100644 index 0000000..db0954e --- /dev/null +++ b/app/components/pages/utilities/roman-converter.tsx @@ -0,0 +1,213 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { MoveRightIcon } from 'lucide-react'; +import { useState } from 'react'; +import type { Resolver } from 'react-hook-form'; +import { Controller, useForm } from 'react-hook-form'; +import { z } from 'zod/v4'; +import { Button } from '~/components/ui/button'; +import { Field, FieldDescription, FieldError, FieldLabel } from '~/components/ui/field'; +import { Input } from '~/components/ui/input'; + +const RomanSchema = z.object({ + value: z.string().regex(/^(?=.)M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$/i, { + message: 'Invalid Roman numeral.', + }), +}); + +const ArabicSchema = z.object({ + value: z.coerce + .number() + .min(1, { message: 'Number must be at least 1.' }) + .max(3999, { message: 'Number must be at most 3999.' }), +}); + +type ArabicSchemaType = z.infer; +type RomanSchemaType = z.infer; + +const ROMAN_NUMERALS = ['M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I']; +const ARABIC_NUMERALS = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1]; + +export default function RomanConverter() { + const [results, setResults] = useState<{ roman: number | null; arabic: string | null }>({ + roman: null, + arabic: null, + }); + + const romanToArabicForm = useForm({ + resolver: zodResolver(RomanSchema) as Resolver, + defaultValues: { value: '' }, + }); + + const arabicToRomanForm = useForm({ + resolver: zodResolver(ArabicSchema) as Resolver, + defaultValues: { value: 0 }, + }); + + function onRomanFormSubmit(data: RomanSchemaType) { + let i = 0; + let num = 0; + let roman = data.value.toUpperCase(); + + while (roman.length > 0 && i < ROMAN_NUMERALS.length) { + const romanCharacter = ROMAN_NUMERALS[i]; + const arabicValue = ARABIC_NUMERALS[i]; + + if (arabicValue === undefined || romanCharacter === undefined) { + throw new Error(`Invalid value in ROMAN_NUMERALS or ARABIC_NUMERALS at index ${i}`); + } + + if (roman.startsWith(romanCharacter)) { + num += arabicValue; + roman = roman.slice(romanCharacter.length); + i = 0; + } else { + i++; + } + } + + setResults((prev) => ({ ...prev, roman: num })); + } + + function onArabicFormSubmit(data: ArabicSchemaType) { + let num = data.value; + let roman = ''; + let i = 0; + + while (num > 0 && i < ARABIC_NUMERALS.length) { + const arabicValue = ARABIC_NUMERALS[i]; + const romanCharacter = ROMAN_NUMERALS[i]; + + if (arabicValue === undefined || romanCharacter === undefined) { + throw new Error(`Invalid value in ARABIC_NUMERALS or ROMAN_NUMERALS at index ${i}`); + } + + if (num >= arabicValue) { + roman += romanCharacter; + num -= arabicValue; + } else { + i++; + } + } + + setResults((prev) => ({ ...prev, arabic: roman })); + } + + return ( +
+ {/* Roman -> Arabic */} +
+

+ Roman Arabic +

+
+ ( + + + Roman numeral + + + + + + Enter a Roman numeral (uppercase or lowercase). + + + {fieldState.invalid && } + + )} + /> + +
+ + +
+ + +
+
+
Result
+
+ {results.roman ?? '—'} +
+
+
+
+ + {/* Arabic -> Roman */} +
+

+ Arabic Roman +

+
+ ( + + + Number + + + + + + Enter an integer between 1 and 3999. + + + {fieldState.invalid && } + + )} + /> + +
+ + +
+ + +
+
+
Result
+
+ {results.arabic ?? '—'} +
+
+
+
+
+ ); +} diff --git a/app/routes.ts b/app/routes.ts index 861c84f..53490c3 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -35,6 +35,7 @@ export default [ route('/meme-generator', 'routes/utilities/meme-generator.tsx'), route('/password-generator', 'routes/utilities/password-generator.tsx'), route('/qrcode-generator', 'routes/utilities/qrcode-generator.tsx'), + route('/roman-converter', 'routes/utilities/roman-converter.tsx'), ]), route('/theme', 'routes/theme.tsx'), route('/sidebar', 'routes/sidebar.tsx'), diff --git a/app/routes/utilities/roman-converter.tsx b/app/routes/utilities/roman-converter.tsx new file mode 100644 index 0000000..a727c99 --- /dev/null +++ b/app/routes/utilities/roman-converter.tsx @@ -0,0 +1,56 @@ +import { Meta } from '~/components/application/meta'; +import RomanConverter from '~/components/pages/utilities/roman-converter'; +import { Metadata } from '~/types/metadata'; + +export const metadata: Metadata = { + title: 'Roman Numeral Converter', + description: + 'Convert between Roman numerals and Arabic numbers. Enter a number to see its Roman numeral equivalent, or enter a Roman numeral to see its numeric value.', + keywords: [ + 'roman numerals', + 'roman converter', + 'arabic to roman', + 'roman to arabic', + 'numeral conversion', + 'converter', + 'utilities', + ], +}; + +export default function Page() { + return ( + <> + +
+

Roman Numeral Converter

+

+ Convert between Roman numerals and Arabic numbers. Enter a number to see its Roman numeral + equivalent, or enter a Roman numeral to see its numeric value. +

+ +
+

How it works

+
    +
  • + Roman to Arabic: Enter a valid Roman numeral (e.g., “XIV”) to + see its numeric value (e.g., 14). +
  • +
  • + Arabic to Roman: Enter a number between 1 and 3999 (e.g., 2024) to see its + Roman numeral equivalent (e.g., “MMXXIV”). +
  • +
  • The converter handles both uppercase and lowercase Roman numerals.
  • +
+
+ +
+ +
+ + +
+ + ); +}