docs: finalize report assets and polish dashboard UI
This commit is contained in:
parent
441e709193
commit
a6edf804b4
42 changed files with 1084 additions and 406 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
}
|
||||
|
|
|
|||
25
apps/web/src/utils/errors.ts
Normal file
25
apps/web/src/utils/errors.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue