Initial commit

This commit is contained in:
Djuri 2025-05-03 18:21:06 +02:00
commit af2f593fb8
Signed by: djuri
GPG key ID: 61B9B2DDE5AA3AC1
66 changed files with 8735 additions and 0 deletions

27
.gitignore vendored Normal file
View file

@ -0,0 +1,27 @@
test-results
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Paraglide
src/lib/paraglide

1
.npmrc Normal file
View file

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

6
.prettierignore Normal file
View file

@ -0,0 +1,6 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb

15
.prettierrc Normal file
View file

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

38
README.md Normal file
View file

@ -0,0 +1,38 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

6
e2e/demo.test.ts Normal file
View file

@ -0,0 +1,6 @@
import { expect, test } from '@playwright/test';
test('home page has expected h1', async ({ page }) => {
await page.goto('/');
await expect(page.locator('h1')).toBeVisible();
});

36
eslint.config.js Normal file
View file

@ -0,0 +1,36 @@
import prettier from 'eslint-config-prettier';
import js from '@eslint/js';
import { includeIgnoreFile } from '@eslint/compat';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import { fileURLToPath } from 'node:url';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default ts.config(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
prettier,
...svelte.configs.prettier,
{
languageOptions: {
globals: { ...globals.browser, ...globals.node }
},
rules: { 'no-undef': 'off' }
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser,
svelteConfig
}
}
}
);

152
messages/de-DE.json Normal file
View file

@ -0,0 +1,152 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"section": {
"settings": {
"title": "Einstellungen",
"textColor": "Textfarbe",
"backgroundColor": "Hintergrundfarbe",
"ledPowerOnTest": "LED-Einschalttest",
"ledFlashOnBlock": "LED blinkt bei neuem Block",
"timePerScreen": "Zeit pro Bildschirm",
"ledBrightness": "LED-Helligkeit",
"flMaxBrightness": "Displaybeleuchtung Helligkeit",
"timezoneOffset": "Zeitzonenoffset",
"timeBetweenPriceUpdates": "Zeit zwischen Preisaktualisierungen",
"fullRefreshEvery": "Vollständige Aktualisierung alle",
"mempoolnstance": "Mempool Instance",
"hostnamePrefix": "Hostnamen-Präfix",
"StealFocusOnNewBlock": "Steal focus on new block",
"useBigCharsMcap": "Verwende große Zeichen für die Marktkapitalisierung",
"useBlkCountdown": "Blocks Countdown zur Halbierung",
"useSatsSymbol": "Sats-Symbol verwenden",
"suffixPrice": "Suffix-Preisformat",
"disableLeds": "Alle LED-Effekte deaktivieren",
"otaUpdates": "OTA updates",
"enableMdns": "mDNS",
"fetchEuroPrice": "€-Preis abrufen",
"shortAmountsWarning": "Geringe Beträge können die Lebensdauer der Displays verkürzen",
"tzOffsetHelpText": "Ein Neustart ist erforderlich, um den TZ-Offset anzuwenden.",
"screens": "Bildschirme",
"wifiTxPowerText": "In den meisten Fällen muss dies nicht eingestellt werden.",
"wifiTxPower": "WiFi-TX-Leistung",
"settingsSaved": "Einstellungen gespeichert",
"errorSavingSettings": "Fehler beim Speichern der Einstellungen",
"ownDataSource": "BTClock-Datenquelle",
"flAlwaysOn": "Displaybeleuchtung immer an",
"flEffectDelay": "Displaybeleuchtungeffekt Geschwindigkeit",
"flFlashOnUpd": "Displaybeleuchting bei neuem Block",
"mempoolInstanceHelpText": "Nur wirksam, wenn die BTClock-Datenquelle deaktiviert ist. \nZur Anwendung ist ein Neustart erforderlich.",
"luxLightToggle": "Automatisches Umschalten des Frontlichts bei Lux",
"wpTimeout": "WiFi-Konfigurationsportal timeout",
"useNostr": "Nostr-Datenquelle verwenden",
"flDisable": "Displaybeleuchtung deaktivieren",
"httpAuthUser": "WebUI-Benutzername",
"httpAuthPass": "WebUI-Passwort",
"httpAuthText": "Schützt nur die WebUI mit einem Passwort, nicht API-Aufrufe.",
"currencies": "Währungen",
"mowMode": "Mow suffixmodus",
"suffixShareDot": "Kompakte Suffix-Notation",
"section": {
"displaysAndLed": "Anzeigen und LEDs",
"screenSettings": "Infospezifisch",
"dataSource": "Datenquelle",
"extraFeatures": "Zusätzliche Funktionen",
"system": "System"
},
"ledFlashOnZap": "LED blinkt bei Nostr Zap",
"flFlashOnZap": "Displaybeleuchting bei Nostr Zap",
"showAll": "Alle anzeigen",
"hideAll": "Alles ausblenden",
"flOffWhenDark": "Displaybeleuchtung aus, wenn es dunkel ist",
"luxLightToggleText": "Zum Deaktivieren auf 0 setzen",
"verticalDesc": "Vrtikale Bildschirmbeschreibung",
"enableDebugLog": "Debug-Protokoll aktivieren",
"bitaxeEnabled": "BitAxe-Integration aktivieren",
"miningPoolStats": "Mining-Pool-Statistiken Integration Aktivieren",
"nostrZapNotify": "Nostr Zap-Benachrichtigungen aktivieren",
"thirdPartySource": "mempool.space/coincap.io Verwenden",
"dataSource": {
"nostr": "Nostr-Verlag",
"custom": "Benutzerdefinierter dataquelle"
},
"fontName": "Schriftart",
"timeBasedDnd": "Aktivieren Sie den Zeitplan „Bitte nicht stören“.",
"dndStartHour": "Startstunde",
"dndStartMinute": "Startminute",
"dndEndHour": "Endstunde",
"dndEndMinute": "Schlussminute"
},
"control": {
"systemInfo": "Systeminfo",
"version": "Version",
"buildTime": "Build time",
"ledColor": "LED-Farbe",
"turnOff": "Ausschalten",
"setColor": "Farbe festlegen",
"showText": "Text anzeigen",
"text": "Text",
"title": "Kontrolle",
"hostname": "Hostname",
"frontlight": "Displaybeleuchtung",
"turnOn": "Einschalten",
"flashFrontlight": "Blinken"
},
"status": {
"title": "Status",
"screenCycle": "Bildschirmzyklus",
"memoryFree": "Speicher frei",
"wsPriceConnection": "WS-Preisverbindung",
"wsMempoolConnection": "WS {instance}-Verbindung",
"fetchEuroNote": "If you use \"Fetch € price\" the WS Price connection will show ❌ since it uses another data source.",
"uptime": "Betriebszeit",
"wifiSignalStrength": "WiFi-Signalstärke",
"wsDataConnection": "BTClock-Datenquelle verbindung",
"lightSensor": "Lichtsensor",
"nostrConnection": "Nostr Relay-Verbindung",
"doNotDisturb": "Bitte nicht stören",
"timeBasedDnd": "Zeitbasierter Zeitplan"
},
"firmwareUpdater": {
"fileUploadSuccess": "Datei erfolgreich hochgeladen, Gerät neu gestartet. WebUI in {countdown} Sekunden neu geladen",
"fileUploadFailed": "Das Hochladen der Datei ist fehlgeschlagen. \nStellen Sie sicher, dass Sie die richtige Datei ausgewählt haben, und versuchen Sie es erneut.",
"uploading": "Hochladen",
"firmwareUpdateText": "Wenn Sie die Firmware-Upload-Funktion verwenden, stellen Sie sicher, dass Sie die richtigen Dateien verwenden. \nDas Hochladen der falschen Dateien kann dazu führen, dass das Gerät nicht mehr funktioniert. \nWenn es schief geht, können Sie die Firmware wiederherstellen, indem Sie das vollständige Image hochladen, nachdem Sie das Gerät in den BOOT-Modus versetzt haben.",
"swUpToDate": "Du hast die neueste Version.",
"swUpdateAvailable": "Eine neuere Version ist verfügbar!",
"latestVersion": "Letzte Version",
"releaseDate": "Veröffentlichungsdatum",
"viewRelease": "Veröffentlichung anzeigen",
"autoUpdate": "Update installieren (experimentell)",
"autoUpdateInProgress": "Automatische Aktualisierung läuft, bitte warten..."
}
},
"colors": {
"black": "Schwarz",
"white": "Weiss"
},
"time": {
"minutes": "Minuten",
"seconds": "Sekunden"
},
"restartRequired": "Neustart erforderlich",
"button": {
"save": "Speichern",
"reset": "Zurücksetzen",
"restart": "Neustart",
"forceFullRefresh": "Vollständige Aktualisierung erzwingen"
},
"timer": {
"running": "läuft",
"stopped": "gestoppt"
},
"sections": {
"control": {
"keepSameColor": "Gleiche Farbe beibehalten"
}
},
"rssiBar": {
"tooltip": "Werte > -67 dBm gelten als gut. > -30 dBm ist erstaunlich"
},
"warning": "Achtung",
"auto-detect": "Automatische Erkennung"
}

153
messages/de.json Normal file
View file

@ -0,0 +1,153 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"hello_world": "Hello, {name} from de!",
"section": {
"settings": {
"title": "Einstellungen",
"textColor": "Textfarbe",
"backgroundColor": "Hintergrundfarbe",
"ledPowerOnTest": "LED-Einschalttest",
"ledFlashOnBlock": "LED blinkt bei neuem Block",
"timePerScreen": "Zeit pro Bildschirm",
"ledBrightness": "LED-Helligkeit",
"flMaxBrightness": "Displaybeleuchtung Helligkeit",
"timezoneOffset": "Zeitzonenoffset",
"timeBetweenPriceUpdates": "Zeit zwischen Preisaktualisierungen",
"fullRefreshEvery": "Vollständige Aktualisierung alle",
"mempoolnstance": "Mempool Instance",
"hostnamePrefix": "Hostnamen-Präfix",
"StealFocusOnNewBlock": "Steal focus on new block",
"useBigCharsMcap": "Verwende große Zeichen für die Marktkapitalisierung",
"useBlkCountdown": "Blocks Countdown zur Halbierung",
"useSatsSymbol": "Sats-Symbol verwenden",
"suffixPrice": "Suffix-Preisformat",
"disableLeds": "Alle LED-Effekte deaktivieren",
"otaUpdates": "OTA updates",
"enableMdns": "mDNS",
"fetchEuroPrice": "€-Preis abrufen",
"shortAmountsWarning": "Geringe Beträge können die Lebensdauer der Displays verkürzen",
"tzOffsetHelpText": "Ein Neustart ist erforderlich, um den TZ-Offset anzuwenden.",
"screens": "Bildschirme",
"wifiTxPowerText": "In den meisten Fällen muss dies nicht eingestellt werden.",
"wifiTxPower": "WiFi-TX-Leistung",
"settingsSaved": "Einstellungen gespeichert",
"errorSavingSettings": "Fehler beim Speichern der Einstellungen",
"ownDataSource": "BTClock-Datenquelle",
"flAlwaysOn": "Displaybeleuchtung immer an",
"flEffectDelay": "Displaybeleuchtungeffekt Geschwindigkeit",
"flFlashOnUpd": "Displaybeleuchting bei neuem Block",
"mempoolInstanceHelpText": "Nur wirksam, wenn die BTClock-Datenquelle deaktiviert ist. \nZur Anwendung ist ein Neustart erforderlich.",
"luxLightToggle": "Automatisches Umschalten des Frontlichts bei Lux",
"wpTimeout": "WiFi-Konfigurationsportal timeout",
"useNostr": "Nostr-Datenquelle verwenden",
"flDisable": "Displaybeleuchtung deaktivieren",
"httpAuthUser": "WebUI-Benutzername",
"httpAuthPass": "WebUI-Passwort",
"httpAuthText": "Schützt nur die WebUI mit einem Passwort, nicht API-Aufrufe.",
"currencies": "Währungen",
"mowMode": "Mow suffixmodus",
"suffixShareDot": "Kompakte Suffix-Notation",
"section": {
"displaysAndLed": "Anzeigen und LEDs",
"screenSettings": "Infospezifisch",
"dataSource": "Datenquelle",
"extraFeatures": "Zusätzliche Funktionen",
"system": "System"
},
"ledFlashOnZap": "LED blinkt bei Nostr Zap",
"flFlashOnZap": "Displaybeleuchting bei Nostr Zap",
"showAll": "Alle anzeigen",
"hideAll": "Alles ausblenden",
"flOffWhenDark": "Displaybeleuchtung aus, wenn es dunkel ist",
"luxLightToggleText": "Zum Deaktivieren auf 0 setzen",
"verticalDesc": "Vrtikale Bildschirmbeschreibung",
"enableDebugLog": "Debug-Protokoll aktivieren",
"bitaxeEnabled": "BitAxe-Integration aktivieren",
"miningPoolStats": "Mining-Pool-Statistiken Integration Aktivieren",
"nostrZapNotify": "Nostr Zap-Benachrichtigungen aktivieren",
"thirdPartySource": "mempool.space/coincap.io Verwenden",
"dataSource": {
"nostr": "Nostr-Verlag",
"custom": "Benutzerdefinierter dataquelle"
},
"fontName": "Schriftart",
"timeBasedDnd": "Aktivieren Sie den Zeitplan „Bitte nicht stören“.",
"dndStartHour": "Startstunde",
"dndStartMinute": "Startminute",
"dndEndHour": "Endstunde",
"dndEndMinute": "Schlussminute"
},
"control": {
"systemInfo": "Systeminfo",
"version": "Version",
"buildTime": "Build time",
"ledColor": "LED-Farbe",
"turnOff": "Ausschalten",
"setColor": "Farbe festlegen",
"showText": "Text anzeigen",
"text": "Text",
"title": "Kontrolle",
"hostname": "Hostname",
"frontlight": "Displaybeleuchtung",
"turnOn": "Einschalten",
"flashFrontlight": "Blinken"
},
"status": {
"title": "Status",
"screenCycle": "Bildschirmzyklus",
"memoryFree": "Speicher frei",
"wsPriceConnection": "WS-Preisverbindung",
"wsMempoolConnection": "WS {instance}-Verbindung",
"fetchEuroNote": "If you use \"Fetch € price\" the WS Price connection will show ❌ since it uses another data source.",
"uptime": "Betriebszeit",
"wifiSignalStrength": "WiFi-Signalstärke",
"wsDataConnection": "BTClock-Datenquelle verbindung",
"lightSensor": "Lichtsensor",
"nostrConnection": "Nostr Relay-Verbindung",
"doNotDisturb": "Bitte nicht stören",
"timeBasedDnd": "Zeitbasierter Zeitplan"
},
"firmwareUpdater": {
"fileUploadSuccess": "Datei erfolgreich hochgeladen, Gerät neu gestartet. WebUI in {countdown} Sekunden neu geladen",
"fileUploadFailed": "Das Hochladen der Datei ist fehlgeschlagen. \nStellen Sie sicher, dass Sie die richtige Datei ausgewählt haben, und versuchen Sie es erneut.",
"uploading": "Hochladen",
"firmwareUpdateText": "Wenn Sie die Firmware-Upload-Funktion verwenden, stellen Sie sicher, dass Sie die richtigen Dateien verwenden. \nDas Hochladen der falschen Dateien kann dazu führen, dass das Gerät nicht mehr funktioniert. \nWenn es schief geht, können Sie die Firmware wiederherstellen, indem Sie das vollständige Image hochladen, nachdem Sie das Gerät in den BOOT-Modus versetzt haben.",
"swUpToDate": "Du hast die neueste Version.",
"swUpdateAvailable": "Eine neuere Version ist verfügbar!",
"latestVersion": "Letzte Version",
"releaseDate": "Veröffentlichungsdatum",
"viewRelease": "Veröffentlichung anzeigen",
"autoUpdate": "Update installieren (experimentell)",
"autoUpdateInProgress": "Automatische Aktualisierung läuft, bitte warten..."
}
},
"colors": {
"black": "Schwarz",
"white": "Weiss"
},
"time": {
"minutes": "Minuten",
"seconds": "Sekunden"
},
"restartRequired": "Neustart erforderlich",
"button": {
"save": "Speichern",
"reset": "Zurücksetzen",
"restart": "Neustart",
"forceFullRefresh": "Vollständige Aktualisierung erzwingen"
},
"timer": {
"running": "läuft",
"stopped": "gestoppt"
},
"sections": {
"control": {
"keepSameColor": "Gleiche Farbe beibehalten"
}
},
"rssiBar": {
"tooltip": "Werte > -67 dBm gelten als gut. > -30 dBm ist erstaunlich"
},
"warning": "Achtung",
"auto-detect": "Automatische Erkennung"
}

