Initial commit

This commit is contained in:
Djuri Baars 2024-03-17 18:35:26 +01:00
commit ba5370c7ca
36 changed files with 4122 additions and 0 deletions

14
.eslintignore Normal file
View file

@ -0,0 +1,14 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
/dist
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

31
.eslintrc.cjs Normal file
View file

@ -0,0 +1,31 @@
/** @type { import("eslint").Linter.Config } */
module.exports = {
root: true,
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:svelte/recommended',
'prettier'
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020,
extraFileExtensions: ['.svelte']
},
env: {
browser: true,
es2017: true,
node: true
},
overrides: [
{
files: ['*.svelte'],
parser: 'svelte-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser'
}
}
]
};

130
.github/workflows/workflow.yml vendored Normal file
View file

@ -0,0 +1,130 @@
name: WebUI CI
on: [push]
env:
PUBLIC_BASE_URL: ''
jobs:
check-changes:
runs-on: ubuntu-latest
outputs:
all_changed_and_modified_files_count: ${{ steps.changed-files.outputs.all_changed_and_modified_files_count }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Get changed files count
id: changed-files
uses: tj-actions/changed-files@v40.1.1
with:
files_ignore: 'doc/**,README.md,Dockerfile,.*'
files_ignore_separator: ','
- name: Print changed files count
run: >
echo "Changed files count: ${{
steps.changed-files.outputs.all_changed_and_modified_files_count }}"
build:
needs: check-changes
runs-on: ubuntu-latest
if: ${{ needs.check-changes.outputs.all_changed_and_modified_files_count >= 1 }}
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: actions/setup-node@v3
with:
node-version: lts/*
cache: yarn
cache-dependency-path: '**/yarn.lock'
- uses: actions/cache@v3
with:
path: |
~/.cache/pip
~/node_modules
key: ${{ runner.os }}-pio
- uses: actions/setup-python@v4
with:
python-version: '3.9'
- name: Get current date
id: dateAndTime
run: echo "dateAndTime=$(date +'%Y-%m-%d-%H:%M')" >> $GITHUB_OUTPUT
- name: Install mklittlefs
run: >
git clone https://github.com/earlephilhower/mklittlefs.git /tmp/mklittlefs &&
cd /tmp/mklittlefs &&
git submodule update --init &&
make dist
- name: Install yarn
run: yarn && yarn postinstall
- name: Run linter
run: yarn lint
# - name: Run vitest tests
# run: yarn vitest run
# - name: Install Playwright Browsers
# run: npx playwright install --with-deps
# - name: Run Playwright tests
# run: npx playwright test
- name: Build WebUI
run: yarn build
- name: Get current block
id: getBlockHeight
run: echo "blockHeight=$(curl -s https://mempool.space/api/blocks/tip/height)" >> $GITHUB_OUTPUT
- name: Write block height to file
env:
BLOCK_HEIGHT: ${{ steps.getBlockHeight.outputs.blockHeight }}
run: mkdir -p output && echo "$BLOCK_HEIGHT" > output/version.txt
- name: gzip build for LittleFS
run: find dist -type f ! -name ".*" -exec sh -c 'mkdir -p "build_gz/$(dirname "${1#dist/}")" && gzip -k "$1" -c > "build_gz/${1#dist/}".gz' _ {} \;
- name: Check GZipped directory size
run: |
# Set the threshold size in bytes
THRESHOLD=409600
# Calculate the total size of files in the directory
DIRECTORY_SIZE=$(du -b -s build_gz | awk '{print $1}')
# Fail the workflow if the size exceeds the threshold
if [ "$DIRECTORY_SIZE" -gt "$THRESHOLD" ]; then
echo "Directory size exceeds the threshold of $THRESHOLD bytes"
exit 1
else
echo "Directory size is within the threshold"
fi
- name: Create tarball
run: tar czf webui.tgz --strip-components=1 dist
- name: Build LittleFS
run: /tmp/mklittlefs/mklittlefs -c build_gz -s 409600 output/littlefs.bin
- name: Upload artifacts
uses: actions/upload-artifact@v3
with:
path: |
webui.tgz
output/littlefs.bin
- name: Create release
uses: ncipollo/release-action@v1
with:
tag: ${{ steps.getBlockHeight.outputs.blockHeight }}
commit: main
name: release-${{ steps.getBlockHeight.outputs.blockHeight }}
artifacts: 'output/littlefs.bin,webui.tgz'
allowUpdates: true
removeArtifacts: true
makeLatest: true
# - name: Pushes littlefs.bin to web flasher
# id: push_directory
# uses: cpina/github-action-push-to-another-repository@main
# env:
# SSH_DEPLOY_KEY: ${{ secrets.SSH_DEPLOY_KEY }}
# with:
# source-directory: output/
# target-directory: webui/
# destination-github-username: 'btclock'
# destination-repository-name: 'web-flasher'
# target-branch: btclock
# user-name: ${{github.actor}}
# user-email: ${{github.actor}}@users.noreply.github.com

11
.gitignore vendored Normal file
View file

@ -0,0 +1,11 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
dist/

1
.npmrc Normal file
View file

@ -0,0 +1 @@
engine-strict=true

5
.prettierignore Normal file
View file

@ -0,0 +1,5 @@
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock
dist/

8
.prettierrc Normal file
View file

@ -0,0 +1,8 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

38
README.md Normal file
View file

