docs: finalize report assets and polish dashboard UI

This commit is contained in:
Syahdan 2026-05-07 00:35:17 +07:00
parent 441e709193
commit a6edf804b4
42 changed files with 1084 additions and 406 deletions

View file

@ -1,7 +1,23 @@
import { Button } from "@minmon/ui/components/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@minmon/ui/components/sheet";
import { cn } from "@minmon/ui/lib/utils";
import { Link, Outlet, useRouterState } from "@tanstack/react-router";
import { Activity, BookText, Globe, LayoutGrid, Server, Wrench } from "lucide-react";
import {
Activity,
BookText,
Globe,
LayoutGrid,
MenuIcon,
Server,
Wrench,
} from "lucide-react";
import { useEffect, useState } from "react";
const dashboardLinks = [
{
@ -36,6 +52,48 @@ const dashboardLinks = [
},
] as const;
function isActive(pathname: string, to: string): boolean {
return to === "/dashboard"
? pathname === to
: pathname === to || pathname.startsWith(`${to}/`);
}
function NavItems({
pathname,
onNavigate,
}: {
pathname: string;
onNavigate?: () => void;
}) {
return (
<nav className="grid gap-1 p-2" aria-label="Dashboard sections">
{dashboardLinks.map(({ to, label, description, icon: Icon }) => {
const active = isActive(pathname, to);
return (
<Link
key={to}
to={to}
aria-current={active ? "page" : undefined}
onClick={onNavigate}
className={cn(
"grid gap-0.5 border px-3 py-2 text-left transition-colors outline-none focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50",
active
? "border-foreground/20 bg-muted"
: "border-transparent hover:border-border hover:bg-muted/40",
)}
>
<span className="flex items-center gap-2 text-sm font-medium">
<Icon className="size-4" />
{label}
</span>
<span className="text-xs text-muted-foreground">{description}</span>
</Link>
);
})}
</nav>
);
}
export function DashboardShell({
userName,
userEmail,
@ -47,25 +105,70 @@ export function DashboardShell({
select: (state) => state.location.pathname,
});
const [mobileOpen, setMobileOpen] = useState(false);
useEffect(() => {
setMobileOpen(false);
}, [pathname]);
return (
<div className="min-h-full bg-muted/20">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6 lg:px-6">
<section className="border bg-card">
<div className="grid gap-4 px-5 py-5 lg:grid-cols-[1.4fr_0.6fr] lg:px-6">
<div className="space-y-2">
<p className="text-[11px] uppercase tracking-[0.24em] text-muted-foreground">
<div className="flex items-center gap-3 lg:hidden">
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label="Open navigation menu"
>
<MenuIcon className="size-4" />
</Button>
}
/>
<SheetContent side="left" className="p-0">
<SheetHeader className="border-b px-4 py-3">
<SheetTitle className="flex items-center gap-2">
<Activity className="size-4" />
Navigation
</SheetTitle>
</SheetHeader>
<NavItems
pathname={pathname}
onNavigate={() => setMobileOpen(false)}
/>
</SheetContent>
</Sheet>
<p className="text-[11px] uppercase tracking-[0.24em] text-muted-foreground">
Server Management Dashboard
</p>
</div>
<p className="hidden text-[11px] uppercase tracking-[0.24em] text-muted-foreground lg:block">
Server Management Dashboard
</p>
<h1 className="text-2xl font-semibold tracking-tight">Operations overview for demos and day-to-day admin tasks</h1>
<h1 className="text-2xl font-semibold tracking-tight">
Operations overview for demos and day-to-day admin tasks
</h1>
<p className="max-w-2xl text-sm text-muted-foreground">
Use this area to review monitored assets, inspect detail states, currently used to monitor my homelab setup.
Use this area to review monitored assets, inspect detail
states, currently used to monitor my homelab setup.
</p>
</div>
<div className="grid gap-3 border-l-0 border-t pt-4 lg:border-l lg:border-t-0 lg:pl-6 lg:pt-0">
<div>
<p className="text-[11px] uppercase tracking-[0.16em] text-muted-foreground">Signed in as</p>
<p className="mt-1 text-sm font-medium">{userName ?? "Administrator"}</p>
<p className="text-xs text-muted-foreground">{userEmail ?? "admin@minmon.local"}</p>
<p className="text-[11px] uppercase tracking-[0.16em] text-muted-foreground">
Signed in as
</p>
<p className="mt-1 text-sm font-medium">
{userName ?? "Administrator"}
</p>
<p className="text-xs text-muted-foreground">
{userEmail ?? "admin@minmon.local"}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Link to="/dashboard/servers/new">
@ -82,35 +185,14 @@ export function DashboardShell({
</section>
<div className="grid gap-6 lg:grid-cols-[240px_minmax(0,1fr)]">
<aside className="h-fit border bg-card">
<aside className="hidden h-fit border bg-card lg:block">
<div className="border-b px-4 py-3">
<div className="flex items-center gap-2 text-sm font-medium">
<Activity className="size-4" />
Navigation
</div>
</div>
<nav className="grid gap-1 p-2">
{dashboardLinks.map(({ to, label, description, icon: Icon }) => {
const active = to === "/dashboard" ? pathname === to : pathname === to || pathname.startsWith(`${to}/`);
return (
<Link
key={to}
to={to}
className={cn(
"grid gap-0.5 border px-3 py-2 text-left transition-colors",
active ? "border-foreground/20 bg-muted" : "border-transparent hover:border-border hover:bg-muted/40",
)}
>
<span className="flex items-center gap-2 text-sm font-medium">
<Icon className="size-4" />
{label}
</span>
<span className="text-xs text-muted-foreground">{description}</span>
</Link>
);
})}
</nav>
<NavItems pathname={pathname} />
</aside>
<main className="min-w-0">

View file

@ -178,44 +178,108 @@ export function FormCard({
);
}
type FieldError = string | { message?: string } | null | undefined;
function normalizeFieldErrors(errors?: FieldError[]): string[] {
if (!errors?.length) return [];
return errors
.map((err) => {
if (!err) return null;
if (typeof err === "string") return err;
return err.message ?? null;
})
.filter((msg): msg is string => Boolean(msg && msg.trim()));
}
export function FieldGroup({
label,
htmlFor,
description,
errors,
children,
}: {
label: string;
htmlFor: string;
description?: string;
errors?: FieldError[];
children: ReactNode;
}) {
const messages = normalizeFieldErrors(errors);
const hasError = messages.length > 0;
const descriptionId = description ? `${htmlFor}-description` : undefined;
const errorId = hasError ? `${htmlFor}-error` : undefined;
return (
<div className="grid gap-2">
<Label htmlFor={htmlFor}>{label}</Label>
{children}
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
{description ? (
<p id={descriptionId} className="text-xs text-muted-foreground">
{description}
</p>
) : null}
{hasError ? (
<p id={errorId} className="text-xs text-destructive" role="alert">
{messages.join(", ")}
</p>
) : null}
</div>
);
}
export function TextField(props: React.ComponentProps<typeof Input> & { label: string; description?: string }) {
const { label, description, id, ...inputProps } = props;
function describedBy(...ids: (string | undefined)[]): string | undefined {
const filtered = ids.filter((id): id is string => Boolean(id));
return filtered.length ? filtered.join(" ") : undefined;
}
export function TextField(
props: React.ComponentProps<typeof Input> & {
label: string;
description?: string;
errors?: FieldError[];
},
) {
const { label, description, errors, id, ...inputProps } = props;
const fieldId = id ?? inputProps.name ?? label;
const messages = normalizeFieldErrors(errors);
const hasError = messages.length > 0;
const descriptionId = description ? `${fieldId}-description` : undefined;
const errorId = hasError ? `${fieldId}-error` : undefined;
return (
<FieldGroup label={label} htmlFor={id ?? inputProps.name ?? label} description={description}>
<Input id={id ?? inputProps.name ?? label} {...inputProps} />
<FieldGroup label={label} htmlFor={fieldId} description={description} errors={errors}>
<Input
id={fieldId}
aria-invalid={hasError || undefined}
aria-describedby={describedBy(descriptionId, errorId)}
{...inputProps}
/>
</FieldGroup>
);
}
export function TextAreaField(
props: React.ComponentProps<typeof Textarea> & { label: string; description?: string },
props: React.ComponentProps<typeof Textarea> & {
label: string;
description?: string;
errors?: FieldError[];
},
) {
const { label, description, id, ...textareaProps } = props;
const { label, description, errors, id, ...textareaProps } = props;
const fieldId = id ?? textareaProps.name ?? label;
const messages = normalizeFieldErrors(errors);
const hasError = messages.length > 0;
const descriptionId = description ? `${fieldId}-description` : undefined;
const errorId = hasError ? `${fieldId}-error` : undefined;
return (
<FieldGroup label={label} htmlFor={id ?? textareaProps.name ?? label} description={description}>
<Textarea id={id ?? textareaProps.name ?? label} {...textareaProps} />
<FieldGroup label={label} htmlFor={fieldId} description={description} errors={errors}>
<Textarea
id={fieldId}
aria-invalid={hasError || undefined}
aria-describedby={describedBy(descriptionId, errorId)}
{...textareaProps}
/>
</FieldGroup>
);
}
@ -223,6 +287,7 @@ export function TextAreaField(
export function SelectField({
label,
description,
errors,
id,
name,
value,
@ -233,6 +298,7 @@ export function SelectField({
}: {
label: string;
description?: string;
errors?: FieldError[];
id?: string;
name?: string;
value: string;
@ -243,11 +309,21 @@ export function SelectField({
}) {
const fieldId = id ?? name ?? label;
const selectedOption = options.find((option) => option.value === value);
const messages = normalizeFieldErrors(errors);
const hasError = messages.length > 0;
const descriptionId = description ? `${fieldId}-description` : undefined;
const errorId = hasError ? `${fieldId}-error` : undefined;
return (
<FieldGroup label={label} htmlFor={fieldId} description={description}>
<FieldGroup label={label} htmlFor={fieldId} description={description} errors={errors}>
<Select name={name} value={value} onValueChange={(nextValue) => onValueChange(nextValue ?? "")}>
<SelectTrigger id={fieldId} className="w-full" onBlur={onBlur}>
<SelectTrigger
id={fieldId}
className="w-full"
onBlur={onBlur}
aria-invalid={hasError || undefined}
aria-describedby={describedBy(descriptionId, errorId)}
>
<SelectValue placeholder={placeholder ?? "Select an option"}>
{selectedOption?.label}
</SelectValue>
@ -299,7 +375,7 @@ export function ConfirmDeleteIconButton({
<div className="flex justify-center">
<Button
type="button"
variant={isConfirming ? "destructive" : "destructive"}
variant={isConfirming ? "destructive" : "ghost"}
size="icon"
className="size-8"
disabled={disabled}
@ -326,15 +402,22 @@ export function EmptyState({
title,
description,
action,
icon,
className,
}: {
title: string;
description: string;
action?: ReactNode;
icon?: ReactNode;
className?: string;
}) {
return (
<div className={"border px-4 py-8 text-center " + (className ?? "")}>
<div className={"border px-4 py-10 text-center " + (className ?? "")}>
{icon ? (
<div className="mx-auto mb-3 flex size-10 items-center justify-center rounded-full bg-muted text-muted-foreground">
{icon}
</div>
) : null}
<p className="text-sm font-medium">{title}</p>
<p className="mx-auto mt-2 max-w-lg text-sm text-muted-foreground">{description}</p>
{action ? <div className="mt-4 flex justify-center">{action}</div> : null}

View file

@ -1,12 +1,12 @@
import { Button } from "@minmon/ui/components/button";
import { Input } from "@minmon/ui/components/input";
import { Label } from "@minmon/ui/components/label";
import { useForm } from "@tanstack/react-form";
import { useNavigate } from "@tanstack/react-router";
import { toast } from "sonner";
import z from "zod";
import { TextField } from "@/components/dashboard-ui";
import { authClient } from "@/lib/auth-client";
import { formatErrorMessage } from "@/utils/errors";
import Loader from "./loader";
@ -35,7 +35,7 @@ export default function SignInForm() {
toast.success("Sign in successful");
},
onError: (error) => {
toast.error(error.error.message || error.error.statusText);
toast.error(formatErrorMessage(error, "Could not sign in"));
},
},
);
@ -70,59 +70,51 @@ export default function SignInForm() {
form.handleSubmit();
}}
className="space-y-4"
noValidate
>
<div>
<form.Field name="email">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Email</Label>
<Input
id={field.name}
name={field.name}
type="email"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-xs text-red-500">
{error?.message}
</p>
))}
</div>
)}
</form.Field>
</div>
<form.Field name="email">
{(field) => (
<TextField
label="Email"
id={field.name}
name={field.name}
type="email"
autoComplete="email"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
errors={field.state.meta.errors}
/>
)}
</form.Field>
<div>
<form.Field name="password">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Password</Label>
<Input
id={field.name}
name={field.name}
type="password"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-xs text-red-500">
{error?.message}
</p>
))}
</div>
)}
</form.Field>
</div>
<form.Field name="password">
{(field) => (
<TextField
label="Password"
id={field.name}
name={field.name}
type="password"
autoComplete="current-password"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
errors={field.state.meta.errors}
/>
)}
</form.Field>
<form.Subscribe
selector={(state) => ({ canSubmit: state.canSubmit, isSubmitting: state.isSubmitting })}
>
{({ canSubmit, isSubmitting }) => (
<Button type="submit" className="w-full" disabled={!canSubmit || isSubmitting}>
{isSubmitting ? "Submitting..." : "Sign In"}
<Button
type="submit"
className="w-full"
disabled={!canSubmit}
loading={isSubmitting}
>
Sign In
</Button>
)}
</form.Subscribe>

View file

@ -1,12 +1,12 @@
import { Button } from "@minmon/ui/components/button";
import { Input } from "@minmon/ui/components/input";
import { Label } from "@minmon/ui/components/label";
import { useForm } from "@tanstack/react-form";
import { useNavigate } from "@tanstack/react-router";
import { toast } from "sonner";
import z from "zod";
import { TextField } from "@/components/dashboard-ui";
import { authClient } from "@/lib/auth-client";
import { formatErrorMessage } from "@/utils/errors";
import Loader from "./loader";
@ -37,7 +37,7 @@ export default function SignUpForm({ onSwitchToSignIn }: { onSwitchToSignIn: ()
toast.success("Sign up successful");
},
onError: (error) => {
toast.error(error.error.message || error.error.statusText);
toast.error(formatErrorMessage(error, "Could not sign up"));
},
},
);
@ -66,92 +66,73 @@ export default function SignUpForm({ onSwitchToSignIn }: { onSwitchToSignIn: ()
form.handleSubmit();
}}
className="space-y-4"
noValidate
>
<div>
<form.Field name="name">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Name</Label>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-red-500">
{error?.message}
</p>
))}
</div>
)}
</form.Field>
</div>
<form.Field name="name">
{(field) => (
<TextField
label="Name"
id={field.name}
name={field.name}
autoComplete="name"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
errors={field.state.meta.errors}
/>
)}
</form.Field>
<div>
<form.Field name="email">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Email</Label>
<Input
id={field.name}
name={field.name}
type="email"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-red-500">
{error?.message}
</p>
))}
</div>
)}
</form.Field>
</div>
<form.Field name="email">
{(field) => (
<TextField
label="Email"
id={field.name}
name={field.name}
type="email"
autoComplete="email"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
errors={field.state.meta.errors}
/>
)}
</form.Field>
<div>
<form.Field name="password">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Password</Label>
<Input
id={field.name}
name={field.name}
type="password"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-red-500">
{error?.message}
</p>
))}
</div>
)}
</form.Field>
</div>
<form.Field name="password">
{(field) => (
<TextField
label="Password"
id={field.name}
name={field.name}
type="password"
autoComplete="new-password"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
errors={field.state.meta.errors}
/>
)}
</form.Field>
<form.Subscribe
selector={(state) => ({ canSubmit: state.canSubmit, isSubmitting: state.isSubmitting })}
>
{({ canSubmit, isSubmitting }) => (
<Button type="submit" className="w-full" disabled={!canSubmit || isSubmitting}>
{isSubmitting ? "Submitting..." : "Sign Up"}
<Button
type="submit"
className="w-full"
disabled={!canSubmit}
loading={isSubmitting}
>
Sign Up
</Button>
)}
</form.Subscribe>
</form>
<div className="mt-4 text-center">
<Button
variant="link"
onClick={onSwitchToSignIn}
className="text-indigo-600 hover:text-indigo-800"
>
<Button variant="link" onClick={onSwitchToSignIn}>
Already have an account? Sign In
</Button>
</div>