174
messages/en-US.json Normal file
View file

@ -0,0 +1,174 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"hello_world": "Hello, {name} from en!",
"section": {
"settings": {
"title": "Settings",
"textColor": "Text color",
"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",
"ownDataSource": "BTClock data source",
"flMaxBrightness": "Frontlight brightness",
"flAlwaysOn": "Frontlight always on",
"flEffectDelay": "Frontlight effect speed",
"flFlashOnUpd": "Frontlight flash on new block",
"mempoolInstanceHelpText": "Only effective when BTClock data-source is disabled. A restart is required to apply.",
"luxLightToggle": "Auto toggle frontlight at lux",
"wpTimeout": "WiFi-config portal timeout",
"nostrPubKey": "Nostr source pubkey",
"nostrZapKey": "Nostr zap pubkey",
"nostrRelay": "Nostr Relay",
"nostrZapNotify": "Enable Nostr Zap Notifications",
"useNostr": "Use Nostr data source",
"bitaxeHostname": "BitAxe hostname or IP",
"bitaxeEnabled": "Enable BitAxe-integration",
"miningPoolStats": "Enable Mining Pool Stats integration",
"miningPoolName": "Mining Pool",
"miningPoolUser": "Mining Pool username or api key",
"nostrZapPubkey": "Nostr Zap pubkey",
"invalidNostrPubkey": "Invalid Nostr pubkey, note that your pubkey does NOT start with npub.",
"convertingValidNpub": "Converting valid npub to pubkey",
"flDisable": "Disable frontlight",
"httpAuthEnabled": "Require authentication for WebUI",
"httpAuthUser": "WebUI Username",
"httpAuthPass": "WebUI Password",
"httpAuthText": "Only password-protects WebUI, not API-calls.",
"currencies": "Currencies",
"customSource": "Use custom data source endpoint",
"useNostrTooltip": "Very experimental and unstable. Nostr data source is not required for Nostr Zap notifications.",
"mowMode": "Mow Suffix Mode",
"suffixShareDot": "Suffix compact notation",
"section": {
"displaysAndLed": "Displays and LEDs",
"screenSettings": "Screen specific",
"dataSource": "Data source",
"extraFeatures": "Extra features",
"system": "System"
},
"ledFlashOnZap": "LED flash on Nostr Zap",
"flFlashOnZap": "Frontlight flash on Nostr Zap",
"showAll": "Show all",
"hideAll": "Hide all",
"flOffWhenDark": "Frontlight off when dark",
"luxLightToggleText": "Set to 0 to disable",
"verticalDesc": "Use vertical screen description",
"enableDebugLog": "Enable Debug-log",
"dataSource": {
"label": "Data Source",
"btclock": "BTClock Data Source",
"thirdParty": "mempool.space/Kraken",
"nostr": "Nostr publisher",
"custom": "Custom Endpoint"
},
"thirdPartySource": "Use mempool.space/coincap.io",
"ceDisableSSL": "Disable SSL",
"ceEndpoint": "Endpoint hostname",
"fontName": "Font",
"timeBasedDnd": "Enable Do Not Disturb time schedule",
"dndStartHour": "Start hour",
"dndStartMinute": "Start minute",
"dndEndHour": "End hour",
"dndEndMinute": "End minute"
},
"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",
"frontlight": "Frontlight",
"turnOn": "Turn on",
"flashFrontlight": "Flash",
"firmwareUpdate": "Firmware update",
"fwCommit": "Firmware commit"
},
"status": {
"title": "Status",
"screenCycle": "Screen cycle",
"memoryFree": "Memory free",
"wsPriceConnection": "WS Price connection",
"wsMempoolConnection": "WS {instance} 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",
"wsDataConnection": "BTClock data-source connection",
"lightSensor": "Light sensor",
"nostrConnection": "Nostr Relay connection",
"doNotDisturb": "Do not disturb",
"timeBasedDnd": "Time-based schedule"
},
"firmwareUpdater": {
"fileUploadFailed": "File upload failed. Make sure you have selected the correct file and try again.",
"fileUploadSuccess": "File uploaded successfully, restarting device and reloading WebUI in {countdown} seconds",
"uploading": "Uploading",
"firmwareUpdateText": "When you use the firmware upload functionality, make sure you use the correct files. Uploading the wrong files can result in a non-working device. If it goes wrong, you can restore firmware by uploading the full image after setting the device in BOOT-mode.",
"swUpdateAvailable": "A newer version is available!",
"swUpToDate": "You are up to date.",
"latestVersion": "Latest Version",
"releaseDate": "Release Date",
"viewRelease": "View Release",
"autoUpdate": "Install update (experimental)",
"autoUpdateInProgress": "Auto-update in progress, please wait..."
}
},
"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"
},
"warning": "Warning",
"auto-detect": "Auto-detect",
"on": "on",
"off": "off"
}

172
messages/en.json Normal file
View file

@ -0,0 +1,172 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"hello_world": "Hello, {name} from en!",
"section": {
"settings": {
"title": "Settings",
"textColor": "Text color",
"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",
"ownDataSource": "BTClock data source",
"flMaxBrightness": "Frontlight brightness",
"flAlwaysOn": "Frontlight always on",
"flEffectDelay": "Frontlight effect speed",
"flFlashOnUpd": "Frontlight flash on new block",
"mempoolInstanceHelpText": "Only effective when BTClock data-source is disabled. A restart is required to apply.",
"luxLightToggle": "Auto toggle frontlight at lux",
"wpTimeout": "WiFi-config portal timeout",
"nostrPubKey": "Nostr source pubkey",
"nostrZapKey": "Nostr zap pubkey",
"nostrRelay": "Nostr Relay",
"nostrZapNotify": "Enable Nostr Zap Notifications",
"useNostr": "Use Nostr data source",
"bitaxeHostname": "BitAxe hostname or IP",
"bitaxeEnabled": "Enable BitAxe-integration",
"miningPoolStats": "Enable Mining Pool Stats integration",
"miningPoolName": "Mining Pool",
"miningPoolUser": "Mining Pool username or api key",
"nostrZapPubkey": "Nostr Zap pubkey",
"invalidNostrPubkey": "Invalid Nostr pubkey, note that your pubkey does NOT start with npub.",
"convertingValidNpub": "Converting valid npub to pubkey",
"flDisable": "Disable frontlight",
"httpAuthEnabled": "Require authentication for WebUI",
"httpAuthUser": "WebUI Username",
"httpAuthPass": "WebUI Password",
"httpAuthText": "Only password-protects WebUI, not API-calls.",
"currencies": "Currencies",
"customSource": "Use custom data source endpoint",
"useNostrTooltip": "Very experimental and unstable. Nostr data source is not required for Nostr Zap notifications.",
"mowMode": "Mow Suffix Mode",
"suffixShareDot": "Suffix compact notation",
"section": {
"displaysAndLed": "Displays and LEDs",
"screenSettings": "Screen specific",
"dataSource": "Data source",
"extraFeatures": "Extra features",
"system": "System"
},
"ledFlashOnZap": "LED flash on Nostr Zap",
"flFlashOnZap": "Frontlight flash on Nostr Zap",
"showAll": "Show all",
"hideAll": "Hide all",
"flOffWhenDark": "Frontlight off when dark",
"luxLightToggleText": "Set to 0 to disable",
"verticalDesc": "Use vertical screen description",
"enableDebugLog": "Enable Debug-log",
"dataSource": {
"label": "Data Source",
"btclock": "BTClock Data Source",
"thirdParty": "mempool.space/Kraken",
"nostr": "Nostr publisher",
"custom": "Custom Endpoint"
},
"thirdPartySource": "Use mempool.space/coincap.io",
"ceDisableSSL": "Disable SSL",
"ceEndpoint": "Endpoint hostname",
"fontName": "Font",
"timeBasedDnd": "Enable Do Not Disturb time schedule",
"dndStartHour": "Start hour",
"dndStartMinute": "Start minute",
"dndEndHour": "End hour",
"dndEndMinute": "End minute"
},
"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",
"frontlight": "Frontlight",
"turnOn": "Turn on",
"flashFrontlight": "Flash",
"firmwareUpdate": "Firmware update",
"fwCommit": "Firmware commit"
},
"status": {
"title": "Status",
"screenCycle": "Screen cycle",
"memoryFree": "Memory free",
"wsPriceConnection": "WS Price connection",
"wsMempoolConnection": "WS {instance} 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",
"wsDataConnection": "BTClock data-source connection",
"lightSensor": "Light sensor",
"nostrConnection": "Nostr Relay connection",
"doNotDisturb": "Do not disturb",
"timeBasedDnd": "Time-based schedule"
},
"firmwareUpdater": {
"fileUploadFailed": "File upload failed. Make sure you have selected the correct file and try again.",
"fileUploadSuccess": "File uploaded successfully, restarting device and reloading WebUI in {countdown} seconds",
"uploading": "Uploading",
"firmwareUpdateText": "When you use the firmware upload functionality, make sure you use the correct files. Uploading the wrong files can result in a non-working device. If it goes wrong, you can restore firmware by uploading the full image after setting the device in BOOT-mode.",
"swUpdateAvailable": "A newer version is available!",
"swUpToDate": "You are up to date.",
"latestVersion": "Latest Version",
"releaseDate": "Release Date",
"viewRelease": "View Release",
"autoUpdate": "Install update (experimental)",
"autoUpdateInProgress": "Auto-update in progress, please wait..."
}
},
"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"
},
"warning": "Warning",
"auto-detect": "Auto-detect"
}

152
messages/es-ES.json Normal file
View file

@ -0,0 +1,152 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"hello_world": "Hello, {name} from es!",
"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",
"ownDataSource": "fuente de datos BTClock",
"flMaxBrightness": "Brillo de luz de la pantalla",
"flAlwaysOn": "Luz de la pantalla siempre encendida",
"flEffectDelay": "Velocidad del efecto de luz de la pantalla",
"flFlashOnUpd": "Luz de la pantalla parpadea con un nuevo bloque",
"mempoolInstanceHelpText": "Solo es efectivo cuando la fuente de datos BTClock está deshabilitada. \nEs necesario reiniciar para aplicar.",
"luxLightToggle": "Cambio automático de luz frontal en lux",
"wpTimeout": "Portal de configuración WiFi timeout",
"useNostr": "Utilice la fuente de datos Nostr",
"flDisable": "Desactivar luz de la pantalla",
"httpAuthUser": "Nombre de usuario WebUI",
"httpAuthPass": "Contraseña WebUI",
"httpAuthText": "Solo la WebUI está protegida con contraseña, no las llamadas API.",
"currencies": "Monedas",
"mowMode": "Modo de sufijo Mow",
"suffixShareDot": "Notación compacta de sufijo",
"section": {
"displaysAndLed": "Pantallas y LED",
"screenSettings": "Específico de la pantalla",
"dataSource": "fuente de datos",
"extraFeatures": "Funciones adicionales",
"system": "Sistema"
},
"ledFlashOnZap": "LED parpadeante con Nostr Zap",
"flFlashOnZap": "Flash de luz frontal con Nostr Zap",
"showAll": "Mostrar todo",
"hideAll": "Ocultar todo",
"flOffWhenDark": "Luz de la pantalla cuando está oscuro",
"luxLightToggleText": "Establecer en 0 para desactivar",
"verticalDesc": "Descripción de pantalla vertical",
"enableDebugLog": "Habilitar registro de depuración",
"bitaxeEnabled": "Habilitar la integración de BitAxe",
"miningPoolStats": "Habilitar la integración de estadísticas del grupo minero",
"nostrZapNotify": "Habilitar notificaciones de Nostr Zap",
"thirdPartySource": "Utilice mempool.space/coincap.io",
"dataSource": {
"nostr": "editorial nostr",
"custom": "Punto final personalizado"
},
"fontName": "Fuente",
"timeBasedDnd": "Habilitar el horario de No molestar",
"dndStartHour": "Hora de inicio",
"dndStartMinute": "Minuto de inicio",
"dndEndHour": "Hora final",
"dndEndMinute": "Minuto final"
},
"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",
"turnOn": "Encender",
"frontlight": "Luz de la pantalla",
"flashFrontlight": "Luz intermitente"
},
"status": {
"memoryFree": "Memoria RAM libre",
"wsPriceConnection": "Conexión WebSocket Precio",
"wsMempoolConnection": "Conexión WebSocket {instance}",
"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",
"wsDataConnection": "Conexión de fuente de datos BTClock",
"lightSensor": "Sensor de luz",
"nostrConnection": "Conexión de relé Nostr",
"doNotDisturb": "No molestar",
"timeBasedDnd": "Horario basado en el tiempo"
},
"firmwareUpdater": {
"fileUploadSuccess": "Archivo cargado exitosamente, reiniciando el dispositivo. Recargando WebUI en {countdown} segundos",
"fileUploadFailed": "Error al cargar el archivo. \nAsegúrese de haber seleccionado el archivo correcto e inténtelo nuevamente.",
"uploading": "Subiendo",
"firmwareUpdateText": "Cuando utilice la función de carga de firmware, asegúrese de utilizar los archivos correctos. \nCargar archivos incorrectos puede provocar que el dispositivo no funcione. \nSi sale mal, puede restaurar el firmware cargando la imagen completa después de configurar el dispositivo en modo BOOT.",
"swUpToDate": "Tienes la ultima version.",
"swUpdateAvailable": "¡Una nueva versión está disponible!",
"latestVersion": "Ultima versión",
"releaseDate": "Fecha de lanzamiento",
"viewRelease": "Ver lanzamiento",
"autoUpdate": "Instalar actualización (experimental)",
"autoUpdateInProgress": "Actualización automática en progreso, espere..."
}
},
"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"
},
"warning": "Aviso",
"auto-detect": "Detección automática"
}