@ -0,0 +1,38 @@
# BTClock WebUI
[![BTClock CI](https://github.com/btclock/oc-webui/actions/workflows/workflow.yml/badge.svg)](https://github.com/btclock/oc-webui/actions/workflows/workflow.yml)
The web user-interface for the OrangeBTClock, based on Svelte-kit and BTClock WebUI. It uses Bootstrap for the lay-out.
## Developing
After installed dependencies with `yarn`, start a development server:
```bash
yarn dev
# or start the server and open the app in a new browser tab
yarn dev -- --open
```
## Building
To create a production version of the WebUI:
```bash
yarn build
```
Make sure the postinstall script is ran, because otherwise the filenames are to long for the LittleFS filesystem.
## Deploying
To upload the firmware to the OC, you need to GZIP all the files. You can use the python script `gzip_build.py` for that.
Then you can make a `LittleFS.bin` with mklittlefs:
```bash
mklittlefs -c build_gz -s 409600 littlefs.bin
```
You can preview the production build with `yarn preview`.

48
package.json Normal file
View file

@ -0,0 +1,48 @@
{
"name": "orangebtclock-webui",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"test": "npm run test:integration && npm run test:unit",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write .",
"test:integration": "playwright test",
"test:unit": "vitest"
},
"devDependencies": {
"@playwright/test": "^1.28.1",
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@types/eslint": "^8.56.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.35.1",
"patch-package": "^8.0.0",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
"sass": "^1.72.0",
"svelte": "^4.2.7",
"svelte-check": "^3.6.0",
"tslib": "^2.4.1",
"typescript": "^5.0.0",
"vite": "^5.0.3",
"vitest": "^1.2.0"
},
"type": "module",
"dependencies": {
"@fontsource/libre-franklin": "^5.0.17",
"bootstrap": "^5.3.3",
"date-fns": "^3.5.0",
"svelte-i18n": "^4.0.0",
"sveltestrap": "^5.11.3"
}
}

View file

@ -0,0 +1,13 @@
diff --git a/node_modules/@sveltejs/kit/src/exports/vite/index.js b/node_modules/@sveltejs/kit/src/exports/vite/index.js
index e80fb78..4536af4 100644
--- a/node_modules/@sveltejs/kit/src/exports/vite/index.js
+++ b/node_modules/@sveltejs/kit/src/exports/vite/index.js
@@ -637,7 +637,7 @@ async function kit({ svelte_config }) {
format: 'esm',
entryFileNames: ssr ? '[name].js' : `${prefix}/[name].[hash].${ext}`,
chunkFileNames: ssr ? 'chunks/[name].js' : `${prefix}/chunks/[name].[hash].${ext}`,
- assetFileNames: `${prefix}/assets/[name].[hash][extname]`,
+ assetFileNames: `${prefix}/assets/[hash][extname]`,
hoistTransitiveImports: false,
sourcemapIgnoreList
},

12
playwright.config.ts Normal file
View file

@ -0,0 +1,12 @@
import type { PlaywrightTestConfig } from '@playwright/test';
const config: PlaywrightTestConfig = {
webServer: {
command: 'npm run build && npm run preview',
port: 4173
},
testDir: 'tests',
testMatch: /(.+\.)?(test|spec)\.[jt]s/
};
export default config;

13
src/app.d.ts vendored Normal file
View file

@ -0,0 +1,13 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

11
src/app.html Normal file
View file

@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

7
src/index.test.ts Normal file
View file

@ -0,0 +1,7 @@
import { describe, it, expect } from 'vitest';
describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
});

5
src/lib/config.ts Normal file
View file

@ -0,0 +1,5 @@
import * as publicEnv from '$env/static/public';
export const PUBLIC_BASE_URL: string = Object.hasOwn(publicEnv, 'PUBLIC_BASE_URL')
? publicEnv.PUBLIC_BASE_URL
: '';

17
src/lib/i18n/index.ts Normal file
View file

@ -0,0 +1,17 @@
import { browser } from '$app/environment';
import { init, register } from 'svelte-i18n';
const defaultLocale = 'en';
register('en', () => import('../locales/en.json'));
register('nl', () => import('../locales/nl.json'));
register('es', () => import('../locales/es.json'));
init({
fallbackLocale: defaultLocale,
initialLocale: browser
? browser && localStorage.getItem('locale')
? localStorage.getItem('locale')
: window.navigator.language.slice(0, 2)
: defaultLocale
});

1
src/lib/index.ts Normal file
View file

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

95
src/lib/locales/en.json Normal file
View file

