taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 3e725183f7b40df493f53cef60d5f3de11537afc
parent ddd2e25bc39cba3769835fbf4f8c32ddf401151f
Author: Florian Dold <dold@taler.net>
Date:   Mon, 31 Aug 2026 15:56:14 +0200

auditor web UI: use Tailwind and Wouter

Diffstat:
Mpackages/taler-auditor-webui/build.mjs | 18+++++++++---------
Mpackages/taler-auditor-webui/dev.mjs | 35+++++++++++++----------------------
Apackages/taler-auditor-webui/esbuild.config.mjs | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-auditor-webui/package.json | 16++++------------
Mpackages/taler-auditor-webui/src/Application.tsx | 16++++++++++++----
Mpackages/taler-auditor-webui/src/ApplicationReadyRoutes.tsx | 34++--------------------------------
Mpackages/taler-auditor-webui/src/InstanceRoutes.tsx | 663++++++++++++++++++++++---------------------------------------------------------
Mpackages/taler-auditor-webui/src/components/exception/loading.tsx | 17+++--------------
Dpackages/taler-auditor-webui/src/components/forms/FormProvider.tsx | 95-------------------------------------------------------------------------------
Mpackages/taler-auditor-webui/src/components/menu/LangSelector.tsx | 85++++++++++++-------------------------------------------------------------------
Mpackages/taler-auditor-webui/src/components/menu/NavigationBar.tsx | 90++++++++++++++++++++++++++++---------------------------------------------------
Mpackages/taler-auditor-webui/src/components/menu/SideBar.tsx | 168++++++++++++++++++++++++++++++++++++-------------------------------------------
Mpackages/taler-auditor-webui/src/components/menu/index.tsx | 245+++++++++++++------------------------------------------------------------------
Mpackages/taler-auditor-webui/src/components/modal/index.tsx | 83++++++++++++++++++++++---------------------------------------------------------
Mpackages/taler-auditor-webui/src/context/backend.ts | 2+-
Mpackages/taler-auditor-webui/src/custom.d.ts | 4----
Mpackages/taler-auditor-webui/src/hooks/backend.ts | 78+++++++++++++++++++++++++++++-------------------------------------------------
Mpackages/taler-auditor-webui/src/hooks/critical.ts | 2+-
Mpackages/taler-auditor-webui/src/hooks/entity.ts | 2+-
Mpackages/taler-auditor-webui/src/hooks/finance.ts | 2+-
Apackages/taler-auditor-webui/src/hooks/index.test.ts | 45+++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-auditor-webui/src/hooks/index.ts | 80+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mpackages/taler-auditor-webui/src/hooks/operational.ts | 2+-
Dpackages/taler-auditor-webui/src/hooks/useSettings.ts | 75---------------------------------------------------------------------------
Mpackages/taler-auditor-webui/src/index.html | 11+++--------
Mpackages/taler-auditor-webui/src/index.tsx | 1-
Mpackages/taler-auditor-webui/src/paths/default/Table.tsx | 172+++++++++++++++++++++++++++++--------------------------------------------------
Mpackages/taler-auditor-webui/src/paths/default/index.tsx | 30+++++++++++-------------------
Mpackages/taler-auditor-webui/src/paths/details/ListPage.tsx | 387++++++++-----------------------------------------------------------------------
Mpackages/taler-auditor-webui/src/paths/details/index.tsx | 8+-------
Apackages/taler-auditor-webui/src/paths/developer/index.tsx | 167+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-auditor-webui/src/paths/finance/ListPage.tsx | 343+++++++++++++++++++++++--------------------------------------------------------
Mpackages/taler-auditor-webui/src/paths/finance/index.tsx | 15+++------------
Mpackages/taler-auditor-webui/src/paths/login/index.tsx | 208++-----------------------------------------------------------------------------
Mpackages/taler-auditor-webui/src/paths/notfound/index.tsx | 14++++++++------
Mpackages/taler-auditor-webui/src/paths/operations/ListPage.tsx | 102+++++++++++++++++++++++--------------------------------------------------------
Mpackages/taler-auditor-webui/src/paths/operations/index.tsx | 15+++------------
Mpackages/taler-auditor-webui/src/paths/security/ListPage.tsx | 91++++++++++++++++++++++++++-----------------------------------------------------
Mpackages/taler-auditor-webui/src/paths/security/index.tsx | 15+++------------
Mpackages/taler-auditor-webui/src/paths/settings/index.tsx | 124+++++++++++++++----------------------------------------------------------------
Apackages/taler-auditor-webui/src/routing/useHashLocation.ts | 48++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-auditor-webui/src/style.css | 2553++-----------------------------------------------------------------------------
Apackages/taler-auditor-webui/tailwind.config.js | 21+++++++++++++++++++++
Mpackages/taler-auditor-webui/tsconfig.json | 6+++---
Mpnpm-lock.yaml | 80+++++++++----------------------------------------------------------------------
45 files changed, 1217 insertions(+), 5116 deletions(-)