152
messages/es.json Normal file
View file

@ -0,0 +1,152 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"hello_world": "Hello, {name} from es!",
"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",
"ownDataSource": "fuente de datos BTClock",
"flMaxBrightness": "Brillo de luz de la pantalla",
"flAlwaysOn": "Luz de la pantalla siempre encendida",
"flEffectDelay": "Velocidad del efecto de luz de la pantalla",
"flFlashOnUpd": "Luz de la pantalla parpadea con un nuevo bloque",
"mempoolInstanceHelpText": "Solo es efectivo cuando la fuente de datos BTClock está deshabilitada. \nEs necesario reiniciar para aplicar.",
"luxLightToggle": "Cambio automático de luz frontal en lux",
"wpTimeout": "Portal de configuración WiFi timeout",
"useNostr": "Utilice la fuente de datos Nostr",
"flDisable": "Desactivar luz de la pantalla",
"httpAuthUser": "Nombre de usuario WebUI",
"httpAuthPass": "Contraseña WebUI",
"httpAuthText": "Solo la WebUI está protegida con contraseña, no las llamadas API.",
"currencies": "Monedas",
"mowMode": "Modo de sufijo Mow",
"suffixShareDot": "Notación compacta de sufijo",
"section": {
"displaysAndLed": "Pantallas y LED",
"screenSettings": "Específico de la pantalla",
"dataSource": "fuente de datos",
"extraFeatures": "Funciones adicionales",
"system": "Sistema"
},
"ledFlashOnZap": "LED parpadeante con Nostr Zap",
"flFlashOnZap": "Flash de luz frontal con Nostr Zap",
"showAll": "Mostrar todo",
"hideAll": "Ocultar todo",
"flOffWhenDark": "Luz de la pantalla cuando está oscuro",
"luxLightToggleText": "Establecer en 0 para desactivar",
"verticalDesc": "Descripción de pantalla vertical",
"enableDebugLog": "Habilitar registro de depuración",
"bitaxeEnabled": "Habilitar la integración de BitAxe",
"miningPoolStats": "Habilitar la integración de estadísticas del grupo minero",
"nostrZapNotify": "Habilitar notificaciones de Nostr Zap",
"thirdPartySource": "Utilice mempool.space/coincap.io",
"dataSource": {
"nostr": "editorial nostr",
"custom": "Punto final personalizado"
},
"fontName": "Fuente",
"timeBasedDnd": "Habilitar el horario de No molestar",
"dndStartHour": "Hora de inicio",
"dndStartMinute": "Minuto de inicio",
"dndEndHour": "Hora final",
"dndEndMinute": "Minuto final"
},
"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",
"turnOn": "Encender",
"frontlight": "Luz de la pantalla",
"flashFrontlight": "Luz intermitente"
},
"status": {
"memoryFree": "Memoria RAM libre",
"wsPriceConnection": "Conexión WebSocket Precio",
"wsMempoolConnection": "Conexión WebSocket {instance}",
"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",
"wsDataConnection": "Conexión de fuente de datos BTClock",
"lightSensor": "Sensor de luz",
"nostrConnection": "Conexión de relé Nostr",
"doNotDisturb": "No molestar",
"timeBasedDnd": "Horario basado en el tiempo"
},
"firmwareUpdater": {
"fileUploadSuccess": "Archivo cargado exitosamente, reiniciando el dispositivo. Recargando WebUI en {countdown} segundos",
"fileUploadFailed": "Error al cargar el archivo. \nAsegúrese de haber seleccionado el archivo correcto e inténtelo nuevamente.",
"uploading": "Subiendo",
"firmwareUpdateText": "Cuando utilice la función de carga de firmware, asegúrese de utilizar los archivos correctos. \nCargar archivos incorrectos puede provocar que el dispositivo no funcione. \nSi sale mal, puede restaurar el firmware cargando la imagen completa después de configurar el dispositivo en modo BOOT.",
"swUpToDate": "Tienes la ultima version.",
"swUpdateAvailable": "¡Una nueva versión está disponible!",
"latestVersion": "Ultima versión",
"releaseDate": "Fecha de lanzamiento",
"viewRelease": "Ver lanzamiento",
"autoUpdate": "Instalar actualización (experimental)",
"autoUpdateInProgress": "Actualización automática en progreso, espere..."
}
},
"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"
},
"warning": "Aviso",
"auto-detect": "Detección automática"
}

142
messages/nl-NL.json Normal file
View file

@ -0,0 +1,142 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"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",
"ownDataSource": "BTClock-gegevensbron gebruiken",
"flMaxBrightness": "Displaylicht helderheid",
"flAlwaysOn": "Displaylicht altijd aan",
"flEffectDelay": "Displaylicht effect snelheid",
"flFlashOnUpd": "Knipper displaylicht bij nieuw blok",
"mempoolInstanceHelpText": "Alleen effectief als de BTClock-gegevensbron is uitgeschakeld. \nOm toe te passen is een herstart nodig.",
"luxLightToggle": "Schakelen displaylicht op lux",
"wpTimeout": "WiFi-config-portal timeout",
"useNostr": "Gebruik Nostr-gegevensbron",
"flDisable": "Schakel Displaylicht uit",
"httpAuthUser": "WebUI-gebruikersnaam",
"httpAuthPass": "WebUI-wachtwoord",
"httpAuthText": "Beveiligd enkel WebUI, niet de API.",
"currencies": "Valuta's",
"mowMode": "Mow achtervoegsel",
"suffixShareDot": "Achtervoegsel compacte notatie",
"section": {
"displaysAndLed": "Displays en LED's",
"screenSettings": "Schermspecifiek",
"dataSource": "Gegevensbron",
"extraFeatures": "Extra functies",
"system": "Systeem"
},
"ledFlashOnZap": "Knipper LED bij Nostr Zap",
"flFlashOnZap": "Knipper displaylicht bij Nostr Zap",
"showAll": "Toon alles",
"hideAll": "Alles verbergen",
"flOffWhenDark": "Displaylicht uit als het donker is",
"luxLightToggleText": "Stel in op 0 om uit te schakelen",
"verticalDesc": "Verticale schermbeschrijving",
"fontName": "Lettertype",
"timeBasedDnd": "Schakel het tijdschema Niet storen in",
"dndStartHour": "Begin uur",
"dndStartMinute": "Beginminuut",
"dndEndHour": "Eind uur",
"dndEndMinute": "Einde minuut"
},
"control": {
"systemInfo": "Systeeminformatie",
"version": "Versie",
"buildTime": "Bouwtijd",
"setColor": "Kleur instellen",
"turnOff": "Uitzetten",
"ledColor": "LED kleur",
"showText": "Toon tekst",
"text": "Tekst",
"title": "Besturing",
"frontlight": "Displaylicht",
"turnOn": "Aanzetten",
"flashFrontlight": "Knipper"
},
"status": {
"title": "Status",
"memoryFree": "Geheugen vrij",
"screenCycle": "Scherm cyclus",
"wsPriceConnection": "WS Prijs verbinding",
"wsMempoolConnection": "WS {instance} 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",
"wsDataConnection": "BTClock-gegevensbron verbinding",
"lightSensor": "Licht sensor",
"nostrConnection": "Nostr Relay-verbinding",
"doNotDisturb": "Niet storen",
"timeBasedDnd": "Op tijd gebaseerd schema"
},
"firmwareUpdater": {
"fileUploadSuccess": "Bestand geüpload, apparaat herstart. WebUI opnieuw geladen over {countdown} seconden",
"fileUploadFailed": "Bestandsupload mislukt. \nZorg ervoor dat het juiste bestand is geselecteerd en probeer het opnieuw.",
"uploading": "Uploaden",
"firmwareUpdateText": "Zorg bij het gebruiken van de firmware upload dat de juiste bestanden gebruikt worden. \nHet uploaden van de verkeerde bestanden kan resulteren in een niet-werkend apparaat. \nAls het misgaat, kunt u de firmware herstellen door de volledige afbeelding te uploaden nadat u het apparaat in de BOOT-modus hebt gezet.",
"swUpToDate": "Je hebt de nieuwste versie.",
"swUpdateAvailable": "Een nieuwere versie is beschikbaar!",
"latestVersion": "Laatste versie",
"releaseDate": "Datum van publicatie",
"viewRelease": "Bekijk publicatie",
"autoUpdate": "Update installeren (experimenteel)",
"autoUpdateInProgress": "Automatische update wordt uitgevoerd. Even geduld a.u.b...."
}
},
"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"
},
"warning": "Waarschuwing",
"auto-detect": "Automatische detectie"
}

142
messages/nl.json Normal file
View file

@ -0,0 +1,142 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"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",
"ownDataSource": "BTClock-gegevensbron gebruiken",
"flMaxBrightness": "Displaylicht helderheid",
"flAlwaysOn": "Displaylicht altijd aan",
"flEffectDelay": "Displaylicht effect snelheid",
"flFlashOnUpd": "Knipper displaylicht bij nieuw blok",
"mempoolInstanceHelpText": "Alleen effectief als de BTClock-gegevensbron is uitgeschakeld. \nOm toe te passen is een herstart nodig.",
"luxLightToggle": "Schakelen displaylicht op lux",
"wpTimeout": "WiFi-config-portal timeout",
"useNostr": "Gebruik Nostr-gegevensbron",
"flDisable": "Schakel Displaylicht uit",
"httpAuthUser": "WebUI-gebruikersnaam",
"httpAuthPass": "WebUI-wachtwoord",
"httpAuthText": "Beveiligd enkel WebUI, niet de API.",
"currencies": "Valuta's",
"mowMode": "Mow achtervoegsel",
"suffixShareDot": "Achtervoegsel compacte notatie",
"section": {
"displaysAndLed": "Displays en LED's",
"screenSettings": "Schermspecifiek",
"dataSource": "Gegevensbron",
"extraFeatures": "Extra functies",
"system": "Systeem"
},
"ledFlashOnZap": "Knipper LED bij Nostr Zap",
"flFlashOnZap": "Knipper displaylicht bij Nostr Zap",
"showAll": "Toon alles",
"hideAll": "Alles verbergen",
"flOffWhenDark": "Displaylicht uit als het donker is",
"luxLightToggleText": "Stel in op 0 om uit te schakelen",
"verticalDesc": "Verticale schermbeschrijving",
"fontName": "Lettertype",
"timeBasedDnd": "Schakel het tijdschema Niet storen in",
"dndStartHour": "Begin uur",
"dndStartMinute": "Beginminuut",
"dndEndHour": "Eind uur",
"dndEndMinute": "Einde minuut"
},
"control": {
"systemInfo": "Systeeminformatie",
"version": "Versie",
"buildTime": "Bouwtijd",
"setColor": "Kleur instellen",
"turnOff": "Uitzetten",
"ledColor": "LED kleur",
"showText": "Toon tekst",
"text": "Tekst",
"title": "Besturing",
"frontlight": "Displaylicht",
"turnOn": "Aanzetten",
"flashFrontlight": "Knipper"
},
"status": {
"title": "Status",
"memoryFree": "Geheugen vrij",
"screenCycle": "Scherm cyclus",
"wsPriceConnection": "WS Prijs verbinding",
"wsMempoolConnection": "WS {instance} 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",
"wsDataConnection": "BTClock-gegevensbron verbinding",
"lightSensor": "Licht sensor",
"nostrConnection": "Nostr Relay-verbinding",
"doNotDisturb": "Niet storen",
"timeBasedDnd": "Op tijd gebaseerd schema"
},
"firmwareUpdater": {
"fileUploadSuccess": "Bestand geüpload, apparaat herstart. WebUI opnieuw geladen over {countdown} seconden",
"fileUploadFailed": "Bestandsupload mislukt. \nZorg ervoor dat het juiste bestand is geselecteerd en probeer het opnieuw.",
"uploading": "Uploaden",
"firmwareUpdateText": "Zorg bij het gebruiken van de firmware upload dat de juiste bestanden gebruikt worden. \nHet uploaden van de verkeerde bestanden kan resulteren in een niet-werkend apparaat. \nAls het misgaat, kunt u de firmware herstellen door de volledige afbeelding te uploaden nadat u het apparaat in de BOOT-modus hebt gezet.",
"swUpToDate": "Je hebt de nieuwste versie.",
"swUpdateAvailable": "Een nieuwere versie is beschikbaar!",
"latestVersion": "Laatste versie",
"releaseDate": "Datum van publicatie",
"viewRelease": "Bekijk publicatie",
"autoUpdate": "Update installeren (experimenteel)",
"autoUpdateInProgress": "Automatische update wordt uitgevoerd. Even geduld a.u.b...."
}
},
"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"
},
"warning": "Waarschuwing",
"auto-detect": "Automatische detectie"
}

59
package.json Normal file
View file

@ -0,0 +1,59 @@
{
"name": "btclock-webui-tailwind",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint .",
"test:unit": "vitest",
"test": "npm run test:unit -- --run && npm run test:e2e",
"test:e2e": "playwright test"
},
"devDependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@playwright/test": "^1.49.1",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.0.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/svelte": "^5.2.4",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",
"globals": "^16.0.0",
"jsdom": "^26.0.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.0.0",
"typescript-eslint": "^8.20.0",
"vite": "^6.2.6",
"vitest": "^3.0.0"
},
"dependencies": {
"@fontsource-variable/oswald": "^5.2.5",
"@fontsource/ubuntu": "^5.2.5",
"@inlang/paraglide-js": "^2.0.0",
"daisyui": "^5.0.35"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild"
],
"patchedDependencies": {
"@sveltejs/kit": "patches/@sveltejs__kit.patch"
}
}
}

