Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 7 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "zigbee2mqtt-windfront",
"version": "2.1.0",
"version": "2.2.0",
"license": "GPL-3.0-or-later",
"type": "module",
"main": "./index.js",
Expand Down 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
@@ -1,17 +1,17 @@
import { faAngleDown, faTowerBroadcast } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { type JSX, memo, useCallback, useMemo, useState } from "react";
import { type CSSProperties, 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 @@ -111,22 +115,23 @@ 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")}>
<FontAwesomeIcon icon={faTowerBroadcast} className="text-success" beatFade />
<SourceDot idx={selectedRouter[0]} autoHide alwaysHideName />
{selectedRouter[1]?.friendly_name ?? t("all")}
{permitJoinTimer}
<div className="indicator w-full mb-4">
<div className="join join-horizontal w-full">
<Button<void> onClick={onPermitJoinClick} className="btn btn-outline btn-primary join-item grow">
<FontAwesomeIcon icon={faTowerBroadcast} className={permitJoin ? "text-success" : "text-error"} />
{permitJoin ? t("disable_join") : t("permit_join")}
{permitJoin && permitJoinTimer}
</Button>
) : (
<Button<void> onClick={onPermitJoinClick} className="btn btn-outline-secondary join-item" title={t("permit_join")}>
<FontAwesomeIcon icon={faTowerBroadcast} className="text-error" />
<SourceDot idx={selectedRouter[0]} autoHide alwaysHideName />
{selectedRouter[1]?.friendly_name ?? t("all")}
</Button>
)}
{!permitJoin && <PermitJoinDropdown selectedRouter={selectedRouter} setSelectedRouter={setSelectedRouter} />}

{!permitJoin && <PermitJoinDropdown selectedRouter={selectedRouter} setSelectedRouter={setSelectedRouter} />}
</div>
<div
className="indicator-item indicator-bottom indicator-center badge badge-primary opacity-95 min-w-0 pointer-events-none"
style={{ "--indicator-y": "65%" } as CSSProperties}
>
<SourceDot idx={selectedRouter[0]} autoHide alwaysHideName />
<span className="truncate">{selectedRouter[1]?.friendly_name ?? t("all")}</span>
</div>
</div>
);
});
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
Loading