diff --git a/packages/taler-auditor-webui/build.mjs b/packages/taler-auditor-webui/build.mjs @@ -15,13 +15,13 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { build } from "@gnu-taler/web-util/build"; +import { buildOptions, buildStyles, copyAssets } from "./esbuild.config.mjs"; +import * as esbuild from "esbuild"; +import fs from "node:fs"; -await build({ - type: "production", - source: { - js: ["src/index.tsx"], - assets: [{base:"src",files:["src/index.html"]}], - }, - destination: "./dist/prod", -}); +const out = "dist/prod"; + +fs.rmSync(out, { recursive: true, force: true }); +await esbuild.build(buildOptions({ outdir: out, dev: false })); +await buildStyles({ outdir: out, minify: true }); +copyAssets(out); diff --git a/packages/taler-auditor-webui/dev.mjs b/packages/taler-auditor-webui/dev.mjs @@ -15,25 +15,16 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { serve } from "@gnu-taler/web-util/node"; -import { initializeDev } from "@gnu-taler/web-util/build"; - -const devEntryPoints = ["src/index.tsx"]; - -const build = initializeDev({ - type: "development", - source: { - js: devEntryPoints, - assets: [{base:"src",files:["src/index.html"]}], - }, - destination: "./dist/dev", -}); - -await build(); - -serve({ - folder: "./dist/dev", - port: 8080, - source: "./src", - onSourceUpdate: build, -}); +import { buildOptions, buildStyles, copyAssets, esbuild } from "./esbuild.config.mjs"; + +const out = "dist/dev"; +const port = Number(process.env.PORT ?? 8080); + +await buildStyles({ outdir: out, minify: false }); +copyAssets(out); + +const ctx = await esbuild.context(buildOptions({ outdir: out, dev: true })); +await ctx.watch(); +const { hosts, port: served } = await ctx.serve({ servedir: out, port }); + +console.log(`serving on http://${hosts[0]}:${served}/`); diff --git a/packages/taler-auditor-webui/esbuild.config.mjs b/packages/taler-auditor-webui/esbuild.config.mjs @@ -0,0 +1,65 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + */ + +import * as esbuild from "esbuild"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +const here = path.dirname(new URL(import.meta.url).pathname); + +export function buildOptions({ outdir, dev }) { + return { + entryPoints: ["src/index.tsx"], + outdir, + bundle: true, + format: "esm", + platform: "browser", + target: ["es2022"], + splitting: true, + minify: !dev, + sourcemap: true, + jsx: "automatic", + jsxImportSource: "preact", + logLevel: "info", + loader: { + ".svg": "file", + ".png": "file", + ".jpeg": "file", + }, + assetNames: "[name]-[hash]", + alias: { + react: "preact/compat", + "react-dom": "preact/compat", + "react/jsx-runtime": "preact/jsx-runtime", + }, + }; +} + +export function buildStyles({ outdir, minify }) { + fs.mkdirSync(outdir, { recursive: true }); + execFileSync( + path.join(here, "node_modules/.bin/tailwindcss"), + [ + "-i", + path.join(here, "src/style.css"), + "-o", + path.join(outdir, "style.css"), + ...(minify ? ["--minify"] : []), + ], + { stdio: "inherit" }, + ); +} + +export function copyAssets(outdir) { + fs.mkdirSync(outdir, { recursive: true }); + fs.copyFileSync(path.join(here, "src/index.html"), path.join(outdir, "index.html")); +} + +export { esbuild }; diff --git a/packages/taler-auditor-webui/package.json b/packages/taler-auditor-webui/package.json @@ -21,23 +21,15 @@ "dependencies": { "@gnu-taler/taler-util": "workspace:*", "@gnu-taler/web-util": "workspace:*", - "date-fns": "2.29.3", - "history": "4.10.1", - "jed": "1.1.1", "preact": "10.11.3", - "preact-router": "3.2.1", - "qrcode-generator": "1.4.4", - "swr": "2.2.2" + "swr": "2.2.2", + "wouter-preact": "^3.0.0" }, "devDependencies": { "@gnu-taler/pogen": "workspace:*", - "@types/chai": "^4.3.0", - "@types/history": "^4.7.8", "@types/node": "^20.19.41", - "chai": "^6.2.2", - "dotenv": "^8.2.0", - "preact-render-to-string": "^5.2.6", - "source-map-support": "^0.5.21", + "esbuild": "^0.25.0", + "tailwindcss": "3.4.17", "typescript": "7.0.2" }, "pogen": { diff --git a/packages/taler-auditor-webui/src/Application.tsx b/packages/taler-auditor-webui/src/Application.tsx @@ -27,6 +27,7 @@ import { } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useMemo } from "preact/hooks"; +import { Route, Router, Switch } from "wouter-preact"; import { ApplicationReadyRoutes } from "./ApplicationReadyRoutes.js"; import { Loading } from "./components/exception/loading.js"; import { @@ -37,13 +38,20 @@ import { BackendContextProvider } from "./context/backend.js"; import { ConfigContextProvider } from "./context/config.js"; import { useBackendConfig } from "./hooks/backend.js"; import { strings } from "./i18n/strings.js"; +import { Developer, DEVELOPER_PATH } from "./paths/developer/index.js"; +import { useHashLocation } from "./routing/useHashLocation.js"; import { ErrorType } from "./utils/http.js"; export function Application(): VNode { return ( <BackendContextProvider> <TranslationProvider source={strings}> - <ApplicationStatusRoutes /> + <Router hook={useHashLocation}> + <Switch> + <Route path={DEVELOPER_PATH} component={Developer} /> + <Route component={ApplicationStatusRoutes} /> + </Switch> + </Router> </TranslationProvider> </BackendContextProvider> ); @@ -98,7 +106,7 @@ function ApplicationStatusRoutes(): VNode { ); } if (result.type === ErrorType.SERVER) { - <Fragment> + return <Fragment> <NotConnectedAppMenu title="Error" /> <NotificationCard notification={{ @@ -110,7 +118,7 @@ function ApplicationStatusRoutes(): VNode { </Fragment>; } if (result.type === ErrorType.UNREADABLE) { - <Fragment> + return <Fragment> <NotConnectedAppMenu title="Error" /> <NotificationCard notification={{ @@ -155,7 +163,7 @@ function ApplicationStatusRoutes(): VNode { } return ( - <div class="has-navbar-fixed-top"> + <div class="min-h-screen"> <ConfigContextProvider value={ctx!}> <ApplicationReadyRoutes /> </ConfigContextProvider> diff --git a/packages/taler-auditor-webui/src/ApplicationReadyRoutes.tsx b/packages/taler-auditor-webui/src/ApplicationReadyRoutes.tsx @@ -19,16 +19,11 @@ * @author Sebastian Javier Marchano (sebasjm) * @author Nic Eigel */ -import { createHashHistory } from "history"; -import { Fragment, VNode, h } from "preact"; -import { Route, Router } from "preact-router"; -import { useState } from "preact/hooks"; +import { VNode, h } from "preact"; import { InstanceRoutes } from "./InstanceRoutes.js"; import { Loading } from "./components/exception/loading.js"; -import { NotYetReadyAppMenu } from "./components/menu/index.js"; import { useBackendToken } from "./hooks/backend.js"; import { LoginPage } from "./paths/login/index.js"; -import { Settings } from "./paths/settings/index.js"; /** * Check if admin against /management/instances @@ -41,30 +36,5 @@ export function ApplicationReadyRoutes(): VNode { if (!result.ok) { return <LoginPage />; } - const [showSettings, setShowSettings] = useState(false); - - if (showSettings) { - return ( - <Fragment> - <NotYetReadyAppMenu - onShowSettings={() => setShowSettings(true)} - title="UI Settings" - /> - <Settings onClose={() => setShowSettings(false)} /> - </Fragment> - ); - } - - const history = createHashHistory(); - return ( - <Router history={history}> - <Route default component={DefaultMainRoute} /> - </Router> - ); -} - -function DefaultMainRoute({ - url, //from preact-router -}: any): VNode { - return <InstanceRoutes path={url} />; + return <InstanceRoutes />; } diff --git a/packages/taler-auditor-webui/src/InstanceRoutes.tsx b/packages/taler-auditor-webui/src/InstanceRoutes.tsx @@ -1,32 +1,17 @@ /* This file is part of GNU Taler - (C) 2021-2024 Taler Systems S.A. + (C) 2021-2026 Taler Systems S.A. GNU Taler is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * - * @author Sebastian Javier Marchano (sebasjm) - * @author Nicola Eigel */ -import { TranslatedString } from "@gnu-taler/taler-util"; -import { VNode, h } from "preact"; -import { Route, Router, route } from "preact-router"; -import { useEffect, useErrorBoundary, useMemo, useState } from "preact/hooks"; +import type { VNode } from "preact"; +import { useErrorBoundary, useMemo } from "preact/hooks"; +import { Redirect, Route, Switch, useLocation } from "wouter-preact"; import { Menu, NotificationCard } from "./components/menu/index.js"; import { EntityContextProvider } from "./context/entity.js"; -import { AuditorBackend } from "./declaration.js"; import DefaultList from "./paths/default/index.js"; import DetailsDashboard from "./paths/details/index.js"; import FinanceDashboard from "./paths/finance/index.js"; @@ -34,526 +19,238 @@ import NotFoundPage from "./paths/notfound/index.js"; import OperationsDashboard from "./paths/operations/index.js"; import SecurityDashboard from "./paths/security/index.js"; import { Settings } from "./paths/settings/index.js"; -import { Notification } from "./utils/types.js"; +import type { HttpError } from "./utils/http.js"; +import type { AuditorBackend } from "./declaration.js"; export enum Paths { error = "/error", settings = "/settings", - key_figures = "/key-figures", critical_errors = "/critical-errors", operating_status = "/operating-status", detail_view = "/detail-view", - amount_arithmethic_inconsistency_list = "/amount-arithmetic-inconsistencies", - bad_sig_losses_list = "/bad-sig-losses", - balance_list = "/balance", - closure_lag_list = "/closure-lags", - coin_inconsistency_list = "/coin-inconsistencies", - denomination_key_validity_withdraw_inconsistency_list = "/denomination-key-validity-withdraw-inconsistencies", - denomination_without_sig_list = "/denominations-without-sig", - deposit_confirmation_list = "/deposit-confirmations", deposit_confirmation_update = "/deposit-confirmation/:rowid/update", - emergency_list = "/emergencies", - emergency_by_count_list = "/emergencies-by-count", - exchange_signkey_list = "/exchange-sign-keys", - fee_time_inconsistency_list = "/fee-time-inconsistencies", - historic_denomination_revenue_list = "/historic-denomination-revenues", - misattribution_in_inconsistency_list = "/misattribution-in-inconsistencies", - progress_list = "/progress", - purse_not_closed_inconsistency_list = "/purse-not-closed-inconsistencies", - purse_list = "/purses", - reserve_balance_insufficient_inconsistency_list = "/reserve-balance-insufficient-inconsistencies", - reserve_balance_summary_wrong_inconsistency_list = "/reserve-balance-summary-wrong-inconsistencies", - reserve_in_inconsistency_list = "/reserve-in-inconsistencies", - reserve_not_closed_inconsistency_list = "/reserve-not-closed-inconsistencies", - reserves_list = "/reserves", - row_inconsistency_list = "/row-inconsistencies", - row_minor_inconsistency_list = "/row-minor-inconsistencies", - wire_format_inconsistency_list = "/wire-format-inconsistencies", - wire_out_inconsistency_list = "/wire-out-inconsistencies", } -interface TestProps { +interface EntityRoute { title: string; endpoint: string; - entity: any; -} - -function getInstanceTitle(path: string): TestProps { - switch (path) { - case Paths.key_figures: - return { title: `Key figures`, endpoint: "helper", entity: null }; - case Paths.critical_errors: - return { title: `Critical errors`, endpoint: "helper", entity: null }; - case Paths.operating_status: - return { title: `Operating status`, endpoint: "helper", entity: null }; - case Paths.detail_view: - return { title: `Inconsistencies`, endpoint: "helper", entity: null }; - case Paths.amount_arithmethic_inconsistency_list: { - let amountArithmeticInconsistency: AuditorBackend.AmountArithmeticInconsistency.ClassAmountArithmeticInconsistency = - {} as AuditorBackend.AmountArithmeticInconsistency.ClassAmountArithmeticInconsistency; - return { - title: `Amount arithmetic inconsistencies`, - endpoint: "amount-arithmetic-inconsistencies", - entity: amountArithmeticInconsistency, - }; - } - case Paths.bad_sig_losses_list: - return { - title: `Bad Sig Losses`, - endpoint: "bad-sig-losses", - entity: null, - }; - case Paths.balance_list: - return { title: "Balances", endpoint: "balances", entity: null }; - case Paths.closure_lag_list: - return { title: `Closure Lags`, endpoint: "closure-lags", entity: null }; - case Paths.coin_inconsistency_list: - return { - title: `Coin inconsistencies`, - endpoint: "coin-inconsistencies", - entity: null, - }; - case Paths.denomination_key_validity_withdraw_inconsistency_list: - return { - title: `Denomination key validity withdraw inconsistency`, - endpoint: "denomination-key-validity-withdraw-inconsistencies", - entity: null, - }; - case Paths.denomination_without_sig_list: - return { - title: `Denominations without sigs`, - endpoint: "denominations-without-sigs", - entity: null, - }; - case Paths.deposit_confirmation_list: - return { - title: "Deposit Confirmations", - endpoint: "deposit-confirmations", - entity: null, - }; - case Paths.emergency_list: - return { title: "Emergencies", endpoint: "emergencies", entity: null }; - case Paths.emergency_by_count_list: - return { - title: "Emergencies by count", - endpoint: "emergencies-by-count", - entity: null, - }; - case Paths.fee_time_inconsistency_list: - return { - title: "Fee time inconsistencies", - endpoint: "fee-time-inconsistencies", - entity: null, - }; - case Paths.historic_denomination_revenue_list: - return { - title: "Historic denomination revenue", - endpoint: "historic-denomination-revenues", - entity: null, - }; - case Paths.misattribution_in_inconsistency_list: - return { - title: "Misattribution in inconsistencies", - endpoint: "misattribution-in-inconsistencies", - entity: null, - }; - case Paths.progress_list: - return { title: "Progress", endpoint: "progress", entity: null }; - case Paths.purse_not_closed_inconsistency_list: - return { - title: "Purse not closed inconsistencies", - endpoint: "purse-not-closed-inconsistencies", - entity: null, - }; - case Paths.purse_list: - return { title: "Purses", endpoint: "purses", entity: null }; - case Paths.reserves_list: - return { title: "Reserves", endpoint: "reserves", entity: null }; - case Paths.reserve_balance_insufficient_inconsistency_list: - return { - title: "Reserve balance insufficient inconsistencies", - endpoint: "reserve-balance-insufficient-inconsistencies", - entity: null, - }; - case Paths.reserve_balance_summary_wrong_inconsistency_list: - return { - title: "Reserve balance summary wrong inconsistencies", - endpoint: "reserve-balance-summary-wrong-inconsistencies", - entity: null, - }; - case Paths.reserve_in_inconsistency_list: - return { - title: "Reserves in inconsistencies", - endpoint: "reserve-in-inconsistencies", - entity: null, - }; - case Paths.reserve_not_closed_inconsistency_list: - return { - title: "Reserves not closed inconsistencies", - endpoint: "reserve-not-closed-inconsistencies", - entity: null, - }; - case Paths.row_inconsistency_list: - return { - title: "Row inconsistencies", - endpoint: "row-inconsistencies", - entity: null, - }; - case Paths.row_minor_inconsistency_list: - return { - title: "Row minor inconsistencies", - endpoint: "row-minor-inconsistencies", - entity: null, - }; - case Paths.wire_format_inconsistency_list: { - let wireFormatInconsistency: AuditorBackend.WireFormatInconsistency.ClassWireFormatInconsistency = - {} as AuditorBackend.WireFormatInconsistency.ClassWireFormatInconsistency; - return { - title: "Wire format inconsistencies", - endpoint: "wire-format-inconsistencies", - entity: wireFormatInconsistency, - }; - } - case Paths.wire_out_inconsistency_list: - return { - title: "Wire out inconsistencies", - endpoint: "wire-out-inconsistencies", - entity: null, - }; - case Paths.settings: - return { title: `Settings`, endpoint: "settings", entity: null }; - default: - return { title: "", endpoint: "", entity: null }; - } + entity: unknown; } -export interface Props { - path: string; -} - -export function InstanceRoutes({ path }: Props): VNode { - type GlobalNotifState = - | (Notification & { to: string | undefined }) - | undefined; - const [globalNotification, setGlobalNotification] = - useState<GlobalNotifState>(undefined); - +const entityRoutes: Partial<Record<Paths, EntityRoute>> = { + [Paths.amount_arithmethic_inconsistency_list]: { + title: "Amount arithmetic inconsistencies", + endpoint: "amount-arithmetic-inconsistencies", + entity: null, + }, + [Paths.bad_sig_losses_list]: { + title: "Bad signature losses", + endpoint: "bad-sig-losses", + entity: null, + }, + [Paths.balance_list]: { title: "Balances", endpoint: "balances", entity: null }, + [Paths.closure_lag_list]: { + title: "Closure lags", + endpoint: "closure-lags", + entity: null, + }, + [Paths.coin_inconsistency_list]: { + title: "Coin inconsistencies", + endpoint: "coin-inconsistencies", + entity: null, + }, + [Paths.denomination_key_validity_withdraw_inconsistency_list]: { + title: "Invalid denomination keys at withdrawal", + endpoint: "denomination-key-validity-withdraw-inconsistencies", + entity: null, + }, + [Paths.denomination_without_sig_list]: { + title: "Denominations without signatures", + endpoint: "denominations-without-sigs", + entity: null, + }, + [Paths.deposit_confirmation_list]: { + title: "Deposit confirmations", + endpoint: "deposit-confirmations", + entity: null, + }, + [Paths.emergency_list]: { title: "Emergencies", endpoint: "emergencies", entity: null }, + [Paths.emergency_by_count_list]: { + title: "Emergencies by count", + endpoint: "emergencies-by-count", + entity: null, + }, + [Paths.exchange_signkey_list]: { + title: "Exchange signing keys", + endpoint: "exchange-sign-keys", + entity: null, + }, + [Paths.fee_time_inconsistency_list]: { + title: "Fee time inconsistencies", + endpoint: "fee-time-inconsistencies", + entity: null, + }, + [Paths.historic_denomination_revenue_list]: { + title: "Historic denomination revenues", + endpoint: "historic-denomination-revenues", + entity: null, + }, + [Paths.misattribution_in_inconsistency_list]: { + title: "Misattribution in inconsistencies", + endpoint: "misattribution-in-inconsistencies", + entity: null, + }, + [Paths.progress_list]: { title: "Progress", endpoint: "progress", entity: null }, + [Paths.purse_not_closed_inconsistency_list]: { + title: "Purses not closed", + endpoint: "purse-not-closed-inconsistencies", + entity: null, + }, + [Paths.purse_list]: { title: "Purses", endpoint: "purses", entity: null }, + [Paths.reserve_balance_insufficient_inconsistency_list]: { + title: "Insufficient reserve balances", + endpoint: "reserve-balance-insufficient-inconsistencies", + entity: null, + }, + [Paths.reserve_balance_summary_wrong_inconsistency_list]: { + title: "Wrong reserve balance summaries", + endpoint: "reserve-balance-summary-wrong-inconsistencies", + entity: null, + }, + [Paths.reserve_in_inconsistency_list]: { + title: "Reserve-in inconsistencies", + endpoint: "reserve-in-inconsistencies", + entity: null, + }, + [Paths.reserve_not_closed_inconsistency_list]: { + title: "Reserves not closed", + endpoint: "reserve-not-closed-inconsistencies", + entity: null, + }, + [Paths.reserves_list]: { title: "Reserves", endpoint: "reserves", entity: null }, + [Paths.row_inconsistency_list]: { + title: "Row inconsistencies", + endpoint: "row-inconsistencies", + entity: null, + }, + [Paths.row_minor_inconsistency_list]: { + title: "Minor row inconsistencies", + endpoint: "row-minor-inconsistencies", + entity: null, + }, + [Paths.wire_format_inconsistency_list]: { + title: "Wire format inconsistencies", + endpoint: "wire-format-inconsistencies", + entity: null, + }, + [Paths.wire_out_inconsistency_list]: { + title: "Wire-out inconsistencies", + endpoint: "wire-out-inconsistencies", + entity: null, + }, +}; + +const dashboardTitles: Partial<Record<Paths, string>> = { + [Paths.key_figures]: "Key figures", + [Paths.critical_errors]: "Critical errors", + [Paths.operating_status]: "Operating status", + [Paths.detail_view]: "Inconsistencies", + [Paths.settings]: "Settings", +}; + +export function InstanceRoutes(): VNode { + const [path] = useLocation(); const [error] = useErrorBoundary(); - const { title, endpoint, entity } = getInstanceTitle(path); - + const entityRoute = entityRoutes[path as Paths]; const value = useMemo( - () => ({ title, path, endpoint, entity }), - [title, path, endpoint, entity], + () => ({ + title: entityRoute?.title ?? dashboardTitles[path as Paths] ?? "", + path, + endpoint: entityRoute?.endpoint ?? "", + entity: entityRoute?.entity ?? null, + }), + [entityRoute, path], ); - //TODO add if needed - /*function ServerErrorRedirectTo(to: Paths) { - return function ServerErrorRedirectToImpl( - error: HttpError<AuditorBackend.ErrorDetail>, - ) { - if (error.type === ErrorType.TIMEOUT) { - setGlobalNotification({ - message: `The request to the backend take too long and was cancelled`, - description: `Diagnostic from ${error.info.url} is "${error.message}"`, - type: "ERROR", - to, - }); - } else { - setGlobalNotification({ - message: `The backend reported a problem: HTTP status #${error.status}`, - description: `Diagnostic from ${error.info.url} is '${error.message}'`, - details: - error.type === ErrorType.CLIENT || error.type === ErrorType.SERVER - ? error.payload.detail - : undefined, - type: "ERROR", - to, - }); - } - return <Redirect to={to} />; - }; - }*/ + const notFound = (): VNode => <NotFoundPage />; + const loadError = (problem: HttpError<AuditorBackend.ErrorDetail>): VNode => ( + <NotificationCard + notification={{ + type: "ERROR", + message: "The auditor API request failed", + description: problem.message, + }} + /> + ); return ( <EntityContextProvider value={value}> - <Menu - // instance={id} - path={path} - title={"Settings"} - onShowSettings={() => { - route(Paths.settings); - }} - /> - <NotificationCard notification={globalNotification} /> + <Menu path={path} title={value.title} onShowSettings={() => undefined} /> {error && ( <NotificationCard notification={{ message: "Internal error, please report", type: "ERROR", - description: ( - <pre> - { - (error instanceof Error - ? error.stack - : String(error)) as TranslatedString - } - </pre> - ), + description: error instanceof Error ? error.message : String(error), }} /> )} - <Router - onChange={(e) => { - const movingOutFromNotification = - globalNotification && e.url !== globalNotification.to; - if (movingOutFromNotification) { - setGlobalNotification(undefined); - } - }} - > - <Route path="/" component={Redirect} to={Paths.key_figures} /> - - <Route - path={Paths.key_figures} - component={FinanceDashboard} - onNotFound={NotFoundPage} - //onLoadError={ServerErrorRedirectTo(Paths.balance_list)} - /> - <Route - path={Paths.critical_errors} - component={SecurityDashboard} - onNotFound={NotFoundPage} - //onLoadError={ServerErrorRedirectTo(Paths.balance_list)} - /> - <Route - path={Paths.operating_status} - component={OperationsDashboard} - onNotFound={NotFoundPage} - //onLoadError={ServerErrorRedirectTo(Paths.balance_list)} - /> - <Route - path={Paths.detail_view} - component={DetailsDashboard} - onNotFound={NotFoundPage} - //onLoadError={ServerErrorRedirectTo(Paths.balance_list)} - /> - <Route - path={Paths.amount_arithmethic_inconsistency_list} - component={DefaultList} - onNotFound={NotFoundPage} - //onLoadError={ServerErrorRedirectTo(Paths.balance_list)} - /> - <Route - path={Paths.bad_sig_losses_list} - component={DefaultList} - onNotFound={NotFoundPage} - //onLoadError={ServerErrorRedirectTo(Paths.balance_list)} - /> - <Route - path={Paths.balance_list} - component={DefaultList} - onNotFound={NotFoundPage} - //onLoadError={ServerErrorRedirectTo(Paths.balance_list)} - /> - <Route - path={Paths.closure_lag_list} - component={DefaultList} - onNotFound={NotFoundPage} - //onLoadError={ServerErrorRedirectTo(Paths.balance_list)} - /> - <Route - path={Paths.coin_inconsistency_list} - component={DefaultList} - onNotFound={NotFoundPage} - //onLoadError={ServerErrorRedirectTo(Paths.balance_list)} - /> - <Route - path={Paths.denomination_key_validity_withdraw_inconsistency_list} - component={DefaultList} - onNotFound={NotFoundPage} - //onLoadError={ServerErrorRedirectTo(Paths.balance_list)} - /> - <Route - path={Paths.denomination_without_sig_list} - component={DefaultList} - onNotFound={NotFoundPage} - //onLoadError={ServerErrorRedirectTo(Paths.balance_list)} - /> - <Route - path={Paths.deposit_confirmation_list} - component={DefaultList} - onNotFound={NotFoundPage} - //onLoadError={ServerErrorRedirectTo(Paths.balance_list)} - /> - <Route - path={Paths.emergency_list} - component={DefaultList} - onNotFound={NotFoundPage} - //onLoadError={ServerErrorRedirectTo(Paths.balance_list)} - /> - <Route - path={Paths.emergency_by_count_list} - component={DefaultList} - onNotFound={NotFoundPage} - //onLoadError={ServerErrorRedirectTo(Paths.balance_list)} - /> - { - <Route - path={Paths.exchange_signkey_list} - component={DefaultList} - onNotFound={NotFoundPage} - /> - } - { - <Route - path={Paths.fee_time_inconsistency_list} - component={DefaultList} - onNotFound={NotFoundPage} - /> - } - { - <Route - path={Paths.historic_denomination_revenue_list} - component={DefaultList} - onNotFound={NotFoundPage} - /> - } - { - <Route - path={Paths.misattribution_in_inconsistency_list} - component={DefaultList} - onNotFound={NotFoundPage} - /> - } - { - <Route - path={Paths.progress_list} - component={DefaultList} - onNotFound={NotFoundPage} - /> - } - { - <Route - path={Paths.purse_not_closed_inconsistency_list} - component={DefaultList} - onNotFound={NotFoundPage} - /> - } - { - <Route - path={Paths.purse_list} - component={DefaultList} - onNotFound={NotFoundPage} - /> - } - { - <Route - path={Paths.reserve_balance_insufficient_inconsistency_list} - component={DefaultList} - onNotFound={NotFoundPage} - /> - } - { - <Route - path={Paths.reserve_balance_summary_wrong_inconsistency_list} - component={DefaultList} - onNotFound={NotFoundPage} - /> - } - { - <Route - path={Paths.reserve_in_inconsistency_list} - component={DefaultList} - onNotFound={NotFoundPage} - /> - } - { - <Route - path={Paths.reserve_not_closed_inconsistency_list} - component={DefaultList} - onNotFound={NotFoundPage} - /> - } - { - <Route - path={Paths.reserves_list} - component={DefaultList} - onNotFound={NotFoundPage} - /> - } - { - <Route - path={Paths.row_inconsistency_list} - component={DefaultList} - onNotFound={NotFoundPage} - /> - } - { - <Route - path={Paths.row_minor_inconsistency_list} - component={DefaultList} - onNotFound={NotFoundPage} - /> - } - { - <Route - path={Paths.wire_out_inconsistency_list} - component={DefaultList} - onNotFound={NotFoundPage} - /> - } - { - <Route - path={Paths.wire_format_inconsistency_list} - component={DefaultList} - onNotFound={NotFoundPage} - /> - } - <Route path={Paths.settings} component={Settings} /> - - { - //TODO add if needed - /** - * Example pages - */ - } - {/* <Route path="/loading" component={Loading}/> - <Route default component={NotFoundPage}/>*/} - </Router> + <main class="min-w-0 flex-1 md:ml-64"> + <Switch> + <Route path="/"> + <Redirect to={Paths.key_figures} replace /> + </Route> + <Route path={Paths.key_figures}> + <FinanceDashboard onUnauthorized={notFound} onNotFound={notFound} onSelect={() => undefined} onCreate={() => undefined} /> + </Route> + <Route path={Paths.critical_errors}> + <SecurityDashboard onUnauthorized={notFound} onNotFound={notFound} onSelect={() => undefined} onCreate={() => undefined} /> + </Route> + <Route path={Paths.operating_status}> + <OperationsDashboard onUnauthorized={notFound} onNotFound={notFound} onSelect={() => undefined} onCreate={() => undefined} /> + </Route> + <Route path={Paths.detail_view}> + <DetailsDashboard /> + </Route> + <Route path={Paths.settings}> + <Settings /> + </Route> + <Route> + {entityRoute ? ( + <DefaultList onNotFound={notFound} onLoadError={loadError} /> + ) : ( + <NotFoundPage /> + )} + </Route> + </Switch> + </main> </EntityContextProvider> ); } - -export function Redirect({ to }: { to: string }): null { - useEffect(() => { - route(to, true); - }); - return null; -} diff --git a/packages/taler-auditor-webui/src/components/exception/loading.tsx b/packages/taler-auditor-webui/src/components/exception/loading.tsx @@ -23,26 +23,15 @@ import { h, VNode } from "preact"; export function Loading(): VNode { return ( - <div - class="columns is-centered is-vcentered" - style={{ - height: "calc(100% - 3rem)", - position: "absolute", - width: "100%", - }} - > + <div class="flex min-h-[50vh] w-full items-center justify-center" role="status"> <Spinner /> + <span class="sr-only">Loading</span> </div> ); } export function Spinner(): VNode { return ( - <div class="lds-ring"> - <div /> - <div /> - <div /> - <div /> - </div> + <div class="h-10 w-10 animate-spin rounded-full border-4 border-blue-100 border-t-taler-brand" /> ); } diff --git a/packages/taler-auditor-webui/src/components/forms/FormProvider.tsx b/packages/taler-auditor-webui/src/components/forms/FormProvider.tsx @@ -1,95 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2021-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * - * @author Sebastian Javier Marchano (sebasjm) - */ - -import { ComponentChildren, createContext, h, VNode } from "preact"; -import { useMemo } from "preact/hooks"; - -type Updater<S> = (value: (prevState: S) => S) => void; - -export interface Props<T> { - object?: Partial<T>; - errors?: FormErrors<T>; - name?: string; - valueHandler: Updater<Partial<T>> | null; - children: ComponentChildren; -} - -const noUpdater: Updater<Partial<unknown>> = () => (s: unknown) => s; - -export function FormProvider<T>({ - object = {}, - errors = {}, - name = "", - valueHandler, - children, -}: Props<T>): VNode { - const initialObject = useMemo(() => object, []); - const value = useMemo<FormType<T>>( - () => ({ - errors, - object, - initialObject, - valueHandler: valueHandler ? valueHandler : noUpdater, - name, - toStr: {}, - fromStr: {}, - }), - [errors, object, valueHandler], - ); - - return ( - <FormContext.Provider value={value}> - <form - class="field" - onSubmit={(e) => { - e.preventDefault(); - // if (valueHandler) valueHandler(object); - }} - > - {children} - </form> - </FormContext.Provider> - ); -} - -export interface FormType<T> { - object: Partial<T>; - initialObject: Partial<T>; - errors: FormErrors<T>; - toStr: FormtoStr<T>; - name: string; - fromStr: FormfromStr<T>; - valueHandler: Updater<Partial<T>>; -} - -const FormContext = createContext<FormType<unknown>>(null!); - -export type FormErrors<T> = { - [P in keyof T]?: string | FormErrors<T[P]>; -}; - -export type FormtoStr<T> = { - [P in keyof T]?: (f?: T[P]) => string; -}; - -export type FormfromStr<T> = { - [P in keyof T]?: (f: string) => T[P]; -}; diff --git a/packages/taler-auditor-webui/src/components/menu/LangSelector.tsx b/packages/taler-auditor-webui/src/components/menu/LangSelector.tsx @@ -1,35 +1,13 @@ /* This file is part of GNU Taler - (C) 2021-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * - * @author Sebastian Javier Marchano (sebasjm) + (C) 2021-2026 Taler Systems S.A. */ import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { h, VNode } from "preact"; -import { useState } from "preact/hooks"; -import langIcon from "../../assets/icons/languageicon.svg"; +import type { VNode } from "preact"; import { strings as messages } from "../../i18n/strings.js"; -type LangsNames = { - [P in keyof typeof messages]: string; -}; - -const names: LangsNames = { +const names: Record<string, string> = { es: "Español [es]", en: "English [en]", fr: "Français [fr]", @@ -38,55 +16,18 @@ const names: LangsNames = { it: "Italiano [it]", }; -function getLangName(s: keyof LangsNames | string) { - if (names[s]) return names[s]; - return s; -} - export function LangSelector(): VNode { - const [updatingLang, setUpdatingLang] = useState(false); const { lang, changeLanguage } = useTranslationContext(); - return ( - <div class="dropdown is-active "> - <div class="dropdown-trigger"> - <button - class="button has-tooltip-left" - data-tooltip="change language selection" - aria-haspopup="true" - aria-controls="dropdown-menu" - onClick={() => setUpdatingLang(!updatingLang)} - > - <div class="icon is-small is-left"> - <img src={langIcon} /> - </div> - <span>{getLangName(lang)}</span> - <div class="icon is-right"> - <i class="mdi mdi-chevron-down" /> - </div> - </button> - </div> - {updatingLang && ( - <div class="dropdown-menu" id="dropdown-menu" role="menu"> - <div class="dropdown-content"> - {Object.keys(messages) - .filter((l) => l !== lang) - .map((l) => ( - <a - key={l} - class="dropdown-item" - value={l} - onClick={() => { - changeLanguage(l); - setUpdatingLang(false); - }} - > - {getLangName(l)} - </a> - ))} - </div> - </div> - )} - </div> + <select + class="auditor-input max-w-xs" + value={lang} + onChange={(event) => changeLanguage(event.currentTarget.value)} + aria-label="Language" + > + {Object.keys(messages).map((code) => ( + <option key={code} value={code}>{names[code] ?? code}</option> + ))} + </select> ); } diff --git a/packages/taler-auditor-webui/src/components/menu/NavigationBar.tsx b/packages/taler-auditor-webui/src/components/menu/NavigationBar.tsx @@ -1,74 +1,48 @@ /* This file is part of GNU Taler - (C) 2021-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * - * @author Sebastian Javier Marchano (sebasjm) + (C) 2021-2026 Taler Systems S.A. */ -import { h, VNode } from "preact"; +import type { VNode } from "preact"; import logo from "../../assets/logo-2021.svg"; interface Props { onMobileMenu: () => void; title: string; + withSidebar?: boolean; } -export function NavigationBar({ onMobileMenu, title }: Props): VNode { +export function NavigationBar({ + onMobileMenu, + title, + withSidebar = true, +}: Props): VNode { return ( - <nav - class="navbar is-fixed-top" - role="navigation" - aria-label="main navigation" + <header + class={`fixed inset-x-0 top-0 z-30 flex h-16 items-center border-b border-gray-200 bg-white/95 px-4 shadow-sm backdrop-blur md:px-6 ${ + withSidebar ? "md:left-64" : "" + }`} > - <div class="navbar-brand"> - <span class="navbar-item" style={{ fontSize: 24, fontWeight: 900 }}> - {title} - </span> - - <a - role="button" - class="navbar-burger" - aria-label="menu" - aria-expanded="false" - onClick={(e) => { - onMobileMenu(); - e.stopPropagation(); - }} - > - <span aria-hidden="true" /> - <span aria-hidden="true" /> - <span aria-hidden="true" /> - </a> - </div> - - <div class="navbar-menu"> - <a - class="navbar-start is-justify-content-center is-flex-grow-1" - href="https://taler.net" + {withSidebar && ( + <button + type="button" + class="mr-3 rounded-lg p-2 text-gray-600 hover:bg-gray-100 md:hidden" + aria-label="Open navigation" + onClick={onMobileMenu} > - <img src={logo} style={{ height: 35, margin: 10 }} /> - </a> - <div class="navbar-end"> - <div - class="navbar-item" - style={{ paddingTop: 4, paddingBottom: 4 }} - ></div> - </div> - </div> - </nav> + <span aria-hidden="true" class="text-xl">☰</span> + </button> + )} + <h1 class="min-w-0 flex-1 truncate text-xl font-bold text-gray-900 md:text-2xl"> + {title} + </h1> + <a + class="ml-4 shrink-0 rounded hover:opacity-80" + href="https://taler.net" + aria-label="GNU Taler website" + > + <img src={logo} class="h-9 w-auto" alt="GNU Taler" /> + </a> + </header> ); } diff --git a/packages/taler-auditor-webui/src/components/menu/SideBar.tsx b/packages/taler-auditor-webui/src/components/menu/SideBar.tsx @@ -1,106 +1,90 @@ /* This file is part of GNU Taler - (C) 2021-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * @author Nic Eigel + (C) 2021-2026 Taler Systems S.A. */ import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { Fragment, h, VNode } from "preact"; +import type { VNode } from "preact"; +import { Link, useLocation } from "wouter-preact"; import { useConfigContext } from "../../context/config.js"; +import { Paths } from "../../InstanceRoutes.js"; + +interface Props { + mobile?: boolean; + onClose?: () => void; +} -export function Sidebar(props: any): VNode { - const configData = useConfigContext(); +export function Sidebar({ mobile = false, onClose }: Props): VNode { + const config = useConfigContext(); const { i18n } = useTranslationContext(); - console.log(configData); + const [location] = useLocation(); + const entries = [ + { path: Paths.key_figures, label: <i18n.Translate>Key figures</i18n.Translate> }, + { path: Paths.critical_errors, label: <i18n.Translate>Critical errors</i18n.Translate> }, + { path: Paths.operating_status, label: <i18n.Translate>Operating status</i18n.Translate> }, + { path: Paths.detail_view, label: <i18n.Translate>Inconsistencies</i18n.Translate> }, + { path: Paths.settings, label: <i18n.Translate>Settings</i18n.Translate> }, + ]; - return ( - <aside - class="aside is-placed-left is-expanded" - style={{ overflowY: "scroll" }} - > - <div class="aside-tools"> - <div class="aside-tools-label"> - <div> - <b>Taler</b> Backoffice - </div> - <div - class="is-size-7 has-text-right" - style={{ lineHeight: 0, marginTop: -10 }} - > - (Version {configData.version}) - </div> + const content = ( + <aside class="flex h-full w-64 flex-col border-r border-gray-800 bg-gray-900 p-4 text-gray-200"> + <div class="mb-8 flex items-start justify-between px-2"> + <div> + <div class="text-xs font-semibold uppercase tracking-widest text-gray-400">GNU Taler</div> + <div class="text-lg font-bold text-white">Auditor</div> + <div class="mt-1 text-xs text-gray-500">API version {config.version}</div> </div> + {mobile && ( + <button + type="button" + class="rounded p-1 text-gray-400 hover:text-white" + onClick={onClose} + aria-label="Close navigation" + > + ✕ + </button> + )} </div> - <div class="menu is-menu-main"> - <Fragment> - <ul class="menu-list"> - <li> - <a href={"/key-figures"} class="has-icon"> - <span class="icon"> - <i class="mdi mdi-bank" /> - </span> - <span class="menu-item-label"> - <i18n.Translate>Key figures</i18n.Translate> - </span> - </a> - </li> - <li> - <a href={"/critical-errors"} class="has-icon"> - <span class="icon"> - <i class="mdi mdi-alert" /> - </span> - <span class="menu-item-label"> - <i18n.Translate>Critical errors</i18n.Translate> - </span> - </a> - </li> - <li> - <a href={"/operating-status"} class="has-icon"> - <span class="icon"> - <i class="mdi mdi-close-network" /> - </span> - <span class="menu-item-label"> - <i18n.Translate>Operating status</i18n.Translate> - </span> - </a> - </li> - <li> - <a href={"/detail-view"} class="has-icon"> - <span class="icon"> - <i class="mdi mdi-format-wrap-tight" /> - </span> - <span class="menu-item-label"> - <i18n.Translate>Inconsistencies</i18n.Translate> - </span> - </a> - </li> - <li> - <a href={"/settings"} class="has-icon"> - <span class="icon"> - <i class="mdi mdi-square-edit-outline" /> - </span> - <span class="menu-item-label"> - <i18n.Translate>Settings</i18n.Translate> - </span> - </a> - </li> - </ul> - </Fragment> - </div> + <nav class="flex-1 space-y-1"> + {entries.map((entry) => { + const active = location === entry.path; + return ( + <Link + key={entry.path} + href={entry.path} + onClick={onClose} + class={`flex items-center rounded-lg px-3 py-2.5 text-sm font-semibold transition ${ + active + ? "bg-taler-brand text-white" + : "text-gray-300 hover:bg-gray-800 hover:text-white" + }`} + > + <span>{entry.label}</span> + </Link> + ); + })} + </nav> + <Link + href="/dev" + class="rounded-lg px-3 py-2 text-xs font-medium text-gray-500 hover:bg-gray-800 hover:text-gray-300" + > + Development settings + </Link> </aside> ); + + if (mobile) { + return ( + <div class="fixed inset-0 z-50 flex bg-gray-950/60 md:hidden"> + {content} + <button + type="button" + class="flex-1" + onClick={onClose} + aria-label="Close navigation" + /> + </div> + ); + } + return <div class="fixed inset-y-0 left-0 z-40 hidden md:block">{content}</div>; } diff --git a/packages/taler-auditor-webui/src/components/menu/index.tsx b/packages/taler-auditor-webui/src/components/menu/index.tsx @@ -1,236 +1,67 @@ /* - -This file is part of GNU Taler -(C) 2021-2024 Taler Systems S.A. - -GNU Taler is free software; you can redistribute it and/or modify it under the -terms of the GNU General Public License as published by the Free Software -Foundation; either version 3, or (at your option) any later version. - -GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY -WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR -A PARTICULAR PURPOSE. See the GNU General Public License for more details. - -You should have received a copy of the GNU General Public License along with -GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - -*/ - -/** - * - * @author Sebastian Javier Marchano (sebasjm) - * @author Nic Eigel + This file is part of GNU Taler + (C) 2021-2026 Taler Systems S.A. */ -import { ComponentChildren, Fragment, h, VNode } from "preact"; +import type { VNode } from "preact"; import { useEffect, useState } from "preact/hooks"; -import { Paths } from "../../InstanceRoutes.js"; -import { Notification } from "../../utils/types.js"; +import type { Notification } from "../../utils/types.js"; import { NavigationBar } from "./NavigationBar.js"; import { Sidebar } from "./SideBar.js"; -function getInstanceTitle(path: string): string { - switch (path) { - case Paths.key_figures: - return "Key figures"; - case Paths.critical_errors: - return "Critical errors"; - case Paths.operating_status: - return "Operating status"; - case Paths.detail_view: - return "Inconsistencies"; - case Paths.amount_arithmethic_inconsistency_list: - return `Amount arithmetic inconsistencies`; - case Paths.bad_sig_losses_list: - return `Bad sig losses`; - case Paths.balance_list: - return `Balances`; - case Paths.closure_lag_list: - return `Closure Lags`; - case Paths.coin_inconsistency_list: - return `Coin inconsistencies`; - case Paths.denomination_key_validity_withdraw_inconsistency_list: - return `Denomination key validity withdraw inconsistency`; - case Paths.denomination_without_sig_list: - return `Denominations without sigs`; - case Paths.deposit_confirmation_list: - return `Deposit confirmations`; - case Paths.deposit_confirmation_update: - return `Update deposit confirmation`; - case Paths.emergency_list: - return `Emergencies`; - case Paths.emergency_by_count_list: - return `Emergencies by count`; - case Paths.exchange_signkey_list: - return `Exchange signkeys`; - case Paths.fee_time_inconsistency_list: - return `Fee time inconsistencies`; - case Paths.historic_denomination_revenue_list: - return `Historic denomination revenue`; - case Paths.misattribution_in_inconsistency_list: - return `Misattribution in inconsistencies`; - case Paths.progress_list: - return `Progress`; - case Paths.purse_not_closed_inconsistency_list: - return `Purse not closed inconsistencies`; - case Paths.purse_list: - return `Purses`; - case Paths.reserve_balance_insufficient_inconsistency_list: - return `Reserve balance insufficient inconsistencies`; - case Paths.reserve_balance_summary_wrong_inconsistency_list: - return `Reserve balance summary wrong inconsistencies`; - case Paths.reserve_in_inconsistency_list: - return `Reserves in inconsistencies`; - case Paths.reserve_not_closed_inconsistency_list: - return `Reserves not closed inconsistencies`; - case Paths.row_inconsistency_list: - return `Row inconsistencies`; - case Paths.row_minor_inconsistency_list: - return `Row minor inconsistencies`; - case Paths.wire_format_inconsistency_list: - return `Wire format inconsistencies`; - case Paths.wire_out_inconsistency_list: - return `Wire out inconsistencies`; - case Paths.settings: - return `Settings`; - default: - return ""; - } -} - interface MenuProps { title?: string; path: string; - onShowSettings: () => void; + onShowSettings?: () => void; } -function WithTitle({ - title, - children, -}: { - title: string; - children: ComponentChildren; -}): VNode { +export function Menu({ title = "Auditor" }: MenuProps): VNode { + const [mobileOpen, setMobileOpen] = useState(false); useEffect(() => { - document.title = `Taler Backoffice: ${title}`; + document.title = `Taler Auditor: ${title}`; }, [title]); - return <Fragment>{children}</Fragment>; -} - -export function Menu({ onShowSettings, title, path }: MenuProps): VNode { - const [mobileOpen, setMobileOpen] = useState(false); - const titleWithSubtitle = getInstanceTitle(path.replace("app/#", "")); return ( - <WithTitle title={titleWithSubtitle}> - <div - class={mobileOpen ? "has-aside-mobile-expanded" : ""} - onClick={() => setMobileOpen(false)} - > - <NavigationBar - onMobileMenu={() => setMobileOpen(!mobileOpen)} - title={titleWithSubtitle} - /> - - <Sidebar onShowSettings={onShowSettings} mobile={mobileOpen} /> - </div> - </WithTitle> + <> + <NavigationBar + title={title} + onMobileMenu={() => setMobileOpen(true)} + /> + <Sidebar /> + {mobileOpen && <Sidebar mobile onClose={() => setMobileOpen(false)} />} + </> ); } -interface NotYetReadyAppMenuProps { - title: string; - onShowSettings: () => void; -} - -interface NotifProps { - notification?: Notification; -} - export function NotificationCard({ - notification: n, -}: NotifProps): VNode | null { - if (!n) return null; + notification, +}: { + notification?: Notification; +}): VNode | null { + if (!notification) return null; + const tone = + notification.type === "ERROR" + ? "border-red-300 bg-red-50 text-red-900" + : notification.type === "WARN" + ? "border-amber-300 bg-amber-50 text-amber-950" + : "border-blue-300 bg-blue-50 text-blue-950"; return ( - <div class="notification"> - <div class="columns is-vcentered"> - <div class="column is-12"> - <article - class={ - n.type === "ERROR" - ? "message is-danger" - : n.type === "WARN" - ? "message is-warning" - : "message is-info" - } - > - <div class="message-header"> - <p>{n.message}</p> - </div> - {n.description && ( - <div class="message-body"> - <div>{n.description}</div> - {n.details && <pre>{n.details}</pre>} - </div> - )} - </article> - </div> - </div> + <div class={`mx-6 my-4 rounded-xl border p-4 shadow-sm ${tone}`} role="status"> + <p class="font-bold">{notification.message}</p> + {notification.description && <div class="mt-1 text-sm">{notification.description}</div>} + {notification.details && ( + <pre class="mt-3 overflow-auto rounded bg-white/60 p-3 text-xs">{notification.details}</pre> + )} </div> ); } -interface NotConnectedAppMenuProps { - title: string; -} - -export function NotConnectedAppMenu({ - title, -}: NotConnectedAppMenuProps): VNode { - const [mobileOpen, setMobileOpen] = useState(false); - +export function NotConnectedAppMenu({ title }: { title: string }): VNode { useEffect(() => { - document.title = `Taler Backoffice: ${title}`; + document.title = `Taler Auditor: ${title}`; }, [title]); - - return ( - <div - class={mobileOpen ? "has-aside-mobile-expanded" : ""} - onClick={() => setMobileOpen(false)} - > - <NavigationBar - onMobileMenu={() => setMobileOpen(!mobileOpen)} - title={title} - /> - </div> - ); + return <NavigationBar title={title} onMobileMenu={() => undefined} withSidebar={false} />; } -export function NotYetReadyAppMenu({ - onShowSettings, - title, -}: NotYetReadyAppMenuProps): VNode { - const [mobileOpen, setMobileOpen] = useState(false); - - useEffect(() => { - document.title = `Taler Backoffice: ${title}`; - }, [title]); - - return ( - <div - class={mobileOpen ? "has-aside-mobile-expanded" : ""} - onClick={() => setMobileOpen(false)} - > - <NavigationBar - onMobileMenu={() => setMobileOpen(!mobileOpen)} - title={title} - /> - ( - <Sidebar - onShowSettings={onShowSettings} - instance="" - mobile={mobileOpen} - /> - ) - </div> - ); +export function NotYetReadyAppMenu({ title }: { title: string; onShowSettings: () => void }): VNode { + return <NotConnectedAppMenu title={title} />; } diff --git a/packages/taler-auditor-webui/src/components/modal/index.tsx b/packages/taler-auditor-webui/src/components/modal/index.tsx @@ -1,29 +1,10 @@ /* This file is part of GNU Taler - (C) 2021-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * - * @author Sebastian Javier Marchano (sebasjm) + (C) 2021-2026 Taler Systems S.A. */ -/** - * Imports. - */ import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { ComponentChildren, Fragment, h, VNode } from "preact"; +import type { ComponentChildren, VNode } from "preact"; interface Props { active?: boolean; @@ -45,50 +26,32 @@ export function ConfirmModal({ danger, disabled, label = "Confirm", -}: Props): VNode { +}: Props): VNode | null { const { i18n } = useTranslationContext(); + if (!active) return null; return ( - <div class={active ? "modal is-active" : "modal"}> - <div class="modal-background " onClick={onCancel} /> - <div class="modal-card" style={{ maxWidth: 700 }}> - <header class="modal-card-head"> - {!description ? null : ( - <p class="modal-card-title"> - <b>{description}</b> - </p> - )} - <button class="delete " aria-label="close" onClick={onCancel} /> + <div class="fixed inset-0 z-50 flex items-center justify-center bg-gray-950/60 p-4" role="dialog" aria-modal="true"> + <div class="auditor-card w-full max-w-xl overflow-hidden"> + <header class="flex items-center justify-between border-b border-gray-200 px-6 py-4"> + <h2 class="text-lg font-bold">{description}</h2> + <button type="button" class="rounded p-2 text-gray-500 hover:bg-gray-100" onClick={onCancel} aria-label="Close">✕</button> </header> - <section class="modal-card-body">{children}</section> - <footer class="modal-card-foot"> - <div class="buttons is-right" style={{ width: "100%" }}> - {onConfirm ? ( - <Fragment> - <button class="button " onClick={onCancel}> - <i18n.Translate>Cancel</i18n.Translate> - </button> - - <button - class={danger ? "button is-danger " : "button is-info "} - disabled={disabled} - onClick={onConfirm} - > - <i18n.Translate>{label}</i18n.Translate> - </button> - </Fragment> - ) : ( - <button class="button " onClick={onCancel}> - <i18n.Translate>Close</i18n.Translate> - </button> - )} - </div> + <div class="px-6 py-5">{children}</div> + <footer class="flex justify-end gap-3 border-t border-gray-200 bg-gray-50 px-6 py-4"> + <button class="auditor-button" onClick={onCancel}> + <i18n.Translate>{onConfirm ? "Cancel" : "Close"}</i18n.Translate> + </button> + {onConfirm && ( + <button + class={`auditor-button ${danger ? "auditor-button-danger" : "auditor-button-primary"}`} + disabled={disabled} + onClick={onConfirm} + > + <i18n.Translate>{label}</i18n.Translate> + </button> + )} </footer> </div> - <button - class="modal-close is-large " - aria-label="close" - onClick={onCancel} - /> </div> ); } diff --git a/packages/taler-auditor-webui/src/context/backend.ts b/packages/taler-auditor-webui/src/context/backend.ts @@ -38,7 +38,7 @@ export const BackendContextProvider = ({ }: { children: any; defaultUrl?: string; -}): VNode => { +}): VNode<any> => { const [url] = useBackendURL(defaultUrl); return h(BackendContext.Provider, { diff --git a/packages/taler-auditor-webui/src/custom.d.ts b/packages/taler-auditor-webui/src/custom.d.ts @@ -17,10 +17,6 @@ declare module "*.po" { const content: any; export default content; } -declare module "jed" { - const x: any; - export = x; -} declare module "*.jpeg" { const content: any; export default content; diff --git a/packages/taler-auditor-webui/src/hooks/backend.ts b/packages/taler-auditor-webui/src/hooks/backend.ts @@ -77,52 +77,37 @@ export function useBackendConfig(): HttpResponse< const { request } = useBackendBaseRequest(); type Type = AuditorBackend.VersionResponse; - type State = { - data: HttpResponse<Type, RequestError<AuditorBackend.ErrorDetail>>; - timer: number; - }; const unloaded = useRef(false); - const [result, setResult] = useState<State>({ - data: { loading: true }, - timer: 0, - }); + const timer = useRef<number>(); + const [result, setResult] = useState< + HttpResponse<Type, RequestError<AuditorBackend.ErrorDetail>> + >({ loading: true }); useEffect(() => { - if (result.timer) { - clearTimeout(result.timer); - } + unloaded.current = false; function tryConfig(): void { request<Type>("config") .then((data) => { - if (unloaded.current) { - return; - } - const timer: any = setTimeout(() => { - tryConfig(); - }, CHECK_CONFIG_INTERVAL_OK); - setResult({ data, timer }); + if (unloaded.current) return; + setResult(data); + timer.current = window.setTimeout(tryConfig, CHECK_CONFIG_INTERVAL_OK); }) .catch((error) => { - if (unloaded.current) { - return; - } - const timer: any = setTimeout(() => { - tryConfig(); - }, CHECK_CONFIG_INTERVAL_FAIL); - const data = error.cause; - setResult({ data, timer }); + if (unloaded.current) return; + setResult(error.cause); + timer.current = window.setTimeout(tryConfig, CHECK_CONFIG_INTERVAL_FAIL); }); } tryConfig(); return () => { unloaded.current = true; - clearTimeout(result.timer); + if (timer.current !== undefined) clearTimeout(timer.current); }; }, [request]); - return result.data; + return result; } export function useBackendToken(): HttpResponse< @@ -132,41 +117,36 @@ export function useBackendToken(): HttpResponse< const { request } = useBackendBaseRequest(); type Type = AuditorBackend.VersionResponse; - type State = { - data: HttpResponse<Type, RequestError<AuditorBackend.ErrorDetail>>; - timer: number; - }; - const [result, setResult] = useState<State>({ - data: { loading: true }, - timer: 0, - }); + const timer = useRef<number>(); + const [result, setResult] = useState< + HttpResponse<Type, RequestError<AuditorBackend.ErrorDetail>> + >({ loading: true }); useEffect(() => { - if (result.timer) { - clearTimeout(result.timer); - } + let unloaded = false; function tryToken(): void { request<Type>(`monitoring/balances`) .then((data) => { - const timer: any = setTimeout(() => { - tryToken(); - }, CHECK_CONFIG_INTERVAL_OK); - setResult({ data, timer }); + if (unloaded) return; + setResult(data); + timer.current = window.setTimeout(tryToken, CHECK_CONFIG_INTERVAL_OK); }) .catch((error) => { - const timer: any = setTimeout(() => { - tryToken(); - }, CHECK_CONFIG_INTERVAL_FAIL); - const data = error.cause; - setResult({ data, timer }); + if (unloaded) return; + setResult(error.cause); + timer.current = window.setTimeout(tryToken, CHECK_CONFIG_INTERVAL_FAIL); }); } tryToken(); + return () => { + unloaded = true; + if (timer.current !== undefined) clearTimeout(timer.current); + }; }, [request]); - return result.data; + return result; } interface useBackendInstanceRequestType { diff --git a/packages/taler-auditor-webui/src/hooks/critical.ts b/packages/taler-auditor-webui/src/hooks/critical.ts @@ -35,7 +35,7 @@ export interface HelperDashboardFilter { detail?: YesOrNo; } -export function getCriticalData( +export function useCriticalData( args?: HelperDashboardFilter, updateFilter?: (d: Date) => void, ): HttpResponse<any, AuditorBackend.ErrorDetail> { diff --git a/packages/taler-auditor-webui/src/hooks/entity.ts b/packages/taler-auditor-webui/src/hooks/entity.ts @@ -32,7 +32,7 @@ interface Props { entity: any; } -export function getEntityList({ +export function useEntityList({ endpoint, entity, }: Props): HttpResponse<any, AuditorBackend.ErrorDetail> { diff --git a/packages/taler-auditor-webui/src/hooks/finance.ts b/packages/taler-auditor-webui/src/hooks/finance.ts @@ -30,7 +30,7 @@ import _useSWR, { SWRHook } from "swr"; const useSWR = _useSWR as unknown as SWRHook; -export function getKeyFiguresData(): HttpResponse< +export function useKeyFiguresData(): HttpResponse< any, AuditorBackend.ErrorDetail > { diff --git a/packages/taler-auditor-webui/src/hooks/index.test.ts b/packages/taler-auditor-webui/src/hooks/index.test.ts @@ -0,0 +1,45 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. +*/ + +import assert from "node:assert"; +import test from "node:test"; +import { + backendBaseUrlFromLocation, + normalizeAuditorBaseUrl, +} from "./index.js"; + +test("derive the auditor URL from the WebUI location", () => { + assert.equal( + backendBaseUrlFromLocation("https://auditor.example", "/webui/"), + "https://auditor.example/", + ); + assert.equal( + backendBaseUrlFromLocation( + "https://example.com", + "/services/auditor/webui/index.html", + ), + "https://example.com/services/auditor/", + ); + assert.equal( + backendBaseUrlFromLocation("http://localhost:8080", "/"), + "http://localhost:8080/", + ); +}); + +test("normalize HTTP auditor base URLs", () => { + assert.equal( + normalizeAuditorBaseUrl("http://localhost:8083"), + "http://localhost:8083/", + ); + assert.equal( + normalizeAuditorBaseUrl("auditor.example/path"), + "https://auditor.example/path/", + ); + assert.throws(() => normalizeAuditorBaseUrl("file:///tmp/auditor")); +}); diff --git a/packages/taler-auditor-webui/src/hooks/index.ts b/packages/taler-auditor-webui/src/hooks/index.ts @@ -15,43 +15,69 @@ */ import { canonicalizeBaseUrl } from "@gnu-taler/taler-util"; -import { StateUpdater, useState } from "preact/hooks"; +import { useState } from "preact/hooks"; import { ValueOrFunction } from "../utils/types.js"; -export function useBackendURL(url?: string): [string, StateUpdater<string>] { - const canonUrl = canonicalizeBaseUrl(url ?? calculateRootPath()); +export const AUDITOR_BASE_URL_OVERRIDE_KEY = "auditor-base-url"; - const [value, setter] = useSimpleLocalStorage("auditor-base-url", canonUrl); +export function backendBaseUrlFromLocation( + origin: string, + pathname: string, +): string { + const webuiSegment = "/webui/"; + const webuiOffset = pathname.indexOf(webuiSegment); + const backendPath = + webuiOffset === -1 ? pathname : pathname.slice(0, webuiOffset + 1); + return canonicalizeBaseUrl(origin + backendPath); +} - const checkedSetter = (v: ValueOrFunction<string>) => { - // FIXME: Explain?! - return setter((p) => - (v instanceof Function ? v(p ?? "") : v).replace(/\/$/, ""), - ); - }; +export function getDefaultBackendUrl(): string { + return typeof window !== "undefined" + ? backendBaseUrlFromLocation( + window.location.origin, + window.location.pathname, + ) + : "/"; +} - return [value!, checkedSetter]; +export function normalizeAuditorBaseUrl(value: string): string { + const canonical = canonicalizeBaseUrl(value); + const protocol = new URL(canonical).protocol; + if (protocol !== "http:" && protocol !== "https:") { + throw new Error("unsupported protocol"); + } + return canonical; } -function calculateRootPath() { - const rootPath = - typeof window !== "undefined" - ? window.location.origin + window.location.pathname - : "/"; - - /* - * By default, auditor backend serves the html content - * from the /webui root. This should cover most of the - * cases and the rootPath will be the auditor backend - * URL where the instances are - */ - return rootPath.replace("/spa/", ""); +type LocalStorageSetter<T> = (value: T | ((previous: T) => T)) => void; + +export function useBackendURL(url?: string): [string, LocalStorageSetter<string>] { + const canonUrl = canonicalizeBaseUrl(url ?? getDefaultBackendUrl()); + + const [value, setter] = useSimpleLocalStorage( + AUDITOR_BASE_URL_OVERRIDE_KEY, + canonUrl, + ); + let normalizedValue = canonUrl; + try { + normalizedValue = normalizeAuditorBaseUrl(value ?? canonUrl); + } catch { + // Keep the development page reachable so an invalid override can be fixed. + } + + const checkedSetter = (v: ValueOrFunction<string>): void => { + setter((previous) => + normalizeAuditorBaseUrl(v instanceof Function ? v(previous ?? "") : v), + ); + }; + + return [normalizedValue, checkedSetter]; } function useSimpleLocalStorage( key: string, initialValue?: string, -): [string | undefined, StateUpdater<string | undefined>] { +): [string | undefined, LocalStorageSetter<string | undefined>] { const [storedValue, setStoredValue] = useState<string | undefined>( (): string | undefined => { return typeof window !== "undefined" @@ -60,9 +86,7 @@ function useSimpleLocalStorage( }, ); - const setValue = ( - value?: string | ((val?: string) => string | undefined), - ) => { + const setValue: LocalStorageSetter<string | undefined> = (value) => { setStoredValue((p) => { const toStore = value instanceof Function ? value(p) : value; if (typeof window !== "undefined") { diff --git a/packages/taler-auditor-webui/src/hooks/operational.ts b/packages/taler-auditor-webui/src/hooks/operational.ts @@ -35,7 +35,7 @@ export interface HelperDashboardFilter { detail?: YesOrNo; } -export function getOperationData( +export function useOperationData( args?: HelperDashboardFilter, updateFilter?: (d: Date) => void, ): HttpResponse<any, AuditorBackend.ErrorDetail> { diff --git a/packages/taler-auditor-webui/src/hooks/useSettings.ts b/packages/taler-auditor-webui/src/hooks/useSettings.ts @@ -1,75 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { - Codec, - buildCodecForObject, - codecForBoolean, - codecForConstString, - codecForEither, -} from "@gnu-taler/taler-util"; -import { buildStorageKey, useLocalStorage } from "@gnu-taler/web-util/browser"; - -export interface Settings { - advanceOrderMode: boolean; - dateFormat: "ymd" | "dmy" | "mdy"; -} - -const defaultSettings: Settings = { - advanceOrderMode: false, - dateFormat: "ymd", -}; - -export const codecForSettings = (): Codec<Settings> => - buildCodecForObject<Settings>() - .property("advanceOrderMode", codecForBoolean()) - .property( - "dateFormat", - codecForEither( - codecForConstString("ymd"), - codecForConstString("dmy"), - codecForConstString("mdy"), - ), - ) - .build("Settings"); - -const SETTINGS_KEY = buildStorageKey("merchant-settings", codecForSettings()); - -export function useSettings(): [Readonly<Settings>, (s: Settings) => void] { - const { value, update } = useLocalStorage(SETTINGS_KEY, defaultSettings); - - // const parsed: Settings = value ?? defaultSettings; - // function updateField<T extends keyof Settings>(k: T, v: Settings[T]) { - // const next = { ...parsed, [k]: v } - // update(next); - // } - return [value, update]; -} - -export function dateFormatForSettings(s: Settings): string { - switch (s.dateFormat) { - case "ymd": - return "yyyy/MM/dd"; - case "dmy": - return "dd/MM/yyyy"; - case "mdy": - return "MM/dd/yyyy"; - } -} - -export function datetimeFormatForSettings(s: Settings): string { - return dateFormatForSettings(s) + " HH:mm:ss"; -} diff --git a/packages/taler-auditor-webui/src/index.html b/packages/taler-auditor-webui/src/index.html @@ -16,10 +16,7 @@ @author Sebastian Javier Marchano --> <!doctype html> -<html - lang="en" - class="has-aside-left has-aside-mobile-transition has-navbar-fixed-top has-aside-expanded" -> +<html lang="en"> <head> <meta charset="utf-8" /> <meta http-equiv="content-type" content="text/html; charset=utf-8" /> @@ -32,12 +29,10 @@ href="data:;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAABILAAASCwAAAAAAAAAAAAD///////////////////////////////////////////////////////////////////////////////////////////////////7//v38//78/P/+/fz//vz7///+/v/+/f3//vz7///+/v/+/fz//v38///////////////////////+/v3///7+/////////////////////////////////////////////////////////v3//v79///////+/v3///////r28v/ct5//06SG/9Gffv/Xqo7/7N/V/9e2nf/bsJb/6uDW/9Sskf/euKH/+/j2///////+/v3//////+3azv+/eE3/2rWd/9Kkhv/Vr5T/48i2/8J+VP/Qn3//3ryn/795Tf/WrpP/2LCW/8B6T//w4Nb///////Pn4P+/d0v/9u3n/+7d0v/EhV7//v///+HDr//fxLD/zph2/+TJt//8/Pv/woBX//Lm3f/y5dz/v3hN//bu6f/JjGn/4sW0///////Df1j/8OLZ//v6+P+/elH/+vj1//jy7f+/elL//////+zYzP/Eg13//////967p//MlHT/wn5X///////v4Nb/yY1s///////jw7H/06KG////////////z5t9/+fNvf//////x4pn//Pp4v/8+vn/w39X/8WEX///////5s/A/9CbfP//////27Oc/9y2n////////////9itlf/gu6f//////86Vdf/r2Mz//////8SCXP/Df1j//////+7d0v/KkG7//////+HBrf/VpYr////////////RnoH/5sq6///////Ii2n/8ubf//39/P/Cf1j/xohk/+bNvv//////wn5W//Tq4//58/D/wHxV//7+/f/59fH/v3xU//39/P/w4Nf/xIFb///////hw7H/yo9t/+/f1f/AeU3/+/n2/+nSxP/FhmD//////9qzm//Upon/4MSx/96+qf//////xINc/+3bz//48e3/v3hN//Pn3///////6M+//752S//gw6//06aK/8J+VP/kzLr/zZd1/8OCWv/q18r/17KZ/9Ooi//fv6r/v3dK/+vWyP///////v39///////27un/1aeK/9Opjv/m1cf/1KCC/9a0nP/n08T/0Jx8/82YdP/QnHz/16yR//jx7P///////v39///////+/f3///7+///////+//7//v7+///////+/v7//v/+/////////////////////////v7//v79///////////////////+/v/+/Pv//v39///+/v/+/Pv///7+//7+/f/+/Pv//v39//79/P/+/Pv///7+////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" /> <link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" /> - <title>Auditor Backoffice</title> - <!-- Optional customization script. --> - <script src="auditor-backoffice-ui-settings.js"></script> + <title>Taler Auditor</title> <!-- Entry point for the SPA. --> <script type="module" src="index.js"></script> - <link rel="stylesheet" href="index.css" /> + <link rel="stylesheet" href="style.css" /> </head> <body> <div id="app"></div> diff --git a/packages/taler-auditor-webui/src/index.tsx b/packages/taler-auditor-webui/src/index.tsx @@ -17,7 +17,6 @@ import { Application } from "./Application.js"; import { h, render } from "preact"; -import "./style.css"; const app = document.getElementById("app"); diff --git a/packages/taler-auditor-webui/src/paths/default/Table.tsx b/packages/taler-auditor-webui/src/paths/default/Table.tsx @@ -1,129 +1,89 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * - * @author Sebastian Javier Marchano (sebasjm) + (C) 2021-2026 Taler Systems S.A. */ import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { Fragment, h, VNode } from "preact"; -import { useState } from "preact/hooks"; -import { - useEntityContext, - useEntityDataContext, -} from "../../context/entity.js"; +import type { VNode } from "preact"; +import { useEntityContext, useEntityDataContext } from "../../context/entity.js"; interface Props { - onSuppress: (id: any) => void; + onSuppress: (id: string) => void; } -export function CardTable({ onSuppress }: Props): any { - const data = useEntityDataContext(); - const [rowSelection, rowSelectionHandler] = useState<string | undefined>( - undefined, - ); +function columnTitle(name: string): string { + const words = name.replaceAll("_", " "); + return words.charAt(0).toUpperCase() + words.slice(1); +} + +function displayValue(value: unknown): string { + if (value === false) return "false"; + if (value === true) return "true"; + if (value === null || value === undefined) return ""; + if (typeof value === "object") return JSON.stringify(value); + return String(value); +} + +export function CardTable({ onSuppress }: Props): VNode { + const { data } = useEntityDataContext(); const { i18n } = useTranslationContext(); const { title } = useEntityContext(); return ( - <div class="card has-table"> - <header class="card-header"> - <p class="card-header-title"> - <span class="icon"> - <i class="mdi mdi-shopping" /> - </span> - <i18n.Translate>{title}</i18n.Translate> - </p> - <div class="card-header-icon" aria-label="more options"></div> + <div class="auditor-card overflow-hidden"> + <header class="border-b border-gray-200 px-5 py-4"> + <h2 class="text-lg font-bold"><i18n.Translate>{title}</i18n.Translate></h2> </header> - <div class="card-content"> - <div class="b-table has-pagination"> - <div class="table-wrapper has-mobile-cards"> - {data.data.length !== 0 ? ( - <Table data={data.data} onSuppress={onSuppress} /> - ) : ( - <EmptyTable /> - )} - </div> - </div> - </div> + {data.length > 0 ? ( + <Table data={data} onSuppress={onSuppress} /> + ) : ( + <EmptyTable /> + )} </div> ); } -interface TableProps { - data: any; - onSuppress: (id: any) => void; -} - -function Table({ data, onSuppress }: TableProps): VNode { +function Table({ + data, + onSuppress, +}: { + data: Array<Record<string, unknown>>; + onSuppress: (id: string) => void; +}): VNode { const { i18n } = useTranslationContext(); - const { entity } = useEntityContext(); - type Entity = typeof entity; - let count = 0; - + const columns = Object.keys(data[0] ?? {}); return ( - <div class="table-container"> - <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> + <div class="overflow-x-auto"> + <table class="auditor-table"> <thead> <tr> - {Object.keys(data[0]).map((i: Entity) => { - const paramName = - i[0].toUpperCase() + i.replace("_", " ").slice(1, i.count); - return ( - <Fragment key={count.toString() + i}> - <th> - <i18n.Translate>{paramName}</i18n.Translate> - </th> - </Fragment> - ); - })} + {columns.map((column) => ( + <th key={column}><i18n.Translate>{columnTitle(column)}</i18n.Translate></th> + ))} + <th><span class="sr-only">Actions</span></th> </tr> </thead> <tbody> - {data.map((key: Entity, value: string) => { - return ( - <tr key={value}> - {Object.keys(data[0]).map((i: Entity, idx) => { - return ( - <Fragment key={idx}> - <td>{key[i] == false ? "false" : key[i]}</td> - </Fragment> - ); - })} - <td class="is-actions-cell right-sticky"> - <div class="buttons is-right"> - <span - class="has-tooltip-bottom" - data-tooltip={i18n.str`suppress`} - > - <button - class="button is-small is-success " - type="button" - onClick={(): void => onSuppress(key["row_id"])} - > - {<i18n.Translate>Suppress</i18n.Translate>} - </button> - </span> - </div> + {data.map((row, index) => ( + <tr key={String(row.row_id ?? index)}> + {columns.map((column) => ( + <td key={column} class="max-w-md break-words font-mono text-xs"> + {displayValue(row[column])} </td> - </tr> - ); - })} + ))} + <td class="sticky right-0 bg-inherit text-right"> + {typeof row.row_id !== "undefined" && ( + <button + class="auditor-button py-1 text-xs" + type="button" + onClick={() => onSuppress(String(row.row_id))} + > + <i18n.Translate>Suppress</i18n.Translate> + </button> + )} + </td> + </tr> + ))} </tbody> </table> </div> @@ -133,15 +93,9 @@ function Table({ data, onSuppress }: TableProps): VNode { function EmptyTable(): VNode { const { i18n } = useTranslationContext(); return ( - <div class="content has-text-grey has-text-centered"> - <p> - <span class="icon is-large"> - <i class="mdi mdi-emoticon-happy mdi-48px" /> - </span> - </p> - <p> - <i18n.Translate>There are no entries yet</i18n.Translate> - </p> + <div class="px-6 py-14 text-center text-gray-500"> + <div class="mb-3 text-4xl" aria-hidden="true">✓</div> + <p><i18n.Translate>There are no entries yet</i18n.Translate></p> </div> ); } diff --git a/packages/taler-auditor-webui/src/paths/default/index.tsx b/packages/taler-auditor-webui/src/paths/default/index.tsx @@ -22,7 +22,7 @@ import { HttpStatusCode } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { h, VNode } from "preact"; -import { route } from "preact-router"; +import { useLocation } from "wouter-preact"; import { useMemo, useState } from "preact/hooks"; import { Loading } from "../../components/exception/loading.js"; import { NotificationCard } from "../../components/menu/index.js"; @@ -31,8 +31,8 @@ import { EntityDataContextProvider, useEntityContext, } from "../../context/entity.js"; -import { AuditorBackend, WithId } from "../../declaration.js"; -import { getEntityList, useEntityAPI } from "../../hooks/entity.js"; +import { AuditorBackend } from "../../declaration.js"; +import { useEntityList, useEntityAPI } from "../../hooks/entity.js"; import { Paths } from "../../InstanceRoutes.js"; import { Notification } from "../../utils/types.js"; import { ErrorType, HttpError } from "../../utils/http.js"; @@ -45,11 +45,10 @@ interface Props { export default function DefaultList({ onLoadError, onNotFound }: Props): VNode { const { endpoint, entity } = useEntityContext(); - const result = getEntityList({ endpoint, entity }); + const result = useEntityList({ endpoint, entity }); const { updateEntity } = useEntityAPI(); - const [suppressing, setSuppressing] = useState< - (typeof entity & WithId) | null - >(null); + const [suppressing, setSuppressing] = useState<string | null>(null); + const [, setLocation] = useLocation(); const [notif, setNotif] = useState<Notification | undefined>(undefined); const { i18n } = useTranslationContext(); @@ -66,23 +65,16 @@ export default function DefaultList({ onLoadError, onNotFound }: Props): VNode { return onLoadError(result); } - function onReturn(): void { - route(Paths.detail_view); - } - return ( - <section class="section is-main-section"> - <button class="button is-fullwidth" onClick={onReturn}> - Back + <section class="space-y-5 px-4 pb-8 pt-24 md:px-8"> + <button class="auditor-button" onClick={() => setLocation(Paths.detail_view)}> + ← Back to inconsistencies </button> - <br /> <NotificationCard notification={notif} /> <EntityDataContextProvider value={value}> - <CardTable - onSuppress={(e: typeof entity & WithId) => setSuppressing(e)} - /> + <CardTable onSuppress={setSuppressing} /> </EntityDataContextProvider> {suppressing && ( @@ -109,7 +101,7 @@ export default function DefaultList({ onLoadError, onNotFound }: Props): VNode { setSuppressing(null); }} > - <p class="warning"> + <p class="text-sm text-gray-600"> Suppressing a row <b>cannot be undone</b> in this GUI. </p> </ConfirmModal> diff --git a/packages/taler-auditor-webui/src/paths/details/ListPage.tsx b/packages/taler-auditor-webui/src/paths/details/ListPage.tsx @@ -1,359 +1,50 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * - * @author Sebastian Javier Marchano (sebasjm) + (C) 2021-2026 Taler Systems S.A. */ -import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { Fragment, h, VNode } from "preact"; -import { route } from "preact-router"; +import type { VNode } from "preact"; +import { Link } from "wouter-preact"; import { Paths } from "../../InstanceRoutes.js"; -export interface ListPageProps { - onShowAll: () => void; - onShowNotPaid: () => void; - onShowPaid: () => void; - onShowRefunded: () => void; - onShowNotWired: () => void; - onShowWired: () => void; - onCopyURL: (id: string) => void; - isAllActive: string; - isPaidActive: string; - isNotPaidActive: string; - isRefundedActive: string; - isNotWiredActive: string; - isWiredActive: string; - - jumpToDate?: Date; - onSelectDate: (date?: Date) => void; - - onLoadMoreBefore?: () => void; - hasMoreBefore?: boolean; - hasMoreAfter?: boolean; - onLoadMoreAfter?: () => void; - - onCreate: () => void; -} - export function ListPage(): VNode { - const { i18n } = useTranslationContext(); + const entries = [ + [Paths.amount_arithmethic_inconsistency_list, "Amount arithmetic inconsistencies"], + [Paths.bad_sig_losses_list, "Bad signature losses"], + [Paths.closure_lag_list, "Closure lags"], + [Paths.coin_inconsistency_list, "Coin inconsistencies"], + [Paths.denomination_key_validity_withdraw_inconsistency_list, "Denomination key validity at withdrawal"], + [Paths.denomination_without_sig_list, "Denominations without signatures"], + [Paths.deposit_confirmation_list, "Deposit confirmations"], + [Paths.emergency_list, "Emergencies"], + [Paths.emergency_by_count_list, "Emergencies by count"], + [Paths.fee_time_inconsistency_list, "Fee time inconsistencies"], + [Paths.misattribution_in_inconsistency_list, "Misattribution-in inconsistencies"], + [Paths.purse_not_closed_inconsistency_list, "Purses not closed"], + [Paths.purse_list, "Purses"], + [Paths.reserve_balance_insufficient_inconsistency_list, "Insufficient reserve balances"], + [Paths.reserve_balance_summary_wrong_inconsistency_list, "Wrong reserve balance summaries"], + [Paths.reserve_in_inconsistency_list, "Reserve-in inconsistencies"], + [Paths.reserve_not_closed_inconsistency_list, "Reserves not closed"], + [Paths.reserves_list, "Reserves"], + [Paths.row_inconsistency_list, "Row inconsistencies"], + [Paths.row_minor_inconsistency_list, "Minor row inconsistencies"], + [Paths.wire_format_inconsistency_list, "Wire format inconsistencies"], + [Paths.wire_out_inconsistency_list, "Wire-out inconsistencies"], + ] as const; return ( - <Fragment> - <div class="columns"> - <div class="column"> - <div class="card"> - <div class="card-body"> - <button - class="button is-fullwidth" - onClick={(e) => - route(Paths.amount_arithmethic_inconsistency_list) - } - value={"Amount arithmetic inconsistencies"} - > - Amount arithmetic inconsistencies - </button> - </div> - </div> - </div> - <div class="column"> - <div class="card"> - <div class="card-body"> - <button - class="button is-fullwidth" - onClick={(e) => route(Paths.bad_sig_losses_list)} - value={"Bad signature losses"} - > - Bad signature losses - </button> - </div> - </div> - </div> - <div class="column"> - <div class="card"> - <div class="card-body"> - <button - class="button is-fullwidth" - onClick={(e) => route(Paths.closure_lag_list)} - > - Closure Lags - </button> - </div> - </div> - </div> - <div class="column"> - <div class="card"> - <div class="card-body"> - <button - class="button is-fullwidth" - onClick={(e) => route(Paths.coin_inconsistency_list)} - > - Coin inconsistencies - </button> - </div> - </div> - </div> - </div> - - <div class="columns"> - <div class="column"> - <div class="card"> - <div class="card-body"> - <button - class="button is-fullwidth" - onClick={(e) => - route( - Paths.denomination_key_validity_withdraw_inconsistency_list, - ) - } - > - Denominations key validity - </button> - </div> - </div> - </div> - <div class="column"> - <div class="card"> - <div class="card-body"> - <button - class="button is-fullwidth" - onClick={(e) => route(Paths.denomination_without_sig_list)} - > - Denominations without signature - </button> - </div> - </div> - </div> - <div class="column"> - <div class="card"> - <div class="card-body"> - <button - class="button is-fullwidth" - onClick={(e) => route(Paths.deposit_confirmation_list)} - > - Deposit confirmations - </button> - </div> - </div> - </div> - </div> - - <div class="columns"> - <div class="column"> - <div class="card"> - <div class="card-body"> - <button - class="button is-fullwidth" - onClick={(e) => route(Paths.emergency_list)} - > - Emergencies - </button> - </div> - </div> - </div> - <div class="column"> - <div class="card"> - <div class="card-body"> - <button - class="button is-fullwidth" - onClick={(e) => route(Paths.emergency_by_count_list)} - > - Emergencies by count - </button> - </div> - </div> - </div> - <div class="column"> - <div class="card"> - <div class="card-body"> - <button - class="button is-fullwidth" - onClick={(e) => route(Paths.fee_time_inconsistency_list)} - > - Fee time inconsistencies - </button> - </div> - </div> - </div> - <div class="column"> - <div class="card"> - <div class="card-body"> - <button - class="button is-fullwidth" - onClick={(e) => - route(Paths.misattribution_in_inconsistency_list) - } - > - Misattribution in inconsistencies - </button> - </div> - </div> - </div> - </div> - - <div class="columns"> - <div class="column"> - <div class="card"> - <div class="card-body"> - <button - class="button is-fullwidth" - onClick={(e) => - route(Paths.purse_not_closed_inconsistency_list) - } - > - Purses not closed - </button> - </div> - </div> - </div> - <div class="column"> - <div class="card"> - <div class="card-body"> - <button - class="button is-fullwidth" - onClick={(e) => route(Paths.purse_list)} - > - Purses - </button> - </div> - </div> - </div> - <div class="column"> - <div class="card"> - <div class="card-body"> - <button - class="button is-fullwidth" - onClick={(e) => - route(Paths.reserve_balance_insufficient_inconsistency_list) - } - > - Reserve balances insufficient - </button> - </div> - </div> - </div> - </div> - - <div class="columns"> - <div class="column"> - <div class="card"> - <div class="card-body"> - <button - class="button is-fullwidth" - onClick={(e) => - route(Paths.reserve_balance_summary_wrong_inconsistency_list) - } - > - Reserve balances summary wrong - </button> - </div> - </div> - </div> - <div class="column"> - <div class="card"> - <div class="card-body"> - <button - class="button is-fullwidth" - onClick={(e) => route(Paths.reserve_in_inconsistency_list)} - > - Reserves in - </button> - </div> - </div> - </div> - <div class="column"> - <div class="card"> - <div class="card-body"> - <button - class="button is-fullwidth" - onClick={(e) => - route(Paths.reserve_not_closed_inconsistency_list) - } - > - Reserves not closed - </button> - </div> - </div> - </div> - <div class="column"> - <div class="card"> - <div class="card-body"> - <button - class="button is-fullwidth" - onClick={(e) => route(Paths.reserves_list)} - > - Reserves - </button> - </div> - </div> - </div> - </div> - - <div class="columns"> - <div class="column"> - <div class="card"> - <div class="card-body"> - <button - class="button is-fullwidth" - onClick={(e) => route(Paths.row_inconsistency_list)} - > - Row inconsistencies - </button> - </div> - </div> - </div> - <div class="column"> - <div class="card"> - <div class="card-body"> - <button - class="button is-fullwidth" - onClick={(e) => route(Paths.row_minor_inconsistency_list)} - > - Row minor inconsistencies - </button> - </div> - </div> - </div> - <div class="column"> - <div class="card"> - <div class="card-body"> - <button - class="button is-fullwidth" - onClick={(e) => route(Paths.wire_format_inconsistency_list)} - > - Wire format inconsistencies - </button> - </div> - </div> - </div> - <div class="column"> - <div class="card"> - <div class="card-body"> - <button - class="button is-fullwidth" - onClick={(e) => route(Paths.wire_out_inconsistency_list)} - > - Wire out inconsistencies - </button> - </div> - </div> - </div> - </div> - </Fragment> + <div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3"> + {entries.map(([path, label]) => ( + <Link + key={path} + href={path} + class="auditor-card flex min-h-20 items-center justify-between gap-4 p-5 font-semibold text-gray-800 transition hover:-translate-y-0.5 hover:border-blue-300 hover:shadow-md" + > + <span>{label}</span> + <span class="text-taler-brand" aria-hidden="true">→</span> + </Link> + ))} + </div> ); } diff --git a/packages/taler-auditor-webui/src/paths/details/index.tsx b/packages/taler-auditor-webui/src/paths/details/index.tsx @@ -21,17 +21,11 @@ */ import { h, VNode } from "preact"; -import { useState } from "preact/hooks"; -import { NotificationCard } from "../../components/menu/index.js"; -import { Notification } from "../../utils/types.js"; import { ListPage } from "./ListPage.js"; export default function DetailsDashboard(): VNode { - const [notif, setNotif] = useState<Notification | undefined>(undefined); - return ( - <section class="section is-main-section"> - <NotificationCard notification={notif} /> + <section class="px-4 pb-8 pt-24 md:px-8"> <ListPage /> </section> ); diff --git a/packages/taler-auditor-webui/src/paths/developer/index.tsx b/packages/taler-auditor-webui/src/paths/developer/index.tsx @@ -0,0 +1,167 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. +*/ + +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { NotConnectedAppMenu } from "../../components/menu/index.js"; +import { useBackendContext } from "../../context/backend.js"; +import { + AUDITOR_BASE_URL_OVERRIDE_KEY, + getDefaultBackendUrl, + normalizeAuditorBaseUrl, +} from "../../hooks/index.js"; + +export const DEVELOPER_PATH = "/dev"; + +export interface DeveloperSettingsProps { + defaultBaseUrl: string; + effectiveBaseUrl: string; + initialOverride?: string; + onApply(baseUrl: string | undefined): void; +} + +export function DeveloperSettings({ + defaultBaseUrl, + effectiveBaseUrl, + initialOverride, + onApply, +}: DeveloperSettingsProps): VNode { + const { i18n } = useTranslationContext(); + const [baseUrl, setBaseUrl] = useState(initialOverride ?? ""); + const [error, setError] = useState<string>(); + + function apply(): void { + const requestedUrl = baseUrl.trim(); + if (!requestedUrl) { + setError(undefined); + onApply(undefined); + return; + } + try { + const normalized = normalizeAuditorBaseUrl(requestedUrl); + setBaseUrl(normalized); + setError(undefined); + onApply(normalized); + } catch { + setError(i18n.str`Enter a valid HTTP or HTTPS URL.`); + } + } + + return ( + <> + <NotConnectedAppMenu title={i18n.str`Development settings`} /> + <section class="mx-auto max-w-3xl px-4 pb-12 pt-28"> + <div class="auditor-card p-6 md:p-8"> + <h1 class="text-3xl font-bold text-gray-900"> + <i18n.Translate>Development settings</i18n.Translate> + </h1> + <p class="mt-3 text-gray-600"> + <i18n.Translate> + These browser-local settings are intended for development and + troubleshooting. + </i18n.Translate> + </p> + + <form + onSubmit={(event) => { + event.preventDefault(); + apply(); + }} + > + <div class="mt-7"> + <label class="mb-2 block text-sm font-bold text-gray-700" htmlFor="auditor-base-url"> + <i18n.Translate>Auditor base URL</i18n.Translate> + </label> + <input + id="auditor-base-url" + name="auditor-base-url" + class={`auditor-input ${error ? "border-red-500" : ""}`} + type="url" + inputMode="url" + value={baseUrl} + placeholder={defaultBaseUrl} + aria-invalid={error !== undefined} + aria-describedby="auditor-base-url-help" + onInput={(event) => { + setBaseUrl(event.currentTarget.value); + setError(undefined); + }} + /> + <p id="auditor-base-url-help" class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + Leave empty to derive the auditor URL from this page. The + override is stored only in this browser. + </i18n.Translate> + </p> + {error ? ( + <p class="mt-2 text-sm font-semibold text-red-700" role="alert"> + {error} + </p> + ) : undefined} + </div> + + <article class="mt-6 rounded-lg border border-blue-200 bg-blue-50 p-4 text-blue-950"> + <div> + <strong> + <i18n.Translate>Currently connected to</i18n.Translate> + </strong>{" "} + <code class="ml-1 rounded bg-white px-1.5 py-0.5 text-sm text-blue-800">{effectiveBaseUrl}</code> + </div> + </article> + + <div class="mt-6 flex flex-wrap gap-3"> + <button class="auditor-button auditor-button-primary" type="submit"> + <i18n.Translate>Apply and reload</i18n.Translate> + </button> + <button + class="auditor-button" + type="button" + onClick={() => { + setBaseUrl(""); + setError(undefined); + onApply(undefined); + }} + > + <i18n.Translate>Use default URL</i18n.Translate> + </button> + <a class="auditor-button" href="#/"> + <i18n.Translate>Back</i18n.Translate> + </a> + </div> + </form> + </div> + </section> + </> + ); +} + +export function Developer(): VNode { + const { url: effectiveBaseUrl } = useBackendContext(); + const initialOverride = + typeof localStorage === "undefined" + ? undefined + : (localStorage.getItem(AUDITOR_BASE_URL_OVERRIDE_KEY) ?? undefined); + + return ( + <DeveloperSettings + defaultBaseUrl={getDefaultBackendUrl()} + effectiveBaseUrl={effectiveBaseUrl} + initialOverride={initialOverride} + onApply={(baseUrl) => { + if (baseUrl) { + localStorage.setItem(AUDITOR_BASE_URL_OVERRIDE_KEY, baseUrl); + } else { + localStorage.removeItem(AUDITOR_BASE_URL_OVERRIDE_KEY); + } + window.location.reload(); + }} + /> + ); +} diff --git a/packages/taler-auditor-webui/src/paths/finance/ListPage.tsx b/packages/taler-auditor-webui/src/paths/finance/ListPage.tsx @@ -1,262 +1,117 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2026 Taler Systems S.A. + */ - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. +import type { VNode } from "preact"; - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. +const coinBalances = new Set([ + "Total recoup loss", + "Coin refund fee revenue", + "Coin deposit fee revenue", + "Coin melt fee revenue", + "Coin irregular loss", + "Coins reported emergency risk by amount", + "Coins emergencies loss by count", + "Coins emergencies loss", + "Coins total arithmetic delta minus", + "Coins total arithmetic delta plus", + "Total escrowed", + "Total refresh hanging", +]); - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ +const reserveBalances = new Set([ + "Total balance summary delta minus", + "Total balance reserve not closed", + "Reserves total arithmetic delta minus", + "Reserves total arithmetic delta plus", + "Reserves total bad signature loss", + "Reserves history fee revenue", + "Reserves open fee revenue", +]); -/** - * - * @author Nic Eigel - * @author Sebastian Javier Marchano (sebasjm) - */ +function balanceName(key: string): string { + const words = key.replaceAll("_", " ").replaceAll("-", " "); + return words.charAt(0).toUpperCase() + words.slice(1); +} -/** - * Imports. - */ -import { Fragment, h, VNode } from "preact"; +function BalanceTable({ + title, + balances, +}: { + title: string; + balances: Array<{ balance_key: string; balance_value: string }>; +}): VNode { + return ( + <div> + <h2 class="mb-3 text-sm font-bold uppercase tracking-wide text-gray-500">{title}</h2> + <div class="overflow-x-auto rounded-lg border border-gray-200"> + <table class="auditor-table"> + <thead><tr><th>Balance</th><th class="text-right">Value</th></tr></thead> + <tbody> + {balances.map((balance) => ( + <tr key={balance.balance_key}> + <td>{balanceName(balance.balance_key)}</td> + <td class="whitespace-nowrap text-right font-mono"> + {balance.balance_value.replace(":", " ")} + </td> + </tr> + ))} + </tbody> + </table> + </div> + </div> + ); +} export function ListPage(data: any): VNode { - const summaries = data.data.data[0]; - const balances = - summaries.find((x: any) => x.endpoint === "balances")?.records ?? []; - let coinBalances = [ - "Total recoup loss", - "Coin refund fee revenue", - "Coin deposit fee revenue", - "Coin melt fee revenue", - "Coin irregular loss", - "Coins reported emergency risk by amount", - "Coins emergencies loss by count", - "Coins emergencies loss", - "Coins total arithmetic delta minus", - "Coins total arithmetic delta plus", - "Total escrowed", - "Total refresh hanging", - ]; - let reserveBalances = [ - "Total balance summary delta minus", - "Total balance reserve not closed", - "Reserves total arithmetic delta minus", - "Reserves total arithmetic delta plus", - "Reserves total bad signature loss", - "Reserves history fee revenue", - "Reserves open fee revenue", - ]; + const summaries = data.data.data[0] as Array<{ + endpoint: string; + label: string; + records: Array<Record<string, unknown>>; + }>; + const balances = (summaries.find((item) => item.endpoint === "balances")?.records ?? []) as Array<{ + balance_key: string; + balance_value: string; + }>; + const findings = summaries.filter((item) => item.endpoint !== "balances"); return ( - <Fragment> - <div class="columns"> - <div class="column is-half"> - <div class="columns"> - <div class="column"> - <div class="card"> - <div class="card-content"> - <table class="table is-striped is-fullwidth is-dark"> - <tbody> - <tr> - <th>Finding</th> - <td class="has-text-right"> - <b>Count</b> - </td> - <td class="has-text-right"> - <b>Gain/Loss</b> - </td> - </tr> - {summaries.map((x: any, idx: number) => { - const value = x.records.length; - if (x.endpoint === "balances") { - //TODO fix - const gains = 0; - - return ( - <tr key={idx} class="is-link"> - <td>{x.label}</td> - <td class="has-text-right"> - <p - class={ - value == 0 ? "text-success" : "text-danger" - } - > - {String(value)} - </p> - </td> - <td class="has-text-right"> - <p - class={ - gains == 0 ? "text-success" : "text-danger" - } - > - {String(gains)} - </p> - </td> - </tr> - ); - } else { - return ( - <tr key={idx} class="is-link"> - <td>{x.label}</td> - <td class="has-text-right"> - <p - class={ - value == 0 ? "text-success" : "text-danger" - } - > - {String(value)} - </p> - </td> - <td class="has-text-right"> - <p> - { - //TODO - } - </p> - </td> - </tr> - ); - } - })} - </tbody> - </table> - </div> - </div> - <div class="card"> - <div class="card-content"> - <table class="table is-striped is-fullwidth is-dark"> - <tbody> - <tr> - <th>Summary</th> - <td class="has-text-right"> - <b>Value</b> - </td> - </tr> - <tr> - <td>Total gain/loss</td> - <td class="has-text-right"> - { - //TODO fix - } - </td> - </tr> - <tr> - <td>Pending gain/loss</td> - <td class="has-text-right"> - { - //TODO fix - } - </td> - </tr> - <tr> - <td>Transaction count</td> - <td class="has-text-right"> - { - //TODO fix - } - </td> - </tr> - <tr> - <td>Transactions pending</td> - <td class="has-text-right"> - { - //TODO fix - } - </td> - </tr> - </tbody> - </table> - </div> - </div> - </div> - </div> + <div class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(28rem,1fr)]"> + <div class="auditor-card overflow-hidden"> + <div class="border-b border-gray-200 px-5 py-4"> + <h2 class="text-lg font-bold">Financial findings</h2> + <p class="text-sm text-gray-500">Current unsuppressed auditor diagnostics</p> </div> - <div class="column is-half"> - <div class="card"> - <div class="card-content"> - <p class="has-text-weight-bold">Helper coin</p> - <table class="table is-striped is-fullwidth is-dark"> - <tbody> - <tr> - <th>Balance</th> - <td> - <b>Value</b> + <div class="overflow-x-auto"> + <table class="auditor-table"> + <thead><tr><th>Finding</th><th class="text-right">Count</th></tr></thead> + <tbody> + {findings.map((finding) => { + const count = finding.records.length; + return ( + <tr key={finding.endpoint}> + <td>{finding.label}</td> + <td class={`text-right font-bold ${count === 0 ? "text-taler-success" : "text-taler-error"}`}> + {count} </td> </tr> - {balances.map((x: any, idx: number) => { - let key = x.balance_key; - let balanceName = - key[0].toUpperCase() + - key - .split("_") - .join(" ") - .split("-") - .join(" ") - .slice(1, key.length); - - if (coinBalances.includes(balanceName)) { - let value = x.balance_value.replace(":", " "); - return ( - <tr key={idx} class="is-link"> - <td>{balanceName}</td> - <td> - <p>{value}</p> - </td> - </tr> - ); - } else { - return null; - } - })} - </tbody> - </table> - <p class="has-text-weight-bold">Helper reserve</p> - <table class="table is-striped is-fullwidth is-dark"> - <tbody> - <tr> - <th>Balance</th> - <td> - <b>Value</b> - </td> - </tr> - {balances.map((x: any, idx: number) => { - let key = x.balance_key; - let balanceName = - key[0].toUpperCase() + - key - .split("_") - .join(" ") - .split("-") - .join(" ") - .slice(1, key.length); - - if (reserveBalances.includes(balanceName)) { - let value = x.balance_value.replace(":", " "); - return ( - <tr key={idx} class="is-link"> - <td>{balanceName}</td> - <td> - <p>{value}</p> - </td> - </tr> - ); - } else { - return null; - } - })} - </tbody> - </table> - </div> - </div> + ); + })} + </tbody> + </table> </div> </div> - </Fragment> + <div class="auditor-card space-y-7 p-5"> + <BalanceTable + title="Coin balances" + balances={balances.filter((balance) => coinBalances.has(balanceName(balance.balance_key)))} + /> + <BalanceTable + title="Reserve balances" + balances={balances.filter((balance) => reserveBalances.has(balanceName(balance.balance_key)))} + /> + </div> + </div> ); } diff --git a/packages/taler-auditor-webui/src/paths/finance/index.tsx b/packages/taler-auditor-webui/src/paths/finance/index.tsx @@ -21,13 +21,9 @@ */ import { HttpStatusCode } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { h, VNode } from "preact"; -import { useState } from "preact/hooks"; import { Loading } from "../../components/exception/loading.js"; -import { NotificationCard } from "../../components/menu/index.js"; -import { getKeyFiguresData } from "../../hooks/finance.js"; -import { Notification } from "../../utils/types.js"; +import { useKeyFiguresData } from "../../hooks/finance.js"; import { ErrorType } from "../../utils/http.js"; import { ListPage } from "./ListPage.js"; @@ -45,11 +41,7 @@ export default function FinanceDashboard({ onSelect, onNotFound, }: Props): VNode { - const result = getKeyFiguresData(); - - const [notif, setNotif] = useState<Notification | undefined>(undefined); - - const { i18n } = useTranslationContext(); + const result = useKeyFiguresData(); if (result.loading) return <Loading />; if (!result.ok) { @@ -67,8 +59,7 @@ export default function FinanceDashboard({ } return ( - <section class="section is-main-section"> - <NotificationCard notification={notif} /> + <section class="px-4 pb-8 pt-24 md:px-8"> <ListPage data={result} /> </section> ); diff --git a/packages/taler-auditor-webui/src/paths/login/index.tsx b/packages/taler-auditor-webui/src/paths/login/index.tsx @@ -1,210 +1,12 @@ /* This file is part of GNU Taler - (C) 2021-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + (C) 2021-2026 Taler Systems S.A. */ -/** - * - * @author Nic Eigel - * @author Sebastian Javier Marchano (sebasjm) - */ - -import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { ComponentChildren, Fragment, h, VNode } from "preact"; -import { Route } from "preact-router"; -import { useCallback, useState } from "preact/hooks"; -import { NotificationCard } from "../../components/menu/index.js"; -import { useBackendTokenContext } from "../../context/backend.js"; -import { useBackendToken } from "../../hooks/backend.js"; -import { Paths, Redirect } from "../../InstanceRoutes.js"; -import { Notification } from "../../utils/types.js"; +import type { VNode } from "preact"; +import { Redirect } from "wouter-preact"; +import { Paths } from "../../InstanceRoutes.js"; export function LoginPage(): VNode { - const [token, setToken] = useState(""); - const [notif, setNotif] = useState<Notification | undefined>(undefined); - const { i18n } = useTranslationContext(); - const doLogin = useCallback( - async function doLoginImpl() { - const result = useBackendToken(); - if (result.ok) { - //TODO fixme - const { token } = useBackendTokenContext(); - /* return ( - <Route path="/" component={Redirect} to={Paths.key_figures}/> - );*/ - } else { - setNotif({ - message: "Your password is incorrect", - type: "ERROR", - }); - } - }, - [token], - ); - - return <Route path="/" component={Redirect} to={Paths.key_figures} />; - - return ( - <div class="columns is-centered" style={{ margin: "auto" }}> - <div class="column is-two-thirds "> - <div class="modal-card" style={{ width: "100%", margin: 0 }}> - <header - class="modal-card-head" - style={{ border: "1px solid", borderBottom: 0 }} - > - <p class="modal-card-title">{i18n.str`Token required`}</p> - </header> - <section - class="modal-card-body" - style={{ border: "1px solid", borderTop: 0, borderBottom: 0 }} - > - <p> - <i18n.Translate> - Need the access token for the API. - </i18n.Translate> - </p> - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label"> - <i18n.Translate>Access token</i18n.Translate> - </label> - </div> - <div class="field-body"> - <div class="field"> - <p class="control is-expanded"> - <input - class="input" - type="password" - placeholder={"current access token"} - name="token" - onKeyPress={(e) => (e.keyCode === 13 ? doLogin() : null)} - value={token} - onInput={(e): void => setToken(e?.currentTarget.value)} - /> - </p> - </div> - </div> - </div> - </section> - <footer - class="modal-card-foot " - style={{ - justifyContent: "flex-end", - border: "1px solid", - borderTop: 0, - }} - > - <AsyncButton onClick={() => doLogin()}> - <i18n.Translate>Confirm</i18n.Translate> - </AsyncButton> - </footer> - </div> - </div> - </div> - ); - - return ( - <Fragment> - <NotificationCard notification={notif} /> - <div class="columns is-centered" style={{ margin: "auto" }}> - <div class="column is-two-thirds "> - <div class="modal-card" style={{ width: "100%", margin: 0 }}> - <header - class="modal-card-head" - style={{ border: "1px solid", borderBottom: 0 }} - > - <p class="modal-card-title">{i18n.str`Login required`}</p> - </header> - <section - class="modal-card-body" - style={{ border: "1px solid", borderTop: 0, borderBottom: 0 }} - > - <i18n.Translate>Please enter your access token.</i18n.Translate> - - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label"> - <i18n.Translate>Access token</i18n.Translate> - </label> - </div> - <div class="field-body"> - <div class="field"> - <p class="control is-expanded"> - <input - class="input" - type="password" - placeholder={"current access token"} - name="token" - onKeyPress={(e) => - e.keyCode === 13 ? doLogin() : null - } - value={token} - onInput={(e): void => setToken(e?.currentTarget.value)} - /> - </p> - </div> - </div> - </div> - </section> - <footer - class="modal-card-foot " - style={{ - justifyContent: "space-between", - border: "1px solid", - borderTop: 0, - }} - > - <div /> - <AsyncButton type="is-info" onClick={doLogin}> - <i18n.Translate>Confirm</i18n.Translate> - </AsyncButton> - </footer> - </div> - </div> - </div> - </Fragment> - ); -} - -function AsyncButton({ - onClick, - disabled, - type = "", - children, -}: { - type?: string; - disabled?: boolean; - onClick: () => Promise<void>; - children: ComponentChildren; -}): VNode { - const [running, setRunning] = useState(false); - return ( - <button - class={"button " + type} - disabled={disabled || running} - onClick={() => { - setRunning(true); - onClick() - .then(() => { - setRunning(false); - }) - .catch(() => { - setRunning(false); - }); - }} - > - {children} - </button> - ); + return <Redirect to={Paths.key_figures} replace />; } diff --git a/packages/taler-auditor-webui/src/paths/notfound/index.tsx b/packages/taler-auditor-webui/src/paths/notfound/index.tsx @@ -20,15 +20,17 @@ */ import { h, VNode } from "preact"; -import { Link } from "preact-router"; +import { Link } from "wouter-preact"; export default function NotFoundPage(): VNode { return ( - <div> - <p>That page doesn&apos;t exist.</p> - <Link href="/"> - <h4>Back to Home</h4> - </Link> + <div class="px-6 pb-12 pt-28 text-center"> + <div class="auditor-card mx-auto max-w-lg p-10"> + <p class="text-xl font-bold text-gray-900">That page doesn&apos;t exist.</p> + <Link href="/" class="auditor-button auditor-button-primary mt-6"> + Back to home + </Link> + </div> </div> ); } diff --git a/packages/taler-auditor-webui/src/paths/operations/ListPage.tsx b/packages/taler-auditor-webui/src/paths/operations/ListPage.tsx @@ -1,82 +1,40 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + (C) 2021-2026 Taler Systems S.A. */ -/** - * - * @author Nic Eigel - * @author Sebastian Javier Marchano (sebasjm) - */ - -/** - * Imports. - */ -import { Fragment, h, VNode } from "preact"; +import type { VNode } from "preact"; export function ListPage(data: any): VNode { + const summaries = data.data.data[0] as Array<{ + endpoint: string; + label: string; + records: unknown[]; + }>; return ( - <Fragment> - <div class="columns is-fullwidth"> - <div class="column is-fullwidth"> - <div class="card"> - <div class="card-content"> - <table class="table is-striped is-fullwidth"> - <tbody> - <tr> - <th>Finding</th> - <td class="has-text-right"> - <b>Count</b> - </td> - <td class="has-text-right"> - <b>Time difference (s)</b> - </td> - <td class="has-text-right"> - <b>Diagnostic</b> - </td> - </tr> - {data.data.data[0].map((x: any, idx: number) => { - const value = x.records.length; - return ( - <tr key={idx} class="is-link"> - <td>{x.label}</td> - <td className="has-text-right"> - <p - class={value == 0 ? "text-success" : "text-danger"} - > - {String(value)} - </p> - </td> - <td className="has-text-right"> - { - //TODO - } - </td> - <td> - { - //TODO - } - </td> - </tr> - ); - })} - </tbody> - </table> - </div> - </div> - </div> + <div class="auditor-card overflow-hidden"> + <div class="border-b border-gray-200 px-5 py-4"> + <h2 class="text-lg font-bold">Operational findings</h2> + <p class="text-sm text-gray-500">Processing and data-quality diagnostics</p> + </div> + <div class="overflow-x-auto"> + <table class="auditor-table"> + <thead><tr><th>Finding</th><th class="text-right">Count</th></tr></thead> + <tbody> + {summaries.map((finding) => { + const count = finding.records.length; + return ( + <tr key={finding.endpoint}> + <td>{finding.label}</td> + <td class={`text-right font-bold ${count === 0 ? "text-taler-success" : "text-taler-error"}`}> + {count} + </td> + </tr> + ); + })} + </tbody> + </table> </div> - </Fragment> + </div> ); } diff --git a/packages/taler-auditor-webui/src/paths/operations/index.tsx b/packages/taler-auditor-webui/src/paths/operations/index.tsx @@ -21,13 +21,9 @@ */ import { HttpStatusCode } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { h, VNode } from "preact"; -import { useState } from "preact/hooks"; import { Loading } from "../../components/exception/loading.js"; -import { NotificationCard } from "../../components/menu/index.js"; -import { getOperationData } from "../../hooks/operational.js"; -import { Notification } from "../../utils/types.js"; +import { useOperationData } from "../../hooks/operational.js"; import { ErrorType } from "../../utils/http.js"; import { ListPage } from "./ListPage.js"; @@ -45,11 +41,7 @@ export default function OperationsDashboard({ onSelect, onNotFound, }: Props): VNode { - const result = getOperationData(); - - const [notif, setNotif] = useState<Notification | undefined>(undefined); - - const { i18n } = useTranslationContext(); + const result = useOperationData(); if (result.loading) return <Loading />; if (!result.ok) { @@ -67,8 +59,7 @@ export default function OperationsDashboard({ } return ( - <section class="section is-main-section"> - <NotificationCard notification={notif} /> + <section class="px-4 pb-8 pt-24 md:px-8"> <ListPage data={result} /> </section> ); diff --git a/packages/taler-auditor-webui/src/paths/security/ListPage.tsx b/packages/taler-auditor-webui/src/paths/security/ListPage.tsx @@ -1,71 +1,40 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + (C) 2021-2026 Taler Systems S.A. */ -/** - * - * @author Nic Eigel - * @author Sebastian Javier Marchano (sebasjm) - */ - -import { Fragment, h, VNode } from "preact"; +import type { VNode } from "preact"; export function ListPage(data: any): VNode { + const summaries = data.data.data[0] as Array<{ + endpoint: string; + label: string; + records: unknown[]; + }>; return ( - <Fragment> - <div class="columns is-fullwidth"> - <div class="column is-fullwidth"> - <div class="card"> - <div class="card-content"> - <table class="table is-striped is-fullwidth"> - <tbody> - <tr> - <th>Finding</th> - <td class="has-text-right"> - <b>Count</b> - </td> - <td class="has-text-right"> - <b>Expiration dates</b> - </td> - </tr> - {data.data.data[0].map((x: any, idx: number) => { - const value = x.records.length; - return ( - <tr key={idx} class="is-link"> - <td>{x.label}</td> - <td class="has-text-right"> - <p - class={value !== 0 ? "text-success" : "text-danger"} - > - {String(value)} - </p> - </td> - <td class="has-text-right"> - { - //TODO - } - </td> - </tr> - ); - })} - </tbody> - </table> - </div> - </div> - </div> + <div class="auditor-card overflow-hidden"> + <div class="border-b border-gray-200 px-5 py-4"> + <h2 class="text-lg font-bold">Critical findings</h2> + <p class="text-sm text-gray-500">Security-sensitive conditions requiring attention</p> + </div> + <div class="overflow-x-auto"> + <table class="auditor-table"> + <thead><tr><th>Finding</th><th class="text-right">Count</th></tr></thead> + <tbody> + {summaries.map((finding) => { + const count = finding.records.length; + return ( + <tr key={finding.endpoint}> + <td>{finding.label}</td> + <td class={`text-right font-bold ${count === 0 ? "text-taler-success" : "text-taler-error"}`}> + {count} + </td> + </tr> + ); + })} + </tbody> + </table> </div> - </Fragment> + </div> ); } diff --git a/packages/taler-auditor-webui/src/paths/security/index.tsx b/packages/taler-auditor-webui/src/paths/security/index.tsx @@ -24,13 +24,9 @@ * Imports. */ import { HttpStatusCode } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { h, VNode } from "preact"; -import { useState } from "preact/hooks"; import { Loading } from "../../components/exception/loading.js"; -import { NotificationCard } from "../../components/menu/index.js"; -import { getCriticalData } from "../../hooks/critical.js"; -import { Notification } from "../../utils/types.js"; +import { useCriticalData } from "../../hooks/critical.js"; import { ErrorType } from "../../utils/http.js"; import { ListPage } from "./ListPage.js"; @@ -48,11 +44,7 @@ export default function SecurityDashboard({ onSelect, onNotFound, }: Props): VNode { - const result = getCriticalData(); - - const [notif, setNotif] = useState<Notification | undefined>(undefined); - - const { i18n } = useTranslationContext(); + const result = useCriticalData(); if (result.loading) return <Loading />; if (!result.ok) { @@ -70,8 +62,7 @@ export default function SecurityDashboard({ } return ( - <section class="section is-main-section"> - <NotificationCard notification={notif} /> + <section class="px-4 pb-8 pt-24 md:px-8"> <ListPage data={result} /> </section> ); diff --git a/packages/taler-auditor-webui/src/paths/settings/index.tsx b/packages/taler-auditor-webui/src/paths/settings/index.tsx @@ -1,117 +1,39 @@ /* This file is part of GNU Taler - (C) 2021-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * - * @author Sebastian Javier Marchano (sebasjm) - * @author Nic Eigel + (C) 2021-2026 Taler Systems S.A. */ -/** - * Imports. - */ import { useLang, useTranslationContext } from "@gnu-taler/web-util/browser"; -import { VNode, h } from "preact"; -import { - FormErrors, - FormProvider, -} from "../../components/forms/FormProvider.js"; +import type { VNode } from "preact"; import { LangSelector } from "../../components/menu/LangSelector.js"; -import { Settings, useSettings } from "../../hooks/useSettings.js"; -function getBrowserLang(): string | undefined { +function browserLanguage(): string | undefined { if (typeof window === "undefined") return undefined; - if (window.navigator.languages) return window.navigator.languages[0]; - if (window.navigator.language) return window.navigator.language; - return undefined; + return window.navigator.languages?.[0] ?? window.navigator.language; } -export function Settings({ onClose }: { onClose?: () => void }): VNode { +export function Settings(): VNode { const { i18n } = useTranslationContext(); - const borwserLang = getBrowserLang(); const { update } = useLang(undefined, {}); - - const [value, updateValue] = useSettings(); - const errors: FormErrors<Settings> = {}; - - function valueHandler(s: (d: Partial<Settings>) => Partial<Settings>): void { - const next = s(value); - const v: Settings = { - advanceOrderMode: next.advanceOrderMode ?? false, - dateFormat: next.dateFormat ?? "ymd", - }; - updateValue(v); - } - + const detected = browserLanguage()?.slice(0, 2); return ( - <div> - <section class="section is-main-section"> - <div class="columns"> - <div class="column" /> - <div class="column is-four-fifths"> - <div> - <FormProvider<Settings> - name="settings" - errors={errors} - object={value} - valueHandler={valueHandler} - > - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label"> - <i18n.Translate>Language</i18n.Translate> - <span - class="icon has-tooltip-right" - data-tooltip={ - "Force language setting instance of taking the browser" - } - > - <i class="mdi mdi-information" /> - </span> - </label> - </div> - <div class="field field-body has-addons is-flex-grow-3"> - <LangSelector /> - &nbsp; - {borwserLang !== undefined && ( - <button - data-tooltip={i18n.str`generate random secret key`} - class="button is-info mr-2" - onClick={(e) => { - update(borwserLang.substring(0, 2)); - }} - > - <i18n.Translate>Set default</i18n.Translate> - </button> - )} - </div> - </div> - </FormProvider> - </div> - </div> - <div class="column" /> + <section class="px-4 pb-8 pt-24 md:px-8"> + <div class="auditor-card max-w-2xl p-6"> + <h2 class="text-lg font-bold"><i18n.Translate>Language</i18n.Translate></h2> + <p class="mt-1 text-sm text-gray-500">Choose the language used by this browser.</p> + <div class="mt-5 flex flex-wrap items-center gap-3"> + <LangSelector /> + {detected && ( + <button + type="button" + class="auditor-button" + onClick={() => update(detected)} + > + <i18n.Translate>Set default</i18n.Translate> + </button> + )} </div> - </section> - {onClose && ( - <section class="section is-main-section"> - <button class="button" onClick={onClose}> - <i18n.Translate>Close</i18n.Translate> - </button> - </section> - )} - </div> + </div> + </section> ); } diff --git a/packages/taler-auditor-webui/src/routing/useHashLocation.ts b/packages/taler-auditor-webui/src/routing/useHashLocation.ts @@ -0,0 +1,48 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + */ + +import { useEffect, useState } from "preact/hooks"; + +function currentPathname(): string { + const hash = window.location.hash; + if (!hash || hash === "#") return "/"; + const path = hash.slice(1).split("?")[0] ?? "/"; + return path.startsWith("/") ? path : `/${path}`; +} + +export function useHashLocation(): [ + string, + (to: string, options?: { replace?: boolean }) => void, +] { + const [location, setLocation] = useState(currentPathname); + + useEffect(() => { + const update = (): void => setLocation(currentPathname()); + window.addEventListener("hashchange", update); + window.addEventListener("popstate", update); + return () => { + window.removeEventListener("hashchange", update); + window.removeEventListener("popstate", update); + }; + }, []); + + const navigate = (to: string, options?: { replace?: boolean }): void => { + const path = to.startsWith("#") ? to.slice(1) : to; + const hash = path.startsWith("/") ? `#${path}` : `#/${path}`; + if (window.location.hash === hash) return; + if (options?.replace) { + history.replaceState(null, "", hash); + window.dispatchEvent(new HashChangeEvent("hashchange")); + } else { + window.location.hash = hash; + } + }; + + return [location, navigate]; +} diff --git a/packages/taler-auditor-webui/src/style.css b/packages/taler-auditor-webui/src/style.css @@ -1,2521 +1,62 @@ -@charset "UTF-8"; -/* - * This file is part of GNU Taler. - * Copyright (C) 2021-2026 Taler Systems S.A. - * - * GNU Taler is free software; you can redistribute it and/or modify it under - * the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 3, or (at your option) any later - * version. - * - * See the third-party license notices at the end of this file. - */ -@font-face { - font-family: Nunito; - font-style: normal; - font-weight: 400; - src: url("./scss/fonts/XRXV3I6Li01BKofINeaE.ttf") format("truetype"); -} -@font-face { - font-family: Material Design Icons; - src: url("./scss/icons/fonts/materialdesignicons-webfont-4.9.95.eot"); - src: - url("./scss/icons/fonts/materialdesignicons-webfont-4.9.95.woff2") - format("woff2"), - url("./scss/icons/fonts/materialdesignicons-webfont-4.9.95.woff") - format("woff"), - url("./scss/icons/fonts/materialdesignicons-webfont-4.9.95.ttf") - format("truetype"); - font-weight: 400; - font-style: normal; -} -.mdi:before { - display: inline-block; - font: 24px/1 Material Design Icons; - font-size: inherit; - text-rendering: auto; - line-height: inherit; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} -.mdi-alert:before { - content: "\f026"; -} -.mdi-bank:before { - content: "\f070"; -} -.mdi-chevron-down:before { - content: "\f140"; -} -.mdi-close-network:before { - content: "\f15b"; -} -.mdi-emoticon-happy:before { - content: "\fc4d"; -} -.mdi-format-wrap-tight:before { - content: "\f28a"; -} -.mdi-information:before { - content: "\f2fc"; -} -.mdi-shopping:before { - content: "\f49a"; -} -.mdi-square-edit-outline:before { - content: "\f90b"; -} -.mdi-48px.mdi:before { - font-size: 48px; -} -@-webkit-keyframes mdi-spin { - 0% { - -webkit-transform: rotate(0deg); - transform: rotate(0); - } - to { - -webkit-transform: rotate(359deg); - transform: rotate(359deg); - } -} -@keyframes mdi-spin { - 0% { - -webkit-transform: rotate(0deg); - transform: rotate(0); - } - to { - -webkit-transform: rotate(359deg); - transform: rotate(359deg); - } -} -[data-tooltip]:not(.is-disabled), -[data-tooltip]:not(.is-loading), -[data-tooltip]:not([disabled]) { - cursor: pointer; - overflow: visible; - position: relative; -} -[data-tooltip]:not(.is-disabled):before, -[data-tooltip]:not(.is-loading):before, -[data-tooltip]:not([disabled]):before { - background: #4a4a4ae6; - border-radius: 2px; - content: attr(data-tooltip); - padding: 0.5rem 1rem; - text-overflow: ellipsis; - white-space: pre; - inset: 0 auto auto 50%; - margin-top: -5px; - margin-bottom: auto; - transform: translate(-50%, -100%); -} -[data-tooltip]:not(.is-disabled):before, -[data-tooltip]:not(.is-loading):before, -[data-tooltip]:not([disabled]):before { - box-sizing: border-box; - color: #fff; - display: inline-block; - font-family: - BlinkMacSystemFont, - -apple-system, - Segoe UI, - Roboto, - Oxygen, - Ubuntu, - Cantarell, - Fira Sans, - Droid Sans, - Helvetica Neue, - Helvetica, - Arial, - sans-serif; - font-size: 0.75rem; - -webkit-hyphens: auto; - -ms-hyphens: auto; - hyphens: auto; - opacity: 0; - overflow: hidden; - pointer-events: none; - position: absolute; - visibility: hidden; - z-index: 1; -} -[data-tooltip]:not(.is-disabled).has-tooltip-bottom:before, -[data-tooltip]:not(.is-loading).has-tooltip-bottom:before, -[data-tooltip]:not([disabled]).has-tooltip-bottom:before { - inset: auto auto 0 50%; - margin-top: auto; - margin-bottom: -5px; - transform: translate(-50%, 100%); -} -[data-tooltip]:not(.is-disabled).has-tooltip-left:before, -[data-tooltip]:not(.is-loading).has-tooltip-left:before, -[data-tooltip]:not([disabled]).has-tooltip-left:before { - inset: auto auto 50% -5px; - transform: translate(-100%, 50%); -} -[data-tooltip]:not(.is-disabled).has-tooltip-right:before, -[data-tooltip]:not(.is-loading).has-tooltip-right:before, -[data-tooltip]:not([disabled]).has-tooltip-right:before { - inset: auto -5px 50% auto; - margin-top: auto; - transform: translate(100%, 50%); -} -[data-tooltip]:not(.is-disabled):hover:after, -[data-tooltip]:not(.is-disabled):hover:before, -[data-tooltip]:not(.is-loading):hover:after, -[data-tooltip]:not(.is-loading):hover:before, -[data-tooltip]:not([disabled]):hover:after, -[data-tooltip]:not([disabled]):hover:before { - opacity: 1; - visibility: visible; -} -span[data-tooltip] { - border-bottom: 1px dashed #dbdbdb; -} -.control span[data-tooltip] { - border-bottom: none; -} -.menu-list div { - border-radius: 0; - color: #727c8f; - display: block; - padding: 0.5rem 0; -} -.input, -.button { - -moz-appearance: none; - -webkit-appearance: none; - align-items: center; - border: 1px solid transparent; - border-radius: 4px; - box-shadow: none; - display: inline-flex; - font-size: 1rem; - height: 2.5em; - justify-content: flex-start; - line-height: 1.5; - padding-bottom: calc(0.5em - 1px); - padding-left: calc(0.75em - 1px); - padding-right: calc(0.75em - 1px); - padding-top: calc(0.5em - 1px); - position: relative; - vertical-align: top; -} -.input:focus, -.button:focus, -.input:active, -.button:active, -.is-active.input, -.is-active.button { - outline: none; -} -[disabled].input, -[disabled].button { - cursor: not-allowed; -} -.button { - -webkit-touch-callout: none; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; -} -.message:not(:last-child), -.title:not(:last-child), -.table-container:not(:last-child), -.table:not(:last-child), -.progress:not(:last-child), -.notification:not(:last-child), -.content:not(:last-child) { - margin-bottom: 1.5rem; -} -.modal-close, -.delete { - -webkit-touch-callout: none; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - -moz-appearance: none; - -webkit-appearance: none; - background-color: #0a0a0a33; - border: none; - border-radius: 9999px; - cursor: pointer; - pointer-events: auto; - display: inline-block; - flex-grow: 0; - flex-shrink: 0; - font-size: 0; - height: 20px; - max-height: 20px; - max-width: 20px; - min-height: 20px; - min-width: 20px; - outline: none; - position: relative; - vertical-align: top; - width: 20px; -} -.modal-close:before, -.delete:before, -.modal-close:after, -.delete:after { - background-color: #fff; - content: ""; - display: block; - left: 50%; - position: absolute; - top: 50%; - transform: translate(-50%) translateY(-50%) rotate(45deg); - transform-origin: center center; -} -.modal-close:before, -.delete:before { - height: 2px; - width: 50%; -} -.modal-close:after, -.delete:after { - height: 50%; - width: 2px; -} -.modal-close:hover, -.delete:hover, -.modal-close:focus, -.delete:focus { - background-color: #0a0a0a4d; -} -.modal-close:active, -.delete:active { - background-color: #0a0a0a66; -} -.is-small.modal-close, -.is-small.delete { - height: 16px; - max-height: 16px; - max-width: 16px; - min-height: 16px; - min-width: 16px; - width: 16px; -} -.is-large.modal-close, -.is-large.delete { - height: 32px; - max-height: 32px; - max-width: 32px; - min-height: 32px; - min-width: 32px; - width: 32px; -} -.modal-background, -.modal { - inset: 0; - position: absolute; -} -.navbar-burger { - -moz-appearance: none; - -webkit-appearance: none; - appearance: none; - background: none; - border: none; - color: currentColor; - font-family: inherit; - font-size: 1em; - margin: 0; - padding: 0; -} -html, -body, -p, -ol, -ul, -li, -dd, -pre, -h4 { - margin: 0; - padding: 0; -} -h4 { - font-size: 100%; - font-weight: 400; -} -ul { - list-style: none; -} -button, -input { - margin: 0; -} -html { - box-sizing: border-box; -} -*, -*:before, -*:after { - box-sizing: inherit; -} -img { - height: auto; - max-width: 100%; -} -table { - border-collapse: collapse; - border-spacing: 0; -} -td, -th { - padding: 0; -} -td:not([align]), -th:not([align]) { - text-align: inherit; -} -html { - background-color: #f8f8f8; - font-size: 16px; - -moz-osx-font-smoothing: grayscale; - -webkit-font-smoothing: antialiased; - min-width: 300px; - overflow-x: hidden; - overflow-y: scroll; - text-rendering: optimizeLegibility; - text-size-adjust: 100%; -} -article, -aside, -footer, -header, -section { - display: block; -} -body, -button, -input { - font-family: Nunito, sans-serif; -} -code, -pre { - -moz-osx-font-smoothing: auto; - -webkit-font-smoothing: auto; - font-family: monospace; -} -body { - color: #4a4a4a; - font-size: 1em; - font-weight: 400; - line-height: 1.5; -} -a { - color: #485fc7; - cursor: pointer; - text-decoration: none; -} -a:hover { - color: #363636; -} -code { - background-color: #f5f5f5; - color: #da1039; - font-size: 0.875em; - font-weight: 400; - padding: 0.25em 0.5em; -} -img { - height: auto; - max-width: 100%; -} -span { - font-style: inherit; - font-weight: inherit; -} -pre { - -webkit-overflow-scrolling: touch; - background-color: #f5f5f5; - color: #4a4a4a; - font-size: 0.875em; - overflow-x: auto; - padding: 1.25rem 1.5rem; - white-space: pre; - word-wrap: normal; -} -pre code { - background-color: transparent; - color: currentColor; - font-size: 1em; - padding: 0; -} -table td, -table th { - vertical-align: top; -} -table td:not([align]), -table th:not([align]) { - text-align: inherit; -} -table th { - color: #363636; -} -@keyframes spinAround { - 0% { - transform: rotate(0); - } - to { - transform: rotate(359deg); - } -} -.button { - background-color: #fff; - border-color: #dbdbdb; - border-width: 1px; - color: #363636; - cursor: pointer; - justify-content: center; - padding-bottom: calc(0.5em - 1px); - padding-left: 1em; - padding-right: 1em; - padding-top: calc(0.5em - 1px); - text-align: center; - white-space: nowrap; -} -.button .icon, -.button .icon.is-small, -.button .icon.is-large { - height: 1.5em; - width: 1.5em; -} -.button .icon:first-child:not(:last-child) { - margin-left: calc(-0.5em - 1px); - margin-right: 0.25em; -} -.button .icon:last-child:not(:first-child) { - margin-left: 0.25em; - margin-right: calc(-0.5em - 1px); -} -.button .icon:first-child:last-child { - margin-left: calc(-0.5em - 1px); - margin-right: calc(-0.5em - 1px); -} -.button:hover { - border-color: #b5b5b5; - color: #363636; -} -.button:focus { - border-color: #485fc7; - color: #363636; -} -.button:focus:not(:active) { - box-shadow: 0 0 0 0.125em #485fc740; -} -.button:active, -.button.is-active { - border-color: #4a4a4a; - color: #363636; -} -.button.is-dark { - background-color: #363636; - border-color: transparent; - color: #fff; -} -.button.is-dark:hover { - background-color: #2f2f2f; - border-color: transparent; - color: #fff; -} -.button.is-dark:focus { - border-color: transparent; - color: #fff; -} -.button.is-dark:focus:not(:active) { - box-shadow: 0 0 0 0.125em #36363640; -} -.button.is-dark:active, -.button.is-dark.is-active { - background-color: #292929; - border-color: transparent; - color: #fff; -} -.button.is-dark[disabled] { - background-color: #363636; - border-color: #363636; - box-shadow: none; -} -.button.is-link { - background-color: #485fc7; - border-color: transparent; - color: #fff; -} -.button.is-link:hover { - background-color: #3e56c4; - border-color: transparent; - color: #fff; -} -.button.is-link:focus { - border-color: transparent; - color: #fff; -} -.button.is-link:focus:not(:active) { - box-shadow: 0 0 0 0.125em #485fc740; -} -.button.is-link:active, -.button.is-link.is-active { - background-color: #3a51bb; - border-color: transparent; - color: #fff; -} -.button.is-link[disabled] { - background-color: #485fc7; - border-color: #485fc7; - box-shadow: none; -} -.button.is-info { - background-color: #3e8ed0; - border-color: transparent; - color: #fff; -} -.button.is-info:hover { - background-color: #3488ce; - border-color: transparent; - color: #fff; -} -.button.is-info:focus { - border-color: transparent; - color: #fff; -} -.button.is-info:focus:not(:active) { - box-shadow: 0 0 0 0.125em #3e8ed040; -} -.button.is-info:active, -.button.is-info.is-active { - background-color: #3082c5; - border-color: transparent; - color: #fff; -} -.button.is-info[disabled] { - background-color: #3e8ed0; - border-color: #3e8ed0; - box-shadow: none; -} -.button.is-success { - background-color: #48c78e; - border-color: transparent; - color: #fff; -} -.button.is-success:hover { - background-color: #3ec487; - border-color: transparent; - color: #fff; -} -.button.is-success:focus { - border-color: transparent; - color: #fff; -} -.button.is-success:focus:not(:active) { - box-shadow: 0 0 0 0.125em #48c78e40; -} -.button.is-success:active, -.button.is-success.is-active { - background-color: #3abb81; - border-color: transparent; - color: #fff; -} -.button.is-success[disabled] { - background-color: #48c78e; - border-color: #48c78e; - box-shadow: none; -} -.button.is-warning { - background-color: #ffe08a; - border-color: transparent; - color: #000000b3; -} -.button.is-warning:hover { - background-color: #ffdc7d; - border-color: transparent; - color: #000000b3; -} -.button.is-warning:focus { - border-color: transparent; - color: #000000b3; -} -.button.is-warning:focus:not(:active) { - box-shadow: 0 0 0 0.125em #ffe08a40; -} -.button.is-warning:active, -.button.is-warning.is-active { - background-color: #ffd970; - border-color: transparent; - color: #000000b3; -} -.button.is-warning[disabled] { - background-color: #ffe08a; - border-color: #ffe08a; - box-shadow: none; -} -.button.is-danger { - background-color: #f14668; - border-color: transparent; - color: #fff; -} -.button.is-danger:hover { - background-color: #f03a5f; - border-color: transparent; - color: #fff; -} -.button.is-danger:focus { - border-color: transparent; - color: #fff; -} -.button.is-danger:focus:not(:active) { - box-shadow: 0 0 0 0.125em #f1466840; -} -.button.is-danger:active, -.button.is-danger.is-active { - background-color: #ef2e55; - border-color: transparent; - color: #fff; -} -.button.is-danger[disabled] { - background-color: #f14668; - border-color: #f14668; - box-shadow: none; -} -.button.is-small { - font-size: 0.75rem; -} -.button.is-small:not(.is-rounded) { - border-radius: 2px; -} -.button.is-normal { - font-size: 1rem; -} -.button.is-large { - font-size: 1.5rem; -} -.button[disabled] { - background-color: #fff; - border-color: #dbdbdb; - box-shadow: none; - opacity: 0.5; -} -.button.is-fullwidth { - display: flex; - width: 100%; -} -.buttons { - align-items: center; - display: flex; - flex-wrap: wrap; - justify-content: flex-start; -} -.buttons .button { - margin-bottom: 0.5rem; -} -.buttons .button:not(:last-child):not(.is-fullwidth) { - margin-right: 0.5rem; -} -.buttons:last-child { - margin-bottom: -0.5rem; -} -.buttons:not(:last-child) { - margin-bottom: 1rem; -} -.buttons.has-addons .button:not(:first-child) { - border-bottom-left-radius: 0; - border-top-left-radius: 0; -} -.buttons.has-addons .button:not(:last-child) { - border-bottom-right-radius: 0; - border-top-right-radius: 0; - margin-right: -1px; -} -.buttons.has-addons .button:last-child { - margin-right: 0; -} -.buttons.has-addons .button:hover { - z-index: 2; -} -.buttons.has-addons .button:focus, -.buttons.has-addons .button:active, -.buttons.has-addons .button.is-active { - z-index: 3; -} -.buttons.has-addons .button:focus:hover, -.buttons.has-addons .button:active:hover, -.buttons.has-addons .button.is-active:hover { - z-index: 4; -} -.buttons.has-addons .button.is-expanded { - flex-grow: 1; - flex-shrink: 1; -} -.buttons.is-centered { - justify-content: center; -} -.buttons.is-centered:not(.has-addons) .button:not(.is-fullwidth) { - margin-left: 0.25rem; - margin-right: 0.25rem; -} -.buttons.is-right { - justify-content: flex-end; -} -.buttons.is-right:not(.has-addons) .button:not(.is-fullwidth) { - margin-left: 0.25rem; - margin-right: 0.25rem; -} -.content li + li { - margin-top: 0.25em; -} -.content p:not(:last-child), -.content ol:not(:last-child), -.content ul:not(:last-child), -.content pre:not(:last-child), -.content table:not(:last-child) { - margin-bottom: 1em; -} -.content h4 { - color: #363636; - font-weight: 600; - line-height: 1.125; -} -.content h4 { - font-size: 1.25em; - margin-bottom: 0.8em; -} -.content ol { - list-style-position: outside; - margin-left: 2em; - margin-top: 1em; -} -.content ol:not([type]) { - list-style-type: decimal; -} -.content ul { - list-style: disc outside; - margin-left: 2em; - margin-top: 1em; -} -.content ul ul { - list-style-type: circle; - margin-top: 0.5em; -} -.content ul ul ul { - list-style-type: square; -} -.content dd { - margin-left: 2em; -} -.content pre { - -webkit-overflow-scrolling: touch; - overflow-x: auto; - padding: 1.25em 1.5em; - white-space: pre; - word-wrap: normal; -} -.content table { - width: 100%; -} -.content table td, -.content table th { - border: 1px solid hsl(0deg, 0%, 86%); - border-width: 0 0 1px; - padding: 0.5em 0.75em; - vertical-align: top; -} -.content table th { - color: #363636; -} -.content table th:not([align]) { - text-align: inherit; -} -.content table thead td, -.content table thead th { - border-width: 0 0 2px; - color: #363636; -} -.content table tbody tr:last-child td, -.content table tbody tr:last-child th { - border-bottom-width: 0; -} -.content.is-small { - font-size: 0.75rem; -} -.content.is-normal { - font-size: 1rem; -} -.content.is-large { - font-size: 1.5rem; -} -.icon { - align-items: center; - display: inline-flex; - justify-content: center; - height: 1.5rem; - width: 1.5rem; -} -.icon.is-small { - height: 1rem; - width: 1rem; -} -.icon.is-large { - height: 3rem; - width: 3rem; -} -.image { - display: block; - position: relative; -} -.image img { - display: block; - height: auto; - width: 100%; -} -.image.is-fullwidth { - width: 100%; -} -.notification { - background-color: #f5f5f5; - border-radius: 4px; - position: relative; - padding: 1.25rem 2.5rem 1.25rem 1.5rem; -} -.notification a:not(.button):not(.dropdown-item) { - color: currentColor; - text-decoration: underline; -} -.notification code, -.notification pre { - background: #fff; -} -.notification pre code { - background: transparent; -} -.notification > .delete { - right: 0.5rem; - position: absolute; - top: 0.5rem; -} -.notification .title, -.notification .content { - color: currentColor; -} -.notification.is-dark { - background-color: #363636; - color: #fff; -} -.notification.is-link { - background-color: #485fc7; - color: #fff; -} -.notification.is-info { - background-color: #3e8ed0; - color: #fff; -} -.notification.is-success { - background-color: #48c78e; - color: #fff; -} -.notification.is-warning { - background-color: #ffe08a; - color: #000000b3; -} -.notification.is-danger { - background-color: #f14668; - color: #fff; -} -.progress { - -moz-appearance: none; - -webkit-appearance: none; - border: none; - border-radius: 9999px; - display: block; - height: 1rem; - overflow: hidden; - padding: 0; - width: 100%; -} -.progress::-webkit-progress-bar { - background-color: #dbdbdb; -} -.progress::-webkit-progress-value { - background-color: #4a4a4a; -} -.progress::-moz-progress-bar { - background-color: #4a4a4a; -} -.progress::-ms-fill { - background-color: #4a4a4a; - border: none; -} -.progress.is-dark::-webkit-progress-value { - background-color: #363636; -} -.progress.is-dark::-moz-progress-bar { - background-color: #363636; -} -.progress.is-dark::-ms-fill { - background-color: #363636; -} -.progress.is-dark:indeterminate { - background-image: linear-gradient(to right, #363636 30%, #dbdbdb 30%); -} -.progress.is-link::-webkit-progress-value { - background-color: #485fc7; -} -.progress.is-link::-moz-progress-bar { - background-color: #485fc7; -} -.progress.is-link::-ms-fill { - background-color: #485fc7; -} -.progress.is-link:indeterminate { - background-image: linear-gradient(to right, #485fc7 30%, #dbdbdb 30%); -} -.progress.is-info::-webkit-progress-value { - background-color: #3e8ed0; -} -.progress.is-info::-moz-progress-bar { - background-color: #3e8ed0; -} -.progress.is-info::-ms-fill { - background-color: #3e8ed0; -} -.progress.is-info:indeterminate { - background-image: linear-gradient(to right, #3e8ed0 30%, #dbdbdb 30%); -} -.progress.is-success::-webkit-progress-value { - background-color: #48c78e; -} -.progress.is-success::-moz-progress-bar { - background-color: #48c78e; -} -.progress.is-success::-ms-fill { - background-color: #48c78e; -} -.progress.is-success:indeterminate { - background-image: linear-gradient(to right, #48c78e 30%, #dbdbdb 30%); -} -.progress.is-warning::-webkit-progress-value { - background-color: #ffe08a; -} -.progress.is-warning::-moz-progress-bar { - background-color: #ffe08a; -} -.progress.is-warning::-ms-fill { - background-color: #ffe08a; -} -.progress.is-warning:indeterminate { - background-image: linear-gradient(to right, #ffe08a 30%, #dbdbdb 30%); -} -.progress.is-danger::-webkit-progress-value { - background-color: #f14668; -} -.progress.is-danger::-moz-progress-bar { - background-color: #f14668; -} -.progress.is-danger::-ms-fill { - background-color: #f14668; -} -.progress.is-danger:indeterminate { - background-image: linear-gradient(to right, #f14668 30%, #dbdbdb 30%); -} -.progress:indeterminate { - animation-duration: 1.5s; - animation-iteration-count: infinite; - animation-name: moveIndeterminate; - animation-timing-function: linear; - background-color: #dbdbdb; - background-image: linear-gradient(to right, #4a4a4a 30%, #dbdbdb 30%); - background-position: top left; - background-repeat: no-repeat; - background-size: 150% 150%; -} -.progress:indeterminate::-webkit-progress-bar { - background-color: transparent; -} -.progress:indeterminate::-moz-progress-bar { - background-color: transparent; -} -.progress:indeterminate::-ms-fill { - animation-name: none; -} -.progress.is-small { - height: 0.75rem; -} -.progress.is-large { - height: 1.5rem; -} -@keyframes moveIndeterminate { - 0% { - background-position: 200% 0; - } - to { - background-position: -200% 0; - } -} -.table { - background-color: #fff; - color: #363636; -} -.table td, -.table th { - border: 1px solid hsl(0deg, 0%, 98%); - border-width: 0 0 1px; - padding: 0.5em 0.75em; - vertical-align: top; -} -.table td.is-dark, -.table th.is-dark { - background-color: #363636; - border-color: #363636; - color: #fff; -} -.table td.is-link, -.table th.is-link { - background-color: #485fc7; - border-color: #485fc7; - color: #fff; -} -.table td.is-info, -.table th.is-info { - background-color: #3e8ed0; - border-color: #3e8ed0; - color: #fff; -} -.table td.is-success, -.table th.is-success { - background-color: #48c78e; - border-color: #48c78e; - color: #fff; -} -.table td.is-warning, -.table th.is-warning { - background-color: #ffe08a; - border-color: #ffe08a; - color: #000000b3; -} -.table td.is-danger, -.table th.is-danger { - background-color: #f14668; - border-color: #f14668; - color: #fff; -} -.table td.is-vcentered, -.table th.is-vcentered { - vertical-align: middle; -} -.table th { - color: #363636; -} -.table th:not([align]) { - text-align: left; -} -.table thead { - background-color: transparent; -} -.table thead td, -.table thead th { - border-width: 0 0 2px; - color: #363636; -} -.table tbody { - background-color: transparent; -} -.table tbody tr:last-child td, -.table tbody tr:last-child th { - border-bottom-width: 0; -} -.table.is-fullwidth { - width: 100%; -} -.table.is-hoverable tbody tr:not(.is-selected):hover { - background-color: #ccc; -} -.table.is-hoverable.is-striped tbody tr:not(.is-selected):hover { - background-color: #ccc; -} -.table.is-hoverable.is-striped tbody tr:not(.is-selected):hover:nth-child(2n) { - background-color: #f5f5f5; -} -.table.is-striped tbody tr:not(.is-selected):nth-child(2n) { - background-color: #fafafa; -} -.table-container { - -webkit-overflow-scrolling: touch; - overflow: auto; - overflow-y: hidden; - max-width: 100%; -} -.title { - word-break: break-word; -} -.title span { - font-weight: inherit; -} -.title { - color: #363636; - font-size: 2rem; - font-weight: 600; - line-height: 1.125; -} -.number { - align-items: center; - background-color: #f5f5f5; - border-radius: 9999px; - display: inline-flex; - font-size: 1.25rem; - height: 2em; - justify-content: center; - margin-right: 1.5rem; - min-width: 2.5em; - padding: 0.25rem 0.5rem; - text-align: center; - vertical-align: top; -} -.input { - background-color: #fff; - border-color: #dbdbdb; - border-radius: 4px; - color: #363636; -} -.input::-moz-placeholder { - color: #3636364d; -} -.input::-webkit-input-placeholder { - color: #3636364d; -} -.input:-moz-placeholder { - color: #3636364d; -} -.input:-ms-input-placeholder { - color: #3636364d; -} -.input:hover { - border-color: #b5b5b5; -} -.input:focus, -.input:active, -.is-active.input { - border-color: #485fc7; - box-shadow: 0 0 0 0.125em #485fc740; -} -[disabled].input { - background-color: #f5f5f5; - border-color: #dbdbdb; - box-shadow: none; - color: #7a7a7a; -} -[disabled].input::-moz-placeholder { - color: #7a7a7a4d; -} -[disabled].input::-webkit-input-placeholder { - color: #7a7a7a4d; -} -[disabled].input:-moz-placeholder { - color: #7a7a7a4d; -} -[disabled].input:-ms-input-placeholder { - color: #7a7a7a4d; -} -.input { - box-shadow: inset 0 0.0625em 0.125em #0a0a0a0d; - max-width: 100%; - width: 100%; -} -.is-dark.input { - border-color: #363636; -} -.is-dark.input:focus, -.is-dark.input:active, -.is-dark.is-active.input { - box-shadow: 0 0 0 0.125em #36363640; -} -.is-link.input { - border-color: #485fc7; -} -.is-link.input:focus, -.is-link.input:active, -.is-link.is-active.input { - box-shadow: 0 0 0 0.125em #485fc740; -} -.is-info.input { - border-color: #3e8ed0; -} -.is-info.input:focus, -.is-info.input:active, -.is-info.is-active.input { - box-shadow: 0 0 0 0.125em #3e8ed040; -} -.is-success.input { - border-color: #48c78e; -} -.is-success.input:focus, -.is-success.input:active, -.is-success.is-active.input { - box-shadow: 0 0 0 0.125em #48c78e40; -} -.is-warning.input { - border-color: #ffe08a; -} -.is-warning.input:focus, -.is-warning.input:active, -.is-warning.is-active.input { - box-shadow: 0 0 0 0.125em #ffe08a40; -} -.is-danger.input { - border-color: #f14668; -} -.is-danger.input:focus, -.is-danger.input:active, -.is-danger.is-active.input { - box-shadow: 0 0 0 0.125em #f1466840; -} -.is-small.input { - border-radius: 2px; - font-size: 0.75rem; -} -.is-large.input { - font-size: 1.5rem; -} -.is-fullwidth.input { - display: block; - width: 100%; -} -.label { - color: #363636; - display: block; - font-size: 1rem; - font-weight: 700; -} -.label:not(:last-child) { - margin-bottom: 0.5em; -} -.label.is-small { - font-size: 0.75rem; -} -.label.is-large { - font-size: 1.5rem; -} -.field:not(:last-child) { - margin-bottom: 0.75rem; -} -.field.has-addons { - display: flex; - justify-content: flex-start; -} -.field.has-addons .control:not(:last-child) { - margin-right: -1px; -} -.field.has-addons .control:not(:first-child):not(:last-child) .button, -.field.has-addons .control:not(:first-child):not(:last-child) .input { - border-radius: 0; -} -.field.has-addons .control:first-child:not(:only-child) .button, -.field.has-addons .control:first-child:not(:only-child) .input { - border-bottom-right-radius: 0; - border-top-right-radius: 0; -} -.field.has-addons .control:last-child:not(:only-child) .button, -.field.has-addons .control:last-child:not(:only-child) .input { - border-bottom-left-radius: 0; - border-top-left-radius: 0; -} -.field.has-addons .control .button:not([disabled]):hover, -.field.has-addons .control .input:not([disabled]):hover { - z-index: 2; -} -.field.has-addons .control .button:not([disabled]):focus, -.field.has-addons .control .button:not([disabled]):active, -.field.has-addons .control .button:not([disabled]).is-active, -.field.has-addons .control .input:not([disabled]):focus, -.field.has-addons .control .input:not([disabled]):active, -.field.has-addons .control .input:not([disabled]).is-active { - z-index: 3; -} -.field.has-addons .control .button:not([disabled]):focus:hover, -.field.has-addons .control .button:not([disabled]):active:hover, -.field.has-addons .control .button:not([disabled]).is-active:hover, -.field.has-addons .control .input:not([disabled]):focus:hover, -.field.has-addons .control .input:not([disabled]):active:hover, -.field.has-addons .control .input:not([disabled]).is-active:hover { - z-index: 4; -} -.field.has-addons .control.is-expanded { - flex-grow: 1; - flex-shrink: 1; -} -@media screen and (min-width: 769px), print { - .field.is-horizontal { - display: flex; - } -} -.field-label .label { - font-size: inherit; -} -@media screen and (max-width: 768px) { - .field-label { - margin-bottom: 0.5rem; - } -} -@media screen and (min-width: 769px), print { - .field-label { - flex-basis: 0; - flex-grow: 1; - flex-shrink: 0; - margin-right: 1.5rem; - text-align: right; - } - .field-label.is-small { - font-size: 0.75rem; - padding-top: 0.375em; - } - .field-label.is-normal { - padding-top: 0.375em; - } - .field-label.is-large { - font-size: 1.5rem; - padding-top: 0.375em; - } -} -.field-body .field .field { - margin-bottom: 0; -} -@media screen and (min-width: 769px), print { - .field-body { - display: flex; - flex-basis: 0; - flex-grow: 5; - flex-shrink: 1; - } - .field-body .field { - margin-bottom: 0; - } - .field-body > .field { - flex-shrink: 1; - } - .field-body > .field:not(.is-narrow) { - flex-grow: 1; - } - .field-body > .field:not(:last-child) { - margin-right: 0.75rem; - } -} -.control { - box-sizing: border-box; - clear: both; - font-size: 1rem; - position: relative; - text-align: inherit; -} -.card { - background-color: #fff; - border-radius: 0.25rem; - box-shadow: none; - color: #4a4a4a; - max-width: 100%; - position: relative; -} -.card-content:first-child, -.card-header:first-child { - border-top-left-radius: 0.25rem; - border-top-right-radius: 0.25rem; -} -.card-content:last-child, -.card-header:last-child { - border-bottom-left-radius: 0.25rem; - border-bottom-right-radius: 0.25rem; -} -.card-header { - background-color: transparent; - align-items: stretch; - box-shadow: none; - display: flex; -} -.card-header-title { - align-items: center; - color: #363636; - display: flex; - flex-grow: 1; - font-weight: 700; - padding: 0.75rem 1rem; -} -.card-header-title.is-centered { - justify-content: center; -} -.card-header-icon { - -moz-appearance: none; - -webkit-appearance: none; - appearance: none; - background: none; - border: none; - color: currentColor; - font-family: inherit; - font-size: 1em; - margin: 0; - padding: 0; - align-items: center; - cursor: pointer; - display: flex; - justify-content: center; - padding: 0.75rem 1rem; -} -.card-content { - background-color: transparent; - padding: 1.5rem; -} -.dropdown { - display: inline-flex; - position: relative; - vertical-align: top; -} -.dropdown.is-active .dropdown-menu, -.dropdown.is-hoverable:hover .dropdown-menu { - display: block; -} -.dropdown.is-right .dropdown-menu { - left: auto; - right: 0; -} -.dropdown-menu { - display: none; - left: 0; - min-width: 12rem; - padding-top: 4px; - position: absolute; - top: 100%; - z-index: 20; -} -.dropdown-content { - background-color: #fff; - border-radius: 4px; - box-shadow: - 0 0.5em 1em -0.125em #0a0a0a1a, - 0 0 0 1px #0a0a0a05; - padding-bottom: 0.5rem; - padding-top: 0.5rem; -} -.dropdown-item { - color: #4a4a4a; - display: block; - font-size: 0.875rem; - line-height: 1.5; - padding: 0.375rem 1rem; - position: relative; -} -a.dropdown-item, -button.dropdown-item { - padding-right: 3rem; - text-align: inherit; - white-space: nowrap; - width: 100%; -} -a.dropdown-item:hover, -button.dropdown-item:hover { - background-color: #f5f5f5; - color: #0a0a0a; -} -a.dropdown-item.is-active, -button.dropdown-item.is-active { - background-color: #485fc7; - color: #fff; -} -.menu { - font-size: 1rem; -} -.menu.is-small { - font-size: 0.75rem; -} -.menu.is-large { - font-size: 1.5rem; -} -.menu-list { - line-height: 1.25; -} -.menu-list a { - border-radius: 0; - color: #727c8f; - display: block; - padding: 0.5rem 0; -} -.menu-list a:hover { - background-color: #262930; - color: #fff; -} -.menu-list a.is-active { - background-color: #282c33; - color: #fff; -} -.menu-list li ul { - border-left: 1px solid hsl(0deg, 0%, 86%); - margin: 0.75em; - padding-left: 0.75em; -} -.message { - background-color: #f5f5f5; - border-radius: 4px; - font-size: 1rem; -} -.message a:not(.button):not(.tag):not(.dropdown-item) { - color: currentColor; - text-decoration: underline; -} -.message.is-small { - font-size: 0.75rem; -} -.message.is-large { - font-size: 1.5rem; -} -.message.is-dark { - background-color: #fafafa; -} -.message.is-dark .message-header { - background-color: #363636; - color: #fff; -} -.message.is-dark .message-body { - border-color: #363636; -} -.message.is-link { - background-color: #eff1fa; -} -.message.is-link .message-header { - background-color: #485fc7; - color: #fff; -} -.message.is-link .message-body { - border-color: #485fc7; - color: #3850b7; -} -.message.is-info { - background-color: #eff5fb; -} -.message.is-info .message-header { - background-color: #3e8ed0; - color: #fff; -} -.message.is-info .message-body { - border-color: #3e8ed0; - color: #296fa8; -} -.message.is-success { - background-color: #effaf5; -} -.message.is-success .message-header { - background-color: #48c78e; - color: #fff; -} -.message.is-success .message-body { - border-color: #48c78e; - color: #257953; -} -.message.is-warning { - background-color: #fffaeb; -} -.message.is-warning .message-header { - background-color: #ffe08a; - color: #000000b3; -} -.message.is-warning .message-body { - border-color: #ffe08a; - color: #946c00; -} -.message.is-danger { - background-color: #feecf0; -} -.message.is-danger .message-header { - background-color: #f14668; - color: #fff; -} -.message.is-danger .message-body { - border-color: #f14668; - color: #cc0f35; -} -.message-header { - align-items: center; - background-color: #4a4a4a; - border-radius: 4px 4px 0 0; - color: #fff; - display: flex; - font-weight: 700; - justify-content: space-between; - line-height: 1.25; - padding: 0.75em 1em; - position: relative; -} -.message-header .delete { - flex-grow: 0; - flex-shrink: 0; - margin-left: 0.75em; -} -.message-header + .message-body { - border-width: 0; - border-top-left-radius: 0; - border-top-right-radius: 0; -} -.message-body { - border-color: #dbdbdb; - border-radius: 4px; - border-style: solid; - border-width: 0 0 0 4px; - color: #4a4a4a; - padding: 1.25em 1.5em; -} -.message-body code, -.message-body pre { - background-color: #fff; -} -.message-body pre code { - background-color: transparent; -} -.modal { - align-items: center; - display: none; - flex-direction: column; - justify-content: center; - overflow: hidden; - position: fixed; - z-index: 40; -} -.modal.is-active { - display: flex; -} -.modal-background { - background-color: #0a0a0adb; -} -.modal-card { - margin: 0 20px; - max-height: calc(100vh - 160px); - overflow: auto; - position: relative; - width: 100%; -} -@media screen and (min-width: 769px) { - .modal-card { - margin: 0 auto; - max-height: calc(100vh - 40px); - width: 640px; - } -} -.modal-close { - background: none; - height: 40px; - position: fixed; - right: 20px; - top: 20px; - width: 40px; -} -.modal-card { - display: flex; - flex-direction: column; - max-height: calc(100vh - 40px); - overflow: hidden; - -ms-overflow-y: visible; -} -.modal-card-head, -.modal-card-foot { - align-items: center; - background-color: #f5f5f5; - display: flex; - flex-shrink: 0; - justify-content: flex-start; - padding: 20px; - position: relative; -} -.modal-card-head { - border-bottom: 1px solid hsl(0deg, 0%, 96%); - border-top-left-radius: 6px; - border-top-right-radius: 6px; -} -.modal-card-title { - color: #363636; - flex-grow: 1; - flex-shrink: 0; - font-size: 1rem; - line-height: 1; -} -.modal-card-foot { - border-bottom-left-radius: 6px; - border-bottom-right-radius: 6px; - border-top: 0; -} -.modal-card-foot .button:not(:last-child) { - margin-right: 0.5em; -} -.modal-card-body { - -webkit-overflow-scrolling: touch; - background-color: #fff; - flex-grow: 1; - flex-shrink: 1; - overflow: auto; - padding: 1.5rem 20px; -} -.navbar { - background-color: #fff; - min-height: 3.25rem; - position: relative; - z-index: 30; -} -.navbar.is-dark { - background-color: #363636; - color: #fff; -} -.navbar.is-dark .navbar-brand > .navbar-item { - color: #fff; -} -.navbar.is-dark .navbar-brand > a.navbar-item:focus, -.navbar.is-dark .navbar-brand > a.navbar-item:hover, -.navbar.is-dark .navbar-brand > a.navbar-item.is-active { - background-color: #292929; - color: #fff; -} -.navbar.is-dark .navbar-burger { - color: #fff; -} -@media screen and (min-width: 1024px) { - .navbar.is-dark .navbar-start > .navbar-item, - .navbar.is-dark .navbar-end > .navbar-item { - color: #fff; - } - .navbar.is-dark .navbar-start > a.navbar-item:focus, - .navbar.is-dark .navbar-start > a.navbar-item:hover, - .navbar.is-dark .navbar-start > a.navbar-item.is-active, - .navbar.is-dark .navbar-end > a.navbar-item:focus, - .navbar.is-dark .navbar-end > a.navbar-item:hover, - .navbar.is-dark .navbar-end > a.navbar-item.is-active { - background-color: #292929; - color: #fff; - } -} -.navbar.is-link { - background-color: #485fc7; - color: #fff; -} -.navbar.is-link .navbar-brand > .navbar-item { - color: #fff; -} -.navbar.is-link .navbar-brand > a.navbar-item:focus, -.navbar.is-link .navbar-brand > a.navbar-item:hover, -.navbar.is-link .navbar-brand > a.navbar-item.is-active { - background-color: #3a51bb; - color: #fff; -} -.navbar.is-link .navbar-burger { - color: #fff; -} -@media screen and (min-width: 1024px) { - .navbar.is-link .navbar-start > .navbar-item, - .navbar.is-link .navbar-end > .navbar-item { - color: #fff; - } - .navbar.is-link .navbar-start > a.navbar-item:focus, - .navbar.is-link .navbar-start > a.navbar-item:hover, - .navbar.is-link .navbar-start > a.navbar-item.is-active, - .navbar.is-link .navbar-end > a.navbar-item:focus, - .navbar.is-link .navbar-end > a.navbar-item:hover, - .navbar.is-link .navbar-end > a.navbar-item.is-active { - background-color: #3a51bb; - color: #fff; - } -} -.navbar.is-info { - background-color: #3e8ed0; - color: #fff; -} -.navbar.is-info .navbar-brand > .navbar-item { - color: #fff; -} -.navbar.is-info .navbar-brand > a.navbar-item:focus, -.navbar.is-info .navbar-brand > a.navbar-item:hover, -.navbar.is-info .navbar-brand > a.navbar-item.is-active { - background-color: #3082c5; - color: #fff; -} -.navbar.is-info .navbar-burger { - color: #fff; -} -@media screen and (min-width: 1024px) { - .navbar.is-info .navbar-start > .navbar-item, - .navbar.is-info .navbar-end > .navbar-item { - color: #fff; - } - .navbar.is-info .navbar-start > a.navbar-item:focus, - .navbar.is-info .navbar-start > a.navbar-item:hover, - .navbar.is-info .navbar-start > a.navbar-item.is-active, - .navbar.is-info .navbar-end > a.navbar-item:focus, - .navbar.is-info .navbar-end > a.navbar-item:hover, - .navbar.is-info .navbar-end > a.navbar-item.is-active { - background-color: #3082c5; - color: #fff; - } -} -.navbar.is-success { - background-color: #48c78e; - color: #fff; -} -.navbar.is-success .navbar-brand > .navbar-item { - color: #fff; -} -.navbar.is-success .navbar-brand > a.navbar-item:focus, -.navbar.is-success .navbar-brand > a.navbar-item:hover, -.navbar.is-success .navbar-brand > a.navbar-item.is-active { - background-color: #3abb81; - color: #fff; -} -.navbar.is-success .navbar-burger { - color: #fff; -} -@media screen and (min-width: 1024px) { - .navbar.is-success .navbar-start > .navbar-item, - .navbar.is-success .navbar-end > .navbar-item { - color: #fff; - } - .navbar.is-success .navbar-start > a.navbar-item:focus, - .navbar.is-success .navbar-start > a.navbar-item:hover, - .navbar.is-success .navbar-start > a.navbar-item.is-active, - .navbar.is-success .navbar-end > a.navbar-item:focus, - .navbar.is-success .navbar-end > a.navbar-item:hover, - .navbar.is-success .navbar-end > a.navbar-item.is-active { - background-color: #3abb81; - color: #fff; - } -} -.navbar.is-warning { - background-color: #ffe08a; - color: #000000b3; -} -.navbar.is-warning .navbar-brand > .navbar-item { - color: #000000b3; -} -.navbar.is-warning .navbar-brand > a.navbar-item:focus, -.navbar.is-warning .navbar-brand > a.navbar-item:hover, -.navbar.is-warning .navbar-brand > a.navbar-item.is-active { - background-color: #ffd970; - color: #000000b3; -} -.navbar.is-warning .navbar-burger { - color: #000000b3; -} -@media screen and (min-width: 1024px) { - .navbar.is-warning .navbar-start > .navbar-item, - .navbar.is-warning .navbar-end > .navbar-item { - color: #000000b3; - } - .navbar.is-warning .navbar-start > a.navbar-item:focus, - .navbar.is-warning .navbar-start > a.navbar-item:hover, - .navbar.is-warning .navbar-start > a.navbar-item.is-active, - .navbar.is-warning .navbar-end > a.navbar-item:focus, - .navbar.is-warning .navbar-end > a.navbar-item:hover, - .navbar.is-warning .navbar-end > a.navbar-item.is-active { - background-color: #ffd970; - color: #000000b3; - } -} -.navbar.is-danger { - background-color: #f14668; - color: #fff; -} -.navbar.is-danger .navbar-brand > .navbar-item { - color: #fff; -} -.navbar.is-danger .navbar-brand > a.navbar-item:focus, -.navbar.is-danger .navbar-brand > a.navbar-item:hover, -.navbar.is-danger .navbar-brand > a.navbar-item.is-active { - background-color: #ef2e55; - color: #fff; -} -.navbar.is-danger .navbar-burger { - color: #fff; -} -@media screen and (min-width: 1024px) { - .navbar.is-danger .navbar-start > .navbar-item, - .navbar.is-danger .navbar-end > .navbar-item { - color: #fff; - } - .navbar.is-danger .navbar-start > a.navbar-item:focus, - .navbar.is-danger .navbar-start > a.navbar-item:hover, - .navbar.is-danger .navbar-start > a.navbar-item.is-active, - .navbar.is-danger .navbar-end > a.navbar-item:focus, - .navbar.is-danger .navbar-end > a.navbar-item:hover, - .navbar.is-danger .navbar-end > a.navbar-item.is-active { - background-color: #ef2e55; - color: #fff; - } -} -.navbar.is-fixed-top { - left: 0; - position: fixed; - right: 0; - z-index: 30; -} -.navbar.is-fixed-top { - top: 0; -} -html.has-navbar-fixed-top, -body.has-navbar-fixed-top { - padding-top: 3.25rem; -} -.navbar-brand { - align-items: stretch; - display: flex; - flex-shrink: 0; - min-height: 3.25rem; -} -.navbar-brand a.navbar-item:focus, -.navbar-brand a.navbar-item:hover { - background-color: transparent; -} -.navbar-burger { - color: #4a4a4a; - -moz-appearance: none; - -webkit-appearance: none; - appearance: none; - background: none; - border: none; - cursor: pointer; - display: block; - height: 3.25rem; - position: relative; - width: 3.25rem; - margin-left: auto; -} -.navbar-burger span { - background-color: currentColor; - display: block; - height: 1px; - left: calc(50% - 8px); - position: absolute; - transform-origin: center; - transition-duration: 86ms; - transition-property: background-color, opacity, transform; - transition-timing-function: ease-out; - width: 16px; -} -.navbar-burger span:nth-child(1) { - top: calc(50% - 6px); -} -.navbar-burger span:nth-child(2) { - top: calc(50% - 1px); -} -.navbar-burger span:nth-child(3) { - top: calc(50% + 4px); -} -.navbar-burger:hover { - background-color: #0000000d; -} -.navbar-burger.is-active span:nth-child(1) { - transform: translateY(5px) rotate(45deg); -} -.navbar-burger.is-active span:nth-child(2) { - opacity: 0; -} -.navbar-burger.is-active span:nth-child(3) { - transform: translateY(-5px) rotate(-45deg); -} -.navbar-menu { - display: none; -} -.navbar-item { - color: #4a4a4a; - display: block; - line-height: 1.5; - padding: 0.5rem 0.75rem; - position: relative; -} -.navbar-item .icon:only-child { - margin-left: -0.25rem; - margin-right: -0.25rem; -} -a.navbar-item { - cursor: pointer; -} -a.navbar-item:focus, -a.navbar-item:focus-within, -a.navbar-item:hover, -a.navbar-item.is-active { - background-color: #fafafa; - color: #485fc7; -} -.navbar-item { - flex-grow: 0; - flex-shrink: 0; -} -.navbar-item img { - max-height: 1.75rem; -} -.navbar-item.is-expanded { - flex-grow: 1; - flex-shrink: 1; -} -@media screen and (max-width: 1023px) { - .navbar-brand .navbar-item { - align-items: center; - display: flex; - } - .navbar-menu { - background-color: #fff; - box-shadow: 0 8px 16px #0a0a0a1a; - padding: 0.5rem 0; - } - .navbar-menu.is-active { - display: block; - } - .navbar.is-fixed-top .navbar-menu { - -webkit-overflow-scrolling: touch; - max-height: calc(100vh - 3.25rem); - overflow: auto; - } -} -@media screen and (min-width: 1024px) { - .navbar, - .navbar-menu, - .navbar-start, - .navbar-end { - align-items: stretch; - display: flex; - } - .navbar { - min-height: 3.25rem; - } - .navbar-burger { - display: none; - } - .navbar-item { - align-items: center; - display: flex; - } - .navbar-menu { - flex-grow: 1; - flex-shrink: 0; - } - .navbar-start { - justify-content: flex-start; - margin-right: auto; - } - .navbar-end { - justify-content: flex-end; - margin-left: auto; - } - a.navbar-item.is-active { - color: #0a0a0a; - } - a.navbar-item.is-active:not(:focus):not(:hover) { - background-color: transparent; - } -} -.column { - display: block; - flex-basis: 0; - flex-grow: 1; - flex-shrink: 1; - padding: 0.75rem; -} -@media screen and (min-width: 769px), print { - .column.is-two-thirds { - flex: none; - width: 66.6666%; - } - .column.is-half { - flex: none; - width: 50%; - } - .column.is-four-fifths { - flex: none; - width: 80%; - } - .column.is-12 { - flex: none; - width: 100%; - } -} -.columns { - margin-left: -0.75rem; - margin-right: -0.75rem; - margin-top: -0.75rem; -} -.columns:last-child { - margin-bottom: -0.75rem; -} -.columns:not(:last-child) { - margin-bottom: 0.75rem; -} -.columns.is-centered { - justify-content: center; -} -.columns.is-vcentered { - align-items: center; -} -@media screen and (min-width: 769px), print { - .columns:not(.is-desktop) { - display: flex; - } -} -.has-text-grey { - color: #7a7a7a !important; -} -.is-justify-content-center { - justify-content: center !important; -} -.is-flex-grow-1 { - flex-grow: 1 !important; -} -.is-flex-grow-3 { - flex-grow: 3 !important; -} -.mr-2 { - margin-right: 0.5rem !important; -} -.is-size-7 { - font-size: 0.75rem !important; -} -.has-text-centered { - text-align: center !important; -} -.has-text-right { - text-align: right !important; -} -.has-text-weight-bold { - font-weight: 700 !important; -} -.section { - padding: 3rem 1.5rem; -} -@media screen and (min-width: 1024px) { - .section { - padding: 3rem; - } - .section.is-large { - padding: 18rem 6rem; - } -} -.footer { - background-color: #fff; - padding: 0.495rem 1.5rem; -} -nav.navbar { - box-shadow: 0 1px #181c210a; -} -nav.navbar .navbar-item .control .input { - color: #363636; - border: 0; - box-shadow: none; - background: transparent; -} -nav.navbar .navbar-item .control .input::placeholder { - color: #dbdbdb; -} -@media screen and (max-width: 1023px) { - nav.navbar { - display: flex; - padding-right: 0; - } - nav.navbar .navbar-brand { - flex: 1; - } - nav.navbar .navbar-brand.is-right { - flex: none; - } - nav.navbar .navbar-menu { - position: absolute; - width: 100vw; - padding-top: 0; - top: 3.25rem; - left: 0; - } - nav.navbar .navbar-menu .navbar-item .icon:first-child { - margin-right: 0.75rem; - } -} -@media screen and (min-width: 1024px) { - nav.navbar .navbar-item { - padding-right: 1.125rem; - padding-left: 1.125rem; - } - nav.navbar .navbar-item:not(.is-desktop-icon-only) .icon:first-child { - margin-right: 0.75rem; - } -} -@media screen and (min-width: 1024px) { - html.has-aside-left.has-aside-expanded nav.navbar, - html.has-aside-left.has-aside-expanded body { - padding-left: 14rem; - } - html.has-aside-left aside.is-placed-left { - display: block; - } - aside.aside.is-expanded { - width: 14rem; - } - aside.aside.is-expanded .menu-list .icon { - width: 3rem; - } - aside.aside.is-expanded .menu-list span.menu-item-label { - display: inline-block; +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + body { + @apply min-h-screen bg-gray-50 text-gray-800 antialiased; } - aside.aside.is-expanded .menu-list li.is-active ul { - display: block; + + button, + a, + input, + select { + @apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-taler-brand focus-visible:ring-offset-2; } } -aside.aside { - display: none; - position: fixed; - top: 0; - left: 0; - z-index: 40; - height: 100vh; - padding: 0; - box-shadow: none; - background: #2e323a; -} -aside.aside .aside-tools { - display: flex; - flex-direction: row; - width: 100%; - background-color: #17191e; - color: #fff; - line-height: 3.25rem; - height: 3.25rem; - padding-left: 0.75rem; - flex: 1; -} -aside.aside .aside-tools .icon { - margin-right: 0.75rem; -} -aside.aside .menu-list li ul { - display: none; - border-left: 0; - background-color: #282c33; - padding-left: 0; - margin: 0 0 0.75rem; -} -aside.aside .menu-list li ul li a { - padding: 0.75rem 0 0.75rem 0.75rem; - font-size: 0.95rem; -} -aside.aside .menu-list li ul li a.has-icon { - padding-left: 0; -} -aside.aside .menu-list li ul li a.is-active:not(:hover) { - background: transparent; -} -@media screen and (max-width: 1023px) { - nav.navbar { - transition: - margin-left 0.25s ease-in-out 50ms, - width 0.25s ease-in-out 50ms; - } - aside.aside { - transition: left 0.25s ease-in-out 50ms; - } - html.has-aside-mobile-transition body { - overflow-x: hidden; - } - html.has-aside-mobile-transition body, - html.has-aside-mobile-transition nav.navbar { - width: 100vw; - } - html.has-aside-mobile-transition aside.aside { - width: 15rem; - display: block; - left: -15rem; + +@layer components { + .auditor-card { + @apply rounded-xl border border-gray-200 bg-white shadow-sm; } - html.has-aside-mobile-transition aside.aside .image img { - max-width: 4.95rem; + + .auditor-button { + @apply inline-flex min-h-10 items-center justify-center rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50; } - html.has-aside-mobile-transition aside.aside .menu-list li.is-active ul { - display: block; + + .auditor-button-primary { + @apply border-taler-brand bg-taler-brand text-white hover:bg-blue-800; } - html.has-aside-mobile-transition aside.aside .menu-list a .icon { - width: 3rem; + + .auditor-button-danger { + @apply border-taler-error bg-taler-error text-white hover:bg-red-800; } - html.has-aside-mobile-transition - aside.aside - .menu-list - a - span.menu-item-label { - display: inline-block; + + .auditor-input { + @apply block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 placeholder:text-gray-400; } - div.has-aside-mobile-expanded nav.navbar { - margin-left: 15rem; - width: calc(100vw - 15rem); + + .auditor-table { + @apply min-w-full divide-y divide-gray-200 text-left text-sm; } - div.has-aside-mobile-expanded aside.aside { - left: 0; + + .auditor-table thead { + @apply bg-gray-50 text-xs uppercase tracking-wide text-gray-500; } -} -.card:not(:last-child) { - margin-bottom: 1.5rem; -} -.card { - border-radius: 6px; - border: 1px solid rgba(24, 28, 33, 0.06); -} -.card.has-table .card-content { - padding: 0; -} -.card.has-table .b-table { - border-radius: 6px; - overflow: hidden; -} -.card .card-header { - border-bottom: 1px solid rgba(24, 28, 33, 0.06); -} -table.table thead th { - border-bottom-width: 1px; -} -table.table td .image { - margin: 0 auto; - width: 1.5rem; - height: 1.5rem; -} -.b-table .table { - border: 0; - border-radius: 0; -} -.b-table .table-wrapper { - margin-bottom: 0; -} -@media screen and (max-width: 768px) { - .b-table .table-wrapper.has-mobile-cards tr { - box-shadow: 0 2px 3px #0a0a0a1a; - margin-bottom: 3px !important; + + .auditor-table th { + @apply whitespace-nowrap px-4 py-3 font-semibold; } - .b-table .table-wrapper.has-mobile-cards td.is-actions-cell:before { - display: none; + + .auditor-table td { + @apply px-4 py-3 align-top text-gray-700; } -} -.input { - box-shadow: none; -} -.input:focus, -.input:active { - box-shadow: none !important; -} -section.section.is-main-section { - padding-top: 1.5rem; -} -.modal-card { - width: 80vw; -} -.modal-card-foot { - background-color: #f5f5f5; -} -footer.footer .logo img { - width: auto; - height: 2rem; -} -:root { - --primary-color: #3298dc; - --primary-text-color-dark: rgba(0, 0, 0, 0.87); - --secondary-text-color-dark: rgba(0, 0, 0, 0.57); - --disabled-text-color-dark: rgba(0, 0, 0, 0.13); - --primary-text-color-light: rgba(255, 255, 255, 0.87); - --secondary-text-color-light: rgba(255, 255, 255, 0.57); - --disabled-text-color-light: rgba(255, 255, 255, 0.13); - --font-stack: "Roboto", "Helvetica Neue", Helvetica, Arial, sans-serif; - --primary-card-color: #fff; - --primary-background-color: #f2f2f2; - --box-shadow-lvl-1: - 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24); - --box-shadow-lvl-2: - 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23); - --box-shadow-lvl-3: - 0 10px 20px rgba(0, 0, 0, 0.19), 0 6px 6px rgba(0, 0, 0, 0.23); - --box-shadow-lvl-4: - 0 14px 28px rgba(0, 0, 0, 0.25), 0 10px 10px rgba(0, 0, 0, 0.22); -} -.lds-ring { - display: inline-block; - position: relative; - width: 80px; - height: 80px; -} -.lds-ring div { - box-sizing: border-box; - display: block; - position: absolute; - width: 64px; - height: 64px; - margin: 8px; - border: 8px solid black; - border-radius: 50%; - animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; - border-color: black transparent transparent transparent; -} -.lds-ring div:nth-child(1) { - animation-delay: -0.45s; -} -.lds-ring div:nth-child(2) { - animation-delay: -0.3s; -} -.lds-ring div:nth-child(3) { - animation-delay: -0.15s; -} -@keyframes lds-ring { - 0% { - transform: rotate(0); + + .auditor-table tbody { + @apply divide-y divide-gray-100; } - to { - transform: rotate(360deg); + + .auditor-table tbody tr:nth-child(even) { + @apply bg-gray-50/70; } } -.notification { - background-color: transparent; -} -.right-sticky { - position: sticky; - right: 0; - background-color: #fff; -} -.right-sticky .buttons { - flex-wrap: nowrap; -} -.table.is-striped tbody tr:not(.is-selected):nth-child(2n) .right-sticky { - background-color: #fafafa; -} -tr:hover .right-sticky { - background-color: #ccc; -} -.table.is-striped tbody tr:nth-child(2n):hover .right-sticky { - background-color: #f2f2f2; -} -.column.is-half { - flex: none; - width: 50%; -} -input:read-only { - cursor: initial; -} -[data-tooltip]:before { - max-width: 15rem; - width: max-content; - text-align: left; - transition: opacity 0.1s linear 1s; - white-space: pre-wrap !important; - font-weight: 400; -} -.icon[data-tooltip]:before { - transition: none; - z-index: 5; -} -span[data-tooltip] { - border-bottom: none; -} -div[data-tooltip]:before { - position: absolute; -} -.modal-card-body > p { - padding: 1em; -} -.modal-card-body > p.warning { - background-color: #fffbdd; - border: solid 1px #f2e9bf; -} -/*! bulma.io v0.9.4 | MIT License | github.com/jgthms/bulma */ -/*! minireset.css v0.0.6 | MIT License | github.com/jgthms/minireset.css */ -/*! Bundled license information: - -@creativebulma/bulma-tooltip/dist/bulma-tooltip.min.css: - (*! @creativebulma/bulma-tooltip v1.2.0 | (c) 2020 Gaetan | MIT License | https://github.com/CreativeBulma/bulma-tooltip *) -*/ diff --git a/packages/taler-auditor-webui/tailwind.config.js b/packages/taler-auditor-webui/tailwind.config.js @@ -0,0 +1,21 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./src/**/*.{ts,tsx,html}", + "./node_modules/@gnu-taler/web-util/src/**/*.{ts,tsx,html}", + ], + theme: { + extend: { + colors: { + taler: { + brand: "#0042b3", + primary: "#0042b3", + success: "#337a40", + warning: "#f99c06", + error: "#b3261e", + }, + }, + }, + }, + plugins: [], +}; diff --git a/packages/taler-auditor-webui/tsconfig.json b/packages/taler-auditor-webui/tsconfig.json @@ -2,9 +2,9 @@ "extends": "../../tsconfig.defaults.json", "compilerOptions": { "lib": ["es2023", "dom"], - "jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */, - "jsxFactory": "h" /* Specify the JSX factory function to use when targeting react JSX emit, e.g. React.createElement or h. */, - "jsxFragmentFactory": "Fragment", // https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-0.html#custom-jsx-factories + "types": ["node"], + "jsx": "react-jsx", + "jsxImportSource": "preact", "noEmit": true /* Do not emit outputs. */, "skipLibCheck": true /* Skip type checking of declaration files. */ }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml @@ -304,52 +304,28 @@ importers: '@gnu-taler/web-util': specifier: workspace:* version: link:../web-util - date-fns: - specifier: 2.29.3 - version: 2.29.3 - history: - specifier: 4.10.1 - version: 4.10.1 - jed: - specifier: 1.1.1 - version: 1.1.1 preact: specifier: 10.11.3 version: 10.11.3 - preact-router: - specifier: 3.2.1 - version: 3.2.1(preact@10.11.3) - qrcode-generator: - specifier: 1.4.4 - version: 1.4.4 swr: specifier: 2.2.2 version: 2.2.2(react@18.3.1) + wouter-preact: + specifier: ^3.0.0 + version: 3.10.0(preact@10.11.3) devDependencies: '@gnu-taler/pogen': specifier: workspace:* version: link:../pogen - '@types/chai': - specifier: ^4.3.0 - version: 4.3.3 - '@types/history': - specifier: ^4.7.8 - version: 4.7.11 '@types/node': specifier: ^20.19.41 version: 20.19.41 - chai: - specifier: ^6.2.2 - version: 6.2.2 - dotenv: - specifier: ^8.2.0 - version: 8.6.0 - preact-render-to-string: - specifier: ^5.2.6 - version: 5.2.6(preact@10.11.3) - source-map-support: - specifier: ^0.5.21 - version: 0.5.21 + esbuild: + specifier: ^0.25.0 + version: 0.25.12 + tailwindcss: + specifier: 3.4.17 + version: 3.4.17(ts-node@10.9.1(@types/node@20.19.41)(typescript@7.0.2)) typescript: specifier: 7.0.2 version: 7.0.2 @@ -2226,10 +2202,6 @@ packages: resolution: {integrity: sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==} engines: {node: '>=18'} - dotenv@8.6.0: - resolution: {integrity: sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==} - engines: {node: '>=10'} - dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -2688,9 +2660,6 @@ packages: resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} - history@4.10.1: - resolution: {integrity: sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==} - html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -3554,9 +3523,6 @@ packages: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} - resolve-pathname@3.0.0: - resolution: {integrity: sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==} - resolve@1.22.8: resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} hasBin: true @@ -3838,12 +3804,6 @@ packages: through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} - tiny-invariant@1.3.1: - resolution: {integrity: sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==} - - tiny-warning@1.0.3: - resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} - tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} @@ -3992,9 +3952,6 @@ packages: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} - value-equal@1.0.1: - resolution: {integrity: sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==} - watchpack@2.5.2: resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==} engines: {node: '>=10.13.0'} @@ -5457,8 +5414,6 @@ snapshots: dependencies: type-fest: 4.41.0 - dotenv@8.6.0: {} - dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -6110,15 +6065,6 @@ snapshots: dependencies: function-bind: 1.1.2 - history@4.10.1: - dependencies: - '@babel/runtime': 7.29.2 - loose-envify: 1.4.0 - resolve-pathname: 3.0.0 - tiny-invariant: 1.3.1 - tiny-warning: 1.0.3 - value-equal: 1.0.1 - html-escaper@2.0.2: {} htmlparser2@10.1.0: @@ -6980,8 +6926,6 @@ snapshots: resolve-from@4.0.0: {} - resolve-pathname@3.0.0: {} - resolve@1.22.8: dependencies: is-core-module: 2.16.2 @@ -7361,10 +7305,6 @@ snapshots: through@2.3.8: {} - tiny-invariant@1.3.1: {} - - tiny-warning@1.0.3: {} - tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) @@ -7571,8 +7511,6 @@ snapshots: '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 - value-equal@1.0.1: {} - watchpack@2.5.2: dependencies: graceful-fs: 4.2.11