Files
meshtastic-web/packages/web/src/pages/Connections/index.tsx
T
390b46f026 fix(ui): add language switcher to connections page (#954)
* fix(ui): add language switcher to connections page

* desciption length fix

* Update packages/web/src/pages/Connections/index.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* add new language picker key

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-13 13:06:20 -05:00

424 lines
14 KiB
TypeScript

import AddConnectionDialog from "@app/components/Dialog/AddConnectionDialog/AddConnectionDialog";
import { TimeAgo } from "@app/components/generic/TimeAgo";
import LanguageSwitcher from "@app/components/LanguageSwitcher";
import { ConnectionStatusBadge } from "@app/components/PageComponents/Connections/ConnectionStatusBadge";
import type { Connection } from "@app/core/stores/deviceStore/types";
import { useConnections } from "@app/pages/Connections/useConnections";
import {
connectionTypeIcon,
formatConnectionSubtext,
} from "@app/pages/Connections/utils";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@components/UI/AlertDialog.tsx";
import { Badge } from "@components/UI/Badge.tsx";
import { Button } from "@components/UI/Button.tsx";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@components/UI/Card.tsx";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@components/UI/DropdownMenu.tsx";
import { Separator } from "@components/UI/Separator.tsx";
import { useToast } from "@core/hooks/useToast.ts";
import { useNavigate } from "@tanstack/react-router";
import {
ArrowLeft,
LinkIcon,
MoreHorizontal,
RotateCw,
RouterIcon,
Star,
StarOff,
Trash2,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
export const Connections = () => {
const {
connections,
addConnectionAndConnect,
connect,
disconnect,
removeConnection,
setDefaultConnection,
refreshStatuses,
syncConnectionStatuses,
} = useConnections();
const { toast } = useToast();
const navigate = useNavigate({ from: "/" });
const [addOpen, setAddOpen] = useState(false);
const isURLHTTPS = useMemo(() => location.protocol === "https:", []);
const { t } = useTranslation("connections");
// On first mount, sync statuses and refresh
// biome-ignore lint/correctness/useExhaustiveDependencies: This can cause the icon to refresh too often
useEffect(() => {
syncConnectionStatuses();
refreshStatuses();
}, []);
const sorted = useMemo(() => {
const copy = [...connections];
return copy.sort((a, b) => {
if (a.isDefault && !b.isDefault) {
return -1;
}
if (!a.isDefault && b.isDefault) {
return 1;
}
const aConnected = a.status === "connected" || a.status === "configured";
const bConnected = b.status === "connected" || b.status === "configured";
if (aConnected && !bConnected) {
return -1;
}
return a.name.localeCompare(b.name);
});
}, [connections]);
return (
<div className="space-y-6 p-6">
<header className="flex items-start justify-between">
<div className="flex items-stretch gap-3">
<button
type="button"
onClick={() => navigate({ to: "/" })}
className="flex items-center justify-center w-9 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
aria-label="Go back"
>
<ArrowLeft className="size-5" />
</button>
<div>
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">
{t("page.title")}
</h1>
<p className="lg:w-4/6 md:w-6 text-slate-500 dark:text-slate-400 mt-1">
{t("page.description")}
</p>
</div>
</div>
<div className="flex flex-col items-end ml-2 gap-2">
<Button onClick={() => setAddOpen(true)} className="gap-2">
<RouterIcon className="size-5" />
{t("button.addConnection")}
</Button>
<LanguageSwitcher />
</div>
</header>
<Separator />
{sorted.length === 0 ? (
<Card className="border-dashed">
<CardHeader>
<CardTitle className="text-lg">
{t("noConnections.title")}{" "}
</CardTitle>
</CardHeader>
<CardContent className="text-slate-500 dark:text-slate-400">
{t("noConnections.description")}
</CardContent>
<CardFooter>
<Button onClick={() => setAddOpen(true)} className="gap-2">
<RouterIcon className="size-5" />
{t("button.addConnection")}
</Button>
</CardFooter>
</Card>
) : (
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
{sorted.map((c) => (
<ConnectionCard
key={c.id}
connection={c}
onConnect={async () => {
const ok = await connect(c.id, { allowPrompt: true });
toast({
title: ok ? t("toasts.connected") : t("toasts.failed"),
description: ok
? t("toasts.nowConnected", {
name: c.name,
interpolation: { escapeValue: false },
})
: t("toasts.checkConnection"),
});
if (ok) {
navigate({ to: "/" });
}
}}
onDisconnect={async () => {
await disconnect(c.id);
toast({
title: t("toasts.disconnected"),
description: t("toasts.nowDisconnected", {
name: c.name,
interpolation: { escapeValue: false },
}),
});
}}
onSetDefault={() => {
setDefaultConnection(c.id);
toast({
title: t("toasts.defaultSet"),
description: t("toasts.defaultConnection", {
name: c.name,
interpolation: { escapeValue: false },
}),
});
}}
onDelete={async () => {
await disconnect(c.id);
removeConnection(c.id);
toast({
title: t("toasts.deleted"),
description: t("toasts.deletedByName", {
name: c.name,
interpolation: { escapeValue: false },
}),
});
}}
onRetry={async () => {
const ok = await connect(c.id, { allowPrompt: true });
toast({
title: ok ? t("toasts.connected") : t("toasts.failed"),
description: ok
? t("toasts.nowConnected", {
name: c.name,
interpolation: { escapeValue: false },
})
: t("toasts.pickConnectionAgain"),
});
if (ok) {
navigate({ to: "/" });
}
}}
/>
))}
</div>
)}
<AddConnectionDialog
open={addOpen}
onOpenChange={setAddOpen}
isHTTPS={isURLHTTPS}
onSave={async (partial, btDevice) => {
const created = await addConnectionAndConnect(partial, btDevice);
if (created) {
setAddOpen(false);
toast({
title: t("toasts.added"),
description: t("toasts.savedByName", {
name: created.name,
interpolation: { escapeValue: false },
}),
});
if (
created.status === "connected" ||
created.status === "configured"
) {
navigate({ to: "/" });
}
} else {
toast({
title: "Unable to connect",
description: "savedCantConnect",
});
}
}}
/>
</div>
);
};
function TypeBadge({ type }: { type: Connection["type"] }) {
const Icon = connectionTypeIcon(type);
const label =
type === "http" ? "HTTP" : type === "bluetooth" ? "Bluetooth" : "Serial";
return (
<Badge variant="default" className="gap-1.5">
<Icon className="h-3.5 w-3.5" />
{label}
</Badge>
);
}
function ConnectionCard({
connection,
onConnect,
onDisconnect,
onSetDefault,
onDelete,
onRetry,
}: {
connection: Connection;
onConnect: () => Promise<boolean> | Promise<void>;
onDisconnect: () => Promise<void> | Promise<void>;
onSetDefault: () => void;
onDelete: () => void;
onRetry: () => Promise<boolean> | Promise<void>;
}) {
const { t } = useTranslation("connections");
const Icon = connectionTypeIcon(connection.type);
const isBusy =
connection.status === "connecting" || connection.status === "configuring";
const isConnected =
connection.status === "connected" || connection.status === "configured";
const isError = connection.status === "error";
return (
<Card className="flex flex-col">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<CardTitle className="flex items-center gap-2">
<Icon className="size-4 text-slate-500 dark:text-slate-400" />
<span className="truncate">{connection.name}</span>
{connection.isDefault ? (
<Badge variant="secondary" className="gap-1">
<Star className="h-3.5 w-3.5 fill-yellow-500 text-yellow-500" />
{t("default")}
</Badge>
) : null}
</CardTitle>
<div className="mt-3 flex flex-wrap items-center gap-2 text-sm">
<TypeBadge type={connection.type} />
<span className="text-slate-500 dark:text-slate-400 truncate">
{formatConnectionSubtext(connection)}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<ConnectionStatusBadge status={connection.status} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="size-4" />
<span className="sr-only">{t("moreActions")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{connection.type === "http" && connection.isDefault && (
<DropdownMenuItem
className="gap-2"
onClick={() => onSetDefault()}
>
<StarOff className="size-4" />
{t("button.unsetDefault")}
</DropdownMenuItem>
)}
{connection.type === "http" && !connection.isDefault && (
<DropdownMenuItem
className="gap-2"
onClick={() => onSetDefault()}
>
<Star className="size-4" />
{t("button.setDefault")}
</DropdownMenuItem>
)}
<AlertDialog>
<AlertDialogTrigger asChild>
<DropdownMenuItem
className="gap-2 text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400"
onSelect={(e) => e.preventDefault()}
>
<Trash2 className="size-4" />
{t("button.delete")}
</DropdownMenuItem>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("deleteConnection")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("areYouSure", { name: connection.name })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("button.cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700"
onClick={() => onDelete()}
>
{t("button.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
{connection.error ? (
<p className="text-sm text-red-600 dark:text-red-400">
{connection.error}
</p>
) : connection.lastConnectedAt ? (
<p className="text-sm text-slate-500 dark:text-slate-400">
{t("lastConnectedAt", { date: "" })}{" "}
<TimeAgo
timestamp={connection.lastConnectedAt}
className="text-sm text-slate-500 dark:text-slate-400"
/>
</p>
) : (
<p className="text-sm text-slate-500 dark:text-slate-400">
{t("neverConnected")}
</p>
)}
</CardContent>
<CardFooter className="flex items-center gap-2 mt-auto">
{isConnected ? (
<Button
variant="subtle"
className="gap-2"
onClick={() => onDisconnect()}
disabled={isBusy}
>
{t("button.disconnect")}
</Button>
) : (
<Button
className="gap-2"
onClick={() => (isError ? onRetry() : onConnect())}
disabled={isBusy}
>
{isError ? (
<>
<RotateCw className="size-4" />
{t("button.retry")}
</>
) : (
<>
<LinkIcon className="size-4" />
{t("button.connect")}
</>
)}
</Button>
)}
</CardFooter>
</Card>
);
}