View file

@ -0,0 +1,29 @@
diff --git a/src/exports/vite/index.js b/src/exports/vite/index.js
index c2e445e865d2fd1f31f28638fa314c725dc53674..26fa6519264be7bb475f9a9ec4de097f58da4103 100644
--- a/src/exports/vite/index.js
+++ b/src/exports/vite/index.js
@@ -670,9 +670,9 @@ Tips:
output: {
format: inline ? 'iife' : 'esm',
name: `__sveltekit_${version_hash}.app`,
- entryFileNames: ssr ? '[name].js' : `${prefix}/[name].[hash].${ext}`,
- chunkFileNames: ssr ? 'chunks/[name].js' : `${prefix}/chunks/[hash].${ext}`,
- assetFileNames: `${prefix}/assets/[name].[hash][extname]`,
+ entryFileNames: ssr ? '[name].js' : `${prefix}/[hash].${ext}`,
+ chunkFileNames: ssr ? 'chunks/[name].js' : `${prefix}/c[hash].${ext}`,
+ assetFileNames: `${prefix}/a[hash][extname]`,
hoistTransitiveImports: false,
sourcemapIgnoreList,
manualChunks: split ? undefined : () => 'bundle',
@@ -699,9 +699,9 @@ Tips:
worker: {
rollupOptions: {
output: {
- entryFileNames: `${prefix}/workers/[name]-[hash].js`,
+ entryFileNames: `${prefix}/workers/[hash].js`,
chunkFileNames: `${prefix}/workers/chunks/[hash].js`,
- assetFileNames: `${prefix}/workers/assets/[name]-[hash][extname]`,
+ assetFileNames: `${prefix}/workers/assets/[hash][extname]`,
hoistTransitiveImports: false
}
}

9
playwright.config.ts Normal file
View file

@ -0,0 +1,9 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
webServer: {
command: 'npm run build && npm run preview',
port: 4173
},
testDir: 'e2e'
});

4157
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

1
project.inlang/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
cache

View file

@ -0,0 +1 @@
HdrJCBg8oujVtKYW00

View file

@ -0,0 +1,12 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js"
],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{locale}.json"
},
"baseLocale": "en-US",
"locales": ["en-US", "es-ES", "nl-NL", "de-DE"]
}

26
src/app.css Normal file
View file

@ -0,0 +1,26 @@
@import 'tailwindcss';
@plugin "daisyui" {
}
:root {
--primary: #3b82f6;
--secondary: #6b7280;
--accent: #f59e0b;
}
html {
scroll-behavior: smooth;
}
html, body {
@apply h-full;
}
html {
@apply bg-base-200;
}
body {
@apply bg-base-200 pt-16;
}

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

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

12
src/app.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="%paraglide.lang%">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents" class="h-full">%sveltekit.body%</div>
</body>
</html>

7
src/demo.spec.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);
});
});

13
src/hooks.server.ts Normal file
View file

@ -0,0 +1,13 @@
import type { Handle } from '@sveltejs/kit';
import { paraglideMiddleware } from '$lib/paraglide/server';
const handleParaglide: Handle = ({ event, resolve }) =>
paraglideMiddleware(event.request, ({ request, locale }) => {
event.request = request;
return resolve(event, {
transformPageChunk: ({ html }) => html.replace('%paraglide.lang%', locale)
});
});
export const handle: Handle = handleParaglide;

3
src/hooks.ts Normal file
View file

@ -0,0 +1,3 @@
import { deLocalizeUrl } from '$lib/paraglide/runtime';
export const reroute = (request) => deLocalizeUrl(request.url).pathname;

112
src/lib/clockControl.ts Normal file
View file

@ -0,0 +1,112 @@
import { PUBLIC_BASE_URL } from '$env/static/public';
import { baseUrl } from './env';
/**
* Sets custom text to display on the clock
*/
export const setCustomText = (newText: string) => {
return fetch(`${baseUrl}/api/show/text/${newText}`).catch(() => {});
};
/**
* Updates the LED colors
*/
export const setLEDcolor = (ledStatus: { hex: string }[]) => {
return fetch(`${baseUrl}/api/lights/set`, {
headers: {
'Content-Type': 'application/json'
},
method: 'PATCH',
body: JSON.stringify(ledStatus)
}).catch(() => {});
};
/**
* Turns off all LEDs
*/
export const turnOffLeds = () => {
return fetch(`${baseUrl}/api/lights/off`).catch(() => {});
};
/**
* Restarts the clock
*/
export const restartClock = () => {
return fetch(`${baseUrl}/api/restart`).catch(() => {});
};
/**
* Forces a full refresh of the clock
*/
export const forceFullRefresh = () => {
return fetch(`${baseUrl}/api/full_refresh`).catch(() => {});
};
/**
* Generates a random color hex code
*/
export const generateRandomColor = () => {
return `#${Math.floor(Math.random() * 16777215)
.toString(16)
.padStart(6, '0')}`;
};
/**
* Sets the active screen
*/
export const setActiveScreen = async (screenId: string) => {
return fetch(`${baseUrl}/api/show/screen/${screenId}`);
}
/**
* Sets the active currency
*/
export const setActiveCurrency = async (currency: string) => {
return fetch(`${baseUrl}/api/show/currency/${currency}`);
}
/**
* Turns on the frontlight
*/
export const turnOnFrontlight = () => {
return fetch(`${baseUrl}/api/frontlight/on`).catch(() => {});
};
/**
* Flashes the frontlight
*/
export const flashFrontlight = () => {
return fetch(`${baseUrl}/api/frontlight/flash`).catch(() => {});
};
/**
* Turns off the frontlight
*/
export const turnOffFrontlight = () => {
return fetch(`${baseUrl}/api/frontlight/off`).catch(() => {});
};
/**
* Toggles the timer
*/
export const toggleTimer = (currentStatus: boolean) => (e: Event) => {
e.preventDefault();
if (currentStatus) {
fetch(`${baseUrl}/api/action/pause`);
} else {
fetch(`${baseUrl}/api/action/timer_restart`);
}
};
/**
* Toggles the do not disturb mode
*/
export const toggleDoNotDisturb = (currentStatus: boolean) => (e: Event) => {
e.preventDefault();
console.log(currentStatus);
if (!currentStatus) {
fetch(`${baseUrl}/api/dnd/enable`);
} else {
fetch(`${baseUrl}/api/dnd/disable`);
}
};

View file

