diff --git a/package-lock.json b/package-lock.json index 20fe437..dbe8533 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@tanstack/react-form-devtools": "^0.1.6", "@tanstack/react-query": "^5.90.2", "@tanstack/react-router": "^1.132.37", - "@tanstack/zod-form-adapter": "^0.42.1", + "@tanstack/react-store": "^0.9.1", "buffer": "^6.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -4035,6 +4035,24 @@ "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@tanstack/react-form/node_modules/@tanstack/react-store": { + "version": "0.7.7", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.7.7.tgz", + "integrity": "sha512-qqT0ufegFRDGSof9D/VqaZgjNgp4tRPHZIJq2+QIHkMUtHjaJ0lYrrXjeIUJvjnTbgPfSD1XgOMEt0lmANn6Zg==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.7.7", + "use-sync-external-store": "^1.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@tanstack/react-query": { "version": "5.90.7", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.7.tgz", @@ -4146,13 +4164,13 @@ } }, "node_modules/@tanstack/react-store": { - "version": "0.7.7", - "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.7.7.tgz", - "integrity": "sha512-qqT0ufegFRDGSof9D/VqaZgjNgp4tRPHZIJq2+QIHkMUtHjaJ0lYrrXjeIUJvjnTbgPfSD1XgOMEt0lmANn6Zg==", + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.1.tgz", + "integrity": "sha512-YzJLnRvy5lIEFTLWBAZmcOjK3+2AepnBv/sr6NZmiqJvq7zTQggyK99Gw8fqYdMdHPQWXjz0epFKJXC+9V2xDA==", "license": "MIT", "dependencies": { - "@tanstack/store": "0.7.7", - "use-sync-external-store": "^1.5.0" + "@tanstack/store": "0.9.1", + "use-sync-external-store": "^1.6.0" }, "funding": { "type": "github", @@ -4163,6 +4181,16 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@tanstack/react-store/node_modules/@tanstack/store": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.1.tgz", + "integrity": "sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/router-core": { "version": "1.134.15", "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.134.15.tgz", @@ -4351,35 +4379,6 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@tanstack/zod-form-adapter": { - "version": "0.42.1", - "resolved": "https://registry.npmjs.org/@tanstack/zod-form-adapter/-/zod-form-adapter-0.42.1.tgz", - "integrity": "sha512-hPRM0lawVKP64yurW4c6KHZH6altMo2MQN14hfi+GMBTKjO9S7bW1x5LPZ5cayoJE3mBvdlahpSGT5rYZtSbXQ==", - "license": "MIT", - "dependencies": { - "@tanstack/form-core": "0.42.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "zod": "^3.x" - } - }, - "node_modules/@tanstack/zod-form-adapter/node_modules/@tanstack/form-core": { - "version": "0.42.1", - "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-0.42.1.tgz", - "integrity": "sha512-jTU0jyHqFceujdtPNv3jPVej1dTqBwa8TYdIyWB5BCwRVUBZEp1PiYEBkC9r92xu5fMpBiKc+JKud3eeVjuMiA==", - "license": "MIT", - "dependencies": { - "@tanstack/store": "^0.7.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", diff --git a/package.json b/package.json index 506809c..f86a20d 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "@tanstack/react-form-devtools": "^0.1.6", "@tanstack/react-query": "^5.90.2", "@tanstack/react-router": "^1.132.37", - "@tanstack/zod-form-adapter": "^0.42.1", + "@tanstack/react-store": "^0.9.1", "buffer": "^6.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/src/components/Header.tsx b/src/components/Header.tsx index e091e8a..5f1d158 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -10,13 +10,9 @@ import { } from '@/components/ui/menu' import { navigation } from '@/lib/navigation' import Logo from '@/components/Logo' -import type {FC} from "react"; import { useThemeController } from '@/hooks/useThemeController' - type HeaderProps = object - - - const Header: FC = () => { +function Header() { const { isDark, setTheme } = useThemeController() const handleToggle = () => { @@ -130,4 +126,4 @@ import { useThemeController } from '@/hooks/useThemeController' ) } -export default Header \ No newline at end of file +export default Header diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index c945bb0..094f928 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,9 +1,7 @@ import { Outlet } from '@tanstack/react-router' import Header from '@/components/Header' -import type { FC } from 'react' -type LayoutProps = object -const Layout: FC = () => { +export default function Layout() { return (
@@ -12,6 +10,4 @@ const Layout: FC = () => {
) -} - -export default Layout \ No newline at end of file +} \ No newline at end of file diff --git a/src/components/Loader.tsx b/src/components/Loader.tsx index 3b7156b..43aba21 100644 --- a/src/components/Loader.tsx +++ b/src/components/Loader.tsx @@ -1,4 +1,5 @@ import { Loader2 } from 'lucide-react' +import { cn } from '@/lib/utils' interface LoaderProps { size?: number @@ -6,12 +7,12 @@ interface LoaderProps { text?: string } -export function Loader({ size = 24, className = '', text }: LoaderProps) { +export function Loader({ size = 24, className, text }: LoaderProps) { return (
{text && {text}}
diff --git a/src/components/form/ConvertActions.tsx b/src/components/form/ConvertActions.tsx new file mode 100644 index 0000000..66c9c4f --- /dev/null +++ b/src/components/form/ConvertActions.tsx @@ -0,0 +1,33 @@ +import { FormButton } from './FormButton' +import { useFormContext } from '@/hooks/form' + +interface ConvertActionsProps { + onReset: () => void +} + +export function ConvertActions({ onReset }: ConvertActionsProps) { + const form = useFormContext() + + return ( +
+ + Reset + + [ + state.canSubmit, + state.isSubmitting, + ]} + > + {([canSubmit, isSubmitting]) => ( + + {isSubmitting ? 'Converting...' : 'Convert'} + + )} + +
+ ) +} diff --git a/src/components/form/ConverterPage.tsx b/src/components/form/ConverterPage.tsx new file mode 100644 index 0000000..6cfeb2c --- /dev/null +++ b/src/components/form/ConverterPage.tsx @@ -0,0 +1,116 @@ +import { useStore } from '@tanstack/react-form' +import { useConverterForm } from '@/hooks/useConverterForm' +import type { ConverterConfig, ConverterFormBase, SelectOption } from '@/lib/converter-configs' +import { FormTextArea } from './FormTextArea' + +interface ConverterPageProps { + config: ConverterConfig +} + +export function ConverterPage({ + config, +}: ConverterPageProps) { + const { + form, + output, + handleReset, + handleModeChange, + encodingOptions, + registerInputRef, + focusFirstError, + } = useConverterForm(config) + + // Selective subscription: only re-render when mode changes + const mode: string = useStore(form.store, (state) => state.values.mode) + + return ( +
+

{config.title}

+

{config.description}

+ +
{ + e.preventDefault() + e.stopPropagation() + form.handleSubmit() + focusFirstError() + }} + className="flex flex-col gap-6" + > + {config.fields.map((field) => { + // Conditional visibility + const visible = !field.visibleWhen || field.visibleWhen( + form.state.values, + ) + if (!visible) return null + + // Resolve dynamic properties + const resolvedLabel = field.isInput + ? config.inputLabel(mode) + : field.label + const resolvedPlaceholder = + typeof field.placeholder === 'function' + ? field.placeholder(mode) + : field.placeholder + const resolvedClassName = + typeof field.className === 'function' + ? field.className(mode) + : field.className + + if (field.type === 'select') { + const resolvedOptions: SelectOption[] = + field.options === 'encodings' + ? encodingOptions + : (field.options ?? []) + + return ( + + {(fieldApi) => ( + + )} + + ) + } + + // textarea + return ( + + {(fieldApi) => ( + + )} + + ) + })} + + + + + + {output && ( + + )} + +
+ ) +} diff --git a/src/components/form/FieldErrorMessage.tsx b/src/components/form/FieldErrorMessage.tsx new file mode 100644 index 0000000..3673a83 --- /dev/null +++ b/src/components/form/FieldErrorMessage.tsx @@ -0,0 +1,14 @@ +import { formatFieldErrors } from '@/lib/errors' + +interface FieldErrorMessageProps { + meta: { isTouched?: boolean; isBlurred?: boolean; errors?: unknown[] } + showWhenSubmitted: boolean +} + +export function FieldErrorMessage({ meta, showWhenSubmitted }: FieldErrorMessageProps) { + const shouldShow = meta.isTouched || meta.isBlurred || showWhenSubmitted + const errs = meta.errors ?? [] + return shouldShow && errs.length > 0 ? ( + {formatFieldErrors(errs)} + ) : null +} diff --git a/src/components/form/SelectField.tsx b/src/components/form/SelectField.tsx new file mode 100644 index 0000000..8d802c8 --- /dev/null +++ b/src/components/form/SelectField.tsx @@ -0,0 +1,29 @@ +import { FormSelect } from './FormSelect' +import { FieldErrorMessage } from './FieldErrorMessage' +import { useFieldContext } from '@/hooks/form' +import type { SelectOption } from '@/lib/converter-configs' + +interface SelectFieldProps { + label: string + options: SelectOption[] +} + +export function SelectField({ label, options }: SelectFieldProps) { + const field = useFieldContext() + + return ( + <> + field.setValue(value)} + options={options} + /> + + + ) +} diff --git a/src/components/form/TextAreaField.tsx b/src/components/form/TextAreaField.tsx new file mode 100644 index 0000000..2b45712 --- /dev/null +++ b/src/components/form/TextAreaField.tsx @@ -0,0 +1,41 @@ +import { FormTextArea } from './FormTextArea' +import { FieldErrorMessage } from './FieldErrorMessage' +import { useFieldContext } from '@/hooks/form' + +interface TextAreaFieldProps { + label: string + placeholder?: string + rows?: number + className?: string + registerRef?: (name: string) => (el: HTMLInputElement | HTMLTextAreaElement | null) => void +} + +export function TextAreaField({ + label, + placeholder, + rows, + className, + registerRef, +}: TextAreaFieldProps) { + const field = useFieldContext() + + return ( + <> + field.handleChange(value)} + className={className} + /> + + + ) +} diff --git a/src/components/form/index.ts b/src/components/form/index.ts index 14d3463..0156376 100644 --- a/src/components/form/index.ts +++ b/src/components/form/index.ts @@ -1,3 +1,5 @@ export { FormSelect } from './FormSelect' export { FormTextArea } from './FormTextArea' -export { FormButton } from './FormButton' \ No newline at end of file +export { FormButton } from './FormButton' +export { FieldErrorMessage } from './FieldErrorMessage' +export { ConverterPage } from './ConverterPage' diff --git a/src/hooks/form.ts b/src/hooks/form.ts new file mode 100644 index 0000000..dc9875a --- /dev/null +++ b/src/hooks/form.ts @@ -0,0 +1,19 @@ +import { createFormHook, createFormHookContexts } from '@tanstack/react-form' +import { SelectField } from '@/components/form/SelectField' +import { TextAreaField } from '@/components/form/TextAreaField' +import { ConvertActions } from '@/components/form/ConvertActions' + +export const { fieldContext, useFieldContext, formContext, useFormContext } = + createFormHookContexts() + +export const { useAppForm } = createFormHook({ + fieldComponents: { + SelectField, + TextAreaField, + }, + formComponents: { + ConvertActions, + }, + fieldContext, + formContext, +}) diff --git a/src/hooks/useConverterForm.ts b/src/hooks/useConverterForm.ts index 723c359..18bcf12 100644 --- a/src/hooks/useConverterForm.ts +++ b/src/hooks/useConverterForm.ts @@ -1,35 +1,30 @@ -import { useForm } from '@tanstack/react-form' -import { useEffect, useState, useRef, useMemo } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' import { POPULAR_ENCODINGS } from '@/lib/encoding' import { getErrorMessage } from '@/lib/errors' +import { useAppForm } from '@/hooks/form' +import type { ConverterConfig, ConverterFormBase, SelectOption } from '@/lib/converter-configs' -interface UseConverterFormOptions { - defaultValues: T - // eslint-disable-next-line @typescript-eslint/no-explicit-any - validationSchema?: any - onSubmit: (values: T) => Promise +interface FieldMetaLike { + errors?: unknown[] } -export function useConverterForm({ - defaultValues, - validationSchema, - onSubmit, - }: UseConverterFormOptions) { +export function useConverterForm( + config: ConverterConfig, +) { const [output, setOutput] = useState('') - const form = useForm({ - defaultValues, - validators: validationSchema, + const form = useAppForm({ + defaultValues: config.defaultValues, + validators: { onChange: config.schema }, onSubmit: async ({ value }) => { try { - setOutput('') // Clear previous output - - await onSubmit(value) + setOutput('') + const result = await config.onSubmit(value) + setOutput(result) } catch (error) { const errorMessage = getErrorMessage(error) setOutput(`Error: ${errorMessage}`) - // Only log detailed errors in development if (import.meta.env.DEV) { console.error('Conversion error:', error) } @@ -37,40 +32,68 @@ export function useConverterForm({ }, }) - // Clear output when mode changes (fixed memory leak) - const prevModeRef = useRef(form.state.values.mode) + // Clear output and reset input — used as a field listener for mode changes. + // ConverterFormBase guarantees 'input' exists, but the generic indirection + // prevents TypeScript from resolving the key — hence `as never`. + const handleModeChange = useCallback(() => { + setOutput('') + form.setFieldValue('input' as never, '' as never) + }, [form]) - useEffect(() => { - const unsubscribe = form.store.subscribe(() => { - const nextMode = form.state.values.mode - if (nextMode !== prevModeRef.current) { - prevModeRef.current = nextMode - setOutput('') - } - }) - return unsubscribe - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [form.store]) + // ----------------------------------------------------------------------- + // Focus management (absorbed from useFormHelpers) + // ----------------------------------------------------------------------- + + const inputRefs = useRef>({}) - const handleReset = () => { + const registerInputRef = useCallback( + (name: string) => + (el: HTMLInputElement | HTMLTextAreaElement | null): void => { + inputRefs.current[name] = el + }, + [], + ) + + const focusFirstError = useCallback((): void => { + const fieldMeta = form.state.fieldMeta as Record + const firstBad = Object.entries(fieldMeta).find( + ([, meta]) => (meta.errors?.length ?? 0) > 0, + ) + if (firstBad) { + const [name] = firstBad + inputRefs.current[name]?.focus() + } + }, [form.state.fieldMeta]) + + // ----------------------------------------------------------------------- + // Callbacks + // ----------------------------------------------------------------------- + + const handleReset = useCallback(() => { form.reset() setOutput('') - } + }, [form]) + + // ----------------------------------------------------------------------- + // Encoding options + // ----------------------------------------------------------------------- - // Memoize encoding options to avoid recreating on every render - const encodingOptions = useMemo( - () => POPULAR_ENCODINGS.map(enc => ({ - value: enc, - label: enc.toUpperCase() - })), - [] + const encodingOptions: SelectOption[] = useMemo( + () => + POPULAR_ENCODINGS.map((enc) => ({ + value: enc, + label: enc.toUpperCase(), + })), + [], ) return { form, output, - setOutput, handleReset, + handleModeChange, encodingOptions, + registerInputRef, + focusFirstError, } -} \ No newline at end of file +} diff --git a/src/hooks/useFormHelpers.ts b/src/hooks/useFormHelpers.ts deleted file mode 100644 index 32ba9ed..0000000 --- a/src/hooks/useFormHelpers.ts +++ /dev/null @@ -1,56 +0,0 @@ -import {useEffect, useRef} from 'react' - -interface FieldMetaLike { - errors?: unknown[] -} - -/** - * Helper hook for TanStack Form mode-change handling and focus management - */ -export function useFormHelpers< - // eslint-disable-next-line @typescript-eslint/no-explicit-any - TFormData extends Record ->( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - form: any -) { - // Keep refs to input / textarea elements - const inputRefs = useRef>({}) - - // Keep track of previous mode to detect changes - const prevModeRef = useRef(form.state.values.mode) - - // Reset "input" field whenever mode changes - useEffect(() => { - return form.store.subscribe(() => { - const nextMode = form.state.values.mode - if (nextMode !== prevModeRef.current) { - prevModeRef.current = nextMode - form.setFieldValue('input', '') - form.resetFieldMeta?.('input') - } - }) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [form.store]) - - // Register field refs in JSX: ref={registerInputRef('input')} - const registerInputRef = - (name: keyof TFormData & string) => - (el: HTMLInputElement | HTMLTextAreaElement | null): void => { - inputRefs.current[name] = el - } - - // Focus the first invalid field after submit - const focusFirstError = (): void => { - const fieldMeta = form.state.fieldMeta as Record - const firstBad = Object.entries(fieldMeta).find( - ([, meta]) => (meta.errors?.length ?? 0) > 0 - ) - if (firstBad) { - const [name] = firstBad - inputRefs.current[name]?.focus() - } - } - - return { inputRefs, registerInputRef, focusFirstError } -} diff --git a/src/hooks/useThemeController.ts b/src/hooks/useThemeController.ts index efd5d0b..76af526 100644 --- a/src/hooks/useThemeController.ts +++ b/src/hooks/useThemeController.ts @@ -24,24 +24,9 @@ export function useThemeController() { apply(isDark); }, [isDark]); - /** - * Public API - * - "dark" → force dark - * - "light" → force light - * - "system" → follow OS - */ const setTheme = (mode: ThemeMode) => { - if (mode === "dark") { - enable(); - } else if (mode === "light") { - disable(); - } else { - if (systemDark) { - enable(); - } else { - disable(); - } - } + const dark = mode === "dark" || (mode === "system" && systemDark); + if (dark) enable(); else disable(); }; return {setTheme, isDark} diff --git a/src/lib/converter-configs.ts b/src/lib/converter-configs.ts new file mode 100644 index 0000000..226a2b4 --- /dev/null +++ b/src/lib/converter-configs.ts @@ -0,0 +1,321 @@ +import type { ZodType } from 'zod' +import { Binary, Base64, Hex, URLEncode, isValidEncoding } from '@/lib/encoding' +import { + binaryConverterSchema, + base64ConverterSchema, + hexConverterSchema, + urlEncoderSchema, + type BinaryConverterForm, + type Base64ConverterForm, + type HexConverterForm, + type URLEncoderForm, +} from '@/lib/validation-schemas' + +// --------------------------------------------------------------------------- +// Shared types +// --------------------------------------------------------------------------- + +export interface SelectOption { + value: string + label: string +} + +export interface ConverterFormBase { + mode: string + input: string +} + +export interface FieldConfig> { + /** Field name — must match a key in the form's default values */ + name: string + /** Render type */ + type: 'select' | 'textarea' + /** Label text (static) */ + label: string + /** Options for select fields; 'encodings' resolves to the encoding list at render time */ + options?: SelectOption[] | 'encodings' + /** Textarea row count */ + rows?: number + /** Static or mode-dependent placeholder */ + placeholder?: string | ((mode: string) => string) + /** Show this field only when the predicate returns true */ + visibleWhen?: (values: T) => boolean + /** Marks this field as the primary input (receives registerInputRef and dynamic label) */ + isInput?: boolean + /** Static or mode-dependent className */ + className?: string | ((mode: string) => string | undefined) +} + +export interface ConverterConfig { + /** Page heading */ + title: string + /** Page sub-heading */ + description: string + /** Zod schema for form-level validation */ + schema: ZodType + /** TanStack Form default values */ + defaultValues: T + /** Ordered list of form fields */ + fields: FieldConfig[] + /** Conversion logic — returns the result string */ + onSubmit: (values: T) => Promise + /** Dynamic label for the primary input field */ + inputLabel: (mode: string) => string + /** Dynamic label for the output area */ + outputLabel: (mode: string) => string +} + +// --------------------------------------------------------------------------- +// Mode options shared across all converters +// --------------------------------------------------------------------------- + +function modeOptions(encodeName: string, decodeName: string): SelectOption[] { + return [ + { value: 'encode', label: `Encode (Text \u2192 ${encodeName})` }, + { value: 'decode', label: `Decode (${decodeName} \u2192 Text)` }, + ] +} + +// --------------------------------------------------------------------------- +// Binary converter config +// --------------------------------------------------------------------------- + +export const binaryConverterConfig: ConverterConfig = { + title: 'Text \u2194 Binary Converter', + description: 'Convert between text and binary. Choose mode, encoding, and delimiter.', + schema: binaryConverterSchema, + defaultValues: { + mode: 'encode', + encoding: 'utf8', + delimiter: ' ', + input: '', + }, + fields: [ + { + name: 'mode', + type: 'select', + label: 'Mode', + options: modeOptions('Binary', 'Binary'), + }, + { + name: 'encoding', + type: 'select', + label: 'Character encoding', + options: 'encodings', + }, + { + name: 'delimiter', + type: 'select', + label: 'Delimiter', + options: [ + { value: ' ', label: 'Space' }, + { value: '', label: 'None' }, + { value: '-', label: 'Dash' }, + { value: ',', label: 'Comma' }, + ], + }, + { + name: 'input', + type: 'textarea', + label: '', + isInput: true, + placeholder: (mode) => + mode === 'decode' + ? 'Enter binary groups e.g. 01001000 01100101' + : 'Enter text...', + className: (mode) => (mode === 'decode' ? 'font-mono' : undefined), + }, + ], + onSubmit: async (values) => { + const { mode, encoding, delimiter, input } = values + const enc = isValidEncoding(encoding) ? encoding : 'utf8' + return mode === 'decode' + ? await Binary.toText(input, { encoding: enc, delimiter }) + : await Binary.fromText(input, { encoding: enc, delimiter }) + }, + inputLabel: (mode) => (mode === 'decode' ? 'Binary input' : 'Text input'), + outputLabel: (mode) => (mode === 'decode' ? 'Text output' : 'Binary output'), +} + +// --------------------------------------------------------------------------- +// Base64 converter config +// --------------------------------------------------------------------------- + +export const base64ConverterConfig: ConverterConfig = { + title: 'Text \u2194 Base64 Converter', + description: 'Convert between text and Base64. Choose mode and encoding.', + schema: base64ConverterSchema, + defaultValues: { + mode: 'encode', + encoding: 'utf8', + input: '', + }, + fields: [ + { + name: 'mode', + type: 'select', + label: 'Mode', + options: modeOptions('Base64', 'Base64'), + }, + { + name: 'encoding', + type: 'select', + label: 'Character encoding', + options: 'encodings', + }, + { + name: 'input', + type: 'textarea', + label: '', + isInput: true, + placeholder: (mode) => + mode === 'decode' + ? 'Enter Base64 string e.g. SGVsbG8gV29ybGQ=' + : 'Enter text...', + className: (mode) => (mode === 'decode' ? 'font-mono' : undefined), + }, + ], + onSubmit: async (values) => { + const { mode, encoding, input } = values + const enc = isValidEncoding(encoding) ? encoding : 'utf8' + return mode === 'decode' + ? await Base64.decode(input, { encoding: enc }) + : await Base64.encode(input, { encoding: enc }) + }, + inputLabel: (mode) => (mode === 'decode' ? 'Base64 input' : 'Text input'), + outputLabel: (mode) => (mode === 'decode' ? 'Text output' : 'Base64 output'), +} + +// --------------------------------------------------------------------------- +// Hex converter config +// --------------------------------------------------------------------------- + +export const hexConverterConfig: ConverterConfig = { + title: 'Text \u2194 Hex Converter', + description: 'Convert between text and hexadecimal. Choose mode, encoding, and format.', + schema: hexConverterSchema, + defaultValues: { + mode: 'encode', + encoding: 'utf8', + uppercase: 'false', + delimiter: '', + input: '', + }, + fields: [ + { + name: 'mode', + type: 'select', + label: 'Mode', + options: modeOptions('Hex', 'Hex'), + }, + { + name: 'encoding', + type: 'select', + label: 'Character encoding', + options: 'encodings', + }, + { + name: 'uppercase', + type: 'select', + label: 'Output case', + options: [ + { value: 'false', label: 'Lowercase' }, + { value: 'true', label: 'Uppercase' }, + ], + visibleWhen: (values) => values.mode === 'encode', + }, + { + name: 'delimiter', + type: 'select', + label: 'Delimiter', + options: [ + { value: '', label: 'None' }, + { value: ' ', label: 'Space' }, + { value: ':', label: 'Colon' }, + { value: '-', label: 'Dash' }, + { value: ',', label: 'Comma' }, + ], + }, + { + name: 'input', + type: 'textarea', + label: '', + isInput: true, + placeholder: (mode) => + mode === 'decode' + ? 'Enter hex string e.g. 48656c6c6f' + : 'Enter text...', + className: (mode) => (mode === 'decode' ? 'font-mono' : undefined), + }, + ], + onSubmit: async (values) => { + const { mode, encoding, uppercase, delimiter, input } = values + const enc = isValidEncoding(encoding) ? encoding : 'utf8' + const isUppercase = uppercase === 'true' + return mode === 'decode' + ? await Hex.decode(input, { encoding: enc, delimiter }) + : await Hex.encode(input, { encoding: enc, delimiter, uppercase: isUppercase }) + }, + inputLabel: (mode) => (mode === 'decode' ? 'Hex input' : 'Text input'), + outputLabel: (mode) => (mode === 'decode' ? 'Text output' : 'Hex output'), +} + +// --------------------------------------------------------------------------- +// URL Encoder config +// --------------------------------------------------------------------------- + +export const urlEncoderConfig: ConverterConfig = { + title: 'URL Encoder/Decoder', + description: + 'Encode or decode URL strings. Choose between component encoding (for query parameters) or full URL encoding.', + schema: urlEncoderSchema, + defaultValues: { + mode: 'encode', + encoding: 'utf8', + encodingMode: 'component', + input: '', + }, + fields: [ + { + name: 'mode', + type: 'select', + label: 'Mode', + options: modeOptions('URL', 'URL'), + }, + { + name: 'encoding', + type: 'select', + label: 'Character encoding', + options: 'encodings', + }, + { + name: 'encodingMode', + type: 'select', + label: 'Encoding Mode', + options: [ + { value: 'component', label: 'Component (for query params, encodes more characters)' }, + { value: 'full', label: "Full URL (preserves :/?#[]@!$&'()*+,;=)" }, + ], + }, + { + name: 'input', + type: 'textarea', + label: '', + isInput: true, + rows: 6, + placeholder: (mode) => + mode === 'decode' + ? 'Enter URL-encoded text (e.g., Hello%20World)' + : 'Enter text to encode (e.g., Hello World)', + }, + ], + onSubmit: async (values) => { + const { mode, encoding, encodingMode, input } = values + const enc = isValidEncoding(encoding) ? encoding : 'utf8' + return mode === 'decode' + ? await URLEncode.decode(input, { mode: encodingMode, encoding: enc }) + : await URLEncode.encode(input, { mode: encodingMode, encoding: enc }) + }, + inputLabel: (mode) => (mode === 'decode' ? 'URL-encoded input' : 'Text input'), + outputLabel: (mode) => (mode === 'decode' ? 'Decoded output' : 'URL-encoded output'), +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index e42b57f..bd0c391 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -4,29 +4,3 @@ import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } - -// Advanced Readonly Utility Types -// Exclude keys that look like setters (e.g., setFoo) - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -type NeverOnSet = T extends `set${infer _Rem}` ? never : T; - -/** - * Deep, customizable readonly utility type. - * - Skips function properties. - * - Optionally skips keys that look like setters. - * - Recursively applies readonly to nested objects if Deep is true. - */ -export type BetterReadonly = { - readonly [Key in keyof T as NeverOnSet]: - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - T[Key] extends Function - ? T[Key] - : Deep extends true - ? T[Key] extends object - ? BetterReadonly - : T[Key] - : T[Key] -}; - - diff --git a/src/lib/validation-schemas.ts b/src/lib/validation-schemas.ts index 2c50a78..d858d63 100644 --- a/src/lib/validation-schemas.ts +++ b/src/lib/validation-schemas.ts @@ -1,4 +1,4 @@ -import { z } from 'zod' +import { z, type RefinementCtx } from 'zod' /** * Maximum input size (10MB) @@ -113,6 +113,25 @@ export const hexStringSchema = z } ) +/** + * URL-encoded string validation + */ +export const urlEncodedStringSchema = z + .string() + .min(1, 'URL input cannot be empty') + .max(MAX_INPUT_SIZE, `Input is too large. Maximum size is ${(MAX_INPUT_SIZE / (1024 * 1024)).toFixed(0)}MB`) + .refine( + (val) => { + // Check for valid percent-encoding format (%XX where XX are hex digits) + const percentMatches = val.match(/%[0-9A-Fa-f]{0,2}/g) + if (!percentMatches) return true // No percent signs is valid + return percentMatches.every(match => match.length === 3) + }, + { + message: 'URL contains malformed percent-encoding. Each % must be followed by exactly 2 hexadecimal digits (e.g., %20)', + } + ) + /** * Delimiter validation */ @@ -123,6 +142,25 @@ const delimiterSchema = z.string() */ const modeSchema = z.enum(['encode', 'decode']) +/** + * Validate input conditionally based on encode/decode mode. + * In encode mode, validates against baseInputValidation. + * In decode mode, validates against the provided decode schema. + */ +function conditionalInputValidation( + decodeSchema: z.ZodType, +) { + return (data: { mode: string; input: string }, ctx: RefinementCtx) => { + const schema = data.mode === 'encode' ? baseInputValidation : decodeSchema + const result = schema.safeParse(data.input) + if (!result.success) { + for (const issue of result.error.issues) { + ctx.addIssue({ ...issue, path: ['input'] }) + } + } + } +} + /** * Schema for Binary converter form */ @@ -131,30 +169,7 @@ export const binaryConverterSchema = z.object({ encoding: encodingSchema, delimiter: delimiterSchema, input: z.string(), -}).superRefine((data, ctx) => { - // Conditional validation based on mode - if (data.mode === 'encode') { - const result = baseInputValidation.safeParse(data.input) - if (!result.success) { - result.error.issues.forEach(issue => { - ctx.addIssue({ - ...issue, - path: ['input'], - }) - }) - } - } else { - const result = binaryStringSchema.safeParse(data.input) - if (!result.success) { - result.error.issues.forEach(issue => { - ctx.addIssue({ - ...issue, - path: ['input'], - }) - }) - } - } -}) +}).superRefine(conditionalInputValidation(binaryStringSchema)) /** * Schema for Base64 converter form @@ -163,30 +178,7 @@ export const base64ConverterSchema = z.object({ mode: modeSchema, encoding: encodingSchema, input: z.string(), -}).superRefine((data, ctx) => { - // Conditional validation based on mode - if (data.mode === 'encode') { - const result = baseInputValidation.safeParse(data.input) - if (!result.success) { - result.error.issues.forEach(issue => { - ctx.addIssue({ - ...issue, - path: ['input'], - }) - }) - } - } else { - const result = base64StringSchema.safeParse(data.input) - if (!result.success) { - result.error.issues.forEach(issue => { - ctx.addIssue({ - ...issue, - path: ['input'], - }) - }) - } - } -}) +}).superRefine(conditionalInputValidation(base64StringSchema)) /** * Schema for Hexadecimal converter form @@ -197,57 +189,7 @@ export const hexConverterSchema = z.object({ uppercase: z.string(), delimiter: delimiterSchema, input: z.string(), -}).superRefine((data, ctx) => { - // Conditional validation based on mode - if (data.mode === 'encode') { - const result = baseInputValidation.safeParse(data.input) - if (!result.success) { - result.error.issues.forEach(issue => { - ctx.addIssue({ - ...issue, - path: ['input'], - }) - }) - } - } else { - const result = hexStringSchema.safeParse(data.input) - if (!result.success) { - result.error.issues.forEach(issue => { - ctx.addIssue({ - ...issue, - path: ['input'], - }) - }) - } - } -}) - -/** - * Type exports - */ -export type BinaryConverterForm = z.infer -export type Base64ConverterForm = z.infer -export type HexConverterForm = z.infer -export type URLEncoderForm = z.infer - -/** - * URL-encoded string validation - */ -export const urlEncodedStringSchema = z - .string() - .min(1, 'URL input cannot be empty') - .max(MAX_INPUT_SIZE, `Input is too large. Maximum size is ${(MAX_INPUT_SIZE / (1024 * 1024)).toFixed(0)}MB`) - .refine( - (val) => { - // Check for valid percent-encoding format (%XX where XX are hex digits) - const percentMatches = val.match(/%[0-9A-Fa-f]{0,2}/g) - if (!percentMatches) return true // No percent signs is valid - return percentMatches.every(match => match.length === 3) - }, - { - message: 'URL contains malformed percent-encoding. Each % must be followed by exactly 2 hexadecimal digits (e.g., %20)', - } - ) +}).superRefine(conditionalInputValidation(hexStringSchema)) /** * URL Encoder form schema @@ -257,27 +199,12 @@ export const urlEncoderSchema = z.object({ encoding: encodingSchema, encodingMode: z.enum(['component', 'full']), input: z.string(), -}).superRefine((data, ctx) => { - // Conditional validation based on mode - if (data.mode === 'encode') { - const result = baseInputValidation.safeParse(data.input) - if (!result.success) { - result.error.issues.forEach(issue => { - ctx.addIssue({ - ...issue, - path: ['input'], - }) - }) - } - } else { - const result = urlEncodedStringSchema.safeParse(data.input) - if (!result.success) { - result.error.issues.forEach(issue => { - ctx.addIssue({ - ...issue, - path: ['input'], - }) - }) - } - } -}) +}).superRefine(conditionalInputValidation(urlEncodedStringSchema)) + +/** + * Type exports + */ +export type BinaryConverterForm = z.infer +export type Base64ConverterForm = z.infer +export type HexConverterForm = z.infer +export type URLEncoderForm = z.infer diff --git a/src/pages/TextBase64.tsx b/src/pages/TextBase64.tsx index 71151b1..bd006ca 100644 --- a/src/pages/TextBase64.tsx +++ b/src/pages/TextBase64.tsx @@ -1,168 +1,6 @@ -import { Base64, isValidEncoding } from '@/lib/encoding' -import { FormButton, FormSelect, FormTextArea } from '@/components/form' -import { useConverterForm } from '@/hooks/useConverterForm' -import { useFormHelpers } from '@/hooks/useFormHelpers' -import { base64ConverterSchema, type Base64ConverterForm } from '@/lib/validation-schemas' -import { formatFieldErrors } from '@/lib/errors' - -function FieldError({ - meta, - showWhenSubmitted, -}: { - meta: { isTouched?: boolean; isBlurred?: boolean; errors?: unknown[] } - showWhenSubmitted: boolean -}) { - const shouldShow = meta.isTouched || meta.isBlurred || showWhenSubmitted - const errs = meta.errors ?? [] - return shouldShow && errs.length > 0 ? ( - {formatFieldErrors(errs)} - ) : null -} +import { ConverterPage } from '@/components/form' +import { base64ConverterConfig } from '@/lib/converter-configs' export default function TextBase64Converter() { - const { encode, decode } = Base64 - - const { form, output, setOutput, handleReset, encodingOptions } = useConverterForm({ - validationSchema: { onChange: base64ConverterSchema }, - defaultValues: { - mode: 'encode', - encoding: 'utf8', - input: '', - }, - onSubmit: async (value) => { - const { mode, encoding, input } = value - const enc = isValidEncoding(encoding) ? encoding : 'utf8' - - const result = mode === 'decode' - ? await decode(input, { encoding: enc }) - : await encode(input, { encoding: enc }) - - setOutput(result) - }, - }) - - const { registerInputRef, focusFirstError } = useFormHelpers(form) - - const inputLabel = form.state.values.mode === 'decode' ? 'Base64 input' : 'Text input' - const outputLabel = form.state.values.mode === 'decode' ? 'Text output' : 'Base64 output' - const inputPlaceholder = form.state.values.mode === 'decode' - ? 'Enter Base64 string e.g. SGVsbG8gV29ybGQ=' - : 'Enter text...' - - return ( -
-

Text ↔ Base64 Converter

-

- Convert between text and Base64. Choose mode and encoding. -

- -
{ - e.preventDefault() - e.stopPropagation() - form.handleSubmit() - focusFirstError() - }} - className="flex flex-col gap-6" - > - - {(field) => { - const handleChange = (value: string) => { - field.setValue(value as Base64ConverterForm['mode']) - } - return ( - <> - - - - ) - }} - - - - {(field) => ( - <> - field.setValue(value)} - options={encodingOptions} - /> - - - )} - - - - {(field) => ( - <> - field.handleChange(e)} - className={form.state.values.mode === 'decode' ? 'font-mono' : undefined} - /> - - - )} - - -
- - Reset - - [state.canSubmit, state.isSubmitting]} - children={([canSubmit, isSubmitting]) => ( - - {isSubmitting ? 'Converting...' : 'Convert'} - - )} - /> -
- - {output && ( - - )} - -
- ) + return } diff --git a/src/pages/TextToBinary.tsx b/src/pages/TextToBinary.tsx index e416aab..56d8da6 100644 --- a/src/pages/TextToBinary.tsx +++ b/src/pages/TextToBinary.tsx @@ -1,185 +1,6 @@ -import { Binary, isValidEncoding } from '@/lib/encoding' -import { FormButton, FormSelect, FormTextArea } from '@/components/form' -import { useConverterForm } from '@/hooks/useConverterForm' -import { useFormHelpers } from '@/hooks/useFormHelpers' -import { binaryConverterSchema, type BinaryConverterForm } from '@/lib/validation-schemas' -import { formatFieldErrors } from '@/lib/errors' - -function FieldError({ - meta, - showWhenSubmitted, -}: { - meta: { isTouched?: boolean; isBlurred?: boolean; errors?: unknown[] } - showWhenSubmitted: boolean -}) { - const shouldShow = meta.isTouched || meta.isBlurred || showWhenSubmitted - const errs = meta.errors ?? [] - return shouldShow && errs.length > 0 ? ( - {formatFieldErrors(errs)} - ) : null -} +import { ConverterPage } from '@/components/form' +import { binaryConverterConfig } from '@/lib/converter-configs' export default function TextBinaryConverter() { - const { fromText, toText } = Binary - - const { form, output, setOutput, handleReset, encodingOptions } = useConverterForm({ - validationSchema: { onChange: binaryConverterSchema }, - defaultValues: { - mode: 'encode', - encoding: 'utf8', - delimiter: ' ', - input: '', - }, - onSubmit: async (value) => { - const { mode, encoding, delimiter, input } = value - const enc = isValidEncoding(encoding) ? encoding : 'utf8' - - const result = mode === 'decode' - ? await toText(input, { encoding: enc, delimiter }) - : await fromText(input, { encoding: enc, delimiter }) - - setOutput(result) - }, - }) - - const { registerInputRef, focusFirstError } = useFormHelpers(form) - - const delimiterOptions = [ - { value: ' ', label: 'Space' }, - { value: '', label: 'None' }, - { value: '-', label: 'Dash' }, - { value: ',', label: 'Comma' }, - ] - - const inputLabel = form.state.values.mode === 'decode' ? 'Binary input' : 'Text input' - const outputLabel = form.state.values.mode === 'decode' ? 'Text output' : 'Binary output' - - return ( -
-

Text ↔ Binary Converter

-

- Convert between text and binary. Choose mode, encoding, and delimiter. -

- -
{ - e.preventDefault() - e.stopPropagation() - form.handleSubmit() - focusFirstError() - }} - className="flex flex-col gap-6" - > - - {(field) => { - const handleChange = (value: string) => { - field.setValue(value as BinaryConverterForm['mode']) - } - return ( - <> - - - - ) - }} - - - - {(field) => ( - <> - field.setValue(value)} - options={encodingOptions} - /> - - - )} - - - - {(field) => ( - <> - field.setValue(value)} - options={delimiterOptions} - /> - - - )} - - - - {(field) => ( - <> - field.handleChange(e)} - className={form.state.values.mode === 'decode' ? 'font-mono' : undefined} - /> - - - )} - - -
- - Reset - - [state.canSubmit, state.isSubmitting]} - children={([canSubmit, isSubmitting]) => ( - - {isSubmitting ? 'Converting...' : 'Convert'} - - )} - /> -
- - {output && ( - - )} - -
- ) + return } diff --git a/src/pages/TextToHexadecimal.tsx b/src/pages/TextToHexadecimal.tsx index 5a3496a..a168385 100644 --- a/src/pages/TextToHexadecimal.tsx +++ b/src/pages/TextToHexadecimal.tsx @@ -1,223 +1,6 @@ -import { Hex, isValidEncoding } from '@/lib/encoding' -import { FormButton, FormSelect, FormTextArea } from '@/components/form' -import { useConverterForm } from '@/hooks/useConverterForm' -import { useFormHelpers } from '@/hooks/useFormHelpers' -import { hexConverterSchema, type HexConverterForm } from '@/lib/validation-schemas' -import { formatFieldErrors } from '@/lib/errors' - -function FieldError({ - meta, - showWhenSubmitted, -}: { - meta: { isTouched?: boolean; isBlurred?: boolean; errors?: unknown[] } - showWhenSubmitted: boolean -}) { - const shouldShow = meta.isTouched || meta.isBlurred || showWhenSubmitted - const errs = meta.errors ?? [] - return shouldShow && errs.length > 0 ? ( - {formatFieldErrors(errs)} - ) : null -} +import { ConverterPage } from '@/components/form' +import { hexConverterConfig } from '@/lib/converter-configs' export default function TextHexConverter() { - const { encode, decode } = Hex - - const { form, output, setOutput, handleReset, encodingOptions } = useConverterForm({ - validationSchema: { onChange: hexConverterSchema }, - defaultValues: { - mode: 'encode', - encoding: 'utf8', - uppercase: 'false', - delimiter: '', - input: '', - }, - onSubmit: async (value) => { - const { mode, encoding, uppercase, delimiter, input } = value - const enc = isValidEncoding(encoding) ? encoding : 'utf8' - const isUppercase = uppercase === 'true' - - const result = mode === 'decode' - ? await decode(input, { encoding: enc, delimiter }) - : await encode(input, { encoding: enc, delimiter, uppercase: isUppercase }) - - setOutput(result) - }, - }) - - const { registerInputRef, focusFirstError } = useFormHelpers(form) - - const delimiterOptions = [ - { value: '', label: 'None' }, - { value: ' ', label: 'Space' }, - { value: ':', label: 'Colon' }, - { value: '-', label: 'Dash' }, - { value: ',', label: 'Comma' }, - ] - - const caseOptions = [ - { value: 'false', label: 'Lowercase' }, - { value: 'true', label: 'Uppercase' }, - ] - - const inputLabel = form.state.values.mode === 'decode' ? 'Hex input' : 'Text input' - const outputLabel = form.state.values.mode === 'decode' ? 'Text output' : 'Hex output' - - return ( -
-

Text ↔ Hex Converter

-

- Convert between text and hexadecimal. Choose mode, encoding, and format. -

- -
{ - e.preventDefault() - e.stopPropagation() - form.handleSubmit() - focusFirstError() - }} - className="flex flex-col gap-6" - > - - {(field) => { - const handleChange = (value: string) => { - field.setValue(value as HexConverterForm['mode']) - } - return ( - <> - - - - ) - }} - - - - {(field) => ( - <> - field.setValue(value)} - options={encodingOptions} - /> - - - )} - - - {form.state.values.mode === 'encode' && ( - - {(field) => ( - <> - field.setValue(value)} - options={caseOptions} - /> - - - )} - - )} - - - {(field) => ( - <> - field.setValue(value)} - options={delimiterOptions} - /> - - - )} - - - - {(field) => ( - <> - field.handleChange(e)} - className={form.state.values.mode === 'decode' ? 'font-mono' : undefined} - /> - - - )} - - -
- - Reset - - [state.canSubmit, state.isSubmitting]} - children={([canSubmit, isSubmitting]) => ( - - {isSubmitting ? 'Converting...' : 'Convert'} - - )} - /> -
- - {output && ( - - )} - -
- ) -} \ No newline at end of file + return +} diff --git a/src/pages/URLEncoder.tsx b/src/pages/URLEncoder.tsx index 4c74bc9..dd21cb9 100644 --- a/src/pages/URLEncoder.tsx +++ b/src/pages/URLEncoder.tsx @@ -1,181 +1,6 @@ -import { URLEncode, isValidEncoding } from '@/lib/encoding' -import { FormButton, FormSelect, FormTextArea } from '@/components/form' -import { useConverterForm } from '@/hooks/useConverterForm' -import { useFormHelpers } from '@/hooks/useFormHelpers' -import { urlEncoderSchema, type URLEncoderForm } from '@/lib/validation-schemas' -import { formatFieldErrors } from '@/lib/errors' - -function FieldError({ - meta, - showWhenSubmitted, -}: { - meta: { isTouched?: boolean; isBlurred?: boolean; errors?: unknown[] } - showWhenSubmitted: boolean -}) { - const shouldShow = meta.isTouched || meta.isBlurred || showWhenSubmitted - const errs = meta.errors ?? [] - return shouldShow && errs.length > 0 ? ( - {formatFieldErrors(errs)} - ) : null -} +import { ConverterPage } from '@/components/form' +import { urlEncoderConfig } from '@/lib/converter-configs' export default function URLEncoder() { - const { form, output, setOutput, handleReset, encodingOptions } = useConverterForm({ - validationSchema: { onChange: urlEncoderSchema }, - defaultValues: { - mode: 'encode', - encoding: 'utf8', - encodingMode: 'component', - input: '', - }, - onSubmit: async (value) => { - const { mode, encoding, encodingMode, input } = value - const enc = isValidEncoding(encoding) ? encoding : 'utf8' - - const result = mode === 'decode' - ? await URLEncode.decode(input, { mode: encodingMode, encoding: enc }) - : await URLEncode.encode(input, { mode: encodingMode, encoding: enc }) - - setOutput(result) - }, - }) - - const { registerInputRef, focusFirstError } = useFormHelpers(form) - - const inputLabel = form.state.values.mode === 'decode' ? 'URL-encoded input' : 'Text input' - const outputLabel = form.state.values.mode === 'decode' ? 'Decoded output' : 'URL-encoded output' - - return ( -
-

URL Encoder/Decoder

-

- Encode or decode URL strings. Choose between component encoding (for query parameters) or full URL encoding. -

- -
{ - e.preventDefault() - e.stopPropagation() - form.handleSubmit() - focusFirstError() - }} - className="flex flex-col gap-6" - > - - {(field) => { - const handleChange = (value: string) => { - field.setValue(value as URLEncoderForm['mode']) - } - return ( - <> - - - - ) - }} - - - - {(field) => ( - <> - - - - )} - - - - {(field) => { - const handleChange = (value: string) => { - field.setValue(value as URLEncoderForm['encodingMode']) - } - return ( - <> - - - - ) - }} - - - - {(field) => ( - <> - field.handleChange(value)} - placeholder={ - form.state.values.mode === 'decode' - ? 'Enter URL-encoded text (e.g., Hello%20World)' - : 'Enter text to encode (e.g., Hello World)' - } - rows={6} - /> - - - )} - - -
- - Reset - - - Convert - -
- - {output && ( -
- -