@ -0,0 +1,95 @@
{
"section": {
"settings": {
"title": "Settings",
"textColor": "Text color",
"preview": "Preview",
"backgroundColor": "Background color",
"ledPowerOnTest": "LED power-on test",
"ledFlashOnBlock": "LED flash on new block",
"timePerScreen": "Time per screen",
"ledBrightness": "LED brightness",
"timezoneOffset": "Timezone offset",
"timeBetweenPriceUpdates": "Time between price updates",
"fullRefreshEvery": "Full refresh every",
"mempoolnstance": "Mempool Instance",
"hostnamePrefix": "Hostname prefix",
"StealFocusOnNewBlock": "Steal focus on new block",
"useBigCharsMcap": "Use big characters for market cap",
"useBlkCountdown": "Blocks countdown for halving",
"useSatsSymbol": "Use sats symbol",
"suffixPrice": "Suffix price format",
"disableLeds": "Disable all LEDs effects",
"otaUpdates": "OTA updates",
"enableMdns": "mDNS",
"fetchEuroPrice": "Fetch € price",
"shortAmountsWarning": "Short amounts might shorten lifespan of the displays",
"tzOffsetHelpText": "A restart is required to apply TZ offset.",
"screens": "Screens",
"wifiTxPowerText": "In most cases this does not need to be set.",
"wifiTxPower": "WiFi TX power",
"settingsSaved": "Settings saved",
"errorSavingSettings": "Error saving settings"
},
"lines": {
"TIME": "Time",
"DATE": "Date",
"SATSPERUNIT": "Sats per unit (Moscow time)",
"FIATPRICE": "Fiat Price",
"BLOCKHEIGHT": "Block Height",
"MEMPOOL_FEES": "Mempool Fees (summary)",
"MEMPOOL_FEES_MEDIAN": "Mempool Fees (median)",
"HALVING_COUNTDOWN": "Halving Countdown (blocks)",
"MARKETCAP": "Market Cap"
},
"control": {
"systemInfo": "System info",
"version": "Version",
"buildTime": "Build time",
"ledColor": "LED color",
"turnOff": "Turn off",
"setColor": "Set color",
"showText": "Show text",
"text": "Text",
"title": "Control",
"hostname": "Hostname"
},
"status": {
"title": "Status",
"screenCycle": "Screen cycle",
"memoryFree": "Memory free",
"wsPriceConnection": "WS Price connection",
"wsMempoolConnection": "WS Mempool.space connection",
"fetchEuroNote": "If you use \"Fetch € price\" the WS Price connection will show ❌ since it uses another data source.",
"uptime": "Uptime",
"wifiSignalStrength": "WiFi Signal strength"
}
},
"colors": {
"black": "Black",
"white": "White"
},
"time": {
"minutes": "minutes",
"seconds": "seconds"
},
"restartRequired": "restart required",
"button": {
"save": "Save",
"reset": "Reset",
"restart": "Restart",
"forceFullRefresh": "Force full refresh"
},
"timer": {
"running": "running",
"stopped": "stopped"
},
"sections": {
"control": {
"keepSameColor": "Keep same color"
}
},
"rssiBar": {
"tooltip": "Values > -67 dBm are considered good. > -30 dBm is amazing"
}
}

82
src/lib/locales/es.json Normal file
View file

@ -0,0 +1,82 @@
{
"section": {
"settings": {
"title": "Configuración",
"textColor": "Color de texto",
"backgroundColor": "Color de fondo",
"ledBrightness": "Brillo LED",
"screens": "Pantallas",
"shortAmountsWarning": "Pequeñas cantidades pueden acortar la vida útil de los displays",
"fullRefreshEvery": "Actualización completa cada",
"timePerScreen": "Tiempo por pantalla",
"tzOffsetHelpText": "Es necesario reiniciar para aplicar la compensación.",
"timezoneOffset": "Compensación de zona horaria",
"StealFocusOnNewBlock": "Presta atención al nuevo bloque",
"ledFlashOnBlock": "El LED parpadea con un bloque nuevo",
"useBigCharsMcap": "Utilice caracteres grandes para la market cap",
"useBlkCountdown": "Cuenta regresiva en bloques",
"useSatsSymbol": "Usar símbolo sats",
"fetchEuroPrice": "Obtener precio en €",
"timeBetweenPriceUpdates": "Tiempo entre actualizaciones de precios",
"ledPowerOnTest": "Prueba de encendido del LED",
"enableMdns": "mDNS",
"hostnamePrefix": "Prefijo de nombre de host",
"mempoolnstance": "Instancia de Mempool",
"suffixPrice": "Precio con sufijos",
"disableLeds": "Desactivar efectos de LED",
"otaUpdates": "Actualización por aire",
"wifiTxPowerText": "En la mayoría de los casos no es necesario configurar esto.",
"settingsSaved": "Configuración guardada",
"errorSavingSettings": "Error al guardar la configuración"
},
"control": {
"turnOff": "Apagar",
"setColor": "Establecer el color",
"version": "Versión",
"ledColor": "color del LED",
"systemInfo": "Info del sistema",
"showText": "Mostrar texto",
"text": "Texto",
"title": "Control",
"buildTime": "Tiempo de compilación",
"hostname": "Nombre del host"
},
"status": {
"memoryFree": "Memoria RAM libre",
"wsPriceConnection": "Conexión WebSocket Precio",
"wsMempoolConnection": "Conexión WebSocket Mempool.space",
"screenCycle": "Ciclo de pantalla",
"uptime": "Tiempo de funcionamiento",
"fetchEuroNote": "Si utiliza \"Obtener precio en €\", la conexión de Precio WS mostrará ❌ ya que utiliza otra fuente de datos.",
"title": "Estado",
"wifiSignalStrength": "Fuerza de la señal WiFi"
}
},
"button": {
"save": "Guardar",
"reset": "Restaurar",
"restart": "Reiniciar",
"forceFullRefresh": "Forzar refresco"
},
"colors": {
"black": "Negro",
"white": "Blanco"
},
"restartRequired": "reinicio requerido",
"time": {
"minutes": "minutos",
"seconds": "segundos"
},
"timer": {
"running": "funcionando",
"stopped": "detenido"
},
"sections": {
"control": {
"keepSameColor": "Mantén el mismo color"
}
},
"rssiBar": {
"tooltip": "Se consideran buenos valores > -67 dBm. > -30 dBm es increíble"
}
}

82
src/lib/locales/nl.json Normal file
View file

