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
41 changes: 41 additions & 0 deletions app/components/auth-page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { type ReactNode } from 'react'

export function AuthPage({
title,
description,
children,
footer,
}: {
title: string
description?: ReactNode
children: ReactNode
footer?: ReactNode
}) {
return (
<main className="container flex flex-1 flex-col items-center justify-center py-12 md:py-20">
<div className="w-full max-w-lg">
<div className="text-center">
<p className="text-muted-foreground text-xs font-semibold tracking-[0.3em] uppercase">
GratiText
</p>
<h1 className="text-foreground mt-3 font-serif text-3xl font-semibold text-balance sm:text-4xl">
{title}
</h1>
{description ? (
<p className="text-muted-foreground mt-3 text-base text-pretty">
{description}
</p>
) : null}
</div>
<div className="border-border bg-card mt-8 rounded-[32px] border px-6 py-8 shadow-sm sm:px-8">
{children}
</div>
{footer ? (
<div className="text-muted-foreground [&_a]:text-foreground mt-6 text-center text-sm [&_a]:font-semibold [&_a]:underline-offset-4 hover:[&_a]:underline">
{footer}
</div>
) : null}
</div>
</main>
)
}
90 changes: 79 additions & 11 deletions app/components/error-boundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,90 @@ import { type ReactElement, useEffect } from 'react'
import {
type ErrorResponse,
isRouteErrorResponse,
Link,
useParams,
useRouteError,
} from 'react-router'
import { getErrorMessage } from '#app/utils/misc.tsx'
import { Icon } from './ui/icon.tsx'

type StatusHandler = (info: {
error: ErrorResponse
params: Record<string, string | undefined>
}) => ReactElement | null

function statusTitle(status: number) {
switch (status) {
case 400:
return 'That request did not look right'
case 401:
return 'Please log in to continue'
case 403:
return 'You are not allowed to do that'
case 404:
return 'We could not find that'
case 429:
return 'Slow down a little'
default:
return status >= 500
? 'Something went wrong on our end'
: 'Something went wrong'
}
}

export function ErrorMessage({
eyebrow,
title,
description,
}: {
eyebrow?: string
title: string
description?: string | null
}) {
return (
<div className="flex flex-col items-center gap-3 text-center">
{eyebrow ? (
<p className="text-muted-foreground text-xs font-semibold tracking-[0.3em] uppercase">
{eyebrow}
</p>
) : null}
<h1 className="text-foreground text-2xl font-bold sm:text-3xl">
{title}
</h1>
{description ? (
<p className="text-muted-foreground max-w-prose text-sm break-words sm:text-base">
{description}
</p>
) : null}
<Link
to="/"
className="text-foreground mt-3 inline-flex items-center gap-2 text-sm font-semibold underline-offset-4 hover:underline"
>
<Icon name="arrow-left" size="sm" aria-hidden="true" />
Back to home
</Link>
</div>
)
}

