first commit

This commit is contained in:
Glcoin Core Developer Team 2026-05-26 14:35:16 +02:00
commit c17f4f4426
238 changed files with 25809 additions and 0 deletions

41
.gitignore vendored Normal file
View file

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
dist
/result
# misc
.env
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# yarn
.yarn/*
!.yarn/patches
!.yarn/releases
!.yarn/plugins
!.yarn/sdks
!.yarn/versions
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
# devenv
.devenv*

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Christoph Stenglein
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

170
README.md Normal file
View file

@ -0,0 +1,170 @@
<h1 align="center">RaspiBlesk Web - a responsive Web UI for RaspiBlesk</h1>
![RaspiBlesk Dashboard](preview.png)
In collaboration with [Bitcoin Design](https://bitcoin.design/).
Source code: [forge.618.ch/raspiblesk/raspiblesk-web](https://forge.618.ch/raspiblesk/raspiblesk-web)
Built with [React](https://reactjs.org/) & [Tailwind CSS](https://tailwindcss.com/).
## Translations
If you want to bring the RaspiBlesk WebUI to other languages, please help us translate it via [Weblate](https://hosted.weblate.org/projects/raspiblitz-web/translations/) :)
Big thanks to our translators, which you can find in this [README](src/i18n/README.md).
## Development
### Dependencies
- [Node.js](https://nodejs.org/en/download/)
- [asdf](https://asdf-vm.com/) is supported but not required.
- [Nix](https://nixos.org/download.html) with [Flakes](https://nixos.wiki/wiki/Flakes) enabled (optional, for devenv.sh or NixOS builds)
#### Update npm dependencies
```sh
npm update
```
### Development with devenv.sh
This project supports [devenv.sh](https://devenv.sh/), a developer environment tool built on Nix.
#### Setup
1. Install devenv.sh by following the instructions at https://devenv.sh/getting-started/
2. Run `devenv shell` to enter the development environment
3. Devenv will install the npm dependencies automatically when entering the shell. To change this behaviour, change `javascript.npm.install.enable = true;` to `false` and run `npm install` after entering the shell.
#### Benefits of using devenv.sh
- Consistent development environment across all contributors
- Automatically installs the correct version of Node.js and other dependencies
- Includes development tools like TypeScript, ESLint, and Nix utilities
- Works on any platform that supports Nix (Linux, macOS, WSL)
### Dev workflow
### Simple dev setup
```bash
npm install
cd backend-mock
npm install
cd ..
npm run dev:local
```
The `npm run dev:local` command starts the frontend and the backend mock server.
The backend mock server restarts automatically when you change a file in the `backend-mock` folder.
#### Frontend
```bash
npm install
npm run start
```
#### Backend
For the backend, there currently exist three options:
- Use an existing RaspiBlesk
- Easy to setup, but needs a RaspiBlesk
- Using the provided [Mock backend](#mock-backend)
- Easy to setup, but limited data
- Using [blitz_api](#blitz-api) with Polar
- Some changes needed for local development
- Possibly more data (depending on your ln setup :) )
##### Mock backend
See [backend-mock folder](./backend-mock)
Open another terminal
```sh
cd backend-mock
npm install
npm run start
```
Then go to `http://localhost:3000` and use the password `password`.
### Testing
#### Unit tests
`npm test`
#### E2E tests
Run tests headless:\
`npx playwright test`
Run test with UI:\
`npx playwright test --ui`
##### Blesk API
This guide uses Polar for easier development, but you can also use a real lightning node.
- Download [Polar](https://lightningpolar.com/) and run it.
- Create at least one bitcoin and one lightning node.
- Clone the [blitz_api](https://github.com/fusion44/blitz_api) and install the dependencies.
- In addition, you will need [redis](https://redis.io/) installed for `blitz_api` to work.
- Create a `.env` file (see [.env_sample in blitz_api](https://github.com/fusion44/blitz_api/blob/main/.env_sample)) and copy the bitcoin and ln info into it.
- Important: When definining `shell_script_path` you need to define a directory where a folder called `config.scripts` and a file called `blesk.debug.sh` reside in. Otherwise `blitz_api` may not work (used on the RaspiBlesk for logging)
- Make the following change in `blitz_api`:
- In [main/app/main.py](https://github.com/fusion44/blitz_api/blob/main/app/main.py#L48), change the `prefix_format` from `/v{major}` to `/api/v{major}`.
- Change the `BACKEND_SERVER` value in [vite.config.ts](vite.config.ts) to your local `blitz_api` installation.
Now you can start the `blitz_api` and run `npm run start` in raspiblesk-web.
Please do not commit the above changes.
### Use a external RaspiBlesk as Backend
- (Optional): Make sure [asdf](https://asdf-vm.com/) is installed
- (Optional): Run `asdf install nodejs latest:20`
- Install the dependencies with `npm install`
- Change the `BACKEND_SERVER` value in [vite.config.ts](vite.config.ts) to your local RaspiBlesk - for example if your RaspiBlesk is running on local IP `192.168.1.123` then change the value to `http://192.168.1.123:80`
- with `npm run start` it should now connect to your external RaspiBlesk
## Credits & Licenses
### Icons
- RaspiBlitz Icon from [RaspiBlitz](https://github.com/rootzoll/raspiblitz)
- [MIT License](https://github.com/rootzoll/raspiblitz/blob/v1.10/LICENSE)
- Other icons from [Hero Icons](https://heroicons.com/) & [BitcoinDesign Icons](https://github.com/bitcoindesign/bitcoin-icons)
- [MIT License Hero Icons](https://github.com/tailwindlabs/heroicons/blob/master/LICENSE)
- [MIT License Bitcoin-Icons](https://github.com/BitcoinDesign/Bitcoin-Icons/blob/main/LICENSE)
- CSS Loading Spinner from [loading.io](https://loading.io/css/)
- [CC0 License](https://loading.io/css/)
- ButtonSpinner from [SpinKit](https://github.com/tobiasahlin/SpinKit)
- [MIT License](https://github.com/tobiasahlin/SpinKit/blob/master/LICENSE)
### App logos
- BTC RPC Logo from [Bitcoin Explorer](https://bitcoinexplorer.org)
- [MIT License](https://github.com/janoside/btc-rpc-explorer)
- BTCPay Logo from [btcpayserver](https://github.com/btcpayserver/btcpayserver)
- [MIT License](https://github.com/btcpayserver/btcpayserver/blob/master/LICENSE)
- RTL Logo from [RTL](https://github.com/Ride-The-Lightning/RTL)
- [MIT License](https://github.com/Ride-The-Lightning/RTL/blob/master/LICENSE)
- Specter Logo from [specter-desktop](https://github.com/cryptoadvance/specter-desktop)
- [MIT License](https://github.com/cryptoadvance/specter-desktop/blob/master/LICENSE)
- Mempool.space Logo from [mempool](https://github.com/mempool/mempool)
- [Mempool.space Trademark Policy](https://mempool.space/trademark-policy)
- LNbits Logo from [lnbits-legend](https://github.com/lnbits/lnbits-legend)
- [MIT License](https://github.com/lnbits/lnbits-legend/blob/master/LICENSE)
- LND Logo from [LightningLabs](https://github.com/lightningnetwork/lnd)
- [MIT License (?)](https://github.com/lightningnetwork/lnd/blob/master/LICENSE)
- Core Lightning Logo from [Blockstream](https://blockstream.com/)
- [Blockstream Corporate Brand Guideline](https://blockstream.com/brand-assets/)
- Alby Logo from [Alby media repo](https://github.com/getAlby/media)
- License unclear

33
biome.json Normal file
View file

@ -0,0 +1,33 @@
{
"$schema": "https://biomejs.dev/schemas/2.1.4/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": false,
"includes": ["**", "!**/backend-mock"]
},
"formatter": {
"enabled": true,
"indentStyle": "space"
},
"assist": { "actions": { "source": { "organizeImports": "on" } } },
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"css": {
"parser": {
"tailwindDirectives": true
}
}
}