@ -0,0 +1,82 @@
{
"section": {
"settings": {
"title": "Instellingen",
"textColor": "Tekstkleur",
"backgroundColor": "Achtergrondkleur",
"timeBetweenPriceUpdates": "Tijd tussen prijs updates",
"timezoneOffset": "Tijdzone afwijking",
"ledBrightness": "LED helderheid",
"timePerScreen": "Tijd per scherm",
"fullRefreshEvery": "Volledig verversen elke",
"shortAmountsWarning": "Lage waardes verkorten mogelijk levensduur schermen",
"tzOffsetHelpText": "Herstart nodig voor toepassen afwijking.",
"enableMdns": "mDNS",
"ledPowerOnTest": "LED test bij aanzetten",
"StealFocusOnNewBlock": "Pak aandacht bij nieuw blok",
"ledFlashOnBlock": "Knipper led bij nieuw blok",
"useBigCharsMcap": "Gebruik grote tekens bij market cap",
"useBlkCountdown": "Blocks aftellen voor halving",
"useSatsSymbol": "Gebruik sats symbol",
"fetchEuroPrice": "Toon € prijs",
"screens": "Schermen",
"hostnamePrefix": "Hostnaam voorvoegsel",
"mempoolnstance": "Mempool instantie",
"suffixPrice": "Achtervoegsel prijs formaat",
"disableLeds": "Alle LEDs effecten uit",
"otaUpdates": "OTA updates",
"wifiTxPower": "WiFi TX power",
"wifiTxPowerText": "Meestal hoeft dit niet aangepast te worden.",
"settingsSaved": "Instellingen opgeslagen",
"errorSavingSettings": "Fout bij opslaan instellingen"
},
"control": {
"systemInfo": "Systeeminformatie",
"version": "Versie",
"buildTime": "Bouwtijd",
"setColor": "Kleur instellen",
"turnOff": "Uitzetten",
"ledColor": "LED kleur",
"showText": "Toon tekst",
"text": "Tekst",
"title": "Besturing"
},
"status": {
"title": "Status",
"memoryFree": "Geheugen vrij",
"screenCycle": "Scherm cyclus",
"wsPriceConnection": "WS Prijs verbinding",
"wsMempoolConnection": "WS Mempool.space verbinding",
"fetchEuroNote": "Wanneer je \"Toon € prijs\" aanzet, zal de prijsverbinding als ❌ verbroken getoond worden vanwege het gebruik van een andere bron.",
"uptime": "Uptime",
"wifiSignalStrength": "WiFi signaalsterkte"
}
},
"colors": {
"black": "Zwart",
"white": "Wit"
},
"time": {
"minutes": "minuten",
"seconds": "seconden"
},
"restartRequired": "herstart nodig",
"button": {
"save": "Opslaan",
"reset": "Herstel",
"restart": "Herstart",
"forceFullRefresh": "Forceer scherm verversen"
},
"timer": {
"running": "actief",
"stopped": "gestopt"
},
"sections": {
"control": {
"keepSameColor": "Behoud zelfde kleur"
}
},
"rssiBar": {
"tooltip": "Waarden > -67 dBm zijn goed. > -30 dBm is verbazingwekkend"
}
}

68
src/lib/strftime.ts Normal file
View file

@ -0,0 +1,68 @@
import { format, parse, isValid } from 'date-fns';
export function strftime(formatString: string, dateString?: string): string {
const placeholders: { [key: string]: string } = {
'%a': 'EEE',
'%A': 'EEEE',
'%b': 'MMM',
'%B': 'MMMM',
'%c': 'EEE MMM dd HH:mm:ss yyyy',
'%C': '',
'%d': 'dd',
'%D': 'MM/dd/yy',
'%e': 'd',
'%F': 'yyyy-MM-dd',
'%g': '',
'%G': '',
'%h': 'MMM',
'%H': 'HH',
'%I': 'hh',
'%j': 'DDD',
'%k': 'H',
'%l': 'h',
'%m': 'MM',
'%M': 'mm',
'%n': '\n',
'%p': 'a',
'%P': 'a',
'%r': 'hh:mm:ss a',
'%R': 'HH:mm',
'%s': '',
'%S': 'ss',
'%t': '\t',
'%T': 'HH:mm:ss',
'%u': 'E',
'%U': '',
'%V': '',
'%w': 'e',
'%W': '',
'%x': 'MM/dd/yy',
'%X': 'HH:mm:ss',
'%y': 'yy',
'%Y': 'yyyy',
'%z': 'xxx',
'%Z': 'zzz',
'%%': '%'
};
let convertedFormatString = formatString;
if (!convertedFormatString) {
return '';
}
for (const placeholder in placeholders) {
if (Object.prototype.hasOwnProperty.call(placeholders, placeholder)) {
convertedFormatString = convertedFormatString.replace(placeholder, placeholders[placeholder]);
}
}
let parsedDate;
if (dateString && isValid(parse(dateString, 'yyyy-MM-dd', new Date()))) {
parsedDate = parse(dateString, 'yyyy-MM-dd', new Date());
} else {
parsedDate = new Date();
}
return format(parsedDate, convertedFormatString);
}

195
src/lib/style/app.scss Normal file
View file