export function GeneralErrorBoundary({
defaultStatusHandler = ({ error }) => (
<p>
{error.status} {error.data}
</p>
<ErrorMessage
eyebrow={`Error ${error.status}`}
title={statusTitle(error.status)}
description={
typeof error.data === 'string' && error.data ? error.data : null
}
/>
),
statusHandlers,
unexpectedErrorHandler = (error) => <p>{getErrorMessage(error)}</p>,
unexpectedErrorHandler = (error) => (
<ErrorMessage
eyebrow="Unexpected error"
title="Something went wrong"
description={getErrorMessage(error)}
/>
),
}: {
defaultStatusHandler?: StatusHandler
statusHandlers?: Record<number, StatusHandler>
Expand All @@ -39,13 +105,15 @@ export function GeneralErrorBoundary({
}

return (
<div className="text-h2 container flex items-center justify-center p-20">
{isRouteErrorResponse(error)
? (statusHandlers?.[error.status] ?? defaultStatusHandler)({
error,
params,
})
: unexpectedErrorHandler(error)}
<div className="container flex min-h-[60vh] flex-1 items-center justify-center py-16 md:py-24">
<div className="border-border bg-card text-foreground w-full max-w-xl rounded-[32px] border px-6 py-10 text-center shadow-sm sm:px-10">
{isRouteErrorResponse(error)
? (statusHandlers?.[error.status] ?? defaultStatusHandler)({
error,
params,
})
: unexpectedErrorHandler(error)}
</div>
</div>
)
}
53 changes: 39 additions & 14 deletions app/components/forms.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type OTPInputProps,
} from 'input-otp'
import React, { useId } from 'react'
import { cn } from '#app/utils/misc.tsx'
import { Checkbox, type CheckboxProps } from './ui/checkbox.tsx'
import { Icon } from './ui/icon.tsx'
import {
Expand Down Expand Up @@ -46,7 +47,7 @@ export function Field({
className,
}: {
labelProps: React.LabelHTMLAttributes<HTMLLabelElement>
inputProps: React.InputHTMLAttributes<HTMLInputElement>
inputProps: React.ComponentProps<'input'>
errors?: ListOfErrors
className?: string
}) {
Expand All @@ -61,6 +62,7 @@ export function Field({
aria-invalid={errorId ? true : undefined}
aria-describedby={errorId}
{...inputProps}
className={cn('mt-2', inputProps.className)}
/>
<div className="min-h-[24px] px-4 pt-2 pb-2">
{errorId ? <ErrorList id={errorId} errors={errors} /> : null}
Expand All @@ -76,7 +78,7 @@ export function SelectField({
className,
}: {
labelProps: React.LabelHTMLAttributes<HTMLLabelElement>
selectProps: React.SelectHTMLAttributes<HTMLSelectElement>
selectProps: React.ComponentProps<'select'>
errors?: ListOfErrors
className?: string
}) {
Expand All @@ -86,7 +88,7 @@ export function SelectField({
return (
<div className={className}>
<Label htmlFor={id} {...labelProps} />
<div className="relative">
<div className="relative mt-2">
<select
id={id}
aria-invalid={errorId ? true : undefined}
Expand Down Expand Up @@ -174,7 +176,7 @@ export function TextareaField({
className,
}: {
labelProps: React.LabelHTMLAttributes<HTMLLabelElement>
textareaProps: React.TextareaHTMLAttributes<HTMLTextAreaElement>
textareaProps: React.ComponentProps<'textarea'>
errors?: ListOfErrors
className?: string
}) {
Expand All @@ -189,6 +191,7 @@ export function TextareaField({
aria-invalid={errorId ? true : undefined}
aria-describedby={errorId}
{...textareaProps}
className={cn('mt-2', textareaProps.className)}
/>
<div className="min-h-[24px] px-4 pt-2 pb-2">
{errorId ? <ErrorList id={errorId} errors={errors} /> : null}
Expand All @@ -200,6 +203,7 @@ export function TextareaField({
export function CheckboxField({
labelProps,
buttonProps,
description,
errors,
className,
}: {
Expand All @@ -209,6 +213,11 @@ export function CheckboxField({
form: string
value?: string
}
/**
* Rendered next to the label but outside of it, so it is the right place
* for links and other interactive content that must not toggle the box.
*/
description?: React.ReactNode
errors?: ListOfErrors
className?: string
}) {
Expand All @@ -223,15 +232,18 @@ export function CheckboxField({
})
const id = buttonProps.id ?? fallbackId
const errorId = errors?.length ? `${id}-error` : undefined
const descriptionId = description ? `${id}-description` : undefined
const describedBy =
[descriptionId, errorId].filter(Boolean).join(' ') || undefined

return (
<div className={className}>
<div className="flex gap-2">
<div className="flex items-start gap-3">
<Checkbox
{...checkboxProps}
id={id}
aria-invalid={errorId ? true : undefined}
aria-describedby={errorId}
aria-describedby={describedBy}
checked={input.value === checkedValue}
onCheckedChange={(state) => {
input.change(state.valueOf() ? checkedValue : '')
Expand All @@ -246,16 +258,29 @@ export function CheckboxField({
buttonProps.onBlur?.(event)
}}
type="button"
className="mt-0.5"
/>
<label
htmlFor={id}
{...labelProps}
className="text-body-xs text-muted-foreground self-center"
/>
</div>
<div className="px-4 pt-2 pb-2">
{errorId ? <ErrorList id={errorId} errors={errors} /> : null}
<div className="grid gap-1">
<label
htmlFor={id}
{...labelProps}
className="text-foreground cursor-pointer text-sm leading-snug"
/>
{description ? (
<div
id={descriptionId}
className="text-muted-foreground text-sm leading-snug"
>
{description}
</div>
) : null}
</div>
</div>
{errorId ? (
<div className="pt-2 pl-8">
<ErrorList id={errorId} errors={errors} />
</div>
) : null}
</div>
)
}
Loading
Loading