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. 26
      src/components/BatteryStatus.tsx
  3. 237
      src/components/DeviceInfoPanel.tsx
  4. 119
      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. 112
      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. 24
      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", "version": "5",
"specifiers": { "specifiers": {
"jsr:@std/path@*": "1.0.6",
"npm:@bufbuild/protobuf@^2.2.5": "2.2.5", "npm:@bufbuild/protobuf@^2.2.5": "2.2.5",
"npm:@jsr/[email protected]": "2.6.2", "npm:@jsr/[email protected]": "2.6.2",
"npm:@jsr/[email protected]": "2.6.0-0", "npm:@jsr/[email protected]": "2.6.0-0",
@ -79,6 +80,11 @@
"npm:zod@^3.24.3": "3.24.3", "npm:zod@^3.24.3": "3.24.3",
"npm:[email protected]": "5.0.4_@[email protected][email protected][email protected]" "npm:[email protected]": "5.0.4_@[email protected][email protected][email protected]"
}, },
"jsr": {
"@std/[email protected]": {
"integrity": "ab2c55f902b380cf28e0eec501b4906e4c1960d13f00e11cfbcd21de15f18fed"
}
},
"npm": { "npm": {
"@adobe/[email protected]": { "@adobe/[email protected]": {
"integrity": "sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==" "integrity": "sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A=="

26
src/components/BatteryStatus.tsx

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

119
src/components/LanguageSwitcher.tsx

@ -1,60 +1,91 @@
import clsx from "clsx"; import { Check, Languages } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { LangCode, supportedLanguages } from "../i18n/config.ts";
import useLang from "@core/hooks/useLang.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 { 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() { interface LanguageSwitcherProps {
const { t, i18n } = useTranslation(); disableHover?: boolean;
const { set } = useLang(); }
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 ( return (
<DropdownMenu <DropdownMenu>
i18nIsDynamicList <DropdownMenuTrigger asChild>
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}`}
>
<Button <Button
role="presentation" variant="ghost"
className={clsx([ className={cn(
"flex flex-row justify-between items-center w-full", "group flex items-center justify-start",
"rounded-lg px-3 py-1 transition duration-75 cursor-default", "transition-colors duration-150 gap-2.5 p-1.5 rounded-md",
])} !disableHover && "hover:bg-gray-100 dark:hover:bg-gray-700",
onClick={() => set(lang.code)} )}
data-testid={`language-button-${lang.code}`}
data-selected={i18n.language === lang.code}
> >
<div <Languages
className="flex flex-row items-center m-1 gap-3 p-1 overflow-hidden" size={16}
data-testid={`language-name-${lang.code}`} 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",
)}
> >
{lang.name} {`${i18n.t("language.changeLanguage")}:`}
</div> </Subtle>
<div <Subtle
className={clsx([ className={cn(
"pr-4 ml-2 flex items-center font-medium text-xs", "text-sm font-medium text-gray-700 dark:text-gray-200 transition-colors duration-150",
"text-iron dark:text-bombay", !disableHover &&
])} "group-hover:text-gray-900 dark:group-hover:text-white",
data-testid={`language-selected-indicator-${lang.code}`} )}
> >
{i18n.language === lang.code && {currentLanguage.code.toUpperCase()}
t("selected", { ns: "glossary" })} </Subtle>
</div>
</Button> </Button>
</li> </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> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); );
} }
export default LanguageSwitcher;

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

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

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

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

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

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

112
src/components/Sidebar.tsx

@ -4,7 +4,6 @@ import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import type { Page } from "@core/stores/deviceStore.ts"; import type { Page } from "@core/stores/deviceStore.ts";
import { Spinner } from "@components/UI/Spinner.tsx"; import { Spinner } from "@components/UI/Spinner.tsx";
import { Avatar } from "@components/UI/Avatar.tsx";
import { import {
CircleChevronLeft, CircleChevronLeft,
@ -13,19 +12,15 @@ import {
type LucideIcon, type LucideIcon,
MapIcon, MapIcon,
MessageSquareIcon, MessageSquareIcon,
PenLine,
SearchIcon,
SettingsIcon, SettingsIcon,
UsersIcon, UsersIcon,
ZapIcon,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@core/utils/cn.ts"; import { cn } from "@core/utils/cn.ts";
import { useSidebar } from "@core/stores/sidebarStore.tsx"; import { useSidebar } from "@core/stores/sidebarStore.tsx";
import ThemeSwitcher from "@components/ThemeSwitcher.tsx";
import { useAppStore } from "@core/stores/appStore.ts"; import { useAppStore } from "@core/stores/appStore.ts";
import BatteryStatus from "@components/BatteryStatus.tsx";
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx"; import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { DeviceInfoPanel } from "./DeviceInfoPanel.tsx";
export interface SidebarProps { export interface SidebarProps {
children?: React.ReactNode; children?: React.ReactNode;
@ -210,98 +205,21 @@ export const Sidebar = ({ children }: SidebarProps) => {
</div> </div>
) )
: ( : (
<> <DeviceInfoPanel
<div isCollapsed={isCollapsed}
className={cn( setCommandPaletteOpen={() => setCommandPaletteOpen(true)}
"flex place-items-center gap-2", setDialogOpen={() => setDialogOpen("deviceName", true)}
isCollapsed && "justify-center", user={{
)} longName: myNode?.user?.longName ?? t("unknown.longName"),
> shortName: myNode?.user?.shortName ?? t("unknown.shortName"),
<Avatar }}
text={myNode.user?.shortName ?? firmwareVersion={myMetadata?.firmwareVersion ??
t("unknown.shortName")} t("unknown.firmwareVersion")}
className={cn("flex-shrink-0 ml-2", isCollapsed && "ml-0")} deviceMetrics={{
size="sm" batteryLevel: myNode.deviceMetrics?.batteryLevel,
/> voltage: myNode.deviceMetrics?.voltage,
<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>
</>
)} )}
</div> </div>
</div> </div>

73
src/components/ThemeSwitcher.tsx

@ -1,22 +1,35 @@
import { useTheme } from "../core/hooks/useTheme.ts"; import { useTheme } from "@core/hooks/useTheme.ts";
import { cn } from "../core/utils/cn.ts"; import { cn } from "@core/utils/cn.ts";
import { Monitor, Moon, Sun } from "lucide-react"; import { Monitor, Moon, Sun } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Subtle } from "./UI/Typography/Subtle.tsx";
import { Button } from "./UI/Button.tsx";
type ThemePreference = "light" | "dark" | "system"; type ThemePreference = "light" | "dark" | "system";
export default function ThemeSwitcher({ interface ThemeSwitcherProps {
className = "",
}: {
className?: string; className?: string;
}) { disableHover?: boolean;
}
export default function ThemeSwitcher({
className: passedClassName = "",
disableHover = false,
}: ThemeSwitcherProps) {
const { preference, setPreference } = useTheme(); const { preference, setPreference } = useTheme();
const { t } = useTranslation("ui"); 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 = { const themeIcons = {
light: <Sun className="size-6" />, light: <Sun className={combinedIconClass} />,
dark: <Moon className="size-6" />, dark: <Moon className={combinedIconClass} />,
system: <Monitor className="size-6" />, system: <Monitor className={combinedIconClass} />,
}; };
const toggleTheme = () => { const toggleTheme = () => {
@ -35,22 +48,46 @@ export default function ThemeSwitcher({
const currentDisplayPreference = preferenceDisplayMap[preference]; const currentDisplayPreference = preferenceDisplayMap[preference];
return ( return (
<button <Button
type="button" variant="ghost"
onClick={toggleTheme}
id="theme-switcher"
aria-label={t("theme.changeTheme")}
className={cn( className={cn(
"transition-all duration-300 scale-100 cursor-pointer m-3 p-2 focus:*:data-label:opacity-100", "group relative flex justify-start",
className, "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 <span
data-label data-label="theme-preference-tooltip"
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" 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} {currentDisplayPreference}
</span> </span>
{themeIcons[preference]} {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(); ref.current.focus();
} }
}, },
ariaLabel: t("filters.clearInput.label"), ariaLabel: t("clearInput.label"),
tooltip: t("filters.clearInput.label"), tooltip: t("clearInput.label"),
condition: !!showClearButton && !!value, condition: !!showClearButton && !!value,
}, },
{ {

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

@ -62,8 +62,8 @@ function HopsLabelContent({ hopsAway, t }: HopsLabelProps) {
return ( return (
<> <>
{t("filters.hops.text", { {t("hops.text", {
value: startHops === 0 ? t("filters.hops.direct") : startHops, value: startHops === 0 ? t("hops.direct") : startHops,
})} })}
{startHops !== endHops ? `${endHops}` : ""} {startHops !== endHops ? `${endHops}` : ""}
</> </>
@ -82,11 +82,11 @@ function LastHeardLabelContent(
const [start, end] = lastHeardRange; const [start, end] = lastHeardRange;
return ( return (
<> <>
{t("filters.lastHeard.labelText", { value: "" })} {t("lastHeard.labelText", { value: "" })}
<br /> <br />
{start === 0 {start === 0
? ( ? (
t("filters.lastHeard.nowLabel") t("lastHeard.nowLabel")
) )
: ( : (
<> <>
@ -123,7 +123,7 @@ function BatteryLevelLabelContent(
return ( return (
<> <>
{t("filters.batteryLevel.labelText", { {t("batteryLevel.labelText", {
value: formatBatteryValue(start), value: formatBatteryValue(start),
})} })}
{start !== end && typeof end !== "undefined" && ( {start !== end && typeof end !== "undefined" && (
@ -231,7 +231,7 @@ export function FilterControl({
: "", : "",
parameters?.popoverTriggerClassName, parameters?.popoverTriggerClassName,
)} )}
aria-label={t("filters.filter.label")} aria-label={t("filter.label")}
> >
{parameters?.triggerIcon ?? <FunnelIcon />} {parameters?.triggerIcon ?? <FunnelIcon />}
</button> </button>
@ -246,14 +246,14 @@ export function FilterControl({
<form className="space-y-4"> <form className="space-y-4">
<Accordion <Accordion
type="single" type="single"
defaultValue={t("filters.general.label")} defaultValue={t("general.label")}
collapsible collapsible
> >
<FilterAccordionItem label={t("filters.general.label")}> <FilterAccordionItem label={t("general.label")}>
{(parameters?.showTextSearch ?? true) && ( {(parameters?.showTextSearch ?? true) && (
<div className="flex flex-col space-y-1 pb-2"> <div className="flex flex-col space-y-1 pb-2">
<label htmlFor="nodeName" className="font-medium text-sm"> <label htmlFor="nodeName" className="font-medium text-sm">
{t("filters.nodeName.label")} {t("nodeName.label")}
</label> </label>
<Input <Input
type="text" type="text"
@ -261,12 +261,12 @@ export function FilterControl({
value={filterState.nodeName} value={filterState.nodeName}
onChange={handleTextChange("nodeName")} onChange={handleTextChange("nodeName")}
showClearButton showClearButton
placeholder={t("filters.nodeName.placeholder")} placeholder={t("nodeName.placeholder")}
/> />
</div> </div>
)} )}
<FilterSlider <FilterSlider
label={t("filters.hops.label")} label={t("hops.label")}
filterKey="hopsAway" filterKey="hopsAway"
filterState={localFilterState} filterState={localFilterState}
defaultFilterValues={defaultFilterValues} defaultFilterValues={defaultFilterValues}
@ -279,7 +279,7 @@ export function FilterControl({
} }
/> />
<FilterSlider <FilterSlider
label={t("filters.lastHeard.label")} label={t("lastHeard.label")}
filterKey="lastHeard" filterKey="lastHeard"
filterState={localFilterState} filterState={localFilterState}
defaultFilterValues={defaultFilterValues} defaultFilterValues={defaultFilterValues}
@ -294,51 +294,51 @@ export function FilterControl({
} }
/> />
<FilterToggle <FilterToggle
label={t("filters.favorites.label")} label={t("favorites.label")}
filterKey="isFavorite" filterKey="isFavorite"
alternativeLabels={[ alternativeLabels={[
t("filters.hide.label"), t("hide.label"),
t("filters.showOnly.label"), t("showOnly.label"),
]} ]}
filterState={filterState} filterState={filterState}
onChange={handleBoolChange} onChange={handleBoolChange}
/> />
<FilterToggle <FilterToggle
label={t("filters.viaMqtt.label")} label={t("viaMqtt.label")}
filterKey="viaMqtt" filterKey="viaMqtt"
alternativeLabels={[ alternativeLabels={[
t("filters.hide.label"), t("hide.label"),
t("filters.showOnly.label"), t("showOnly.label"),
]} ]}
filterState={filterState} filterState={filterState}
onChange={handleBoolChange} onChange={handleBoolChange}
/> />
</FilterAccordionItem> </FilterAccordionItem>
<FilterAccordionItem label={t("filters.metrics.label")}> <FilterAccordionItem label={t("metrics.label")}>
<FilterSlider <FilterSlider
label={t("filters.snr.label")} label={t("snr.label")}
filterKey="snr" filterKey="snr"
filterState={localFilterState} filterState={localFilterState}
defaultFilterValues={defaultFilterValues} defaultFilterValues={defaultFilterValues}
onChange={handleRangeChange} onChange={handleRangeChange}
/> />
<FilterSlider <FilterSlider
label={t("filters.channelUtilization.label")} label={t("channelUtilization.label")}
filterKey="channelUtilization" filterKey="channelUtilization"
filterState={localFilterState} filterState={localFilterState}
defaultFilterValues={defaultFilterValues} defaultFilterValues={defaultFilterValues}
onChange={handleRangeChange} onChange={handleRangeChange}
/> />
<FilterSlider <FilterSlider
label={t("filters.airtimeUtilization.label")} label={t("airtimeUtilization.label")}
filterKey="airUtilTx" filterKey="airUtilTx"
filterState={localFilterState} filterState={localFilterState}
defaultFilterValues={defaultFilterValues} defaultFilterValues={defaultFilterValues}
onChange={handleRangeChange} onChange={handleRangeChange}
/> />
<FilterSlider <FilterSlider
label={t("filters.batteryLevel.label")} label={t("batteryLevel.label")}
filterKey="batteryLevel" filterKey="batteryLevel"
filterState={localFilterState} filterState={localFilterState}
defaultFilterValues={defaultFilterValues} defaultFilterValues={defaultFilterValues}
@ -351,7 +351,7 @@ export function FilterControl({
} }
/> />
<FilterSlider <FilterSlider
label={t("filters.batteryVoltage.label")} label={t("batteryVoltage.label")}
filterKey="voltage" filterKey="voltage"
filterState={localFilterState} filterState={localFilterState}
defaultFilterValues={defaultFilterValues} defaultFilterValues={defaultFilterValues}
@ -359,7 +359,7 @@ export function FilterControl({
/> />
</FilterAccordionItem> </FilterAccordionItem>
<FilterAccordionItem label={t("filters.role.label")}> <FilterAccordionItem label={t("role.label")}>
<FilterMulti <FilterMulti
filterKey="role" filterKey="role"
filterState={filterState} filterState={filterState}
@ -374,7 +374,7 @@ export function FilterControl({
/> />
</FilterAccordionItem> </FilterAccordionItem>
<FilterAccordionItem label={t("filters.hardware.label")}> <FilterAccordionItem label={t("hardware.label")}>
<FilterMulti <FilterMulti
filterKey="hwModel" filterKey="hwModel"
filterState={filterState} filterState={filterState}
@ -392,7 +392,7 @@ export function FilterControl({
onClick={resetFilters} 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" 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> </button>
{children && <div className="mt-4 border-t pt-4">{children}</div>} {children && <div className="mt-4 border-t pt-4">{children}</div>}
</form> </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 Backend from "i18next-http-backend";
import LanguageDetector from "i18next-browser-languagedetector"; 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 type LangCode = Lang["code"];
export const supportedLanguages: Lang[] = [ export const supportedLanguages: Lang[] = [
// { code: "de", name: "Deutsch" }, // { code: "de", name: "Deutsch", flag: "🇩🇪" },
{ code: "en", name: "English" }, { code: "en", name: "English", flag: "🇺🇸" },
// { code: "es", name: "Español" }, // { code: "es", name: "Español", flag: "🇪🇸" },
// { code: "fr", name: "Français" }, // { code: "fr", name: "Français", flag: "🇫🇷" },
// { code: "zh", name: "中文", flag: "🇨🇳" },
]; ];
i18next i18next

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

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

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

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

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

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

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

@ -22,11 +22,18 @@
}, },
"deviceInfo": { "deviceInfo": {
"volts": "{{voltage}} volts", "volts": "{{voltage}} volts",
"firmwareVersion": "v{{version}}", "firmware": {
"button": { "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" "editDeviceName": "Edit device name"
} }
}
}, },
"batteryStatus": { "batteryStatus": {
"charging": "{{level}}% charging", "charging": "{{level}}% charging",
@ -75,7 +82,6 @@
"label": "Show password" "label": "Show password"
} }
}, },
"filters": {
"general": { "general": {
"label": "General" "label": "General"
}, },
@ -109,7 +115,8 @@
"labelText": "Battery level (%): {{value}}" "labelText": "Battery level (%): {{value}}"
}, },
"batteryVoltage": { "batteryVoltage": {
"label": "Battery voltage (V)" "label": "Battery voltage (V)",
"title": "Voltage"
}, },
"channelUtilization": { "channelUtilization": {
"label": "Channel Utilization (%)" "label": "Channel Utilization (%)"
@ -138,13 +145,16 @@
}, },
"viaMqtt": { "viaMqtt": {
"label": "Connected via MQTT" "label": "Connected via MQTT"
} },
"language": {
"label": "Language",
"changeLanguage": "Change Language"
}, },
"theme": { "theme": {
"dark": "Dark", "dark": "Dark",
"light": "Light", "light": "Light",
"system": "Automatic", "system": "Automatic",
"changeTheme": "Change current theme" "changeTheme": "Change Color Scheme"
}, },
"footer": { "footer": {
"text": "Powered by <0>▲ Vercel</0> | Meshtastic® is a registered trademark of Meshtastic LLC. | <1>Legal Information</1>" "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"; } from "lucide-react";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import LanguageSwitcher from "@components/LanguageSwitcher.tsx";
export const Dashboard = () => { export const Dashboard = () => {
const { t } = useTranslation("dashboard"); const { t } = useTranslation("dashboard");
@ -24,7 +25,7 @@ export const Dashboard = () => {
return ( 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="flex items-center justify-between">
<div className="space-y-1"> <div className="space-y-1">
<Heading as="h3"> <Heading as="h3">
@ -34,6 +35,7 @@ export const Dashboard = () => {
{t("dashboard.description")} {t("dashboard.description")}
</Subtle> </Subtle>
</div> </div>
<LanguageSwitcher />
</div> </div>
<Separator /> <Separator />

31
vite.config.ts

@ -1,15 +1,26 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { VitePWA } from "vite-plugin-pwa"; import { VitePWA } from "vite-plugin-pwa";
import { execSync } from "node:child_process"; import { resolve } from "jsr:@std/path/resolve";
import process from "node:process";
import path from "node:path";
let hash = ""; let hash = "";
try { 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) { } catch (error) {
console.error("Error getting git hash:", error); console.error("Failed to execute git command:", error);
hash = "DEV"; hash = "DEV";
} }
@ -37,11 +48,11 @@ export default defineConfig({
}, },
resolve: { resolve: {
alias: { alias: {
"@app": path.resolve(process.cwd(), "./src"), "@app": resolve(Deno.cwd(), "./src"),
"@pages": path.resolve(process.cwd(), "./src/pages"), "@pages": resolve(Deno.cwd(), "./src/pages"),
"@components": path.resolve(process.cwd(), "./src/components"), "@components": resolve(Deno.cwd(), "./src/components"),
"@core": path.resolve(process.cwd(), "./src/core"), "@core": resolve(Deno.cwd(), "./src/core"),
"@layouts": path.resolve(process.cwd(), "./src/layouts"), "@layouts": resolve(Deno.cwd(), "./src/layouts"),
}, },
}, },
server: { server: {

Loading…
Cancel
Save