Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,5 +96,9 @@
"frontend",
"zigbee",
"zigbee2mqtt"
]
}
],
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Nerivec"
}
}
73 changes: 73 additions & 0 deletions scripts/update-i18n.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
import difference from "lodash/difference.js";
import get from "lodash/get.js";
import set from "lodash/set.js";
import unset from "lodash/unset.js";

const LOCALES_PATH = "./src/i18n/locales/";
const EN_LOCALE_FILE = "en.json";

const isObject = (value: unknown) => value !== null && typeof value === "object";

const enTranslations = JSON.parse(readFileSync(`${LOCALES_PATH}${EN_LOCALE_FILE}`, "utf8"));

const getKeys = (content: Record<string, unknown>, path?: string) => {
const keys: string[] = [];
const obj = path ? (get(content, path) as Record<string, unknown>) : content;

for (const key in obj) {
const newPath = path ? `${path}.${key}` : key;

if (isObject(obj[key])) {
const nestedKeys = getKeys(obj, newPath);

keys.push(...nestedKeys);
} else {
keys.push(newPath);
}
}

return keys;
};

const enKeys = getKeys(enTranslations);

const missingByFile: Record<string, string[]> = {};

for (const localFile of readdirSync(LOCALES_PATH)) {
if (localFile === EN_LOCALE_FILE) {
continue;
}

const filePath = `${LOCALES_PATH}${localFile}`;
const translations = JSON.parse(readFileSync(filePath, "utf8"));
const keys = getKeys(translations);

if (keys.length !== 0) {
const missing = difference(enKeys, keys);

if (missing.length !== 0) {
console.error(`[${localFile}]: Missing keys:`);
console.error(missing);

for (const missingEntry of missing) {
set(translations, missingEntry, get(enTranslations, missingEntry));
}

missingByFile[filePath] = missing;
}

const removed = difference(keys, enKeys);

if (removed.length !== 0) {
console.error(`[${localFile}]: Invalid keys:`);
console.error(removed);

for (const removedEntry of removed) {
unset(translations, removedEntry);
}
}
}

writeFileSync(filePath, JSON.stringify(translations, undefined, 4), "utf8");
}
17 changes: 9 additions & 8 deletions src/Main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import React, { lazy, Suspense, useEffect } from "react";
import { I18nextProvider } from "react-i18next";
import { HashRouter, Route, Routes } from "react-router";
import { useShallow } from "zustand/react/shallow";
import NavBarWithNotifications from "./components/navbar/NavBar.js";
import ScrollToTop from "./components/ScrollToTop.js";
import Toasts from "./components/Toasts.js";
import { ErrorBoundary } from "./ErrorBoundary.js";
import i18n from "./i18n/index.js";
import AppLayout from "./layout/AppLayout.js";
import { LoginPage } from "./pages/LoginPage.js";
import { useAppStore } from "./store.js";
import { startWebSocketManager } from "./websocket/WebSocketManager.js";
Expand All @@ -24,6 +24,7 @@ const TouchlinkPage = lazy(async () => await import("./pages/TouchlinkPage.js"))
const LogsPage = lazy(async () => await import("./pages/LogsPage.js"));
const SettingsPage = lazy(async () => await import("./pages/SettingsPage.js"));
const FrontendSettingsPage = lazy(async () => await import("./pages/FrontendSettingsPage.js"));
const ContributePage = lazy(async () => await import("./pages/ContributePage.js"));

