Browse Source

feat: added language switcher, updated some translations

pull/627/head
Dan Ditomaso 1 year ago
parent
commit
13a0c26f67
  1. 6
      deno.lock
  2. 28
      src/components/BatteryStatus.tsx
  3. 237
      src/components/DeviceInfoPanel.tsx
  4. 127
      src/components/LanguageSwitcher.tsx
  5. 2
      src/components/PageComponents/Config/Display.tsx
  6. 2
      src/components/PageComponents/Config/Position.tsx
  7. 2
      src/components/PageComponents/ModuleConfig/ExternalNotification.tsx
  8. 114
      src/components/Sidebar.tsx
  9. 73
      src/components/ThemeSwitcher.tsx
  10. 4
      src/components/UI/Input.tsx
  11. 54
      src/components/generic/Filter/FilterControl.tsx
  12. 4
      src/components/types.ts
  13. 11
      src/i18n/config.ts
  14. 3
      src/i18n/locales/en/commandPalette.json
  15. 3
      src/i18n/locales/en/common.json
  16. 4
      src/i18n/locales/en/deviceConfig.json
  17. 148
      src/i18n/locales/en/ui.json
  18. 4
      src/pages/Dashboard/index.tsx
  19. 31
      vite.config.ts

6
deno.lock

@ -1,6 +1,7 @@
{
"version": "5",
"specifiers": {
"jsr:@std/path@*": "1.0.6",
"npm:@bufbuild/protobuf@^2.2.5": "2.2.5",
"npm:@jsr/[email protected]": "2.6.2",
"npm:@jsr/[email protected]": "2.6.0-0",
@ -79,6 +80,11 @@
"npm:zod@^3.24.3": "3.24.3",
"npm:[email protected]": "5.0.4_@[email protected][email protected][email protected]"
},
"jsr": {
"@std/[email protected]": {
"integrity": "ab2c55f902b380cf28e0eec501b4906e4c1960d13f00e11cfbcd21de15f18fed"
}
},
"npm": {
"@adobe/[email protected]": {
"integrity": "sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A=="

28
src/components/BatteryStatus.tsx

@ -7,15 +7,7 @@ import {
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
interface DeviceMetrics {
batteryLevel?: number | null;
voltage?: number | null;
}
interface BatteryStatusProps {
deviceMetrics?: DeviceMetrics | null;
}
import { DeviceMetrics } from "./types.ts";
interface BatteryStateConfig {
condition: (level: number) => boolean;
@ -24,6 +16,10 @@ interface BatteryStateConfig {
text: (level: number) => string;
}
interface BatteryStatusProps {
deviceMetrics?: DeviceMetrics | null;
}
const getBatteryStates = (
t: (key: string, options?: object) => string,
): BatteryStateConfig[] => {
@ -73,7 +69,7 @@ const BatteryStatus: React.FC<BatteryStatusProps> = ({ deviceMetrics }) => {
const { t } = useTranslation();
const batteryStates = getBatteryStates(t);
const { batteryLevel, voltage } = deviceMetrics;
const { batteryLevel } = deviceMetrics;
const currentState = getBatteryState(batteryLevel, batteryStates) ??
batteryStates[batteryStates.length - 1];
@ -81,19 +77,13 @@ const BatteryStatus: React.FC<BatteryStatusProps> = ({ deviceMetrics }) => {
const iconClassName = currentState.className;
const statusText = currentState.text(batteryLevel);
const voltageTitle = `${voltage?.toPrecision(3) ?? t("unknown.shortName")} ${
t("unit.volts")
}`;
return (
<div
className="flex items-center gap-1 mt-0.5 text-gray-500"
title={voltageTitle}
className="flex items-center gap-1 mt-0.5 "
aria-label={t("batteryStatus.title")}
>
<BatteryIcon size={22} className={iconClassName} />
<Subtle aria-label={t("batteryStatus.title")}>
{statusText}
</Subtle>
{statusText}
</div>
);
};

237
src/components/DeviceInfoPanel.tsx

@ -0,0 +1,237 @@
import { cn } from "@core/utils/cn.ts";
import {
CpuIcon,
Languages,
LucideIcon,
Palette,
PenLine,
Search as SearchIcon,
ZapIcon,
} from "lucide-react";
import BatteryStatus from "./BatteryStatus.tsx";
import { Subtle } from "./UI/Typography/Subtle.tsx";
import { Avatar } from "./UI/Avatar.tsx";
import { DeviceMetrics } from "./types.ts";
import { Button } from "./UI/Button.tsx";
import React from "react";
import { useTranslation } from "react-i18next";
import ThemeSwitcher from "./ThemeSwitcher.tsx";
import LanguageSwitcher from "./LanguageSwitcher.tsx";
import { Code } from "./UI/Typography/Code.tsx";
interface DeviceInfoPanelProps {
isCollapsed: boolean;
deviceMetrics: DeviceMetrics;
firmwareVersion: string;
user: {
shortName: string;
longName: string;
};
setDialogOpen: () => void;
setCommandPaletteOpen: () => void;
disableHover?: boolean;
}
interface InfoDisplayItem {
id: string;
label: string;
icon?: LucideIcon;
customComponent?: React.ReactNode;
value?: string | number | null;
}
interface ActionButtonConfig {
id: string;
label: string;
icon: LucideIcon;
onClick?: () => void;
render?: () => React.ReactNode;
}
export const DeviceInfoPanel = ({
deviceMetrics,
firmwareVersion,
user,
isCollapsed,
setDialogOpen,
setCommandPaletteOpen,
disableHover = false,
}: DeviceInfoPanelProps) => {
const { t } = useTranslation();
const { batteryLevel, voltage } = deviceMetrics;
const deviceInfoItems: InfoDisplayItem[] = [
{
id: "battery",
label: t("batteryStatus.title"),
customComponent: <BatteryStatus deviceMetrics={deviceMetrics} />,
value: batteryLevel !== undefined ? `${batteryLevel}%` : "N/A",
},
{
id: "voltage",
label: t("batteryVoltage.title"),
icon: ZapIcon,
value: voltage !== undefined
? `${voltage?.toPrecision(3)} V`
: t("unknown.notAvailable", "N/A"),
},
{
id: "firmware",
label: t("sidebar.deviceInfo.firmware.title"),
icon: CpuIcon,
value: firmwareVersion ?? t("unknown.notAvailable", "N/A"),
},
];
const actionButtons: ActionButtonConfig[] = [
{
id: "changeName",
label: t("sidebar.deviceInfo.deviceName.changeName"),
icon: PenLine,
onClick: setDialogOpen,
},
{
id: "commandMenu",
label: t("page.title", { ns: "commandPalette" }),
icon: SearchIcon,
onClick: setCommandPaletteOpen,
},
{
id: "theme",
label: t("theme.changeTheme"),
icon: Palette,
render: () => <ThemeSwitcher />,
},
{
id: "language",
label: t("language.changeLanguage"),
icon: Languages,
render: () => <LanguageSwitcher />,
},
];
return (
<div className="p-1">
<div className="flex flex-col">
<div
className={cn(
"flex items-center gap-3 p-1",
isCollapsed && "justify-center",
)}
>
<Avatar
text={user.shortName}
className={cn("flex-shrink-0", isCollapsed && "")}
size="sm"
/>
{!isCollapsed && (
<p
className={cn(
"text-sm font-medium text-gray-800 dark:text-gray-200",
"transition-opacity duration-300 ease-in-out",
)}
>
{user.longName}
</p>
)}
</div>
{!isCollapsed && (
<div className="my-2 h-px bg-gray-200 dark:bg-gray-700"></div>
)}
<div
className={cn(
"flex flex-col gap-2 mt-1",
"transition-all duration-300 ease-in-out",
isCollapsed
? "opacity-0 max-w-0 h-0 invisible pointer-events-none"
: "opacity-100 max-w-xs h-auto visible",
)}
>
{deviceInfoItems.map((item) => {
const IconComponent = item.icon;
return (
<div
key={item.id}
className="flex items-center gap-2.5 text-sm"
>
{IconComponent && (
<IconComponent
size={16}
className="text-gray-500 dark:text-gray-400 w-4 flex-shrink-0"
/>
)}
{item.customComponent}
{item.id !== "battery" && (
<Subtle className="text-gray-600 dark:text-gray-300">
{item.label}: {item.value}
</Subtle>
)}
</div>
);
})}
</div>
{!isCollapsed && (
<div className="my-2 h-px bg-gray-200 dark:bg-gray-700"></div>
)}
<div
className={cn(
"flex flex-col gap-1 mt-1",
"transition-all duration-300 ease-in-out",
isCollapsed
? "opacity-0 max-w-0 h-0 invisible pointer-events-none"
: "opacity-100 max-w-xs visible",
)}
>
{actionButtons.map((buttonItem) => {
const Icon = buttonItem.icon;
if (buttonItem.render) {
return buttonItem.render();
}
return (
<Button
key={buttonItem.id}
variant="ghost"
aria-label={buttonItem.label}
onClick={buttonItem.onClick}
className={cn(
"group",
"flex w-full items-center justify-start text-sm p-1.5 rounded-md",
"gap-2.5",
"transition-colors duration-150",
!disableHover && "hover:bg-gray-100 dark:hover:bg-gray-700",
)}
>
<Icon
size={16}
className={cn(
"flex-shrink-0 w-4",
"text-gray-500 dark:text-gray-400",
"transition-colors duration-150",
!disableHover &&
"group-hover:text-gray-700 dark:group-hover:text-gray-200",
)}
/>
<Subtle
className={cn(
"text-sm",
"text-gray-600 dark:text-gray-300",
"transition-colors duration-150",
!disableHover &&
"group-hover:text-gray-800 dark:group-hover:text-gray-100",
)}
>
{buttonItem.label}
</Subtle>
</Button>
);
})}
{/* <Code>{import.meta.env.COMMIT_HASH}</Code> */}
</div>
</div>
</div>
);
};

127
src/components/LanguageSwitcher.tsx

@ -1,60 +1,91 @@
import clsx from "clsx";
import { Check, Languages } from "lucide-react";
import { useTranslation } from "react-i18next";
import { LangCode, supportedLanguages } from "../i18n/config.ts";
import useLang from "@core/hooks/useLang.ts";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "./UI/DropdownMenu.tsx";
import { Subtle } from "./UI/Typography/Subtle.tsx";
import { cn } from "@core/utils/cn.ts";
import { Button } from "./UI/Button.tsx";
import { supportedLanguages } from "../i18n/config.ts";
import { DropdownMenu, DropdownMenuContent } from "./UI/DropdownMenu.tsx";
import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu";
function LanguageSwitcher() {
const { t, i18n } = useTranslation();
const { set } = useLang();
interface LanguageSwitcherProps {
disableHover?: boolean;
}
export default function LanguageSwitcher(
{ disableHover = false }: LanguageSwitcherProps,
) {
const { i18n } = useTranslation("ui");
const { set: setLanguage } = useLang();
const currentLanguage =
supportedLanguages.find((lang) => lang.code === i18n.language) ||
supportedLanguages[0];
const handleLanguageChange = async (languageCode: LangCode) => {
await setLanguage(languageCode, true);
};
return (
<DropdownMenu
i18nIsDynamicList
data-testid="language-list"
>
<DropdownMenuTrigger>asdlkfj</DropdownMenuTrigger>
<DropdownMenuContent className="flex border border-b-2 border-b-gray-700 flex-col w-full items-stretch gap-1">
{supportedLanguages?.map((lang) => (
<li
key={lang.code}
className="list-none w-full"
data-testid={`language-item-${lang.code}`}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className={cn(
"group flex items-center justify-start",
"transition-colors duration-150 gap-2.5 p-1.5 rounded-md",
!disableHover && "hover:bg-gray-100 dark:hover:bg-gray-700",
)}
>
<Languages
size={16}
className={cn(
"text-gray-500 dark:text-gray-400 w-4 flex-shrink-0 transition-colors duration-150",
!disableHover &&
"group-hover:text-gray-700 dark:group-hover:text-gray-200",
)}
/>
<Subtle
className={cn(
"text-sm text-gray-600 dark:text-gray-100 transition-colors duration-150",
!disableHover &&
"group-hover:text-gray-800 dark:group-hover:text-gray-100",
)}
>
<Button
role="presentation"
className={clsx([
"flex flex-row justify-between items-center w-full",
"rounded-lg px-3 py-1 transition duration-75 cursor-default",
])}
onClick={() => set(lang.code)}
data-testid={`language-button-${lang.code}`}
data-selected={i18n.language === lang.code}
>
<div
className="flex flex-row items-center m-1 gap-3 p-1 overflow-hidden"
data-testid={`language-name-${lang.code}`}
>
{lang.name}
</div>
<div
className={clsx([
"pr-4 ml-2 flex items-center font-medium text-xs",
"text-iron dark:text-bombay",
])}
data-testid={`language-selected-indicator-${lang.code}`}
>
{i18n.language === lang.code &&
t("selected", { ns: "glossary" })}
</div>
</Button>
</li>
{`${i18n.t("language.changeLanguage")}:`}
</Subtle>
<Subtle
className={cn(
"text-sm font-medium text-gray-700 dark:text-gray-200 transition-colors duration-150",
!disableHover &&
"group-hover:text-gray-900 dark:group-hover:text-white",
)}
>
{currentLanguage.code.toUpperCase()}
</Subtle>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-64">
{supportedLanguages.map((language) => (
<DropdownMenuItem
key={language.code}
onClick={() => handleLanguageChange(language.code as LangCode)}
className="flex items-center justify-between cursor-pointer"
>
<div className="flex items-center gap-2">
<span>{language.flag}</span>
<span>{language.name}</span>
</div>
{i18n.language === language.code && (
<Check className="h-4 w-4 text-primary" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
export default LanguageSwitcher;

2
src/components/PageComponents/Config/Display.tsx

@ -98,7 +98,7 @@ export const Display = () => {
type: "select",
name: "displaymode",
label: t("display.displayMode.label"),
description: t("display.displayMode.descriptio"),
description: t("display.displayMode.description"),
properties: {
enumValue: Protobuf.Config.Config_DisplayConfig_DisplayMode,
formatEnumName: true,

2
src/components/PageComponents/Config/Position.tsx

@ -76,7 +76,7 @@ export const Position = () => {
activeFlags?.includes(name as FlagName) ?? false,
onValueChange: onPositonFlagChange,
label: t("position.positionFlags.label"),
placeholder: t("position.positionFlags.placeholder"),
placeholder: t("position.flags.placeholder"),
description: t("position.positionFlags.description"),
properties: {
enumValue: getAllFlags(),

2
src/components/PageComponents/ModuleConfig/ExternalNotification.tsx

@ -47,7 +47,7 @@ export const ExternalNotification = () => {
},
],
properties: {
suffix: t("unit.ms", { ns: "common" }),
suffix: t("unit.millisecond.suffix"),
},
},
{

114
src/components/Sidebar.tsx

@ -4,7 +4,6 @@ import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import type { Page } from "@core/stores/deviceStore.ts";
import { Spinner } from "@components/UI/Spinner.tsx";
import { Avatar } from "@components/UI/Avatar.tsx";
import {
CircleChevronLeft,
@ -13,19 +12,15 @@ import {
type LucideIcon,
MapIcon,
MessageSquareIcon,
PenLine,
SearchIcon,
SettingsIcon,
UsersIcon,
ZapIcon,
} from "lucide-react";
import { cn } from "@core/utils/cn.ts";
import { useSidebar } from "@core/stores/sidebarStore.tsx";
import ThemeSwitcher from "@components/ThemeSwitcher.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import BatteryStatus from "@components/BatteryStatus.tsx";
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
import { useTranslation } from "react-i18next";
import { DeviceInfoPanel } from "./DeviceInfoPanel.tsx";
export interface SidebarProps {
children?: React.ReactNode;
@ -210,98 +205,21 @@ export const Sidebar = ({ children }: SidebarProps) => {
</div>
)
: (
<>
<div
className={cn(
"flex place-items-center gap-2",
isCollapsed && "justify-center",
)}
>
<Avatar
text={myNode.user?.shortName ??
t("unknown.shortName")}
className={cn("flex-shrink-0 ml-2", isCollapsed && "ml-0")}
size="sm"
/>
<p
className={cn(
"max-w-[20ch] text-wrap text-sm font-medium",
"transition-all duration-300 ease-in-out overflow-hidden",
isCollapsed
? "opacity-0 max-w-0 invisible"
: "opacity-100 max-w-full visible",
)}
>
{myNode.user?.longName}
</p>
</div>
<div
className={cn(
"flex flex-col gap-0.5 ml-2 mt-2",
"transition-all duration-300 ease-in-out",
isCollapsed
? "opacity-0 max-w-0 h-0 invisible"
: "opacity-100 max-w-xs h-auto visible",
)}
>
<div className="inline-flex gap-2">
<BatteryStatus deviceMetrics={myNode.deviceMetrics} />
</div>
<div className="inline-flex gap-2">
<ZapIcon
size={18}
className="text-gray-500 dark:text-gray-400 w-4 flex-shrink-0"
/>
<Subtle>
{t("sidebar.deviceInfo.volts", {
voltage: myNode.deviceMetrics?.voltage?.toPrecision(3) ??
t("unknown.shortName"),
})}
</Subtle>
</div>
<div className="inline-flex gap-2">
<CpuIcon
size={18}
className="text-gray-500 dark:text-gray-400 w-4 flex-shrink-0"
/>
<Subtle>
{t("sidebar.deviceInfo.firmwareVersion", {
version: myMetadata?.firmwareVersion ??
t("unknown.shortName"),
})}
</Subtle>
</div>
</div>
<div
className={cn(
"flex items-center flex-shrink-0 ml-2",
"transition-all duration-300 ease-in-out",
isCollapsed
? "opacity-0 max-w-0 invisible pointer-events-none"
: "opacity-100 max-w-xs visible",
)}
>
<button
type="button"
aria-label={t(
"sidebar.deviceInfo.button.editDeviceName",
)}
className="p-1 rounded transition-colors cursor-pointer hover:text-accent"
onClick={() => setDialogOpen("deviceName", true)}
>
<PenLine size={22} />
</button>
<ThemeSwitcher />
<button
type="button"
className="transition-all cursor-pointer hover:text-accent"
onClick={() => setCommandPaletteOpen(true)}
>
<SearchIcon />
</button>
</div>
</>
<DeviceInfoPanel
isCollapsed={isCollapsed}
setCommandPaletteOpen={() => setCommandPaletteOpen(true)}
setDialogOpen={() => setDialogOpen("deviceName", true)}
user={{
longName: myNode?.user?.longName ?? t("unknown.longName"),
shortName: myNode?.user?.shortName ?? t("unknown.shortName"),
}}
firmwareVersion={myMetadata?.firmwareVersion ??
t("unknown.firmwareVersion")}
deviceMetrics={{
batteryLevel: myNode.deviceMetrics?.batteryLevel,
voltage: myNode.deviceMetrics?.voltage,
}}
/>
)}
</div>
</div>

73
src/components/ThemeSwitcher.tsx

@ -1,22 +1,35 @@
import { useTheme } from "../core/hooks/useTheme.ts";
import { cn } from "../core/utils/cn.ts";
import { useTheme } from "@core/hooks/useTheme.ts";
import { cn } from "@core/utils/cn.ts";
import { Monitor, Moon, Sun } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Subtle } from "./UI/Typography/Subtle.tsx";
import { Button } from "./UI/Button.tsx";
type ThemePreference = "light" | "dark" | "system";
export default function ThemeSwitcher({
className = "",
}: {
interface ThemeSwitcherProps {
className?: string;
}) {
disableHover?: boolean;
}
export default function ThemeSwitcher({
className: passedClassName = "",
disableHover = false,
}: ThemeSwitcherProps) {
const { preference, setPreference } = useTheme();
const { t } = useTranslation("ui");
const iconBaseClass =
"size-4 flex-shrink-0 text-gray-500 dark:text-gray-400 transition-colors duration-150";
const iconHoverClass = !disableHover
? "group-hover:text-gray-700 dark:group-hover:text-gray-200"
: "";
const combinedIconClass = cn(iconBaseClass, iconHoverClass);
const themeIcons = {
light: <Sun className="size-6" />,
dark: <Moon className="size-6" />,
system: <Monitor className="size-6" />,
light: <Sun className={combinedIconClass} />,
dark: <Moon className={combinedIconClass} />,
system: <Monitor className={combinedIconClass} />,
};
const toggleTheme = () => {
@ -35,22 +48,46 @@ export default function ThemeSwitcher({
const currentDisplayPreference = preferenceDisplayMap[preference];
return (
<button
type="button"
<Button
variant="ghost"
onClick={toggleTheme}
id="theme-switcher"
aria-label={t("theme.changeTheme")}
className={cn(
"transition-all duration-300 scale-100 cursor-pointer m-3 p-2 focus:*:data-label:opacity-100",
className,
"group relative flex justify-start",
"gap-2.5 p-1.5 rounded-md transition-colors duration-150",
"cursor-pointer",
!disableHover && "hover:bg-gray-100 dark:hover:bg-gray-700",
"focus:*:data-label:opacity-100",
passedClassName,
)}
onClick={toggleTheme}
aria-description={t("theme.changeTheme")}
>
<span
data-label
className="transition-all block absolute w-full mb-auto mt-auto ml-0 mr-0 text-xs left-0 -top-3 opacity-0 rounded-lg"
data-label="theme-preference-tooltip"
className={cn(
"transition-opacity duration-150",
"block absolute w-max max-w-xs",
"p-1 text-xs text-white dark:text-black bg-black dark:bg-white",
"rounded-md shadow-lg",
"left-1/2 -translate-x-1/2 -top-8",
"opacity-0",
)}
>
{currentDisplayPreference}
</span>
{themeIcons[preference]}
</button>
<Subtle
className={cn(
"text-sm",
"text-gray-600 dark:text-gray-300",
"transition-colors duration-150",
!disableHover &&
"group-hover:text-gray-800 dark:group-hover:text-gray-100",
)}
>
{t("theme.changeTheme")}
</Subtle>
</Button>
);
}

4
src/components/UI/Input.tsx

@ -82,8 +82,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
ref.current.focus();
}
},
ariaLabel: t("filters.clearInput.label"),
tooltip: t("filters.clearInput.label"),
ariaLabel: t("clearInput.label"),
tooltip: t("clearInput.label"),
condition: !!showClearButton && !!value,
},
{

54
src/components/generic/Filter/FilterControl.tsx

@ -62,8 +62,8 @@ function HopsLabelContent({ hopsAway, t }: HopsLabelProps) {
return (
<>
{t("filters.hops.text", {
value: startHops === 0 ? t("filters.hops.direct") : startHops,
{t("hops.text", {
value: startHops === 0 ? t("hops.direct") : startHops,
})}
{startHops !== endHops ? `${endHops}` : ""}
</>
@ -82,11 +82,11 @@ function LastHeardLabelContent(
const [start, end] = lastHeardRange;
return (
<>
{t("filters.lastHeard.labelText", { value: "" })}
{t("lastHeard.labelText", { value: "" })}
<br />
{start === 0
? (
t("filters.lastHeard.nowLabel")
t("lastHeard.nowLabel")
)
: (
<>
@ -123,7 +123,7 @@ function BatteryLevelLabelContent(
return (
<>
{t("filters.batteryLevel.labelText", {
{t("batteryLevel.labelText", {
value: formatBatteryValue(start),
})}
{start !== end && typeof end !== "undefined" && (
@ -231,7 +231,7 @@ export function FilterControl({
: "",
parameters?.popoverTriggerClassName,
)}
aria-label={t("filters.filter.label")}
aria-label={t("filter.label")}
>
{parameters?.triggerIcon ?? <FunnelIcon />}
</button>
@ -246,14 +246,14 @@ export function FilterControl({
<form className="space-y-4">
<Accordion
type="single"
defaultValue={t("filters.general.label")}
defaultValue={t("general.label")}
collapsible
>
<FilterAccordionItem label={t("filters.general.label")}>
<FilterAccordionItem label={t("general.label")}>
{(parameters?.showTextSearch ?? true) && (
<div className="flex flex-col space-y-1 pb-2">
<label htmlFor="nodeName" className="font-medium text-sm">
{t("filters.nodeName.label")}
{t("nodeName.label")}
</label>
<Input
type="text"
@ -261,12 +261,12 @@ export function FilterControl({
value={filterState.nodeName}
onChange={handleTextChange("nodeName")}
showClearButton
placeholder={t("filters.nodeName.placeholder")}
placeholder={t("nodeName.placeholder")}
/>
</div>
)}
<FilterSlider
label={t("filters.hops.label")}
label={t("hops.label")}
filterKey="hopsAway"
filterState={localFilterState}
defaultFilterValues={defaultFilterValues}
@ -279,7 +279,7 @@ export function FilterControl({
}
/>
<FilterSlider
label={t("filters.lastHeard.label")}
label={t("lastHeard.label")}
filterKey="lastHeard"
filterState={localFilterState}
defaultFilterValues={defaultFilterValues}
@ -294,51 +294,51 @@ export function FilterControl({
}
/>
<FilterToggle
label={t("filters.favorites.label")}
label={t("favorites.label")}
filterKey="isFavorite"
alternativeLabels={[
t("filters.hide.label"),
t("filters.showOnly.label"),
t("hide.label"),
t("showOnly.label"),
]}
filterState={filterState}
onChange={handleBoolChange}
/>
<FilterToggle
label={t("filters.viaMqtt.label")}
label={t("viaMqtt.label")}
filterKey="viaMqtt"
alternativeLabels={[
t("filters.hide.label"),
t("filters.showOnly.label"),
t("hide.label"),
t("showOnly.label"),
]}
filterState={filterState}
onChange={handleBoolChange}
/>
</FilterAccordionItem>
<FilterAccordionItem label={t("filters.metrics.label")}>
<FilterAccordionItem label={t("metrics.label")}>
<FilterSlider
label={t("filters.snr.label")}
label={t("snr.label")}
filterKey="snr"
filterState={localFilterState}
defaultFilterValues={defaultFilterValues}
onChange={handleRangeChange}
/>
<FilterSlider
label={t("filters.channelUtilization.label")}
label={t("channelUtilization.label")}
filterKey="channelUtilization"
filterState={localFilterState}
defaultFilterValues={defaultFilterValues}
onChange={handleRangeChange}
/>
<FilterSlider
label={t("filters.airtimeUtilization.label")}
label={t("airtimeUtilization.label")}
filterKey="airUtilTx"
filterState={localFilterState}
defaultFilterValues={defaultFilterValues}
onChange={handleRangeChange}
/>
<FilterSlider
label={t("filters.batteryLevel.label")}
label={t("batteryLevel.label")}
filterKey="batteryLevel"
filterState={localFilterState}
defaultFilterValues={defaultFilterValues}
@ -351,7 +351,7 @@ export function FilterControl({
}
/>
<FilterSlider
label={t("filters.batteryVoltage.label")}
label={t("batteryVoltage.label")}
filterKey="voltage"
filterState={localFilterState}
defaultFilterValues={defaultFilterValues}
@ -359,7 +359,7 @@ export function FilterControl({
/>
</FilterAccordionItem>
<FilterAccordionItem label={t("filters.role.label")}>
<FilterAccordionItem label={t("role.label")}>
<FilterMulti
filterKey="role"
filterState={filterState}
@ -374,7 +374,7 @@ export function FilterControl({
/>
</FilterAccordionItem>
<FilterAccordionItem label={t("filters.hardware.label")}>
<FilterAccordionItem label={t("hardware.label")}>
<FilterMulti
filterKey="hwModel"
filterState={filterState}
@ -392,7 +392,7 @@ export function FilterControl({
onClick={resetFilters}
className="w-full py-1 shadow-sm hover:shadow-md bg-slate-600 dark:bg-slate-900 text-white rounded text-sm hover:text-slate-100 hover:bg-slate-700 active:bg-slate-950"
>
{t("filters.resetFilters.label")}
{t("button.reset")}
</button>
{children && <div className="mt-4 border-t pt-4">{children}</div>}
</form>

4
src/components/types.ts

@ -0,0 +1,4 @@
export type DeviceMetrics = {
batteryLevel?: number | null;
voltage?: number | null;
};

11
src/i18n/config.ts

@ -3,14 +3,15 @@ import { initReactI18next } from "react-i18next";
import Backend from "i18next-http-backend";
import LanguageDetector from "i18next-browser-languagedetector";
export type Lang = { code: string; name: string };
export type Lang = { code: string; name: string; flag: string };
export type LangCode = Lang["code"];
export const supportedLanguages: Lang[] = [
// { code: "de", name: "Deutsch" },
{ code: "en", name: "English" },
// { code: "es", name: "Español" },
// { code: "fr", name: "Français" },
// { code: "de", name: "Deutsch", flag: "🇩🇪" },
{ code: "en", name: "English", flag: "🇺🇸" },
// { code: "es", name: "Español", flag: "🇪🇸" },
// { code: "fr", name: "Français", flag: "🇫🇷" },
// { code: "zh", name: "中文", flag: "🇨🇳" },
];
i18next

3
src/i18n/locales/en/commandPalette.json

@ -1,5 +1,8 @@
{
"emptyState": "No results found.",
"page": {
"title": "Command Palette"
},
"pinGroup": {
"label": "Pin command group"
},

3
src/i18n/locales/en/common.json

@ -55,7 +55,7 @@
},
"second": { "one": "Second", "plural": "Seconds" },
"snr": "SNR",
"volts": "Volts",
"volt": { "one": "Volt", "plural": "Volts", "suffix": "V" },
"record": { "one": "Records", "plural": "Records" }
},
"security": {
@ -64,6 +64,7 @@
"unknown": {
"longName": "Unknown",
"shortName": "UNK",
"notAvailable": "N/A",
"num": "??"
},
"nodeUnknownPrefix": "!",

4
src/i18n/locales/en/deviceConfig.json

@ -78,7 +78,7 @@
"display": {
"description": "Settings for the device display",
"title": "Display Settings",
"boldHeading": {
"headingBold": {
"description": "Bolden the heading text",
"label": "Bold Heading"
},
@ -320,12 +320,12 @@
"description": "GPS module TX pin override",
"label": "Transmit Pin"
},
"fieldPlaceholder_selectPositionFlags": "Select position flags...",
"intervalsSettings": {
"description": "How often to send position updates",
"label": "Intervals"
},
"flags": {
"placeholder": "Select position flags...",
"altitude": "Altitude",
"altitudeGeoidalSeparation": "Altitude Geoidal Separation",
"altitudeMsl": "Altitude is Mean Sea Level",

148
src/i18n/locales/en/ui.json

@ -22,10 +22,17 @@
},
"deviceInfo": {
"volts": "{{voltage}} volts",
"firmwareVersion": "v{{version}}",
"button": {
"editDeviceName": "Edit device name"
}
"firmware": {
"title": "Firmware",
"version": "v{{version}}",
"buildDate": "Build date: {{date}}"
},
"deviceName": {
"title": "Device Name",
"changeName": "Change Device Name",
"placeholder": "Enter device name"
},
"editDeviceName": "Edit device name"
}
},
"batteryStatus": {
@ -75,76 +82,79 @@
"label": "Show password"
}
},
"filters": {
"general": {
"label": "General"
},
"hardware": {
"label": "Hardware"
},
"metrics": {
"label": "Metrics"
},
"role": {
"label": "Role"
},
"filter": {
"label": "Filter"
},
"clearInput": {
"label": "Clear input"
},
"resetFilters": {
"label": "Reset Filters"
},
"nodeName": {
"label": "Node name/number",
"placeholder": "Meshtastic 1234"
},
"airtimeUtilization": {
"label": "Airtime Utilization (%)"
},
"batteryLevel": {
"label": "Battery level (%)",
"labelText": "Battery level (%): {{value}}"
},
"batteryVoltage": {
"label": "Battery voltage (V)"
},
"channelUtilization": {
"label": "Channel Utilization (%)"
},
"hops": {
"direct": "Direct",
"label": "Number of hops",
"text": "Number of hops: {{value}}"
},
"lastHeard": {
"label": "Last heard",
"labelText": "Last heard: {{value}}",
"nowLabel": "Now"
},
"snr": {
"label": "SNR (db)"
},
"favorites": {
"label": "Favorites"
},
"hide": {
"label": "Hide"
},
"showOnly": {
"label": "Show Only"
},
"viaMqtt": {
"label": "Connected via MQTT"
}
"general": {
"label": "General"
},
"hardware": {
"label": "Hardware"
},
"metrics": {
"label": "Metrics"
},
"role": {
"label": "Role"
},
"filter": {
"label": "Filter"
},
"clearInput": {
"label": "Clear input"
},
"resetFilters": {
"label": "Reset Filters"
},
"nodeName": {
"label": "Node name/number",
"placeholder": "Meshtastic 1234"
},
"airtimeUtilization": {
"label": "Airtime Utilization (%)"
},
"batteryLevel": {
"label": "Battery level (%)",
"labelText": "Battery level (%): {{value}}"
},
"batteryVoltage": {
"label": "Battery voltage (V)",
"title": "Voltage"
},
"channelUtilization": {
"label": "Channel Utilization (%)"
},
"hops": {
"direct": "Direct",
"label": "Number of hops",
"text": "Number of hops: {{value}}"
},
"lastHeard": {
"label": "Last heard",
"labelText": "Last heard: {{value}}",
"nowLabel": "Now"
},
"snr": {
"label": "SNR (db)"
},
"favorites": {
"label": "Favorites"
},
"hide": {
"label": "Hide"
},
"showOnly": {
"label": "Show Only"
},
"viaMqtt": {
"label": "Connected via MQTT"
},
"language": {
"label": "Language",
"changeLanguage": "Change Language"
},
"theme": {
"dark": "Dark",
"light": "Light",
"system": "Automatic",
"changeTheme": "Change current theme"
"changeTheme": "Change Color Scheme"
},
"footer": {
"text": "Powered by <0>▲ Vercel</0> | Meshtastic® is a registered trademark of Meshtastic LLC. | <1>Legal Information</1>"

4
src/pages/Dashboard/index.tsx

@ -14,6 +14,7 @@ import {
} from "lucide-react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import LanguageSwitcher from "@components/LanguageSwitcher.tsx";
export const Dashboard = () => {
const { t } = useTranslation("dashboard");
@ -24,7 +25,7 @@ export const Dashboard = () => {
return (
<>
<div className="flex flex-col gap-3 p-3">
<div className="flex flex-col gap-3 p-3 px-8">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Heading as="h3">
@ -34,6 +35,7 @@ export const Dashboard = () => {
{t("dashboard.description")}
</Subtle>
</div>
<LanguageSwitcher />
</div>
<Separator />

31
vite.config.ts

@ -1,15 +1,26 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { VitePWA } from "vite-plugin-pwa";
import { execSync } from "node:child_process";
import process from "node:process";
import path from "node:path";
import { resolve } from "jsr:@std/path/resolve";
let hash = "";
try {
hash = execSync("git rev-parse --short HEAD", { encoding: "utf8" }).trim();
const command = new Deno.Command("git", {
args: ["rev-parse", "--short", "HEAD"],
stdout: "piped",
stderr: "piped",
});
const { code, stdout, stderr } = await command.output();
if (code === 0) {
hash = new TextDecoder().decode(stdout).trim();
} else {
const errorOutput = new TextDecoder().decode(stderr);
console.error("Error getting git hash:", errorOutput);
hash = "DEV";
}
} catch (error) {
console.error("Error getting git hash:", error);
console.error("Failed to execute git command:", error);
hash = "DEV";
}
@ -37,11 +48,11 @@ export default defineConfig({
},
resolve: {
alias: {
"@app": path.resolve(process.cwd(), "./src"),
"@pages": path.resolve(process.cwd(), "./src/pages"),
"@components": path.resolve(process.cwd(), "./src/components"),
"@core": path.resolve(process.cwd(), "./src/core"),
"@layouts": path.resolve(process.cwd(), "./src/layouts"),
"@app": resolve(Deno.cwd(), "./src"),
"@pages": resolve(Deno.cwd(), "./src/pages"),
"@components": resolve(Deno.cwd(), "./src/components"),
"@core": resolve(Deno.cwd(), "./src/core"),
"@layouts": resolve(Deno.cwd(), "./src/layouts"),
},
},
server: {

Loading…
Cancel
Save