@ -0,0 +1,195 @@
@import '../node_modules/bootstrap/scss/functions';
@import '../node_modules/bootstrap/scss/variables';
@import '../node_modules/bootstrap/scss/variables-dark';
@import '@fontsource/libre-franklin';
@font-face {
font-family: 'orangeclock-icons';
src:
url('/subset-orangeclock-icons.woff2') format('woff2'),
url('/subset-orangeclock-icons.woff') format('woff');
font-weight: normal;
font-style: normal;
font-display: swap;
}
.icon {
font-family: 'orangeclock-icons';
}
$form-range-track-bg: #fff;
$color-mode-type: media-query;
$font-family-base: 'Ubuntu';
$font-size-base: 0.9rem;
//$font-size-sm: $font-size-base * .875 !default;
//$form-label-font-size: $font-size-base * .575 !default;
//$input-btn-font-size-sm: 0.4rem;
//$form-label-font-size: 0.4rem;
$input-font-size-sm: $font-size-base * 0.875;
// $border-radius: .675rem;
@import '../node_modules/bootstrap/scss/mixins';
@import '../node_modules/bootstrap/scss/maps';
@import '../node_modules/bootstrap/scss/utilities';
@import '../node_modules/bootstrap/scss/root';
@import '../node_modules/bootstrap/scss/reboot';
@import '../node_modules/bootstrap/scss/type';
@import '../node_modules/bootstrap/scss/containers';
@import '../node_modules/bootstrap/scss/grid';
@import '../node_modules/bootstrap/scss/forms';
@import '../node_modules/bootstrap/scss/buttons';
@import '../node_modules/bootstrap/scss/button-group';
@import '../node_modules/bootstrap/scss/pagination';
@import '../node_modules/bootstrap/scss/dropdown';
@import '../node_modules/bootstrap/scss/navbar';
@import '../node_modules/bootstrap/scss/nav';
@import '../node_modules/bootstrap/scss/card';
@import '../node_modules/bootstrap/scss/progress';
@import '../node_modules/bootstrap/scss/tooltip';
@import '../node_modules/bootstrap/scss/toasts';
@import '../node_modules/bootstrap/scss/helpers';
@import '../node_modules/bootstrap/scss/utilities/api';
@include media-breakpoint-down(xl) {
html {
font-size: 85%;
}
button.btn,
input[type='button'].btn,
input[type='submit'].btn,
input[type='reset'].btn {
@include button-size(
$btn-padding-y-sm,
$btn-padding-x-sm,
$font-size-sm,
$btn-border-radius-sm
);
}
}
@include media-breakpoint-down(lg) {
html {
font-size: 75%;
}
}
nav {
margin-bottom: 15px;
}
.splitText div:first-child::after {
display: block;
content: '';
margin-top: 0px;
border-bottom: 2px solid;
margin-bottom: 3px;
}
#screen-wrapper {
margin: 0 auto;
display: flex;
width: 75%; /* Container takes up full width */
max-width: 75%; /* Ensure it doesn't exceed the available width */
position: relative; /* Establishes the containing block for absolute positioning */
// background-color: lightgray; /* Optional: Just for visualization */
}
.ar-wrapper {
// padding-top: calc(122 / 250 * 100%); /* Aspect ratio: height / width * 100% */
// position: relative;
// width: 100%; /* Ensure the wrapper takes up full width */
padding-top: calc(122 / 250 * 100%); /* Aspect ratio: height / width * 100% */
position: relative;
width: 100%; /* Ensure the wrapper takes up full width */
}
.oc-screen {
position: absolute; /* Position content absolutely within the wrapper */
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
font-size: 16px;
color: black;
// border: 1px solid darkgray;
background: lightgrey;
// border-radius: 5px;
// padding: 10px;
// margin: 0 auto;
// display: flex;
flex-direction: column;
// flex-wrap: nowrap;
// justify-content: center;
// align-items: center;
// align-content: center;
font-family: 'Libre Franklin';
font-weight: 600;
.oc-row .icon {
display: inline-block;
font-weight: normal;
font-size: 90%;
}
.oc-row:nth-child(1) {
font-size: 1.5vw;
display: block;
flex-grow: 0;
flex-shrink: 1;
flex-basis: auto;
align-self: auto;
order: 0;
}
.oc-row:nth-child(2) {
font-size: 3vw;
display: block;
font-weight: bold;
flex-grow: 0;
flex-shrink: 1;
flex-basis: auto;
align-self: auto;
order: 0;
}
.oc-row:nth-child(3) {
font-size: 2vw;
display: block;
flex-grow: 0;
flex-shrink: 1;
flex-basis: auto;
align-self: auto;
order: 0;
}
}
#customText {
text-transform: uppercase;
}
.system_info {
padding: 0;
li {
list-style: none;
}
}
.card-title {
margin-bottom: 0;
}
.navbar-brand {
font-style: italic;
font-weight: 600;
}

80
src/routes/+layout.svelte Normal file
View file