@ -0,0 +1,382 @@
<script lang="ts">
import '@fontsource-variable/oswald'; // Import the Oswald variable font
import '@fontsource/ubuntu';
type DisplayMode = 'single' | 'medium' | 'split';
type DisplayTheme = 'light' | 'dark'; // New type for display theme
// Props for the component
export let displays = ['B', 'T', 'C', 'L', 'O', 'C', 'K'];
export let mode: DisplayMode | DisplayMode[] = 'single'; // 'single', 'medium', 'split', or an array of modes
export let mixedMode = false; // Whether to use mixed modes for displays
export let theme: DisplayTheme = 'dark'; // New prop for display theme (default: dark)
export let containerRadius = '0.5rem'; // New prop for container border radius
export let displayRadius = '6px'; // New prop for display border radius
export let splitTextPadding = '2px'; // New prop for padding between split text and separator
export let primaryColor = 'gold'; // Primary color (default: blue)
export let frameBorderColor = '#000000'; // Frame border color (default: same as primary)
export let screwColor = 'orange'; // Screw color (default: same as primary)
export let verticalDesc = false;
// For responsive sizing
let containerWidth = 0;
// let containerHeight = 0;
let containerElement: HTMLDivElement;
let initialWidth = 0;
// Device dimensions
const deviceWidth = 224; // mm
const deviceHeight = 85; // mm
// const deviceRatio = deviceHeight / deviceWidth; // Overall device aspect ratio
// Display dimensions
// const displayAspectRatio = 122 / 250; // Width to height ratio of each display
// Function to get the display mode for a specific index
function getDisplayMode(index: number): DisplayMode {
if (displays[index].length > 1) {
if (displays[index].includes('/')) {
return 'split';
} else {
return 'medium';
}
} else {
return 'single';
}
}
// Function to get the content parts for split text
function getSplitParts(content: string): [string, string] {
const parts = content.split('/');
return [parts[0] || '', parts[1] || ''];
}
// Store initial width on first render
$: if (containerWidth > 0 && initialWidth === 0) {
initialWidth = containerWidth;
}
// // Calculate container height based on width
// $: if (containerWidth > 0) {
// containerHeight = containerWidth * deviceRatio;
// }
const fontSizeSingle = '4.5rem';
const fontSizeMedium = '2.0rem';
const fontSizeSplit = '1.0rem';
</script>
<div
class="btclock-container"
bind:this={containerElement}
bind:clientWidth={containerWidth}
style="aspect-ratio: {deviceWidth} / {deviceHeight};
border-radius: {containerRadius};
border-color: {frameBorderColor};"
>
<div class="screw-top-left" style="background-color: {screwColor};"></div>
<div class="screw-top-right" style="background-color: {screwColor};"></div>
<div class="screw-bottom-left" style="background-color: {screwColor};"></div>
<div class="screw-bottom-right" style="background-color: {screwColor};"></div>
<div class="displays-row">
{#each displays as display, i (i)}
<div class="btclock-display-wrapper">
<div
class="btclock-display {theme}"
style="border-radius: {displayRadius};
border-color: {primaryColor};"
>
<div class="display-content {verticalDesc ? 'vertical-desc' : ''}">
{#if getDisplayMode(i) === 'single'}
<div class="single-char">{display}</div>
{:else if getDisplayMode(i) === 'medium'}
<div class="medium-chars">{display}</div>
{:else if getDisplayMode(i) === 'split'}
{@const [topText, bottomText] = getSplitParts(display)}
<div class="split-text" style="--split-text-padding: {splitTextPadding};">
<div class="top-text">{topText}</div>
<div class="divider"></div>
<div class="bottom-text">{bottomText}</div>
</div>
{/if}
</div>
</div>
</div>
{/each}
</div>
<div class="btclock-label">BTClock</div>
</div>
<style>
:root {
--font-family: 'Oswald Variable', sans-serif;
--primary-color: #0000ff;
--frame-color: white;
--display-dark-bg: black;
--display-light-bg: white;
--display-dark-text: white;
--display-light-text: black;
--label-color: rgba(0, 0, 0, 0.5);
--divider-light-color: #333;
--divider-dark-color: #fff;
/* Font weights */
--font-weight-regular: 400;
--font-weight-medium: 400;
--font-weight-semibold: 400;
--font-weight-bold: 400;
/* Sizes */
--border-width: 2px;
--gap-size: 2px;
--screw-size: 6px;
--label-font-size: 0.4rem;
/* Font sizes */
--font-size-single: 4cqw;
--font-size-medium: 1.5cqw;
--font-size-split: 1cqw;
}
/* Screw base style */
.screw-top-left,
.screw-top-right,
.screw-bottom-left,
.screw-bottom-right {
position: absolute;
width: var(--screw-size);
height: var(--screw-size);
border-radius: 50%;
z-index: 3;
/* Background color set via inline style */
}
/* Display text base style */
.single-char,
.medium-chars {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
line-height: 1;
font-weight: var(--font-weight-medium); /* Set all font weights to medium */
}
.single-char {
font-size: var(--font-size-single);
}
.medium-chars {
font-size: var(--font-size-medium);
}
.btclock-container {
position: relative;
width: 100%;
background-color: var(--frame-color);
padding: 0.5rem 0.25rem;
border: var(--border-width) solid; /* Color set via inline style */
margin-bottom: 1rem;
/* Maintain the overall device aspect ratio */
max-width: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
font-family: var(--font-family);
}
.vertical-desc .split-text {
transform: rotate(270deg);
height: 50%;
}
.vertical-desc .split-text .divider {
width: 75%;
}
.displays-row {
display: flex;
justify-content: center;
align-items: center;
gap: var(--gap-size);
width: 100%;
height: 70%;
padding: 0;
}
.btclock-display-wrapper {
display: flex;
justify-content: center;
align-items: center;
flex: 1;
height: 100%;
min-width: 0; /* Prevent flex items from overflowing */
}
.screw-top-left {
top: var(--screw-size);
left: var(--screw-size);
}
.screw-top-right {
top: var(--screw-size);
right: var(--screw-size);
}
.screw-bottom-left {
bottom: var(--screw-size);
left: var(--screw-size);
}
.screw-bottom-right {
bottom: var(--screw-size);
right: var(--screw-size);
}
/* BTClock label */
.btclock-label {
position: absolute;
bottom: 0.5rem;
left: 50%;
transform: translateX(-50%);
font-size: var(--label-font-size);
color: gold;
font-weight: 500;
font-family: 'Ubuntu', sans-serif;
font-style: italic;
letter-spacing: 0.5px;
}
.btclock-display {
border: 1px solid; /* Color set via inline style */
position: relative;
overflow: hidden;
z-index: 1;
padding: 0;
box-sizing: border-box;
width: 100%;
height: 100%;
/* Ensure the display maintains its proportions */
aspect-ratio: 122 / 250;
}
/* Theme variants */
.btclock-display.dark {
background-color: var(--display-dark-bg);
color: var(--display-dark-text);
}
.btclock-display.dark .divider {
background-color: var(--divider-dark-color);
}
.btclock-display.light {
background-color: var(--display-light-bg);
color: var(--display-light-text);
}
.btclock-display.light .divider {
background-color: var(--divider-light-color);
}
.display-content {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
font-family: var(--font-family);
font-weight: var(--font-weight-medium); /* Set all font weights to medium */
padding: 6px;
box-sizing: border-box;
width: 100%;
height: 100%;
}
.split-text {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
width: 100%;
height: 100%;
box-sizing: border-box;
}
.split-text .top-text,
.split-text .bottom-text {
line-height: 1.2;
font-weight: var(--font-weight-medium); /* Set all font weights to medium */
font-size: var(--font-size-split);
width: 100%;
display: flex;
justify-content: center;
align-items: center;
/* Remove flex: 1 to prevent flexbox from overriding the padding */
height: calc(50% - var(--split-text-padding, 2px) - 0.5px);
padding: 0;
}
.split-text .divider {
width: 90%;
height: 1px;
margin: 0 auto;
}
/* Screw size adjustments based on container width */
@media (max-width: 768px) {
.btclock-container {
padding: 0.4rem 0.2rem;
border-width: 1.5px;
}
.displays-row {
gap: var(--gap-size);
}
.display-content {
padding: 5px;
}
}
@media (max-width: 480px) {
.btclock-container {
padding: 0.3rem 0.15rem;
border-width: 1px;
}
.displays-row {
gap: 1px;
}
.display-content {
padding: 3px;
}
}
/* Ensure displays grow properly on larger screens */
@media (min-width: 1200px) {
.btclock-label {
font-size: 1cqw;
bottom: 0.35cqh;
}
.displays-row {
gap: 4px;
}
.btclock-display {
border-width: 2px;
}
}
</style>

View file

@ -0,0 +1,16 @@
<script lang="ts">
let {
currency,
active = false,
onClick,
...restProps
} = $props();
</script>
<button
class="btn join-item {active ? 'btn-primary' : 'btn-outline'} btn-xs"
on:click={onClick}
{...restProps}
>
{currency}
</button>

View file

@ -0,0 +1,26 @@
<script lang="ts">
let {
value = $bindable(''),
label = "",
placeholder = "",
id = "",
type = "text",
...restProps
} = $props();
</script>
<div class="form-control w-full">
{#if label}
<label for={id} class="label">
<span class="label-text">{label}</span>
</label>
{/if}
<input
type={type}
{placeholder}
{id}
class="input input-bordered w-full"
bind:value
{...restProps}
/>
</div>

View file

@ -0,0 +1,21 @@
<script lang="ts">
let {
checked = $bindable(false),
label = "",
id = "",
...restProps
} = $props();
</script>
<label class="flex items-center justify-between gap-2 cursor-pointer">
{#if label}
<span class="label-text text-xs">{label}</span>
{/if}
<input
type="checkbox"
class="toggle toggle-primary toggle-xs"
{id}
bind:checked
{...restProps}
/>
</label>

View file

@ -0,0 +1,21 @@
// UI Components
export { default as CardContainer } from './ui/CardContainer.svelte';
export { default as TabButton } from './ui/TabButton.svelte';
export { default as Toast } from './ui/Toast.svelte';
export { default as Stat } from './ui/Stat.svelte';
export { default as Status } from './ui/Status.svelte';
// Form Components
export { default as InputField } from './form/InputField.svelte';
export { default as Toggle } from './form/Toggle.svelte';
export { default as CurrencyButton } from './form/CurrencyButton.svelte';
// Layout Components
export { default as Navbar } from './layout/Navbar.svelte';
export { default as CollapsibleSection } from './layout/CollapsibleSection.svelte';
// Section Components
export { default as ControlSection } from './sections/ControlSection.svelte';
export { default as StatusSection } from './sections/StatusSection.svelte';
export { default as SettingsSection } from './sections/SettingsSection.svelte';
export { default as SystemSection } from './sections/SystemSection.svelte';

View file

@ -0,0 +1,17 @@
<script lang="ts">
let {
title,
open = $bindable(false),
...restProps
} = $props();
</script>
<div class="collapse collapse-arrow bg-base-200 rounded-lg mb-2" {...restProps}>
<input type="checkbox" bind:checked={open} />
<div class="collapse-title text-lg font-medium">
{title}
</div>
<div class="collapse-content">
<slot />
</div>
</div>

View file

@ -0,0 +1,87 @@
<script lang="ts">
let {
...restProps
} = $props();
import { setLocale, getLocale } from '$lib/paraglide/runtime';
import { locales } from '$lib/paraglide/runtime';
import { page } from '$app/stores';
// Navigation items
const navItems = [
{ href: '/', label: 'Home' },
{ href: '/settings', label: 'Settings' },
{ href: '/system', label: 'System' },
{ href: '/apidoc', label: 'API' }
];
// Helper function to check if a link is active
function isActive(href: string) {
return $page.url.pathname === href;
}
const getLocaleName = (locale: string) => {
return new Intl.DisplayNames([locale], { type: 'language' }).of(locale)
}
const getLanguageName = (locale: string) => {
return getLocaleName(locale.split('-')[0])
}
const getEmojiFlag = (locale: string) => {
const countryCode = locale.split('-')[1];
if (!countryCode || countryCode === 'US') {
return '🇺🇸';
}
return [...countryCode.toUpperCase()]
.map(char => String.fromCodePoint(127397 + char.charCodeAt(0)))
.join('');
}
// Function to get the current flag
const getCurrentFlag = () => getEmojiFlag(getLocale()) || '🇬🇧';
</script>
<div class="navbar bg-base-100 fixed top-0 z-50 shadow-sm w-full" {...restProps}>
<div class="navbar-start">
<div class="dropdown">
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h8m-8 6h16" />
</svg>
</div>
<ul tabindex="-1" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52">
{#each navItems as { href, label }}
<li>
<a href={href} class={isActive(href) ? 'menu-active' : ''}>{label}</a>
</li>
{/each}
</ul>
</div>
<a href="/" class="btn btn-ghost text-xl">BTClock</a>
</div>
<div class="navbar-center hidden lg:flex">
<ul class="menu menu-horizontal px-1">
{#each navItems as { href, label }}
<li>
<a href={href} class={isActive(href) ? 'menu-active' : ''}>{label}</a>
</li>
{/each}
</ul>
</div>
<div class="navbar-end">
<div class="dropdown dropdown-end mr-2">
<div tabindex="0" role="button" class="btn btn-ghost">
<span class="text-sm">{getCurrentFlag()} {getLanguageName(getLocale())}</span>
</div>
<ul tabindex="-1" class="mt-3 z-[1] p-2 shadow menu dropdown-content bg-base-100 rounded-box w-auto">
{#each locales as locale}
<li><button onclick={() => setLocale(locale)} class="flex items-center gap-2 text-nowrap">{getEmojiFlag(locale)} {getLanguageName(locale)}</button></li>
{/each}
</ul>
</div>
</div>
</div>

View file

@ -0,0 +1,123 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages';
import { CardContainer, InputField, Toggle } from '$lib/components';
import { settings, status } from '$lib/stores';
import { onDestroy } from 'svelte';
import {
setCustomText,
setLEDcolor,
turnOffLeds,
restartClock,
forceFullRefresh,
generateRandomColor,
flashFrontlight,
turnOnFrontlight,
turnOffFrontlight
} from '$lib/clockControl';
import type { LedStatus } from '$lib/types';
let ledStatus = $state<LedStatus[]>([
{hex: '#000000'},
{hex: '#000000'},
{hex: '#000000'},
{hex: '#000000'}
]);
let customText = $state('');
let keepLedsSameColor = $state(false);
const checkSyncLeds = (e: Event) => {
if (keepLedsSameColor && e.target instanceof HTMLInputElement) {
const targetValue = e.target.value;
ledStatus.forEach((element, i) => {
if (ledStatus[i].hex != targetValue) {
ledStatus[i].hex = targetValue;
}
});
}
};
let firstLedDataSubscription = () => {};
firstLedDataSubscription = status.subscribe(async (val) => {
if (val && val.leds) {
ledStatus = val.leds.map((obj) => ({ ['hex']: obj['hex'] }));
for (let led of ledStatus) {
if (led['hex'] == '#000000') {
led['hex'] = generateRandomColor();
}
}
firstLedDataSubscription();
}
});
onDestroy(firstLedDataSubscription);
</script>
<CardContainer title={m['section.control.title']()}>
<div class="grid gap-4">
<div class="form-control">
<label class="label" for="customText">
<span class="label-text">{m['section.control.text']()}</span>
</label>
<div class="flex gap-2">
<InputField
id="customText"
maxLength="7"
bind:value={customText}
placeholder={m['section.control.text']()}
style="text-transform: uppercase;"
/>
<button class="btn btn-primary" onclick={() => setCustomText(customText)}
>{m['section.control.showText']()}</button
>
</div>
</div>
<div class="">
<h3 class="mb-2 font-medium">{m['section.control.ledColor']()}</h3>
<div class="flex justify-between gap-2">
<div class="mb-4 flex flex-wrap gap-2">
{#if ledStatus.length > 0}
{#each ledStatus as led}
<input
type="color"
class="btn btn-square"
bind:value={led.hex}
onchange={checkSyncLeds}
/>
{/each}
{/if}
<Toggle label={m['sections.control.keepSameColor']()} bind:checked={keepLedsSameColor} />
</div>
<div class="flex gap-2">
<button class="btn btn-secondary" onclick={turnOffLeds}>{m['section.control.turnOff']()}</button>
<button class="btn btn-primary" onclick={() => setLEDcolor(ledStatus)}
>{m['section.control.setColor']()}</button
>
</div>
</div>
</div>
{#if $settings.hasFrontlight && !$settings.flDisable}
<div>
<h3 class="mb-2 font-medium">{m['section.control.frontlight']()}</h3>
<div class="flex gap-2 justify-end">
<button class="btn btn-secondary" onclick={() => turnOnFrontlight()}>{m['section.control.turnOn']()}</button>
<button class="btn btn-primary" onclick={() => turnOffFrontlight()}>{m['section.control.turnOff']()}</button>
<button class="btn btn-accent" onclick={() => flashFrontlight()}>{m['section.control.flashFrontlight']()}</button>
</div>
</div>
{/if}
<div>
<h3 class="mb-2 font-medium">{m['section.control.title']()}</h3>
<div class="flex gap-2 justify-end">
<button class="btn btn-error" onclick={restartClock}>{m['button.restart']()}</button>
<button class="btn" onclick={forceFullRefresh}>{m['button.forceFullRefresh']()}</button>
</div>
</div>
</div>
</CardContainer>

View file

@ -0,0 +1,341 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages';
import { CardContainer, Toggle, CollapsibleSection } from '$lib/components';
import { settings } from '$lib/stores';
let { ...restProps } = $props();
// Show/hide toggles
let showAll = $state(false);
let hideAll = $state(false);
function toggleShowAll() {
showAll = true;
hideAll = false;
}
function toggleHideAll() {
hideAll = true;
showAll = false;
}
</script>
<CardContainer title={m["section.settings.title"]()} {...restProps}>
<div class="flex justify-end gap-2 mb-4">
<button class="btn btn-sm" onclick={toggleShowAll}>{m["section.settings.showAll"]()}</button>
<button class="btn btn-sm" onclick={toggleHideAll}>{m["section.settings.hideAll"]()}</button>
</div>
<div class="grid gap-4 grid-cols-2">
<CollapsibleSection title={m["section.settings.section.screenSettings"]()} open={showAll || !hideAll}>
<div class="grid gap-4 grid-cols-2">
<div class="form-control">
<Toggle
label={m["section.settings.StealFocusOnNewBlock"]()}
bind:checked={$settings.stealFocus}
/>
<p class="text-xs">When a new block is mined, it will switch focus from the current screen.</p>
</div>
<div class="form-control">
<Toggle
label={m["section.settings.useBigCharsMcap"]()}
bind:checked={$settings.mcapBigChar}
/>
<p class="text-xs">Use big characters for the market cap screen instead of using a suffix.</p>
</div>
<div class="form-control">
<Toggle
label={m["section.settings.useBlkCountdown"]()}
bind:checked={$settings.useBlkCountdown}
/>
<p class="text-xs">When enabled it count down blocks instead of years/monts/days/hours/minutes.</p>
</div>
<div class="form-control">
<Toggle
label={m["section.settings.useSatsSymbol"]()}
bind:checked={$settings.useSatsSymbol}
/>
<p class="text-xs">Prefix satoshi amounts with the sats symbol.</p>
</div>
<div class="form-control">
<Toggle
label={m["section.settings.suffixPrice"]()}
bind:checked={$settings.suffixPrice}
/>
<p class="text-xs">Always use a suffix for the ticker screen.</p>
</div>
<div class="form-control">
<Toggle
label={m["section.settings.verticalDesc"]()}
bind:checked={$settings.verticalDesc}
/>
<p class="text-xs">Rotate the description of the screen 90 degrees.</p>
</div>
</div>
</CollapsibleSection>
<CollapsibleSection title={m["section.settings.screens"]()} open={showAll || !hideAll}>
<div class="grid gap-4 grid-cols-2">
{#each $settings.screens as screen}
<div class="form-control">
<Toggle
label={screen.name}
checked={screen.enabled}
/>
</div>
{/each}
</div>
</CollapsibleSection>
<CollapsibleSection title={m["section.settings.currencies"]()} open={showAll || !hideAll}>
<div class="alert alert-warning">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span>restart required</span>
</div>
<div class="grid gap-4 grid-cols-2">
{#each $settings.actCurrencies as currency}
<div class="form-control">
<Toggle
label={currency}
checked={$settings.actCurrencies.includes(currency)}
/>
</div>
{/each}
</div>
</CollapsibleSection>
<CollapsibleSection title={m["section.settings.section.displaysAndLed"]()} open={showAll || !hideAll}>
<div class="grid gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">{m["section.settings.textColor"]()}</span>
</label>
<select class="select select-bordered w-full">
<option>White on Black</option>
<option>Black on White</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Font</span>
</label>
<select class="select select-bordered w-full">
<option>Oswald</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">{m["section.settings.timePerScreen"]()}</span>
</label>
<div class="flex items-center gap-2">
<input type="number" class="input input-bordered w-20" min="1" max="60" value="1" />
<span>{m["time.minutes"]()}</span>
</div>
</div>
<div class="form-control flex justify-between">
<label class="label">
<span class="label-text">{m["section.settings.fullRefreshEvery"]()}</span>
</label>
<div class="w-auto input">
<input type="number" class="" min="1" max="60" value="60" />
<span class="label">{m["time.minutes"]()}</span>
</div>
</div>
<div class="form-control flex justify-between">
<label class="label">
<span class="label-text">{m["section.settings.timeBetweenPriceUpdates"]()}</span>
</label>
<div class="w-auto input">
<input type="number" class="" min="1" max="60" value="30" />
<span class="label">{m["time.seconds"]()}</span>
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">{m["section.settings.ledBrightness"]()}</span>
</label>
<input type="range" min="0" max="100" class="range" value="50" />
</div>
<div class="form-control">
<Toggle
label={m["section.settings.ledPowerOnTest"]()}
checked={$settings.ledTestOnPower}
/>
</div>
<div class="form-control">
<Toggle
label={m["section.settings.ledFlashOnBlock"]()}
checked={$settings.ledFlashOnUpd}
/>
</div>
<div class="form-control">
<Toggle
label={m["section.settings.disableLeds"]()}
checked={$settings.disableLeds}
/>
</div>
</div>
</CollapsibleSection>
{#if $settings.hasFrontlight}
<CollapsibleSection title="Frontlight Settings" open={showAll || !hideAll}>
<div class="grid gap-4">
<div class="form-control">
<Toggle
label="Disable Frontlight"
checked={$settings.flDisable}
/>
</div>
<div class="form-control">
<Toggle
label="Always On"
checked={$settings.flAlwaysOn}
/>
</div>
<div class="form-control">
<Toggle
label="Flash on Updates"
checked={$settings.flFlashOnUpd}
/>
</div>
<div class="form-control">
<Toggle
label="Flash on Zaps"
checked={$settings.flFlashOnZap}
/>
</div>
{#if $settings.hasLightLevel}
<div class="form-control">
<Toggle
label="Turn Off in Dark"
checked={$settings.flOffWhenDark}
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Light Level Threshold</span>
</label>
<input type="range" min="0" max="255" class="range" value={$settings.luxLightToggle} />
</div>
{/if}
<div class="form-control">
<label class="label">
<span class="label-text">Maximum Brightness</span>
</label>
<input type="range" min="0" max="4095" class="range" value={$settings.flMaxBrightness} />
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Effect Delay (ms)</span>
</label>
<input type="number" class="input input-bordered w-20" min="10" max="1000" value={$settings.flEffectDelay} />
</div>
</div>
</CollapsibleSection>
{/if}
<CollapsibleSection title={m["section.settings.section.dataSource"]()} open={showAll || !hideAll}>
<div class="grid gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">{m["section.settings.dataSource.label"]()}</span>
</label>
<select class="select select-bordered w-full">
<option value="btclock">{m["section.settings.dataSource.btclock"]()}</option>
<option value="thirdparty">{m["section.settings.dataSource.thirdParty"]()}</option>
<option value="nostr">{m["section.settings.dataSource.nostr"]()}</option>
<option value="custom">{m["section.settings.dataSource.custom"]()}</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">{m["section.settings.mempoolnstance"]()}</span>
</label>
<input type="text" class="input input-bordered w-full" value="mempool.space/coinlcp.io" />
</div>
<div class="form-control">
<label class="label">
<span class="label-text">{m["section.settings.ceEndpoint"]()}</span>
</label>
<input type="text" class="input input-bordered w-full" placeholder="Custom Endpoint URL" />
</div>
</div>
</CollapsibleSection>
<CollapsibleSection title={m["section.settings.section.extraFeatures"]()} open={showAll || !hideAll}>
<div class="grid gap-4">
<div class="form-control">
<Toggle
label={m["section.settings.timeBasedDnd"]()}
checked={$settings.dnd.enabled}
/>
</div>
</div>
</CollapsibleSection>
<CollapsibleSection title={m["section.settings.section.system"]()} open={showAll || !hideAll}>
<div class="grid gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">{m["section.settings.timezoneOffset"]()}</span>
</label>
<div class="flex items-center gap-2">
<select class="select select-bordered w-full">
<option>Europe/Amsterdam</option>
</select>
<button class="btn">{m["auto-detect"]()}</button>
</div>
<p class="text-sm mt-1">{m["section.settings.tzOffsetHelpText"]()}</p>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">{m["section.settings.hostnamePrefix"]()}</span>
</label>
<input type="text" class="input input-bordered w-full" value="btclock" />
</div>
<div class="form-control">
<label class="label">
<span class="label-text">{m["section.settings.wpTimeout"]()}</span>
</label>
<div class="flex items-center gap-2">
<input type="number" class="input input-bordered w-20" min="1" max="900" value="600" />
<span>{m["time.seconds"]()}</span>
</div>
</div>
</div>
</CollapsibleSection>
</div>
<div class="flex justify-between mt-6">
<button class="btn btn-error">{m["button.reset"]()}</button>
<button class="btn btn-primary">{m["button.save"]()}</button>
</div>
</CardContainer>

View file

@ -0,0 +1,75 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages';
import { CardContainer, TabButton, CurrencyButton, Stat, Status } from '$lib/components';
import { status, settings } from '$lib/stores';
import { setActiveScreen, setActiveCurrency } from '$lib/clockControl';
import BTClock from '../BTClock.svelte';
import { DataSourceType } from '$lib/types';
import { toUptimestring } from '$lib/utils';
const screens = $settings.screens.map(screen => ({
id: screen.id,
label: screen.name
}));
</script>
<CardContainer title={m['section.status.title']()}>
<div class="space-y-4 mx-auto">
<div class="join">
{#each screens as screen}
<TabButton active={$status.currentScreen === screen.id} onClick={() => setActiveScreen(screen.id)}>
{screen.label}
</TabButton>
{/each}
</div>
<div class="join flex justify-center">
{#each $settings.actCurrencies as currency}
<CurrencyButton
currency={currency}
active={$status.currency === currency}
onClick={() => setActiveCurrency(currency)}
/>
{/each}
</div>
<div class="mt-8 flex justify-center">
<div class="w-3/4">
<!-- Bitcoin value display showing blocks/price -->
<BTClock displays={$status.data} verticalDesc={$settings.verticalDesc} />
{$settings.verticalDesc}
</div>
</div>
<div class="text-center text-sm text-gray-500">
{m['section.status.screenCycle']()}: is {$status.timerRunning ? 'running' : 'stopped'}<br />
{m['section.status.doNotDisturb']()}: {$status.dnd.enabled ? m['on']() : m['off']()}
<small>
{#if $status.dnd?.timeBasedEnabled}
{m['section.status.timeBasedDnd']()} ( {$settings.dnd
.startHour}:{$settings.dnd.startMinute.toString().padStart(2, '0')} - {$settings
.dnd.endHour}:{$settings.dnd.endMinute.toString().padStart(2, '0')} )
{/if}
</small>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
{#if $settings.dataSource === DataSourceType.NOSTR_SOURCE || $settings.nostrZapNotify}
<Status text="Nostr Relay connection" status={$status.nostr ? 'online' : 'offline'} />
{/if}
{#if $settings.dataSource === DataSourceType.THIRD_PARTY_SOURCE}
<Status text={m['section.status.wsPriceConnection']()} status={$status.connectionStatus.price ? 'online' : 'offline'} />
<Status text={m['section.status.wsMempoolConnection']({ instance: $settings.mempoolInstance })} status={$status.connectionStatus.blocks ? 'online' : 'offline'} />
{:else}
<Status text={m['section.status.wsDataConnection']()} status={$status.connectionStatus.V2 ? 'online' : 'offline'} />
{/if}
</div>
</div>
<div class="flex justify-center stats shadow mt-4">
<Stat title={m['section.status.memoryFree']()} value={`${Math.round($status.espFreeHeap / 1024)} / ${Math.round($status.espHeapSize / 1024)} KiB`} />
<Stat title={m['section.status.wifiSignalStrength']()} value={`${$status.rssi} dBm`} />
<Stat title={m['section.status.uptime']()} value={`${toUptimestring($status.espUptime)}`} />
</div>
</CardContainer>

View file

@ -0,0 +1,102 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages';
import { CardContainer } from '$lib/components';
import { settings } from '$lib/stores';
import {
restartClock,
forceFullRefresh
} from '$lib/clockControl';
</script>
<CardContainer title="System Information">
<div>
<div class="overflow-x-auto">
<table class="table-sm table">
<thead>
<tr>
<th colspan="2">System info</th>
</tr>
</thead>
<tbody>
<tr>
<td>{m['section.control.version']()}</td>
<td>{$settings.gitTag}</td>
</tr>
<tr>
<td>{m['section.control.buildTime']()}</td>
<td>{$settings.lastBuildTime}</td>
</tr>
<tr>
<td>IP</td>
<td>{$settings.ip}</td>
</tr>
<tr>
<td>HW revision</td>
<td>{$settings.hwRev}</td>
</tr>
<tr>
<td>{m['section.control.fwCommit']()}</td>
<td class="text-xs">{$settings.gitRev}</td>
</tr>
<tr>
<td>{m['section.control.hostname']()}</td>
<td>{$settings.hostname}</td>
</tr>
</tbody>
</table>
</div>
<div class="mt-4 flex gap-2">
<button class="btn btn-error" onclick={restartClock}>{m['button.restart']()}</button>
<button class="btn" onclick={forceFullRefresh}>{m['button.forceFullRefresh']()}</button>
</div>
</div>
</CardContainer>
<CardContainer title={m['section.control.firmwareUpdate']()} className="mt-4">
<div>
<p class="mb-2 text-sm">
Latest Version: 3.3.5 - Release Date: 5/2/2025, 12:37:14 AM - <a
href="#"
class="link link-primary">{m['section.firmwareUpdater.viewRelease']()}</a
>
</p>
<p class="text-success mb-4 text-sm">{m['section.firmwareUpdater.swUpToDate']()}</p>
<div class="form-control mb-4">
<label class="label" for="firmwareFile">
<span class="label-text">Firmware File (blib_s3_mini_213epd_firmware.bin)</span>
</label>
<div class="flex gap-2">
<input type="file" class="file-input file-input-bordered w-full" id="firmwareFile" />
<button class="btn btn-primary">{m['section.control.firmwareUpdate']()}</button>
</div>
</div>
<div class="form-control">
<label class="label" for="webuiFile">
<span class="label-text">WebUI File (littlefs_4MB.bin)</span>
</label>
<div class="flex gap-2">
<input type="file" class="file-input file-input-bordered w-full" id="webuiFile" />
<button class="btn btn-primary">Update WebUI</button>
</div>
</div>
<div class="alert alert-warning mt-4">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/></svg
>
<span>{m['section.firmwareUpdater.firmwareUpdateText']()}</span>
</div>
</div>
</CardContainer>

View file

@ -0,0 +1,16 @@
<script lang="ts">
let {
title,
className = "",
...restProps
} = $props();
</script>
<div class="card bg-base-100 shadow-xl w-full {className}" {...restProps}>
<div class="card-body">
{#if title}
<h2 class="card-title">{title}</h2>
{/if}
<slot />
</div>
</div>

View file

@ -0,0 +1,23 @@
<script lang="ts">
let {
title,
value,
desc = "",
icon = "",
className = "",
...restProps
} = $props();
</script>
<div class="stat {className}" {...restProps}>
{#if icon}
<div class="stat-figure text-primary">
{@html icon}
</div>
{/if}
<div class="stat-title">{title}</div>
<div class="stat-value">{value}</div>
{#if desc}
<div class="stat-desc">{desc}</div>
{/if}
</div>

View file

@ -0,0 +1,33 @@
<script lang="ts">
let {
status = "offline" as "online" | "offline" | "error" | "warning",
text,
className = "",
...restProps
} = $props();
const getStatusColor = () => {
switch (status) {
case "online":
return "bg-success";
case "offline":
return "bg-base-300";
case "error":
return "bg-error";
case "warning":
return "bg-warning";
default:
return "bg-base-300";
}
};
</script>
<div class="flex items-center gap-2 {className}" {...restProps}>
<div class="relative flex">
<div class="{getStatusColor()} h-3 w-3 rounded-full"></div>
{#if status === "online"}
<div class="{getStatusColor()} animate-ping absolute inline-flex h-3 w-3 rounded-full opacity-75"></div>
{/if}
</div>
<span class="text-sm">{text}</span>
</div>

View file

@ -0,0 +1,15 @@
<script lang="ts">
let {
active = false,
onClick,
...restProps
} = $props();
</script>
<button
class="btn btn-sm join-item {active ? 'btn-primary' : 'btn-outline'}"
on:click={onClick}
{...restProps}
>
<slot />
</button>

View file

@ -0,0 +1,71 @@
<script lang="ts">
type Position =
| 'top-start' | 'top-center' | 'top-end'
| 'middle-start' | 'middle-center' | 'middle-end'
| 'bottom-start' | 'bottom-center' | 'bottom-end';
type AlertType = 'info' | 'success' | 'warning' | 'error';
let props = $props();
let message = props.message || '';
let type = (props.type as AlertType) || 'info';
let position = (props.position as Position) || 'bottom-end';
let duration = props.duration || 3000;
let showClose = props.showClose || false;
// Create a new object without the known props
let restProps = { ...props };
delete restProps.message;
delete restProps.type;
delete restProps.position;
delete restProps.duration;
delete restProps.showClose;
let visible = $state(true);
// Map position prop to DaisyUI classes
const getPositionClasses = (pos: Position): string => {
const [vertical, horizontal] = pos.split('-');
let classes = 'toast';
// Vertical position
if (vertical === 'top') classes += ' toast-top';
if (vertical === 'middle') classes += ' toast-middle';
// bottom is default, no class needed
// Horizontal position
if (horizontal === 'start') classes += ' toast-start';
if (horizontal === 'center') classes += ' toast-center';
if (horizontal === 'end') classes += ' toast-end';
return classes;
};
// Auto-hide the toast after duration
if (duration > 0) {
setTimeout(() => {
visible = false;
}, duration);
}
const closeToast = () => {
visible = false;
};
</script>
{#if visible}
<div class={getPositionClasses(position)} {...restProps}>
<div class="alert alert-{type}">
<span>{message}</span>
{#if showClose}
<button class="btn btn-circle btn-xs" onclick={closeToast}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
{/if}
</div>
</div>
{/if}

3
src/lib/env.ts Normal file
View file

@ -0,0 +1,3 @@
import { PUBLIC_BASE_URL } from '$env/static/public';
export const baseUrl = PUBLIC_BASE_URL;

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.

2
src/lib/stores/index.ts Normal file
View file

@ -0,0 +1,2 @@
export { settings, type Settings } from './settings';
export { status, type Status } from './status';

161
src/lib/stores/settings.ts Normal file
View file

@ -0,0 +1,161 @@
import { writable } from 'svelte/store';
import { baseUrl } from '$lib/env';
import type { Settings } from '$lib/types';
// Create a default settings object
const defaultSettings: Settings = {
numScreens: 7,
invertedColor: false,
timerSeconds: 60,
timerRunning: false,
minSecPriceUpd: 30,
fullRefreshMin: 60,
wpTimeout: 600,
tzString: "UTC",
dataSource: 0,
mempoolInstance: "mempool.space",
mempoolSecure: true,
localPoolEndpoint: "localhost:2019",
nostrPubKey: "",
nostrRelay: "wss://relay.damus.io",
nostrZapNotify: false,
nostrZapPubkey: "",
ledFlashOnZap: true,
fontName: "oswald",
availableFonts: ["antonio", "oswald"],
customEndpoint: "ws-staging.btclock.dev",
customEndpointDisableSSL: false,
ledTestOnPower: true,
ledFlashOnUpd: true,
ledBrightness: 255,
stealFocus: false,
mcapBigChar: true,
mdnsEnabled: true,
otaEnabled: true,
useSatsSymbol: true,
useBlkCountdown: true,
suffixPrice: false,
disableLeds: false,
mowMode: false,
verticalDesc: true,
suffixShareDot: false,
enableDebugLog: false,
hostnamePrefix: "btclock",
hostname: "btclock",
ip: "",
txPower: 80,
gitReleaseUrl: "https://git.btclock.dev/api/v1/repos/btclock/btclock_v3/releases/latest",
bitaxeEnabled: false,
bitaxeHostname: "bitaxe1",
miningPoolStats: false,
miningPoolName: "noderunners",
miningPoolUser: "",
availablePools: [
"ocean",
"noderunners",
"satoshi_radio",
"braiins",
"public_pool",
"local_public_pool",
"gobrrr_pool",
"ckpool",
"eu_ckpool"
],
httpAuthEnabled: false,
httpAuthUser: "btclock",
httpAuthPass: "satoshi",
hasFrontlight: false,
// Default frontlight settings
flDisable: false,
flMaxBrightness: 2684,
flAlwaysOn: false,
flEffectDelay: 50,
flFlashOnUpd: true,
flFlashOnZap: true,
// Default light sensor settings
hasLightLevel: false,
luxLightToggle: 128,
flOffWhenDark: false,
hwRev: "",
fsRev: "",
gitRev: "",
gitTag: "",
lastBuildTime: "",
screens: [
{id: 0, name: "Block Height", enabled: true},
{id: 3, name: "Time", enabled: false},
{id: 4, name: "Halving countdown", enabled: false},
{id: 6, name: "Block Fee Rate", enabled: false},
{id: 10, name: "Sats per dollar", enabled: true},
{id: 20, name: "Ticker", enabled: true},
{id: 30, name: "Market Cap", enabled: false}
],
actCurrencies: ["USD"],
availableCurrencies: ["USD", "EUR", "GBP", "JPY", "AUD", "CAD"],
poolLogosUrl: "https://git.btclock.dev/btclock/mining-pool-logos/raw/branch/main",
ceEndpoint: "ws-staging.btclock.dev",
ceDisableSSL: false,
dnd: {
enabled: false,
timeBasedEnabled: false,
startHour: 23,
startMinute: 0,
endHour: 7,
endMinute: 0
}
};
// Create the Svelte store
function createSettingsStore() {
const { subscribe, set, update } = writable<Settings>(defaultSettings);
return {
subscribe,
fetch: async () => {
try {
const response = await fetch(`${baseUrl}/api/settings`);
if (!response.ok) {
throw new Error(`Error fetching settings: ${response.statusText}`);
}
const data = await response.json();
set(data);
return data;
} catch (error) {
console.error('Failed to fetch settings:', error);
return defaultSettings;
}
},
update: async (newSettings: Partial<Settings>) => {
try {
const response = await fetch(`${baseUrl}/api/settings`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(newSettings),
});
if (!response.ok) {
throw new Error(`Error updating settings: ${response.statusText}`);
}
// Update the local store with the new settings
update(currentSettings => ({ ...currentSettings, ...newSettings }));
return true;
} catch (error) {
console.error('Failed to update settings:', error);
return false;
}
},
set: (newSettings: Settings) => set(newSettings),
reset: () => set(defaultSettings)
};
}
export const settings = createSettingsStore();
// Initialize the store by fetching settings when this module is first imported
if (typeof window !== 'undefined') {
settings.fetch();
}

180
src/lib/stores/status.ts Normal file
View file

@ -0,0 +1,180 @@
import { writable } from 'svelte/store';
import { baseUrl } from '$lib/env';
import type { Status } from '$lib/types';
// Create a default status object
const defaultStatus: Status = {
currentScreen: 0,
numScreens: 0,
timerRunning: false,
isOTAUpdating: false,
espUptime: 0,
espFreeHeap: 0,
espHeapSize: 0,
connectionStatus: {
price: false,
blocks: false,
V2: false,
nostr: false
},
rssi: 0,
currency: "USD",
dnd: {
enabled: false,
timeBasedEnabled: false,
startTime: "00:00",
endTime: "00:00",
active: false
},
data: [],
leds: [
{red: 0, green: 0, blue: 0, hex: "#000000"},
{red: 0, green: 0, blue: 0, hex: "#000000"},
{red: 0, green: 0, blue: 0, hex: "#000000"},
{red: 0, green: 0, blue: 0, hex: "#000000"}
]
};
// Create the Svelte store
function createStatusStore() {
const { subscribe, set, update } = writable<Status>(defaultStatus);
let eventSource: EventSource | null = null;
// Clean up function to close SSE connection
const cleanup = () => {
if (eventSource) {
eventSource.close();
eventSource = null;
}
};
// Create the store object with methods
const store = {
subscribe,
fetch: async () => {
try {
const response = await fetch(`${baseUrl}/api/status`);
if (!response.ok) {
throw new Error(`Error fetching status: ${response.statusText}`);
}
const data = await response.json();
set(data);
return data;
} catch (error) {
console.error('Failed to fetch status:', error);
return defaultStatus;
}
},
startListening: () => {
// Clean up any existing connections first
cleanup();
// Only run in the browser, not during SSR
if (typeof window === 'undefined') return;
try {
// Create a new EventSource connection
eventSource = new EventSource(`${baseUrl}/events`);
// Handle status updates
eventSource.addEventListener('status', (event) => {
try {
const data = JSON.parse(event.data);
update(currentStatus => ({ ...currentStatus, ...data }));
} catch (error) {
console.error('Error processing status event:', error);
}
});
// Handle connection status updates
eventSource.addEventListener('connection', (event) => {
try {
const data = JSON.parse(event.data);
update(currentStatus => ({
...currentStatus,
connectionStatus: {
...currentStatus.connectionStatus,
...data
}
}));
} catch (error) {
console.error('Error processing connection event:', error);
}
});
// Handle screen updates
eventSource.addEventListener('screen', (event) => {
try {
const data = JSON.parse(event.data);
update(currentStatus => ({
...currentStatus,
currentScreen: data.screen || currentStatus.currentScreen
}));
} catch (error) {
console.error('Error processing screen event:', error);
}
});
// Handle generic messages
eventSource.onmessage = (event) => {
if (event.type === 'message') {
return;
}
try {
const data = JSON.parse(event.data);
update(currentStatus => ({ ...currentStatus, ...data }));
} catch (error) {
console.error('Error processing message event:', error);
}
};
// Handle errors
eventSource.onerror = (error) => {
console.error('EventSource failed:', error);
cleanup();
// Attempt to reconnect after a delay
setTimeout(() => store.startListening(), 5000);
};
} catch (error) {
console.error('Failed to setup event source:', error);
}
},
stopListening: cleanup,
// Function to set the current screen
setScreen: async (id: number): Promise<boolean> => {
try {
// Make the GET request to change the screen
const response = await fetch(`${baseUrl}/api/show/screen/${id}`);
if (!response.ok) {
throw new Error(`Error setting screen: ${response.statusText}`);
}
// Update the store with the new screen ID
update(currentStatus => ({
...currentStatus,
currentScreen: id
}));
return true;
} catch (error) {
console.error('Failed to set screen:', error);
return false;
}
}
};
return store;
}
export const status = createStatusStore();
// Initialize the store by fetching initial status and starting to listen for updates
if (typeof window !== 'undefined') {
status.fetch().then(() => status.startListening());
// Clean up the EventSource when the window is unloaded
window.addEventListener('beforeunload', () => {
status.stopListening();
});
}

142
src/lib/types.ts Normal file
View file

@ -0,0 +1,142 @@
/**
* Types for the BTClock application
*/
export interface LedStatus {
hex: string;
}
/**
* Data source types
*/
export enum DataSourceType {
BTCLOCK_SOURCE = 0,
THIRD_PARTY_SOURCE = 1,
NOSTR_SOURCE = 2,
CUSTOM_SOURCE = 3
}
// Define the Status interface based on the API response structure
export interface Status {
currentScreen: number;
numScreens: number;
timerRunning: boolean;
isOTAUpdating: boolean;
espUptime: number;
espFreeHeap: number;
espHeapSize: number;
connectionStatus: {
price: boolean;
blocks: boolean;
V2: boolean;
nostr: boolean;
};
rssi: number;
currency: string;
dnd: {
enabled: boolean;
timeBasedEnabled: boolean;
startTime: string;
endTime: string;
active: boolean;
};
data: string[];
leds: Array<{
red: number;
green: number;
blue: number;
hex: string;
}>;
[key: string]: unknown;
}
// Define the Settings interface based on the API response structure
export interface Settings {
numScreens: number;
invertedColor: boolean;
timerSeconds: number;
timerRunning: boolean;
minSecPriceUpd: number;
fullRefreshMin: number;
wpTimeout: number;
tzString: string;
dataSource: number;
mempoolInstance: string;
mempoolSecure: boolean;
localPoolEndpoint: string;
nostrPubKey: string;
nostrRelay: string;
nostrZapNotify: boolean;
nostrZapPubkey: string;
ledFlashOnZap: boolean;
fontName: string;
availableFonts: string[];
customEndpoint: string;
customEndpointDisableSSL: boolean;
ledTestOnPower: boolean;
ledFlashOnUpd: boolean;
ledBrightness: number;
stealFocus: boolean;
mcapBigChar: boolean;
mdnsEnabled: boolean;
otaEnabled: boolean;
useSatsSymbol: boolean;
useBlkCountdown: boolean;
suffixPrice: boolean;
disableLeds: boolean;
mowMode: boolean;
verticalDesc: boolean;
suffixShareDot: boolean;
enableDebugLog: boolean;
hostnamePrefix: string;
hostname: string;
ip: string;
txPower: number;
gitReleaseUrl: string;
bitaxeEnabled: boolean;
bitaxeHostname: string;
miningPoolStats: boolean;
miningPoolName: string;
miningPoolUser: string;
availablePools: string[];
httpAuthEnabled: boolean;
httpAuthUser: string;
httpAuthPass: string;
hasFrontlight?: boolean;
// Frontlight settings
flDisable?: boolean;
flMaxBrightness?: number;
flAlwaysOn?: boolean;
flEffectDelay?: number;
flFlashOnUpd?: boolean;
flFlashOnZap?: boolean;
// Light sensor settings
hasLightLevel?: boolean;
luxLightToggle?: number;
flOffWhenDark?: boolean;
hwRev: string;
fsRev: string;
gitRev: string;
gitTag: string;
lastBuildTime: string;
screens: Array<{
id: number;
name: string;
enabled: boolean;
}>;
actCurrencies: string[];
availableCurrencies: string[];
poolLogosUrl: string;
ceEndpoint: string;
ceDisableSSL: boolean;
dnd: {
enabled: boolean;
timeBasedEnabled: boolean;
startHour: number;
startMinute: number;
endHour: number;
endMinute: number;
};
[key: string]: unknown;
}

22
src/lib/utils.ts Normal file
View file

@ -0,0 +1,22 @@
export const toTime = (secs: number) => {
const hours = Math.floor(secs / (60 * 60));
const divisor_for_minutes = secs % (60 * 60);
const minutes = Math.floor(divisor_for_minutes / 60);
const divisor_for_seconds = divisor_for_minutes % 60;
const seconds = Math.ceil(divisor_for_seconds);
const obj = {
h: hours,
m: minutes,
s: seconds
};
return obj;
};
export const toUptimestring = (secs: number): string => {
const time = toTime(secs);
return `${time.h}h ${time.m}m ${time.s}s`;
};

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

@ -0,0 +1,35 @@
<script lang="ts">
import '../app.css';
import { Navbar } from '$lib/components';
import { settings, status } from '$lib/stores';
import { onMount, onDestroy } from 'svelte';
let { children } = $props();
let unsubscribeSettings: () => void;
let unsubscribeStatus: () => void;
// Initialize stores when page loads
onMount(() => {
if (typeof window !== 'undefined') {
console.log('Initializing stores');
unsubscribeSettings = settings.subscribe(() => {});
unsubscribeStatus = status.subscribe(() => {});
}
});
// Clean up on component destroy
onDestroy(() => {
if (unsubscribeSettings) unsubscribeSettings();
if (unsubscribeStatus) unsubscribeStatus();
if (typeof window !== 'undefined') {
status.stopListening();
}
});
export const prerender = true;
</script>
<Navbar />
<main class="bg-base-200">
{@render children()}
</main>

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

@ -0,0 +1,21 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages';
import { settings, status } from '$lib/stores';
import { onMount, onDestroy } from 'svelte';
import { ControlSection, StatusSection, SettingsSection } from '$lib/components';
</script>
<div class=" mx-auto px-2 py-4">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<div>
<ControlSection />
</div>
<div class="col-span-2">
<StatusSection />
</div>
</div>
</div>

View file

@ -0,0 +1,68 @@
<script lang="ts">
import { baseUrl } from '$lib/env';
import { onMount, onDestroy } from 'svelte';
let isLoaded = $state(false);
let scalarApiReference;
function initializeScalar() {
// @ts-ignore - Scalar is loaded dynamically
if (window.Scalar) {
// @ts-ignore - Scalar is loaded dynamically
scalarApiReference = window.Scalar.createApiReference('#app', {
url: '/swagger.json',
hideDarkModeToggle: true,
hideClientButton: true,
baseServerURL: baseUrl
});
isLoaded = true;
} else {
setTimeout(initializeScalar, 100); // Check again in 100ms
}
}
function loadScalarScript() {
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/@scalar/api-reference';
script.onload = () => {
initializeScalar();
};
script.onerror = (error) => {
console.error('Failed to load Scalar API Reference:', error);
};
document.head.appendChild(script);
}
let darkMode = $state(false);
let handler: (e: MediaQueryListEvent) => void;
let mediaQuery: MediaQueryList;
onMount(() => {
loadScalarScript();
darkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
handler = (e: MediaQueryListEvent) => {
darkMode = e.matches;
};
mediaQuery.addEventListener('change', handler);
return () => {
isLoaded = false;
};
});
onDestroy(() => {
if (mediaQuery) {
mediaQuery.removeEventListener('change', handler);
}
if (isLoaded) {
document.querySelectorAll('style[data-scalar]').forEach(el => el.remove());
scalarApiReference.destroy();
}
});
</script>
<div class="relative">
<div id="app" class="w-full" class:dark-mode={darkMode}></div>
</div>

View file

View file

@ -0,0 +1,11 @@
import { describe, test, expect } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render, screen } from '@testing-library/svelte';
import Page from './+page.svelte';
describe('/+page.svelte', () => {
test('should render h1', () => {
render(Page);
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
});
});

View file

@ -0,0 +1,8 @@
<script lang="ts">
import { SettingsSection } from '$lib/components';
</script>
<div class="container mx-auto p-4">
<h1 class="text-2xl font-bold mb-4">Settings</h1>
<SettingsSection />
</div>

View file

@ -0,0 +1,8 @@
<script lang="ts">
import { SystemSection } from '$lib/components';
</script>
<div class="container mx-auto p-4">
<h1 class="text-2xl font-bold mb-4">System Management</h1>
<SystemSection />
</div>

BIN
static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

457
static/swagger.json Normal file
View file

@ -0,0 +1,457 @@
{
"openapi": "3.0.3",
"info": {
"title": "BTClock API",
"version": "3.0",
"description": "BTClock V3 API"
},
"servers": [
{
"url": "/api/"
}
],
"paths": {
"/status": {
"get": {
"tags": ["system"],
"summary": "Get current status",
"responses": {
"200": {
"description": "successful operation"
}
}
}
},
"/system_status": {
"get": {
"tags": ["system"],
"summary": "Get system status",
"responses": {
"200": {
"description": "successful operation"
}
}
}
},
"/settings": {
"get": {
"tags": ["system"],
"summary": "Get current settings",
"responses": {
"200": {
"description": "successful operation",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ArrayOfLeds"
}
}
}
}
}
},
"post": {
"tags": ["system"],
"summary": "Save current settings",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Settings"
}
}
}
},
"responses": {
"200": {
"description": "successful operation"
}
}
},
"patch": {
"tags": ["system"],
"summary": "Save current settings",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Settings"
}
}
}
},
"responses": {
"200": {
"description": "successful operation"
}
}
}
},
"/action/pause": {
"get": {
"tags": ["timer"],
"summary": "Pause screen rotation",
"responses": {
"200": {
"description": "successful operation"
}
}
}
},
"/action/timer_restart": {
"get": {
"tags": ["timer"],
"summary": "Restart screen rotation",
"responses": {
"200": {
"description": "successful operation"
}
}
}
},
"/show/screen/{id}": {
"get": {
"tags": ["screens"],
"summary": "Set screen to show",
"parameters": [
{
"in": "path",
"name": "id",
"schema": {
"type": "integer",
"default": 1
},
"required": true,
"description": "ID of screen to show"
}
],
"responses": {
"200": {
"description": "successful operation"
}
}
}
},
"/show/text/{text}": {
"get": {
"tags": ["screens"],
"summary": "Set text to show",
"parameters": [
{
"in": "path",
"name": "text",
"schema": {
"type": "string",
"default": "text"
},
"required": true,
"description": "Text to show"
}
],
"responses": {
"200": {
"description": "successful operation"
}
}
}
},
"/show/custom": {
"post": {
"tags": ["screens"],
"summary": "Set text to show (advanced)",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CustomText"
}
}
}
},
"responses": {
"200": {
"description": "successful operation"
}
}
}
},
"/full_refresh": {
"get": {
"tags": ["system"],
"summary": "Force full refresh of all displays",
"responses": {
"200": {
"description": "successful operation"
}
}
}
},
"/lights": {
"get": {
"tags": ["lights"],
"summary": "Get LEDs status",
"responses": {
"200": {
"description": "successful operation",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ArrayOfLeds"
}
}
}
}
}
}
},
"/lights/set": {
"patch": {
"tags": ["lights"],
"summary": "Set individual LEDs",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ArrayOfLedsInput"
}
}
}
},
"responses": {
"200": {
"description": "succesful operation"
},
"400": {
"description": "invalid colors or wrong amount of LEDs"
}
}
}
},
"/lights/color/{color}": {
"get": {
"tags": ["lights"],
"summary": "Turn on LEDs with specific color",
"parameters": [
{
"in": "path",
"name": "color",
"schema": {
"type": "string",
"default": "FFCC00"
},
"required": true,
"description": "Color in RGB hex"
}
],
"responses": {
"200": {
"description": "successful operation"
}
}
}
},
"/lights/off": {
"get": {
"tags": ["lights"],
"summary": "Turn LEDs off",
"responses": {
"200": {
"description": "successful operation"
}
}
}
},
"/restart": {
"get": {
"tags": ["system"],
"summary": "Restart BTClock",
"responses": {
"200": {
"description": "successful operation"
}
}
}
}
},
"components": {
"schemas": {
"RgbColorValues": {
"type": "object",
"properties": {
"red": {
"type": "integer",
"minimum": 0,
"maximum": 255,
"example": 255
},
"green": {
"type": "integer",
"minimum": 0,
"maximum": 255,
"example": 204
},
"blue": {
"type": "integer",
"minimum": 0,
"maximum": 255,
"example": 0
}
}
},
"RgbColorHex": {
"type": "object",
"properties": {
"hex": {
"type": "string",
"pattern": "^#(?:[0-9a-fA-F]{3}){1,2}$",
"example": "#FFCC00"
}
}
},
"RgbColorValueAndHex": {
"allOf": [
{
"$ref": "#/components/schemas/RgbColorValues"
},
{
"$ref": "#/components/schemas/RgbColorHex"
}
]
},
"RgbColorValueOrHex": {
"oneOf": [
{
"$ref": "#/components/schemas/RgbColorValues"
},
{
"$ref": "#/components/schemas/RgbColorHex"
}
]
},
"ArrayOfLeds": {
"type": "array",
"items": {
"$ref": "#/components/schemas/RgbColorValueAndHex"
}
},
"ArrayOfLedsInput": {
"type": "array",
"items": {
"$ref": "#/components/schemas/RgbColorValueOrHex"
}
},
"Settings": {
"type": "object",
"properties": {
"fetchEurPrice": {
"type": "boolean",
"description": "Fetch EUR price instead of USD"
},
"fgColor": {
"type": "string",
"default": 16777215,
"description": "ePaper foreground (text) color"
},
"bgColor": {
"type": "string",
"default": 0,
"description": "ePaper background color"
},
"ledTestOnPower": {
"type": "boolean",
"default": true,
"description": "Do LED test on power-on"
},
"ledFlashOnUpd": {
"type": "boolean",
"default": false,
"description": "Flash LEDs on new block"
},
"mdnsEnabled": {
"type": "boolean",
"default": true,
"description": "Enable mDNS"
},
"otaEnabled": {
"type": "boolean",
"default": true,
"description": "Enable over-the-air updates"
},
"stealFocus": {
"type": "boolean",
"default": false,
"description": "Steal focus on new block"
},
"mcapBigChar": {
"type": "boolean",
"default": false,
"description": "Use big characters for market cap screen"
},
"mempoolInstance": {
"type": "string",
"default": "mempool.space",
"description": "Mempool.space instance to connect to"
},
"ledBrightness": {
"type": "integer",
"default": 128,
"description": "Brightness of LEDs"
},
"fullRefreshMin": {
"type": "integer",
"default": 60,
"description": "Full refresh time of ePaper displays in minutes"
},
"screen[0]": {
"type": "boolean"
},
"screen[1]": {
"type": "boolean"
},
"screen[2]": {
"type": "boolean"
},
"screen[3]": {
"type": "boolean"
},
"screen[4]": {
"type": "boolean"
},
"screen[5]": {
"type": "boolean"
},
"screen[6]": {
"type": "boolean"
},
"tzOffset": {
"type": "integer",
"default": 60,
"description": "Timezone offset in minutes"
},
"minSecPriceUpd": {
"type": "integer",
"default": 30,
"description": "Minimum time between price updates in seconds"
},
"timePerScreen": {
"type": "integer",
"default": 30,
"description": "Time between screens when rotating in minutes"
},
"txPower": {
"type": "integer",
"description": "WiFi Tx Power"
}
}
},
"CustomText": {
"type": "array",
"items": {
"type": "string"
},
"minItems": 7,
"maxItems": 7
}
}
}
}

316
static/swagger.yml Normal file
View file

@ -0,0 +1,316 @@
openapi: 3.0.3
info:
title: BTClock API
version: '3.0'
description: BTClock V3 API
servers:
- url: /api/
paths:
/status:
get:
tags:
- system
summary: Get current status
responses:
'200':
description: successful operation
/system_status:
get:
tags:
- system
summary: Get system status
responses:
'200':
description: successful operation
/settings:
get:
tags:
- system
summary: Get current settings
responses:
'200':
description: successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/ArrayOfLeds'
post:
tags:
- system
summary: Save current settings
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/Settings'
responses:
'200':
description: successful operation
patch:
tags:
- system
summary: Save current settings
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/Settings'
responses:
'200':
description: successful operation
/action/pause:
get:
tags:
- timer
summary: Pause screen rotation
responses:
'200':
description: successful operation
/action/timer_restart:
get:
tags:
- timer
summary: Restart screen rotation
responses:
'200':
description: successful operation
/show/screen/{id}:
get:
tags:
- screens
summary: Set screen to show
parameters:
- in: path
name: id
schema:
type: integer
default: 1
required: true
description: ID of screen to show
responses:
'200':
description: successful operation
/show/text/{text}:
get:
tags:
- screens
summary: Set text to show
parameters:
- in: path
name: text
schema:
type: string
default: text
required: true
description: Text to show
responses:
'200':
description: successful operation
/show/custom:
post:
tags:
- screens
summary: Set text to show (advanced)
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/CustomText'
responses:
'200':
description: successful operation
/full_refresh:
get:
tags:
- system
summary: Force full refresh of all displays
responses:
'200':
description: successful operation
/lights:
get:
tags:
- lights
summary: Get LEDs status
responses:
'200':
description: successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/ArrayOfLeds'
/lights/set:
patch:
tags:
- lights
summary: Set individual LEDs
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/ArrayOfLedsInput'
responses:
'200':
description: succesful operation
'400':
description: invalid colors or wrong amount of LEDs
/lights/color/{color}:
get:
tags:
- lights
summary: Turn on LEDs with specific color
parameters:
- in: path
name: color
schema:
type: string
default: FFCC00
required: true
description: Color in RGB hex
responses:
'200':
description: successful operation
/lights/off:
get:
tags:
- lights
summary: Turn LEDs off
responses:
'200':
description: successful operation
/restart:
get:
tags:
- system
summary: Restart BTClock
responses:
'200':
description: successful operation
components:
schemas:
RgbColorValues:
type: object
properties:
red:
type: integer
minimum: 0
maximum: 255
example: 255
green:
type: integer
minimum: 0
maximum: 255
example: 204
blue:
type: integer
minimum: 0
maximum: 255
example: 0
RgbColorHex:
type: object
properties:
hex:
type: string
pattern: ^#(?:[0-9a-fA-F]{3}){1,2}$
example: '#FFCC00'
RgbColorValueAndHex:
allOf:
- $ref: '#/components/schemas/RgbColorValues'
- $ref: '#/components/schemas/RgbColorHex'
RgbColorValueOrHex:
oneOf:
- $ref: '#/components/schemas/RgbColorValues'
- $ref: '#/components/schemas/RgbColorHex'
ArrayOfLeds:
type: array
items:
$ref: '#/components/schemas/RgbColorValueAndHex'
ArrayOfLedsInput:
type: array
items:
$ref: '#/components/schemas/RgbColorValueOrHex'
Settings:
type: object
properties:
fetchEurPrice:
type: boolean
description: Fetch EUR price instead of USD
fgColor:
type: string
default: 16777215
description: ePaper foreground (text) color
bgColor:
type: string
default: 0
description: ePaper background color
ledTestOnPower:
type: boolean
default: true
description: Do LED test on power-on
ledFlashOnUpd:
type: boolean
default: false
description: Flash LEDs on new block
mdnsEnabled:
type: boolean
default: true
description: Enable mDNS
otaEnabled:
type: boolean
default: true
description: Enable over-the-air updates
stealFocus:
type: boolean
default: false
description: Steal focus on new block
mcapBigChar:
type: boolean
default: false
description: Use big characters for market cap screen
mempoolInstance:
type: string
default: mempool.space
description: Mempool.space instance to connect to
ledBrightness:
type: integer
default: 128
description: Brightness of LEDs
fullRefreshMin:
type: integer
default: 60
description: Full refresh time of ePaper displays in minutes
screen[0]:
type: boolean
screen[1]:
type: boolean
screen[2]:
type: boolean
screen[3]:
type: boolean
screen[4]:
type: boolean
screen[5]:
type: boolean
screen[6]:
type: boolean
tzOffset:
type: integer
default: 60
description: Timezone offset in minutes
minSecPriceUpd:
type: integer
default: 30
description: Minimum time between price updates in seconds
timePerScreen:
type: integer
default: 30
description: Time between screens when rotating in minutes
txPower:
type: integer
description: WiFi Tx Power
CustomText:
type: array
items:
type: string
minItems: 7
maxItems: 7

13
svelte.config.js Normal file
View file

@ -0,0 +1,13 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
const config = {
preprocess: vitePreprocess(),
kit: { adapter: adapter({
fallback: 'index.html',
precompress: false,
strict: true
}) }
};
export default config;

19
tsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"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://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// 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
}