29
default.nix Normal file
View file

@ -0,0 +1,29 @@
{
lib,
config,
dream2nix,
...
}: {
imports = [
dream2nix.modules.dream2nix.nodejs-package-lock-v3
dream2nix.modules.dream2nix.nodejs-granular-v3
];
mkDerivation = {
src = ./build;
};
deps = {nixpkgs, ...}: {
inherit
(nixpkgs)
stdenv
;
};
nodejs-package-lock-v3 = {
packageLockFile = "${config.mkDerivation.src}/package-lock.json";
};
name = "raspiblitz-web";
version = "1.3.0-dev";
}

103
devenv.lock Normal file
View file

@ -0,0 +1,103 @@
{
"nodes": {
"devenv": {
"locked": {
"dir": "src/modules",
"lastModified": 1742320965,
"owner": "cachix",
"repo": "devenv",
"rev": "6bde92766ddd3ee1630029a03d36baddd51934e2",
"type": "github"
},
"original": {
"dir": "src/modules",
"owner": "cachix",
"repo": "devenv",
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1733328505,
"owner": "edolstra",
"repo": "flake-compat",
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"git-hooks": {
"inputs": {
"flake-compat": "flake-compat",
"gitignore": "gitignore",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1742300892,
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "ea26a82dda75bee6783baca6894040c8e6599728",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"git-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1733477122,
"owner": "cachix",
"repo": "devenv-nixpkgs",
"rev": "7bd9e84d0452f6d2e63b6e6da29fe73fac951857",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "rolling",
"repo": "devenv-nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"devenv": "devenv",
"git-hooks": "git-hooks",
"nixpkgs": "nixpkgs",
"pre-commit-hooks": [
"git-hooks"
]
}
}
},
"root": "root",
"version": 7
}

37
devenv.nix Normal file
View file

@ -0,0 +1,37 @@
{
pkgs,
lib,
config,
...
}: {
# https://devenv.sh/languages/javascript/
languages = {
nix = {
enable = true;
lsp.package = pkgs.nixd;
};
javascript = {
enable = true;
npm = {
enable = true;
install.enable = true;
};
};
typescript = {
enable = true;
};
};
packages = [
pkgs.typescript
pkgs.eslint_d
pkgs.biome
pkgs.vscode-js-debug
pkgs.statix
pkgs.alejandra
pkgs.nixd
pkgs.statix
];
}

61
flake.lock generated Normal file
View file

@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1742288794,
"narHash": "sha256-Txwa5uO+qpQXrNG4eumPSD+hHzzYi/CdaM80M9XRLCo=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "b6eaf97c6960d97350c584de1b6dcff03c9daf42",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

69
flake.nix Normal file
View file

@ -0,0 +1,69 @@
{
description = "A mobile-first responsive Web UI for the RaspiBlitz";
inputs = {
flake-utils.url = "github:numtide/flake-utils";
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs = inputs @ {
self,
nixpkgs,
flake-utils,
}: let
name = "blitz-web";
systems =
flake-utils.lib.eachDefaultSystem
(system: let
pkgs = nixpkgs.legacyPackages.${system};
in {
packages = {
default = self.packages.${system}.${name};
${name} =
pkgs.buildNpmPackage
{
name = "${name}";
buildInputs = [pkgs.nodejs_22];
src = ./.;
npmDepsHash = "sha256-DT5+1KH06cMLHgMMPIiL1LMfxoOM4i15Z1MQok/eLS8=";
preBuild = ''
sed -i "s(const BACKEND_SERVER = "http://localhost:8000";(const BACKEND_SERVER = "http://127.0.0.1";(" vite.config.ts
'';
installPhase = ''
mkdir $out
npm run build
cp -r build/* $out
'';
};
};
devShells.default = pkgs.mkShell {
inputsFrom = [self.packages.${system}.${name}];
packages = with pkgs; [nodejs_22 alejandra];
};
});
overlays.overlays = {
default = final: prev: {
${name} = self.packages.${prev.stdenv.hostPlatform.system}.${name};
};
};
module = {
nixosModules.default = {
pkgs,
lib,
config,
...
}: {
imports = [./modules/raspiblitz-web.nix];
nixpkgs.overlays = [self.overlays.default];
};
};
in
systems // overlays // module;
}

26
index.html Normal file
View file

@ -0,0 +1,26 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="RaspiBlesk Web, a WebUI for RaspiBlesk"
/>
<link rel="apple-touch-icon" href="/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="/manifest.json" />
<title>RaspiBlesk</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="modal-root"></div>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,72 @@
{
config,
pkgs,
lib,
...
}: let
name = "blitz-web";
cfg = config.services.${name};
inherit (lib) mkOption mkIf mkEnableOption types literalExpression;
in {
options = {
services.${name} = {
enable = mkEnableOption "${name}";
package = mkOption {
type = types.package;
defaultText = literalExpression "pkgs.${name}";
default = pkgs.${name};
description = "The ${name} package to use.";
};
nginx = {
enable = mkEnableOption "Whether to enable nginx server for ${name}.";
description = "This is used to generate the nginx configuration.";
hostName = mkOption {
type = types.str;
example = "my.node.net";
default = "localhost";
description = "The hostname to use for the nginx virtual host.";
};
location = mkOption {
type = types.str;
example = "/";
default = "/";
description = "The location to serve the webui fronted from.";
};
openFirewall = mkOption {
type = types.bool;
default = false;
description = "Whether to open the ports used by ${name} in the firewall for the server";
};
};
};
};
config = mkIf cfg.enable {
services.nginx = mkIf cfg.nginx.enable {
enable = true;
virtualHosts.${cfg.nginx.hostName} = {
forceSSL = false;
enableACME = false;
locations."${cfg.nginx.location}" = {
root = "${pkgs.${name}}";
extraConfig = ''
# Forward any unknown urls to the UI.
# Client side routing of react will fail otherwise.
try_files $uri /index.html;
'';
};
};
};
networking.firewall = mkIf cfg.nginx.openFirewall {
allowedTCPPorts = [80];
};
};
}

1
openapi.json Normal file

File diff suppressed because one or more lines are too long

10561
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

77
package.json Normal file
View file

@ -0,0 +1,77 @@
{
"name": "raspiblesk-web",
"version": "1.4.0",
"license": "MIT",
"type": "module",
"author": {
"name": "Christoph Stenglein",
"email": "christoph@cstenglein.com",
"url": "https://cstenglein.com/"
},
"scripts": {
"start": "vite",
"dev": "npm run start",
"dev:local": "concurrently \"npm run backend\" \"npm run start\"",
"backend": "npm start --prefix ./backend-mock",
"build": "vite build",
"tsc": "tsc --noEmit",
"test": "vitest run --exclude 'tests'",
"test:watch": "vitest watch --exclude 'tests'",
"coverage": "vitest run --coverage --exclude 'tests'",
"prepare": "husky",
"lint": "biome check src"
},
"config": {
"port": "3000"
},
"lint-staged": {
"{src}/**/*.{js,jsx,ts,tsx,json,css,scss,md}": [
"biome check --write"
]
},
"dependencies": {
"@bitcoin-design/bitcoin-icons-react": "^0.1.10",
"@heroicons/react": "^2.2.0",
"@heroui/react": "^2.8.2",
"@heroui/system": "^2.4.20",
"@heroui/theme": "^2.4.20",
"@tailwindcss/vite": "^4.1.17",
"axios": "^1.13.2",
"framer-motion": "^12.23.12",
"i18next": "^25.6.3",
"qrcode.react": "^4.2.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-hook-form": "^7.66.1",
"react-i18next": "^16.3.5",
"react-router": "^7.9.6",
"react-toastify": "^11.0.5",
"react-tooltip": "^5.28.1",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@biomejs/biome": "^2.3.7",
"@playwright/test": "^1.52.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.6",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.0.12",
"concurrently": "^9.1.2",
"husky": "^9.1.7",
"jsdom": "^27.2.0",
"lint-staged": "^16.2.7",
"msw": "^2.12.2",
"resize-observer-polyfill": "^1.5.1",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
"vite": "^7.2.4",
"vite-plugin-svgr": "^4.3.0",
"vite-tsconfig-paths": "^6.0.5",
"vitest": "^4.0.12"
}
}

BIN
preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

5
public/assets/cloud.svg Normal file
View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
</svg>

After

Width:  |  Height:  |  Size: 298 B

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

BIN
public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

25
public/manifest.json Normal file
View file

@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

3
public/robots.txt Normal file
View file

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

42
src/App.css Normal file
View file

@ -0,0 +1,42 @@
.content-container {
/* biome-ignore lint/correctness/noUnknownFunction: value is expected to exist at this point */
min-height: calc(100vh - theme("spacing.16"));
}
.sidebar {
/* biome-ignore lint/correctness/noUnknownFunction: value is expected to exist at this point */
max-height: calc(100vh - theme("spacing.16"));
}
/* Custom Scrollbar Chrome */
::-webkit-scrollbar {
width: 5px;
}
::-webkit-scrollbar-thumb {
background: darkgray;
border-radius: 25px;
}
::-webkit-scrollbar-track {
box-shadow: inset 0 0 5px #ccc;
border-radius: 10px;
}
/* Custom Scrollbar Firefox */
div,
ul {
scrollbar-width: thin;
}
/* Blur Hidden Text */
.text-blur {
color: transparent;
text-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
user-select: none;
pointer-events: none;
cursor: default;
overflow: hidden;
}

181
src/App.tsx Normal file
View file

@ -0,0 +1,181 @@
import "./App.css";
import RequireAuth from "./components/RequireAuth";
import RequireSetup from "./components/RequireSetup";
import { AppContext } from "./context/app-context";
import "./i18n/config";
import {
type FC,
lazy,
Suspense,
useContext,
useEffect,
useState,
} from "react";
import { Route, Routes, useLocation, useNavigate } from "react-router";
import AppPage from "@/pages/Apps/AppPage";
import Login from "@/pages/Login";
import Layout from "./layouts/Layout";
import LoadingScreen from "./layouts/LoadingScreen";
import SkeletonLoadingScreen from "./layouts/SkeletonLoadingScreen";
import { SetupPhase } from "./models/setup.model";
import { ACCESS_TOKEN, parseJwt, REFRESH_TIME } from "./utils";
import { instance } from "./utils/interceptor";
import "react-toastify/dist/ReactToastify.css";
const LazySetup = lazy(() => import("./pages/Setup"));
const LazyHome = lazy(() => import("./pages/Home"));
const LazyApps = lazy(() => import("./pages/Apps"));
const LazySettings = lazy(() => import("./pages/Settings"));
const LazyAppInfo = lazy(() => import("./pages/Apps/AppInfo"));
const App: FC = () => {
const [isLoading, setIsLoading] = useState(true);
const [firstCall, setFirstCall] = useState(true);
const [needsSetup, setNeedsSetup] = useState(false);
const { isLoggedIn } = useContext(AppContext);
const navigate = useNavigate();
const { pathname } = useLocation();
useEffect(() => {
if (firstCall) {
async function check() {
setFirstCall(false);
if (pathname.startsWith("/login")) {
setIsLoading(false);
return;
}
await instance.get("/setup/status").then((resp) => {
const setupPhase = resp.data.setupPhase;
const initialSync = resp.data.initialsync;
if (setupPhase !== SetupPhase.DONE || initialSync === "running") {
setNeedsSetup(true);
navigate("/setup");
} else {
setNeedsSetup(false);
}
setIsLoading(false);
});
}
check();
}
}, [firstCall, navigate, pathname]);
useEffect(() => {
if (!isLoggedIn) {
return;
}
function refresh(triggerTime: number) {
setTimeout(() => doRefresh(), triggerTime);
}
function doRefresh() {
if (!localStorage.getItem(ACCESS_TOKEN)) {
return;
}
instance
.post("system/refresh-token", {})
.then((resp) => {
const token = resp.data;
if (token) {
localStorage.setItem(ACCESS_TOKEN, token);
const payload = parseJwt(token);
refresh(REFRESH_TIME(payload.expires));
}
})
.catch((e) => {
console.error("Error: token refresh failed with: ", e);
});
}
const token = localStorage.getItem(ACCESS_TOKEN);
if (token) {
const payload = parseJwt(token);
refresh(REFRESH_TIME(payload.expires));
}
}, [isLoggedIn]);
if (isLoading) {
return <LoadingScreen />;
}
return (
<Routes>
<Route path="/" element={<Login />} />
<Route path="/login" element={<Login />} />
<Route
path="/setup"
element={
<Suspense fallback={<LoadingScreen />}>
<RequireSetup needsSetup={needsSetup}>
<LazySetup />
</RequireSetup>
</Suspense>
}
/>
<Route
path="/home"
element={
<Suspense fallback={<SkeletonLoadingScreen />}>
<RequireAuth>
<Layout>
<LazyHome />
</Layout>
</RequireAuth>
</Suspense>
}
/>
<Route
path="/apps"
element={
<Suspense fallback={<SkeletonLoadingScreen />}>
<RequireAuth>
<Layout>
<LazyApps />
</Layout>
</RequireAuth>
</Suspense>
}
/>
<Route
path="/settings"
element={
<Suspense fallback={<SkeletonLoadingScreen />}>
<RequireAuth>
<Layout>
<LazySettings />
</Layout>
</RequireAuth>
</Suspense>
}
/>
<Route
path="/apps/:appId/info"
element={
<Suspense fallback={<SkeletonLoadingScreen />}>
<RequireAuth>
<Layout>
<LazyAppInfo />
</Layout>
</RequireAuth>
</Suspense>
}
/>
<Route
path="/apps/:appId"
element={
<Suspense fallback={<SkeletonLoadingScreen />}>
<RequireAuth>
<Layout>
<AppPage />
</Layout>
</RequireAuth>
</Suspense>
}
/>
</Routes>
);
};
export default App;

62
src/ErrorBoundary.tsx Normal file
View file

@ -0,0 +1,62 @@
import type { TFunction } from "i18next";
import { Component, type ErrorInfo, type PropsWithChildren } from "react";
import { withTranslation } from "react-i18next";
interface Props {
t: TFunction<[string, string], undefined>;
}
interface State {
hasError: boolean;
error?: Error;
errorInfo?: ErrorInfo;
}
class ErrorBoundary extends Component<PropsWithChildren<Props>, State> {
public state: State = {
hasError: false,
};
public static getDerivedStateFromError(_error: Error): State {
// Update state so the next render will show the fallback UI.
return { hasError: true };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
this.setState({ ...this.state, errorInfo, error });
console.error("Uncaught error:", error, errorInfo);
}
public render() {
if (this.state.hasError) {
const { t } = this.props;
return (
<main className="flex h-screen w-screen flex-col items-center justify-center gap-5 bg-gray-700 p-10 text-white transition-colors">
<h1 className="text-xl font-bold">{t("login.error")} 😓</h1>
<section>
<p className="rounded bg-red-500 p-2 text-center text-white">
{this.state.error?.name}:{this.state.error?.message}
</p>
<p className="mt-2">
{t("error.report")}{" "}
<a
href="https://forge.618.ch/raspiblesk/raspiblesk-web/issues"
className="cursor-pointer text-blue-500"
>
https://forge.618.ch/raspiblesk/raspiblesk-web/issues
</a>
</p>
</section>
<section className="md:w-1/2">
<p>{t("error.stack")}:</p>
<p>{this.state.errorInfo?.componentStack}</p>
</section>
</main>
);
}
return this.props.children;
}
}
export default withTranslation()(ErrorBoundary);

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="28 5 48 62">
<polygon points="42,9 62,9 72,27 72,45 62,63 42,63 32,45 32,27" fill="none" stroke="#1a1a1a" stroke-width="3"/>
<polygon fill="#7C3AED" points="56,21 44,38 52,38 49,54 61,37 53,37"/>
</svg>

After

Width:  |  Height:  |  Size: 256 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="28 5 48 62">
<polygon points="42,9 62,9 72,27 72,45 62,63 42,63 32,45 32,27" fill="none" stroke="#ffffff" stroke-width="3"/>
<polygon fill="#A78BFA" points="56,21 44,38 52,38 49,54 61,37 53,37"/>
</svg>

After

Width:  |  Height:  |  Size: 256 B

View file

@ -0,0 +1,13 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 460 81">
<defs><style>
.d { fill: #1a1a1a; }
.v { fill: #7C3AED; }
.vl { fill: #A78BFA; }
.wm { font-family: 'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif; font-weight: 700; font-size: 56px; letter-spacing: -0.5px; }
</style></defs>
<title>RaspiBlesk_Logo_Main</title>
<polygon class="d" points="42,9 62,9 72,27 72,45 62,63 42,63 32,45 32,27" fill="none" stroke="#1a1a1a" stroke-width="3"/>
<polygon class="v" points="56,21 44,38 52,38 49,54 61,37 53,37"/>
<text class="wm d" x="83" y="57">Raspi</text>
<text class="wm v" x="242" y="57">Blesk</text>
</svg>

After

Width:  |  Height:  |  Size: 684 B

View file

@ -0,0 +1,14 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 460 81">
<defs><style>
.d { fill: #ffffff; }
.v { fill: #A78BFA; }
.vl { fill: #C4B5FD; }
.wm { font-family: 'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif; font-weight: 700; font-size: 56px; letter-spacing: -0.5px; }
</style></defs>
<title>RaspiBlesk_Logo_Main_Negative</title>
<rect width="460" height="81" fill="#1a1a1a"/>
<polygon points="42,9 62,9 72,27 72,45 62,63 42,63 32,45 32,27" fill="none" stroke="#ffffff" stroke-width="3"/>
<polygon class="v" points="56,21 44,38 52,38 49,54 61,37 53,37"/>
<text class="wm d" x="83" y="57">Raspi</text>
<text class="wm v" x="242" y="57">Blesk</text>
</svg>

After

Width:  |  Height:  |  Size: 732 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 747 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 525 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4091.27 4091.73">
<path fill="#F7931A"
d="M4030.06 2540.77c-273.24,1096.01 -1383.32,1763.02 -2479.46,1489.71 -1095.68,-273.24 -1762.69,-1383.39 -1489.33,-2479.31 273.12,-1096.13 1383.2,-1763.19 2479,-1489.95 1096.06,273.24 1763.03,1383.51 1489.76,2479.57l0.02 -0.02z" />
<path fill="white"
d="M2947.77 1754.38c40.72,-272.26 -166.56,-418.61 -450,-516.24l91.95 -368.8 -224.5 -55.94 -89.51 359.09c-59.02,-14.72 -119.63,-28.59 -179.87,-42.34l90.16 -361.46 -224.36 -55.94 -92 368.68c-48.84,-11.12 -96.81,-22.11 -143.35,-33.69l0.26 -1.16 -309.59 -77.31 -59.72 239.78c0,0 166.56,38.18 163.05,40.53 90.91,22.69 107.35,82.87 104.62,130.57l-104.74 420.15c6.26,1.59 14.38,3.89 23.34,7.49 -7.49,-1.86 -15.46,-3.89 -23.73,-5.87l-146.81 588.57c-11.11,27.62 -39.31,69.07 -102.87,53.33 2.25,3.26 -163.17,-40.72 -163.17,-40.72l-111.46 256.98 292.15 72.83c54.35,13.63 107.61,27.89 160.06,41.3l-92.9 373.03 224.24 55.94 92 -369.07c61.26,16.63 120.71,31.97 178.91,46.43l-91.69 367.33 224.51 55.94 92.89 -372.33c382.82,72.45 670.67,43.24 791.83,-303.02 97.63,-278.78 -4.86,-439.58 -206.26,-544.44 146.69,-33.83 257.18,-130.31 286.64,-329.61l-0.07 -0.05zm-512.93 719.26c-69.38,278.78 -538.76,128.08 -690.94,90.29l123.28 -494.2c152.17,37.99 640.17,113.17 567.67,403.91zm69.43 -723.3c-63.29,253.58 -453.96,124.75 -580.69,93.16l111.77 -448.21c126.73,31.59 534.85,90.55 468.94,355.05l-0.02 0z" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
src/assets/lnd.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

39
src/components/Alert.tsx Normal file
View file

@ -0,0 +1,39 @@
import type { HTMLAttributes, ReactNode } from "react";
import { twMerge } from "tailwind-merge";
export interface Props extends HTMLAttributes<HTMLElement> {
as?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "div";
children: ReactNode;
color?: Colors;
className?: string;
}
const colors = {
success: "text-success border-success bg-green-900",
warning: "text-violet-300 border-violet-500 bg-violet-900",
danger: "text-danger border-danger bg-red-900",
info: "text-primary border-primary bg-blue-900",
};
type Colors = keyof typeof colors;
export const Alert = ({
as = "p",
color = "success",
className,
children,
}: Props) => {
const Component = as;
return (
<Component
className={twMerge(
"rounded-xl border p-4 text-center font-semibold",
colors[color],
className,
)}
>
{children}
</Component>
);
};

View file

@ -0,0 +1,97 @@
import { ArrowsRightLeftIcon } from "@heroicons/react/24/outline";
import { Input } from "@heroui/react";
import { type ChangeEvent, type FC, useContext, useState } from "react";
import type { FieldError, UseFormRegisterReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { AppContext, Unit } from "@/context/app-context";
import { convertGlcToGsat, convertGsatToGlc, formatAmount } from "@/utils/format";
export type Props = {
amount?: number;
register: UseFormRegisterReturn;
errorMessage?: FieldError;
disabled?: boolean;
};
const AmountInput: FC<Props> = ({
amount,
register,
errorMessage,
disabled = false,
}) => {
const { t } = useTranslation();
const [amountInput, setAmountInput] = useState<string>(
amount ? `${amount}` : "",
);
const { unit, toggleUnit } = useContext(AppContext);
const toggleHandler = () => {
let formattedValue = amountInput;
if (unit === Unit.GLC && formattedValue) {
formattedValue = new Intl.NumberFormat("en-US").format(
convertGlcToGsat(+formattedValue),
);
} else {
// remove separators
formattedValue = formattedValue.replace(/[,.]/g, "");
if (formattedValue) {
formattedValue = new Intl.NumberFormat("en-US", {
minimumFractionDigits: 8,
// biome-ignore lint/style/noNonNullAssertion: value is expected to exist at this point
}).format(convertGsatToGlc(Number.parseInt(formattedValue, 10))!);
}
}
setAmountInput(formattedValue);
toggleUnit();
register.onChange({ target: { value: formattedValue } });
};
const onChangeHandler = async (e: ChangeEvent<HTMLInputElement>) => {
let value = e.target.value;
let selectionStart = e.target.selectionStart;
let selectionEnd = e.target.selectionEnd;
value = formatAmount(value, unit);
// do not shift position of cursor if comma was added
if (value.length > e.target.value.length) {
selectionStart = selectionStart ? selectionStart + 1 : null;
selectionEnd = selectionEnd ? selectionEnd + 1 : null;
}
setAmountInput(value);
e.target.value = value.replace(/,/g, "");
await register.onChange(e);
e.target.setSelectionRange(selectionStart, selectionEnd);
};
return (
<Input
{...register}
label={t("wallet.amount")}
id={register.name}
type="text"
classNames={{
inputWrapper:
"bg-tertiary group-data-[focus=true]:bg-tertiary group-data-[hover=true]:bg-tertiary",
}}
value={amountInput}
onChange={onChangeHandler}
isDisabled={disabled}
isInvalid={!!errorMessage}
errorMessage={errorMessage?.message}
endContent={
<button
className="focus:outline-none h-full"
type="button"
onClick={toggleHandler}
aria-label="toggle password visibility"
>
<span className="whitespace-nowrap text-small text-default-foreground">
{unit}
<ArrowsRightLeftIcon className="ml-1 inline-block h-5 w-5" />
</span>
</button>
}
/>
);
};
export default AmountInput;

View file

@ -0,0 +1,21 @@
import type { FC } from "react";
type Props = {
appId: string;
className?: string;
};
const AppIcon: FC<Props> = ({ appId, className = "" }) => {
return (
<img
className={`${className}`}
src={`./assets/apps/logos/${appId}.png`}
onError={(e) => {
(e.target as HTMLImageElement).src = "./assets/cloud.svg";
}}
alt={`${appId} Logo`}
/>
);
};
export default AppIcon;

View file

@ -0,0 +1,53 @@
import type { FC } from "react";
import { useNavigate } from "react-router";
import AppIcon from "@/components/AppIcon";
import type { AppStatus } from "@/models/app-status";
import { getHrefFromApp } from "@/utils";
import { availableApps } from "@/utils/availableApps";
type Props = {
app: AppStatus;
};
export const AppStatusItem: FC<Props> = ({ app }) => {
const { id } = app;
const navigate = useNavigate();
// biome-ignore lint/style/noNonNullAssertion: value is expected to exist at this point
const appName = availableApps[id]!.name;
// biome-ignore lint/style/noNonNullAssertion: value is expected to exist at this point
const customComponent = availableApps[id]!.customComponent;
if (customComponent) {
return (
<button
type="submit"
onClick={() => navigate(`/apps/${id}`)}
onKeyUp={(e) => {
if (e.key === "Enter") navigate(`/apps/${id}`);
}}
className="flex w-full cursor-pointer items-center justify-center py-4 text-white opacity-80 hover:text-violet-400 md:flex-col lg:flex-row"
>
{/* Icon */}
<AppIcon appId={id} className="h-10 inline w-10" />
{/* Content */}
<span className="mx-3 w-1/2 justify-start flex text-lg">{appName}</span>
</button>
);
}
return (
<a
href={getHrefFromApp(app)}
target="_blank"
rel="noopener noreferrer"
className="flex w-full cursor-pointer items-center justify-center py-4 text-white opacity-80 hover:text-violet-400 md:flex-col lg:flex-row"
>
{/* Icon */}
<AppIcon appId={id} className="h-10 inline w-10" />
{/* Content */}
<span className="mx-3 w-1/2 justify-center text-lg">{appName}</span>
</a>
);
};
export default AppStatusItem;

View file

@ -0,0 +1,30 @@
import { type FC, useContext } from "react";
import { useTranslation } from "react-i18next";
import { AppContext, Unit } from "@/context/app-context";
import { convertGsatToGlc, convertToString } from "@/utils/format";
type Props = {
balance: number;
};
const AvailableBalance: FC<Props> = ({ balance }) => {
const { unit } = useContext(AppContext);
const { t } = useTranslation();
const convertedBalance =
unit === Unit.GLC
? convertToString(unit, convertGsatToGlc(balance))
: convertToString(unit, balance);
return (
<div className="rounded-xl px-4 py-3">
<p className="text-xs text-secondary">{t("wallet.available_balance")}</p>
<p className="text-lg font-bold">
{convertedBalance}{" "}
<span className="text-sm font-normal text-secondary">{unit}</span>
</p>
</div>
);
};
export default AvailableBalance;

12
src/components/Button.tsx Normal file
View file

@ -0,0 +1,12 @@
import { type ButtonProps, Button as HeroUIButton } from "@heroui/react";
import type { ReactNode } from "react";
interface CustomButtonProps extends ButtonProps {
children: ReactNode;
}
export const Button = ({ children, ...props }: CustomButtonProps) => (
<HeroUIButton radius="full" className="px-8 py-6 font-semibold" {...props}>
{children}
</HeroUIButton>
);

View file

@ -0,0 +1,16 @@
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import { useTranslation } from "react-i18next";
import { Alert } from "@/components/Alert";
const CapsLockWarning = () => {
const { t } = useTranslation();
return (
<Alert as="p" color="warning">
<ExclamationTriangleIcon className="inline size-4" />
&nbsp;
{t("login.caps_lock")}
</Alert>
);
};
export default CapsLockWarning;

View file

@ -0,0 +1,84 @@
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from "@heroui/react";
import type { UseDisclosureReturn } from "@heroui/use-disclosure";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/Button";
export type Props = {
disclosure: UseDisclosureReturn;
headline?: string;
children?: ReactNode;
onConfirm?: () => void;
confirmText?: string;
cancelText?: string;
isLoading?: boolean;
} & ({ custom: true } | { custom?: false; body?: ReactNode });
type ConfirmModalComponent = {
(props: Props): React.JSX.Element;
Header: typeof ModalHeader;
Body: typeof ModalBody;
Footer: typeof ModalFooter;
};
export const ConfirmModalHeader = ModalHeader;
export const ConfirmModalBody = ModalBody;
export const ConfirmModalFooter = ModalFooter;
export const ConfirmModal: ConfirmModalComponent = ({
disclosure,
headline,
children,
onConfirm,
confirmText,
cancelText,
isLoading = false,
...props
}: Props) => {
const { t } = useTranslation();
const { isOpen, onOpenChange, onClose } = disclosure;
const renderContent = () => {
if ("custom" in props && props.custom) {
return children;
}
return (
<>
{headline && <ConfirmModalHeader>{headline}</ConfirmModalHeader>}
<ConfirmModalBody>{children || props.body}</ConfirmModalBody>
<ConfirmModalFooter>
<Button onPress={onClose} isDisabled={isLoading}>
{cancelText || t("settings.cancel")}
</Button>
<Button
color="primary"
onPress={onConfirm}
isDisabled={isLoading || !onConfirm}
isLoading={isLoading}
>
{confirmText || t("settings.confirm")}
</Button>
</ConfirmModalFooter>
</>
);
};
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} onClose={onClose}>
<ModalContent>{renderContent()}</ModalContent>
</Modal>
);
};
ConfirmModal.Header = ConfirmModalHeader;
ConfirmModal.Body = ConfirmModalBody;
ConfirmModal.Footer = ConfirmModalFooter;

View file

@ -0,0 +1,48 @@
import type { ReactNode } from "react";
import { twMerge } from "tailwind-merge";
export interface HeadlineProps {
as?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
children: ReactNode;
size?: FontSizes;
align?: Alignment;
}
export const alignment = {
left: "text-left",
center: "text-center",
right: "text-right",
} as const;
export type Alignment = keyof typeof alignment;
export const fontSizes = {
"5xl": "text-5xl",
"4xl": "text-4xl",
"3xl": "text-3xl",
"2xl": "text-2xl",
xl: "text-xl",
base: "text-base",
sm: "text-sm",
} as const;
export type FontSizes = keyof typeof fontSizes;
export const Headline = ({
as: Component = "h1",
size = "3xl",
align = "center",
children,
}: HeadlineProps) => {
return (
<Component
className={twMerge(
fontSizes[size],
alignment[align],
"whitespace-pre-line font-semibold",
)}
>
{children}
</Component>
);
};

View file

@ -0,0 +1,38 @@
import { Select, SelectItem } from "@heroui/react";
import type { ChangeEvent, FC } from "react";
import { useTranslation } from "react-i18next";
import { resources } from "@/i18n/config";
import { saveSettings } from "@/utils";
const I18nSelect: FC = () => {
const { t, i18n } = useTranslation();
const langs = Object.keys(resources);
const selectLangHandler = (event: ChangeEvent<HTMLSelectElement>) => {
if (event.target.value === i18n.language) {
return;
}
i18n.changeLanguage(event.target.value);
saveSettings({ lang: i18n.language });
};
return (
<Select
id="lngSelect"
aria-label="Select language"
defaultSelectedKeys={[i18n.language]}
onChange={selectLangHandler}
classNames={{
trigger: "bg-tertiary",
}}
>
{langs.map((lang) => {
return <SelectItem key={lang}>{t(`language.${lang}`)}</SelectItem>;
})}
</Select>
);
};
export default I18nSelect;

View file

@ -0,0 +1,17 @@
import { type FC, type PropsWithChildren, useContext } from "react";
import { Navigate, useLocation } from "react-router";
import { AppContext } from "@/context/app-context";
// see https://reactrouter.com/docs/en/v6/examples/auth
const RequireAuth: FC<PropsWithChildren> = ({ children }) => {
const { isLoggedIn } = useContext(AppContext);
const location = useLocation();
if (!isLoggedIn) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
};
export default RequireAuth;

View file

@ -0,0 +1,20 @@
import type { FC, PropsWithChildren } from "react";
import { Navigate } from "react-router";
type Props = {
needsSetup: boolean;
};
// see https://reactrouter.com/docs/en/v6/examples/auth
const RequireSetup: FC<PropsWithChildren<Props>> = ({
needsSetup,
children,
}) => {
if (!needsSetup) {
return <Navigate to="/home" replace />;
}
return children;
};
export default RequireSetup;

Some files were not shown because too many files have changed in this diff Show more