@ -0,0 +1,80 @@
<script lang="ts">
import {
Collapse,
Dropdown,
DropdownItem,
DropdownMenu,
DropdownToggle,
Nav,
NavItem,
NavLink,
Navbar,
NavbarBrand
} from 'sveltestrap';
import { page } from '$app/stores';
import { locale, locales, isLoading } from 'svelte-i18n';
export const setLocale = (lang: string) => () => {
locale.set(lang);
localStorage.setItem('locale', lang);
};
export const getFlagEmoji = (languageCode: string): string | null => {
const flagMap: { [key: string]: string } = {
en: '🇬🇧', // English flag emoji
nl: '🇳🇱', // Dutch flag emoji
es: '🇪🇸' // Spanish flag emoji
};
// Convert the language code to lowercase for case-insensitive matching
const lowercaseCode = languageCode.toLowerCase();
// Check if the language code is in the flagMap
if (Object.prototype.hasOwnProperty.call(flagMap, lowercaseCode)) {
return flagMap[lowercaseCode];
} else {
// Return null for unsupported language codes
return null;
}
};
let languageNames = {};
locale.subscribe(() => {
let newLanguageNames = new Intl.DisplayNames([$locale], { type: 'language' });
for (let l: string of $locales) {
languageNames[l] = newLanguageNames.of(l);
}
});
</script>
<Navbar expand="md">
<NavbarBrand>OrangeBTClock</NavbarBrand>
<Collapse navbar expand="md">
<Nav class="me-auto" navbar>
<NavItem>
<NavLink href="/" active={$page.url.pathname === '/'}>Home</NavLink>
</NavItem>
<NavItem>
<NavLink href="/api" active={$page.url.pathname === '/api'}>API</NavLink>
</NavItem>
</Nav>
{#if !$isLoading}
<Dropdown id="nav-language-dropdown" inNavbar>
<DropdownToggle nav caret>{getFlagEmoji($locale)} {languageNames[$locale]}</DropdownToggle>
<DropdownMenu end>
{#each $locales as locale}
<DropdownItem on:click={setLocale(locale)}
>{getFlagEmoji(locale)} {languageNames[locale]}</DropdownItem
>
{/each}
</DropdownMenu>
</Dropdown>
{/if}
</Collapse>
</Navbar>
<!-- +layout.svelte -->
<slot />

19
src/routes/+layout.ts Normal file
View file

@ -0,0 +1,19 @@
import '$lib/style/app.scss';
import { browser } from '$app/environment';
import '$lib/i18n'; // Import to initialize. Important :)
import { locale, waitLocale } from 'svelte-i18n';
import type { LayoutLoad } from './$types';
export const load: LayoutLoad = async () => {
if (browser && localStorage.getItem('locale')) {
locale.set(localStorage.getItem('locale'));
} else if (browser) {
locale.set(window.navigator.language);
}
await waitLocale();
};
export const prerender = true;
export const ssr = false;
export const csr = true;

79
src/routes/+page.svelte Normal file
View file

@ -0,0 +1,79 @@
<script lang="ts">
import { PUBLIC_BASE_URL } from '$lib/config';
import { Container, Row, Toast, ToastBody } from 'sveltestrap';
import Settings from './Settings.svelte';
import Status from './Status.svelte';
import Control from './Control.svelte';
import { onDestroy, onMount } from 'svelte';
import { writable } from 'svelte/store';
let settings = writable({
fgColor: '0'
});
let status = writable({});
let statusPollInterval;
const fetchSettingsData = () => {
fetch(PUBLIC_BASE_URL + `/api/settings`)
.then((res) => res.json())
.then((data) => {
settings.set(data);
});
};
const fetchStatusData = () => {
fetch(`${PUBLIC_BASE_URL}/api/status`)
.then((res) => res.json())
.then((data) => {
status.set(data);
});
};
onMount(() => {
fetchSettingsData();
fetchStatusData();
statusPollInterval = setInterval(fetchStatusData, 10000);
});
let toastIsOpen = false;
let toastColor = 'success';
let toastBody = '';
export const showToast = (event) => {
toastIsOpen = true;
toastColor = event.detail.color;
toastBody = event.detail.text;
};
onDestroy(() => {
clearInterval(statusPollInterval); // Cleanup interval when component is destroyed
});
</script>
<svelte:head>
<title>OrangeBTClock</title>
</svelte:head>
<Container fluid>
<Row>
<Control bind:settings></Control>
<Status bind:status></Status>
<Settings bind:settings on:showToast={showToast} on:formReset={fetchSettingsData}></Settings>
</Row>
</Container>
<div class="position-fixed bottom-0 end-0 p-2">
<div class="">
<Toast
isOpen={toastIsOpen}
class="me-1 bg-{toastColor}"
autohide
on:close={() => (toastIsOpen = false)}
>
<ToastBody>
{toastBody}
</ToastBody>
</Toast>
</div>
</div>

40
src/routes/Control.svelte Normal file
View file

@ -0,0 +1,40 @@
<script lang="ts">
import { PUBLIC_BASE_URL } from '$lib/config';
import { _ } from 'svelte-i18n';
import { Button, Card, CardBody, CardHeader, CardTitle, Col } from 'sveltestrap';
export let settings = {};
const restartClock = () => {
fetch(`${PUBLIC_BASE_URL}/api/restart`).catch(() => {});
};
const forceFullRefresh = () => {
fetch(`${PUBLIC_BASE_URL}/api/full_refresh`).catch(() => {});
};
</script>
<Col>
<Card>
<CardHeader>
<CardTitle>{$_('section.control.title', { default: 'Control' })}</CardTitle>
</CardHeader>
<CardBody>
<h3>{$_('section.control.systemInfo')}</h3>
<ul class="small system_info">
<li>{$_('section.control.version')}: {$settings.gitRev}</li>
<li>
{$_('section.control.buildTime')}: {new Date(
$settings.lastBuildTime * 1000
).toLocaleString()}
</li>
<li>IP: {$settings.ip}</li>
<li>{$_('section.control.hostname')}: {$settings.hostname}</li>
</ul>
<Button color="danger" id="restartBtn" on:click={restartClock}>{$_('button.restart')}</Button>
<Button color="warning" id="forceFullRefresh" on:click={forceFullRefresh}
>{$_('button.forceFullRefresh')}</Button
>
</CardBody>
</Card>
</Col>

View file

@ -0,0 +1,22 @@
<script lang="ts">
export let status = {};
</script>
<div class="screen-wrapper" id="screen-wrapper">
<div class="ar-wrapper">
<div class="oc-screen">
<div class="oc-row">
<div class="icon">{status.icon1}</div>
{status.row1}
</div>
<div class="oc-row">
<div class="icon">{status.icon2}</div>
{status.row2}
</div>
<div class="oc-row">
<div class="icon">{status.icon3}</div>
{status.row3}
</div>
</div>
</div>
</div>

261
src/routes/Settings.svelte Normal file
View file

@ -0,0 +1,261 @@
<script lang="ts">
import { PUBLIC_BASE_URL } from '$lib/config';
import { strftime } from '$lib/strftime';
import { createEventDispatcher } from 'svelte';
import { _ } from 'svelte-i18n';
import {
Button,
Card,
CardBody,
CardHeader,
CardTitle,
Col,
Form,
FormText,
Input,
InputGroup,
InputGroupText,
Label,
Row
} from 'sveltestrap';
export let settings;
const wifiTxPowerMap = new Map<string, number>([
['Default', 80],
['19.5dBm', 78], // 19.5dBm
['19dBm', 76], // 19dBm
['18.5dBm', 74], // 18.5dBm
['17dBm', 68], // 17dBm
['15dBm', 60], // 15dBm
['13dBm', 52], // 13dBm
['11dBm', 44], // 11dBm
['8.5dBm', 34], // 8.5dBm
['7dBm', 28], // 7dBm
['5dBm', 20] // 5dBm
]);
const rowOptions = new Map<string, number>([
['BLOCKHEIGHT', 0],
['MEMPOOL_FEES', 1],
['MEMPOOL_FEES_MEDIAN', 2],
['HALVING_COUNTDOWN', 10],
['SATSPERUNIT', 20],
['FIATPRICE', 30],
['MARKETCAP', 40],
['TIME', 99],
['DATE', 100]
]);
const currencyOptions = ['USD', 'EUR', 'GBP', 'YEN'];
const dispatch = createEventDispatcher();
const handleReset = (e: Event) => {
e.preventDefault();
dispatch('formReset');
};
const onSave = async (e: Event) => {
e.preventDefault();
let formSettings = $settings;
delete formSettings['gitRev'];
delete formSettings['ip'];
delete formSettings['lastBuildTime'];
await fetch(`${PUBLIC_BASE_URL}/api/json/settings`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formSettings)
})
.then(() => {
dispatch('showToast', {
color: 'success',
text: $_('section.settings.settingsSaved')
});
})
.catch(() => {
dispatch('showToast', {
color: 'danger',
text: $_('section.settings.errorSavingSettings')
});
});
};
</script>
<Col>
<Card>
<CardHeader>
<CardTitle>{$_('section.settings.title', { default: 'Settings' })}</CardTitle>
</CardHeader>
<CardBody>
<Form on:submit={onSave}>
<Row>
<Label md={6} for="fgColor" size="sm"
>{$_('section.settings.row1', { default: 'Row 1' })}</Label
>
<Col md="6">
<Input
type="select"
bind:value={$settings.row1}
name="select"
id="row1"
bsSize="sm"
class="form-select-sm"
>
{#each rowOptions as [key, value]}
<option {value}>{$_(`section.lines.${key}`)}</option>
{/each}
</Input>
</Col>
</Row>
<Row>
<Label md={6} for="fgColor" size="sm"
>{$_('section.settings.row2', { default: 'Row 2' })}</Label
>
<Col md="6">
<Input
type="select"
bind:value={$settings.row2}
name="select"
id="row2"
bsSize="sm"
class="form-select-sm"
>
{#each rowOptions as [key, value]}
<option {value}>{$_(`section.lines.${key}`)}</option>
{/each}
</Input>
</Col>
</Row>
<Row>
<Label md={6} for="row3" size="sm"
>{$_('section.settings.row3', { default: 'Row 3' })}</Label
>
<Col md="6">
<Input
type="select"
bind:value={$settings.row3}
name="select"
id="row3"
bsSize="sm"
class="form-select-sm"
>
{#each rowOptions as [key, value]}
<option {value}>{$_(`section.lines.${key}`)}</option>
{/each}
</Input>
</Col>
</Row>
<Row>
<Label md={6} for="currency" size="sm"
>{$_('section.settings.currency', { default: 'Currency' })}</Label
>
<Col md="6">
<Input
type="select"
bind:value={$settings.currency}
name="select"
id="currency"
bsSize="sm"
class="form-select-sm"
>
{#each currencyOptions as value}
<option {value}>{value}</option>
{/each}
</Input>
</Col>
</Row>
<Row>
<Label md={6} for="timeFormat" size="sm"
>{$_('section.settings.timeFormat', { default: 'Time format' })}</Label
>
<Col md="6">
<Input
type="text"
bind:value={$settings.timeFormat}
name="timeFormat"
id="timeFormat"
bsSize="sm"
maxlength="16"
></Input>
<FormText>{$_('section.settings.preview')}: {strftime($settings.timeFormat)}</FormText>
</Col>
</Row>
<Row>
<Label md={6} for="dateFormat" size="sm"
>{$_('section.settings.dateFormat', { default: 'Date format' })}</Label
>
<Col md="6">
<Input
type="text"
bind:value={$settings.dateFormat}
name="dateFormat"
id="dateFormat"
bsSize="sm"
maxlength="16"
></Input>
<FormText>{$_('section.settings.preview')}: {strftime($settings.dateFormat)}</FormText>
</Col>
</Row>
<Row>
<Label md={6} for="tzOffset" size="sm">{$_('section.settings.timezoneOffset')}</Label>
<Col md="6">
<InputGroup size="sm">
<Input
type="number"
step="1"
name="tzOffset"
id="tzOffset"
bind:value={$settings.timeOffsetMin}
/>
<InputGroupText>{$_('time.minutes')}</InputGroupText>
</InputGroup>
<FormText>{$_('section.settings.tzOffsetHelpText')}</FormText>
</Col>
</Row>
<Row>
<Label md={6} for="wifiTxPower" size="sm"
>{$_('section.settings.wifiTxPower', { default: 'WiFi Tx Power' })}</Label
>
<Col md="6">
<Input
type="select"
bind:value={$settings.txPower}
name="select"
id="fgColor"
bsSize="sm"
class="form-select-sm"
>
{#each wifiTxPowerMap as [key, value]}
<option {value}>{key}</option>
{/each}
</Input>
<FormText>{$_('section.settings.wifiTxPowerText')}</FormText>
</Col>
</Row>
<Row>
<Label md={6} for="mempoolInstance" size="sm"
>{$_('section.settings.mempoolnstance')}</Label
>
<Col md="6">
<Input
type="text"
bind:value={$settings.mempoolInstance}
name="mempoolInstance"
id="mempoolInstance"
bsSize="sm"
></Input>
</Col>
</Row>
<Button on:click={handleReset} color="secondary">{$_('button.reset')}</Button>
<Button color="primary">{$_('button.save')}</Button>
</Form>
</CardBody>
</Card>
</Col>

86
src/routes/Status.svelte Normal file
View file

@ -0,0 +1,86 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { Card, CardBody, CardHeader, CardTitle, Col, Progress, Tooltip } from 'sveltestrap';
import Rendered from './Rendered.svelte';
import { writable } from 'svelte/store';
export let status: writable<object>;
const toTime = (secs: number) => {
var hours = Math.floor(secs / (60 * 60));
var divisor_for_minutes = secs % (60 * 60);
var minutes = Math.floor(divisor_for_minutes / 60);
var divisor_for_seconds = divisor_for_minutes % 60;
var seconds = Math.ceil(divisor_for_seconds);
var obj = {
h: hours,
m: minutes,
s: seconds
};
return obj;
};
const toUptimestring = (secs: number): string => {
let time = toTime(secs);
return `${time.h}h ${time.m}m ${time.s}s`;
};
let memoryFreePercent: number = 50;
let rssiPercent: number = 50;
let wifiStrengthColor: string = 'info';
status.subscribe((value: object) => {
memoryFreePercent = Math.round((value.espFreeHeap / value.espHeapSize) * 100);
rssiPercent = Math.round(((value.rssi + 120) / (-30 + 120)) * 100);
if (value.rssi > -55) {
wifiStrengthColor = 'success';
} else if (value.rssi < -87) {
wifiStrengthColor = 'warning';
} else {
wifiStrengthColor = 'info';
}
});
</script>
<Col>
<Card>
<CardHeader>
<CardTitle>{$_('section.status.title', { default: 'Status' })}</CardTitle>
</CardHeader>
<CardBody>
<section>
<Rendered status={$status}></Rendered>
</section>
<hr />
<Progress striped value={memoryFreePercent}>{memoryFreePercent}%</Progress>
<div class="d-flex justify-content-between">
<div>{$_('section.status.memoryFree')}</div>
<div>
{Math.round($status.espFreeHeap / 1024)} / {Math.round($status.espHeapSize / 1024)} KiB
</div>
</div>
<hr />
<Progress striped id="rssiBar" color={wifiStrengthColor} value={rssiPercent}
>{rssiPercent}%</Progress
>
<Tooltip target="rssiBar" placement="bottom">{$_('rssiBar.tooltip')}</Tooltip>
<div class="d-flex justify-content-between">
<div>{$_('section.status.wifiSignalStrength')}</div>
<div>
{$status.rssi} dBm
</div>
</div>
<hr />
{$_('section.status.uptime')}: {toUptimestring($status.espUptime)}
</CardBody>
</Card>
</Col>

Binary file not shown.

Binary file not shown.

31
svelte.config.js Normal file
View file

@ -0,0 +1,31 @@
import adapter from '@sveltejs/adapter-static';
import preprocess from 'svelte-preprocess';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
// for more information about preprocessors
preprocess: preprocess({}),
build: {
rollupOptions: {
output: {
assetFilenames: '[hash]'
}
}
},
kit: {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter({
pages: 'dist',
assets: 'dist',
fallback: 'bundle.html',
precompress: false,
strict: true
}),
appDir: 'build'
}
};
export default config;

6
tests/test.ts Normal file
View file

@ -0,0 +1,6 @@
import { expect, test } from '@playwright/test';
test('index page has expected h1', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Welcome to SvelteKit' })).toBeVisible();
});

18
tsconfig.json Normal file
View file

@ -0,0 +1,18 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

19
vite.config.ts Normal file
View file

@ -0,0 +1,19 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [sveltekit()],
build: {
minify: true,
cssCodeSplit: false,
rollupOptions: {
output: {
manualChunks: () => 'app',
assetFileNames: '[name][extname]'
}
}
},
test: {
include: ['src/**/*.{test,spec}.{js,ts}']
}
});

2574
yarn.lock Normal file

File diff suppressed because it is too large Load diff