59
vite.config.ts Normal file
View file

@ -0,0 +1,59 @@
import { paraglideVitePlugin } from '@inlang/paraglide-js';
import tailwindcss from '@tailwindcss/vite';
import { svelteTesting } from '@testing-library/svelte/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
tailwindcss(),
sveltekit(),
paraglideVitePlugin({
project: './project.inlang',
outdir: './src/lib/paraglide'
})
],
build: {
minify: 'esbuild',
cssCodeSplit: false,
chunkSizeWarningLimit: 550,
rollupOptions: {
output: {
// assetFileNames: '[hash][extname]',
entryFileNames: `[hash][extname]`,
chunkFileNames: `[hash][extname]`,
assetFileNames: `[hash][extname]`,
preserveModules: false,
manualChunks: () => {
return 'app';
}
}
}
},
test: {
workspace: [
{
extends: './vite.config.ts',
plugins: [svelteTesting()],
test: {
name: 'client',
environment: 'jsdom',
clearMocks: true,
include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
exclude: ['src/lib/server/**'],
setupFiles: ['./vitest-setup-client.ts']
}
},
{
extends: './vite.config.ts',
test: {
name: 'server',
environment: 'node',
include: ['src/**/*.{test,spec}.{js,ts}'],
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}']
}
}
]
}
});

18
vitest-setup-client.ts Normal file
View file

@ -0,0 +1,18 @@
import '@testing-library/jest-dom/vitest';
import { vi } from 'vitest';
// required for svelte5 + jsdom as jsdom does not support matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
enumerable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn()
}))
});
// add more mocks here if you need them