View file

@ -41,8 +41,17 @@ function RouteComponent() {
remarks: values.remarks || null,
}),
onSuccess: async () => {
toast.success("Domain updated");
await queryClient.invalidateQueries();
try {
await trpcClient.domains.check.mutate({ id: domainId });
toast.success("Domain updated and DNS re-checked");
} catch (error) {
toast.success("Domain updated");
toast.error(
error instanceof Error ? `DNS check failed: ${error.message}` : "DNS check failed",
);
}
await queryClient.invalidateQueries({ queryKey: trpc.domains.byId.queryKey({ id: domainId }) });
await queryClient.invalidateQueries({ queryKey: trpc.domains.list.queryKey() });
},
onError: (error) => toast.error(error.message),
});
@ -50,7 +59,8 @@ function RouteComponent() {
mutationFn: async () => trpcClient.domains.check.mutate({ id: domainId }),
onSuccess: async () => {
toast.success("DNS check completed");
await queryClient.invalidateQueries();
await queryClient.invalidateQueries({ queryKey: trpc.domains.byId.queryKey({ id: domainId }) });
await queryClient.invalidateQueries({ queryKey: trpc.domains.list.queryKey() });
},
onError: (error) => toast.error(error.message),
});
@ -80,14 +90,13 @@ function RouteComponent() {
});
}, [domain.data, form]);
if (domain.isLoading || services.isLoading) {
if (!domain.data || !services.data) {
if (domain.isError || services.isError) {
return <QueryStateCard title="Domain unavailable" description="The requested domain could not be loaded." />;
}
return <QueryStateCard title="Loading domain" description="Fetching the selected domain details." />;
}
if (domain.isError || services.isError || !domain.data || !services.data) {
return <QueryStateCard title="Domain unavailable" description="The requested domain could not be loaded." />;
}
const serviceOptions = services.data.map((item) => ({
value: item.id,
label: `${item.name} (${item.id})`,
@ -131,7 +140,7 @@ function RouteComponent() {
Check DNS
</Button>
<form.Subscribe selector={(state) => ({ isSubmitting: state.isSubmitting })}>
{({ isSubmitting }) => <Button type="submit">{isSubmitting ? "Saving..." : "Save changes"}</Button>}
{({ isSubmitting }) => <Button type="submit" loading={isSubmitting}>Save changes</Button>}
</form.Subscribe>
</div>
</>

View file

@ -29,8 +29,16 @@ function RouteComponent() {
remarks: values.remarks || null,
}),
onSuccess: async (created) => {
toast.success("Domain created");
await queryClient.invalidateQueries();
try {
await trpcClient.domains.check.mutate({ id: created.id });
toast.success("Domain created and DNS checked");
} catch (error) {
toast.success("Domain created");
toast.error(
error instanceof Error ? `DNS check failed: ${error.message}` : "DNS check failed",
);
}
await queryClient.invalidateQueries({ queryKey: trpc.domains.list.queryKey() });
navigate({ to: "/dashboard/domains/$domainId", params: { domainId: created.id } });
},
onError: (error) => toast.error(error.message),
@ -82,7 +90,7 @@ function RouteComponent() {
Cancel
</Button>
<form.Subscribe selector={(state) => ({ isSubmitting: state.isSubmitting })}>
{({ isSubmitting }) => <Button type="submit">{isSubmitting ? "Saving..." : "Save domain"}</Button>}
{({ isSubmitting }) => <Button type="submit" loading={isSubmitting}>Save domain</Button>}
</form.Subscribe>
</>
}

View file

@ -113,7 +113,7 @@ function RouteComponent() {
Back
</Button>
<form.Subscribe selector={(state) => ({ isSubmitting: state.isSubmitting })}>
{({ isSubmitting }) => <Button type="submit">{isSubmitting ? "Saving..." : "Save changes"}</Button>}
{({ isSubmitting }) => <Button type="submit" loading={isSubmitting}>Save changes</Button>}
</form.Subscribe>
</>
}

View file

@ -82,7 +82,7 @@ function RouteComponent() {
Cancel
</Button>
<form.Subscribe selector={(state) => ({ isSubmitting: state.isSubmitting })}>
{({ isSubmitting }) => <Button type="submit">{isSubmitting ? "Saving..." : "Save note"}</Button>}
{({ isSubmitting }) => <Button type="submit" loading={isSubmitting}>Save note</Button>}
</form.Subscribe>
</>
}

View file

@ -180,7 +180,7 @@ function RouteComponent() {
Back
</Button>
<form.Subscribe selector={(state) => ({ isSubmitting: state.isSubmitting })}>
{({ isSubmitting }) => <Button type="submit">{isSubmitting ? "Saving..." : "Save changes"}</Button>}
{({ isSubmitting }) => <Button type="submit" loading={isSubmitting}>Save changes</Button>}
</form.Subscribe>
</>
}

View file

@ -86,7 +86,7 @@ function RouteComponent() {
Cancel
</Button>
<form.Subscribe selector={(state) => ({ isSubmitting: state.isSubmitting })}>
{({ isSubmitting }) => <Button type="submit">{isSubmitting ? "Saving..." : "Save server"}</Button>}
{({ isSubmitting }) => <Button type="submit" loading={isSubmitting}>Save server</Button>}
</form.Subscribe>
</>
}

View file

@ -155,7 +155,7 @@ function RouteComponent() {
Back
</Button>
<form.Subscribe selector={(state) => ({ isSubmitting: state.isSubmitting })}>
{({ isSubmitting }) => <Button type="submit">{isSubmitting ? "Saving..." : "Save changes"}</Button>}
{({ isSubmitting }) => <Button type="submit" loading={isSubmitting}>Save changes</Button>}
</form.Subscribe>
</>
}

View file

@ -117,7 +117,7 @@ function RouteComponent() {
Cancel
</Button>
<form.Subscribe selector={(state) => ({ isSubmitting: state.isSubmitting })}>
{({ isSubmitting }) => <Button type="submit">{isSubmitting ? "Saving..." : "Save service"}</Button>}
{({ isSubmitting }) => <Button type="submit" loading={isSubmitting}>Save service</Button>}
</form.Subscribe>
</>
}

View file

@ -0,0 +1,25 @@
type AnyError = {
message?: string;
error?: { message?: string; statusText?: string };
};
export function formatErrorMessage(
error: unknown,
fallback = "Something went wrong",
): string {
if (!error) return fallback;
if (typeof error === "string") return error;
const e = error as AnyError;
if (e.error && typeof e.error === "object") {
const inner = e.error.message?.trim() || e.error.statusText?.trim();
if (inner) return inner;
}
if (typeof e.message === "string" && e.message.trim()) {
return e.message.trim();
}
return fallback;
}