function App() {
const authRequired = useAppStore(useShallow((s) => s.authRequired.some((v) => v === true)));
Expand All @@ -40,8 +41,7 @@ function App() {
return (
<HashRouter>
<ScrollToTop />
<NavBarWithNotifications />
<main className="pt-3 px-2">
<AppLayout>
<Suspense
fallback={
<div className="flex flex-row justify-center items-center gap-2">
Expand All @@ -61,11 +61,12 @@ function App() {
<Route path="/logs/:sourceIdx?" element={<LogsPage />} />
<Route path="/settings/:sourceIdx?/:tab?/:subTab?" element={<SettingsPage />} />
<Route path="/frontend-settings" element={<FrontendSettingsPage />} />
<Route path="/contribute" element={<ContributePage />} />
<Route path="/" element={<HomePage />} />
<Route path="*" element={<HomePage />} />
</Routes>
</Suspense>
</main>
</AppLayout>
<Toasts />
</HashRouter>
);
Expand All @@ -74,13 +75,13 @@ function App() {
export function Main() {
return (
<React.StrictMode>
<I18nextProvider i18n={i18n}>
<ErrorBoundary>
<NiceModal.Provider>
<ErrorBoundary>
<I18nextProvider i18n={i18n}>
<App />
</ErrorBoundary>
</I18nextProvider>
</NiceModal.Provider>
</I18nextProvider>
</ErrorBoundary>
</React.StrictMode>
);
}
33 changes: 18 additions & 15 deletions src/components/DialogDropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type HTMLAttributes, memo, type ReactElement, useState } from "react";
import { createPortal } from "react-dom";
import Button from "./Button.js";

interface DialogDropdownProps extends HTMLAttributes<HTMLUListElement> {
Expand All @@ -16,21 +17,23 @@ const DialogDropdown = memo(({ buttonChildren, buttonStyle, buttonDisabled, chil
<Button item={!open} onClick={setOpen} className={`btn${buttonStyle ? ` ${buttonStyle}` : ""}`} disabled={buttonDisabled}>
{buttonChildren}
</Button>
{open && (
<dialog
className="modal modal-bottom sm:modal-middle"
open
onClick={(event) => {
if ((event.target as HTMLElement).tagName !== "INPUT") {
setOpen(false);
}
}}
>
<div className="modal-box flex-nowrap p-1 w-auto! max-h-[90vh] menu" style={{ scrollbarWidth: "thin" }}>
{children}
</div>
</dialog>
)}
{open &&
createPortal(
<dialog
className="modal modal-bottom sm:modal-middle"
open
onClick={(event) => {
if ((event.target as HTMLElement).tagName !== "INPUT") {
setOpen(false);
}
}}
>
<ul className="modal-box flex-nowrap p-1 w-auto! menu" style={{ scrollbarWidth: "thin" }}>
{children}
</ul>
</dialog>,
document.body,
)}
</>
);
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { type JSX, memo, useMemo } from "react";
import { useTranslation } from "react-i18next";
import DialogDropdown from "../DialogDropdown.js";
import DialogDropdown from "./DialogDropdown.js";

const LOCALES_NAMES_MAP = {
bg: "Български",
Expand Down Expand Up @@ -57,7 +57,11 @@ const LanguageSwitcher = memo(() => {
return languages;
}, [currentLanguage, i18n.changeLanguage, i18n.options.resources]);

return <DialogDropdown buttonChildren={currentLanguage}>{children}</DialogDropdown>;
return (
<DialogDropdown buttonChildren={currentLanguage} buttonStyle="btn-outline btn-primary">
{children}
</DialogDropdown>
);
});

export default LanguageSwitcher;
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import { type JSX, memo, useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import store2 from "store2";
import { useShallow } from "zustand/react/shallow";
import { PERMIT_JOIN_TIME_KEY } from "../../localStoreConsts.js";
import { API_URLS, useAppStore } from "../../store.js";
import type { Device } from "../../types.js";
import { sendMessage } from "../../websocket/WebSocketManager.js";
import Button from "../Button.js";
import DialogDropdown from "../DialogDropdown.js";
import SourceDot from "../SourceDot.js";
import Countdown from "../value-decorators/Countdown.js";
import { PERMIT_JOIN_TIME_KEY } from "../localStoreConsts.js";
import { API_NAMES, API_URLS, MULTI_INSTANCE, useAppStore } from "../store.js";
import type { Device } from "../types.js";
import { sendMessage } from "../websocket/WebSocketManager.js";
import Button from "./Button.js";
import DialogDropdown from "./DialogDropdown.js";
import SourceDot from "./SourceDot.js";
import Countdown from "./value-decorators/Countdown.js";

type PermitJoinDropdownProps = {
selectedRouter: [number, Device | undefined];
Expand All @@ -31,12 +31,14 @@ const PermitJoinDropdown = memo(({ selectedRouter, setSelectedRouter }: PermitJo
filteredDevices.push(
<li
key={`${device.friendly_name}-${device.ieee_address}-${sourceIdx}`}
className="truncate"
onClick={() => setSelectedRouter([sourceIdx, device])}
onKeyUp={(e) => {
if (e.key === "enter") {
setSelectedRouter([sourceIdx, device]);
}
}}
title={MULTI_INSTANCE ? `${API_NAMES[sourceIdx]} - ${device.friendly_name}` : device.friendly_name}
>
<span
className={`dropdown-item${selectedRouter[0] === sourceIdx && selectedRouter[1]?.ieee_address === device.ieee_address ? " menu-active" : ""}`}
Expand All @@ -56,12 +58,14 @@ const PermitJoinDropdown = memo(({ selectedRouter, setSelectedRouter }: PermitJo
filteredDevices.unshift(
<li
key={`${sourceIdx}-all`}
className="truncate"
onClick={() => setSelectedRouter([sourceIdx, undefined])}
onKeyUp={(e) => {
if (e.key === "enter") {
setSelectedRouter([sourceIdx, undefined]);
}
}}
title={MULTI_INSTANCE ? `${API_NAMES[sourceIdx]} - ${t("all")}` : t("all")}
>
<span className={`dropdown-item${selectedRouter[0] === sourceIdx && selectedRouter[1] === undefined ? " menu-active" : ""}`}>
<SourceDot idx={sourceIdx} autoHide namePostfix=" - " />
Expand All @@ -81,7 +85,7 @@ const PermitJoinDropdown = memo(({ selectedRouter, setSelectedRouter }: PermitJo
<FontAwesomeIcon icon={faAngleDown} />
</span>
}
buttonStyle="btn-square join-item"
buttonStyle="btn-outline btn-primary btn-square join-item"
>
{routers}
</DialogDropdown>
Expand Down Expand Up @@ -113,17 +117,17 @@ const PermitJoinButton = memo(() => {
return (
<div className="join join-horizontal">
{permitJoin ? (
<Button<void> onClick={onPermitJoinClick} className="btn btn-outline-secondary join-item" title={t("disable_join")}>
<Button<void> onClick={onPermitJoinClick} className="btn btn-outline btn-primary join-item grow" title={t("disable_join")}>
<FontAwesomeIcon icon={faTowerBroadcast} className="text-success" beatFade />
<SourceDot idx={selectedRouter[0]} autoHide alwaysHideName />
{selectedRouter[1]?.friendly_name ?? t("all")}
<span className="truncate">{selectedRouter[1]?.friendly_name ?? t("all")}</span>
{permitJoinTimer}
</Button>
) : (
<Button<void> onClick={onPermitJoinClick} className="btn btn-outline-secondary join-item" title={t("permit_join")}>
<Button<void> onClick={onPermitJoinClick} className="btn btn-outline btn-primary join-item grow" title={t("permit_join")}>
<FontAwesomeIcon icon={faTowerBroadcast} className="text-error" />
<SourceDot idx={selectedRouter[0]} autoHide alwaysHideName />
{selectedRouter[1]?.friendly_name ?? t("all")}
<span className="truncate">{selectedRouter[1]?.friendly_name ?? t("all")}</span>
</Button>
)}
{!permitJoin && <PermitJoinDropdown selectedRouter={selectedRouter} setSelectedRouter={setSelectedRouter} />}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { memo, useEffect, useState } from "react";
import { useLocation } from "react-router";
import store2 from "store2";
import { THEME_KEY } from "../../localStoreConsts.js";
import DialogDropdown from "../DialogDropdown.js";
import { THEME_KEY } from "../localStoreConsts.js";
import DialogDropdown from "./DialogDropdown.js";

const ALL_THEMES = [
"", // "Default"
Expand Down Expand Up @@ -63,7 +63,7 @@ const ThemeSwitcher = memo(() => {
return (
<DialogDropdown
buttonChildren={<FontAwesomeIcon icon={faPaintBrush} />}
buttonStyle="btn-square"
buttonStyle="btn-outline btn-primary"
// do not allow theme-switching while on network page due to rendering of reagraph
buttonDisabled={routerLocation.pathname.startsWith("/network")}
>
Expand Down
8 changes: 6 additions & 2 deletions src/components/dashboard-page/DashboardItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import DeviceCard from "../device/DeviceCard.js";
import { RemoveDeviceModal } from "../modal/components/RemoveDeviceModal.js";
import DashboardFeatureWrapper from "./DashboardFeatureWrapper.js";

const DashboardItem = ({ original: { sourceIdx, device, deviceState, features, lastSeenConfig, removeDevice } }: Row<DashboardTableData>) => {
const DashboardItem = ({
original: { sourceIdx, device, deviceState, deviceAvailability, features, lastSeenConfig, removeDevice },
}: Row<DashboardTableData>) => {
const { t } = useTranslation("zigbee");

const onCardChange = useCallback(
Expand All @@ -27,7 +29,9 @@ const DashboardItem = ({ original: { sourceIdx, device, deviceState, features, l
);

return (
<div className="mb-3 card bg-base-200 rounded-box shadow-md">
<div
className={`mb-3 card card-border bg-base-200 rounded-box shadow-md ${deviceAvailability === "offline" ? "border-error/50" : "border-base-300"}`}
>
<DeviceCard
features={features}
sourceIdx={sourceIdx}
Expand Down
4 changes: 2 additions & 2 deletions src/components/device-page/AddScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ const AddScene = memo(({ sourceIdx, target, deviceState }: AddSceneProps) => {
required
/>
{scenesFeatures.length > 0 && (
<div className="card card-border bg-base-200 shadow my-2">
<div className="card-body">
<div className="card card-border bg-base-100 shadow my-2">
<div className="card-body p-4">
{scenesFeatures.map((feature) => (
<Feature
key={getFeatureKey(feature)}
Expand Down
Loading