Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 34 additions & 35 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 2 additions & 6 deletions src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HeaderProps> = () => {
function Header() {
const { isDark, setTheme } = useThemeController()

const handleToggle = () => {
Expand Down Expand Up @@ -130,4 +126,4 @@ import { useThemeController } from '@/hooks/useThemeController'
)
}

export default Header
export default Header
8 changes: 2 additions & 6 deletions src/components/Layout.tsx
Original file line number Diff line number Diff line change
@@ -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<LayoutProps> = () => {
export default function Layout() {
return (
<div className="min-h-screen flex flex-col">
<Header />
Expand All @@ -12,6 +10,4 @@ const Layout: FC<LayoutProps> = () => {
</main>
</div>
)
}

export default Layout
}
5 changes: 3 additions & 2 deletions src/components/Loader.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import { Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'

interface LoaderProps {
size?: number
className?: string
text?: string
}

export function Loader({ size = 24, className = '', text }: LoaderProps) {
export function Loader({ size = 24, className, text }: LoaderProps) {
return (
<div className="flex items-center justify-center gap-2">
<Loader2
size={size}
className={`animate-spin ${className}`}
className={cn("animate-spin", className)}
/>
{text && <span className="text-sm text-muted-foreground">{text}</span>}
</div>
Expand Down
33 changes: 33 additions & 0 deletions src/components/form/ConvertActions.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex gap-2">
<FormButton type="button" variant="secondary" onPress={onReset}>
Reset
</FormButton>
<form.Subscribe
selector={(state: { canSubmit: boolean; isSubmitting: boolean }) => [
state.canSubmit,
state.isSubmitting,
]}
>
{([canSubmit, isSubmitting]) => (
<FormButton
type="submit"
isDisabled={!canSubmit || isSubmitting}
>
{isSubmitting ? 'Converting...' : 'Convert'}
</FormButton>
)}
</form.Subscribe>
</div>
)
}
116 changes: 116 additions & 0 deletions src/components/form/ConverterPage.tsx
Original file line number Diff line number Diff line change
@@ -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<T extends ConverterFormBase> {
config: ConverterConfig<T>
}

export function ConverterPage<T extends ConverterFormBase>({
config,
}: ConverterPageProps<T>) {
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 (
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl font-bold mb-2">{config.title}</h1>
<p className="text-muted-foreground mb-6">{config.description}</p>

<form
onSubmit={(e) => {
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 (
<form.AppField
key={field.name}
name={field.name}
listeners={field.name === 'mode' ? {
onChange: handleModeChange,
} : undefined}
>
{(fieldApi) => (
<fieldApi.SelectField
label={resolvedLabel}
options={resolvedOptions}
/>
)}
</form.AppField>
)
}

// textarea
return (
<form.AppField key={field.name} name={field.name}>
{(fieldApi) => (
<fieldApi.TextAreaField
label={resolvedLabel}
placeholder={resolvedPlaceholder}
rows={field.rows}
className={resolvedClassName}
registerRef={field.isInput ? registerInputRef : undefined}
/>
)}
</form.AppField>
)
})}

<form.AppForm>
<form.ConvertActions onReset={handleReset} />
</form.AppForm>

{output && (
<FormTextArea
name="output"
label={config.outputLabel(mode)}
value={output}
readOnly
className="font-mono"
/>
)}
</form>
</div>
)
}
14 changes: 14 additions & 0 deletions src/components/form/FieldErrorMessage.tsx
Original file line number Diff line number Diff line change
@@ -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 ? (
<em className="text-red-500 text-sm">{formatFieldErrors(errs)}</em>
) : null
}
29 changes: 29 additions & 0 deletions src/components/form/SelectField.tsx
Original file line number Diff line number Diff line change
@@ -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<string>()

return (
<>
<FormSelect
name={field.name}
label={label}
value={field.state.value}
onChange={(value: string) => field.setValue(value)}
options={options}
/>
<FieldErrorMessage
meta={field.state.meta}
showWhenSubmitted={field.form.state.isSubmitted}
/>
</>
)
}
Loading