first commit
41
.gitignore
vendored
Normal 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
|
|
@ -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
|
|
@ -0,0 +1,170 @@
|
|||
<h1 align="center">RaspiBlesk Web - a responsive Web UI for RaspiBlesk</h1>
|
||||
|
||||

|
||||
|
||||
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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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>
|
||||
72
modules/raspiblitz-web.nix
Normal 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
10561
package-lock.json
generated
Normal file
77
package.json
Normal 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
|
After Width: | Height: | Size: 430 KiB |
BIN
public/assets/apps/logos/alby.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
public/assets/apps/logos/albyhub.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
public/assets/apps/logos/btc-rpc-explorer.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
public/assets/apps/logos/btcpayserver.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
public/assets/apps/logos/electrs.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
public/assets/apps/logos/jam.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
public/assets/apps/logos/lnbits.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/assets/apps/logos/mempool.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
public/assets/apps/logos/rtl.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
public/assets/apps/logos/specter.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
public/assets/apps/logos/thunderhub.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
public/assets/apps/videos/mempool.mp4
Normal file
5
public/assets/cloud.svg
Normal 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
|
After Width: | Height: | Size: 24 KiB |
BIN
public/logo192.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
public/logo512.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
25
public/manifest.json
Normal 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
|
|
@ -0,0 +1,3 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
42
src/App.css
Normal 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
|
|
@ -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
|
|
@ -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);
|
||||
4
src/assets/RaspiBlesk_Logo_Icon.svg
Normal 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 |
4
src/assets/RaspiBlesk_Logo_Icon_Negative.svg
Normal 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 |
13
src/assets/RaspiBlesk_Logo_Main.svg
Normal 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 |
14
src/assets/RaspiBlesk_Logo_Main_Negative.svg
Normal 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 |
BIN
src/assets/apps/preview/albyhub/1.png
Normal file
|
After Width: | Height: | Size: 411 KiB |
BIN
src/assets/apps/preview/albyhub/2.png
Normal file
|
After Width: | Height: | Size: 340 KiB |
BIN
src/assets/apps/preview/albyhub/3.png
Normal file
|
After Width: | Height: | Size: 747 KiB |
BIN
src/assets/apps/preview/btc-rpc-explorer/1.png
Normal file
|
After Width: | Height: | Size: 160 KiB |
BIN
src/assets/apps/preview/btc-rpc-explorer/2.png
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
src/assets/apps/preview/btc-rpc-explorer/3.png
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
src/assets/apps/preview/btcpayserver/1.png
Normal file
|
After Width: | Height: | Size: 525 KiB |
BIN
src/assets/apps/preview/btcpayserver/2.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
src/assets/apps/preview/btcpayserver/3.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
src/assets/apps/preview/jam/1.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
src/assets/apps/preview/jam/2.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
src/assets/apps/preview/jam/3.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
src/assets/apps/preview/lnbits/1.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
src/assets/apps/preview/lnbits/2.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
src/assets/apps/preview/lnbits/3.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
src/assets/apps/preview/mempool/1.png
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
src/assets/apps/preview/mempool/2.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
src/assets/apps/preview/mempool/3.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
src/assets/apps/preview/rtl/1.png
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
src/assets/apps/preview/rtl/2.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
src/assets/apps/preview/rtl/3.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
src/assets/apps/preview/specter/1.png
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
src/assets/apps/preview/specter/2.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
src/assets/apps/preview/specter/3.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
src/assets/apps/preview/thunderhub/1.png
Normal file
|
After Width: | Height: | Size: 115 KiB |
BIN
src/assets/apps/preview/thunderhub/2.png
Normal file
|
After Width: | Height: | Size: 185 KiB |
BIN
src/assets/apps/preview/thunderhub/3.png
Normal file
|
After Width: | Height: | Size: 219 KiB |
6
src/assets/bitcoin-logo.svg
Normal 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 |
BIN
src/assets/core_lightning_logo_only.png
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
src/assets/fonts/Metropolis-Black.woff
Normal file
BIN
src/assets/fonts/Metropolis-BlackItalic.woff
Normal file
BIN
src/assets/fonts/Metropolis-Bold.woff
Normal file
BIN
src/assets/fonts/Metropolis-BoldItalic.woff
Normal file
BIN
src/assets/fonts/Metropolis-ExtraBold.woff
Normal file
BIN
src/assets/fonts/Metropolis-ExtraBoldItalic.woff
Normal file
BIN
src/assets/fonts/Metropolis-ExtraLight.woff
Normal file
BIN
src/assets/fonts/Metropolis-ExtraLightItalic.woff
Normal file
BIN
src/assets/fonts/Metropolis-Light.woff
Normal file
BIN
src/assets/fonts/Metropolis-LightItalic.woff
Normal file
BIN
src/assets/fonts/Metropolis-Medium.woff
Normal file
BIN
src/assets/fonts/Metropolis-MediumItalic.woff
Normal file
BIN
src/assets/fonts/Metropolis-Regular.woff
Normal file
BIN
src/assets/fonts/Metropolis-RegularItalic.woff
Normal file
BIN
src/assets/fonts/Metropolis-SemiBold.woff
Normal file
BIN
src/assets/fonts/Metropolis-SemiBoldItalic.woff
Normal file
BIN
src/assets/fonts/Metropolis-Thin.woff
Normal file
BIN
src/assets/fonts/Metropolis-ThinItalic.woff
Normal file
BIN
src/assets/lnd.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
39
src/components/Alert.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
97
src/components/AmountInput.tsx
Normal 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;
|
||||
21
src/components/AppIcon.tsx
Normal 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;
|
||||
53
src/components/AppStatusItem.tsx
Normal 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;
|
||||
30
src/components/AvailableBalance.tsx
Normal 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
|
|
@ -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>
|
||||
);
|
||||
16
src/components/CapsLockWarning.tsx
Normal 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" />
|
||||
|
||||
{t("login.caps_lock")}
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
export default CapsLockWarning;
|
||||
84
src/components/ConfirmModal.tsx
Normal 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;
|
||||
48
src/components/Headline.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
38
src/components/I18nDropdown.tsx
Normal 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;
|
||||
17
src/components/RequireAuth.tsx
Normal 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;
|
||||
20
src/components/RequireSetup.tsx
Normal 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;
|
||||