feat: Most settings implemented
This commit is contained in:
parent
f8c2f4f228
commit
98ad7d1432
41 changed files with 1976 additions and 421 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -25,3 +25,5 @@ vite.config.ts.timestamp-*
|
||||||
|
|
||||||
# Paraglide
|
# Paraglide
|
||||||
src/lib/paraglide
|
src/lib/paraglide
|
||||||
|
build
|
||||||
|
build_gz
|
|
@ -37,7 +37,8 @@ export default ts.config(
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
rules: {
|
rules: {
|
||||||
'svelte/no-at-html-tags': 'off'
|
'svelte/no-at-html-tags': 'off',
|
||||||
|
'@typescript-eslint/no-unsafe-function-type': 'off'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
@ -118,6 +118,12 @@
|
||||||
"viewRelease": "Veröffentlichung anzeigen",
|
"viewRelease": "Veröffentlichung anzeigen",
|
||||||
"autoUpdate": "Update installieren (experimentell)",
|
"autoUpdate": "Update installieren (experimentell)",
|
||||||
"autoUpdateInProgress": "Automatische Aktualisierung läuft, bitte warten..."
|
"autoUpdateInProgress": "Automatische Aktualisierung läuft, bitte warten..."
|
||||||
|
},
|
||||||
|
"system": {
|
||||||
|
"firmwareOutdated": "WebUI und Firmware sind nicht synchronisiert. Dies kann zu unerwartetem Verhalten führen."
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"title": "Start"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"colors": {
|
"colors": {
|
||||||
|
|
|
@ -92,7 +92,8 @@
|
||||||
"dndStartHour": "Start hour",
|
"dndStartHour": "Start hour",
|
||||||
"dndStartMinute": "Start minute",
|
"dndStartMinute": "Start minute",
|
||||||
"dndEndHour": "End hour",
|
"dndEndHour": "End hour",
|
||||||
"dndEndMinute": "End minute"
|
"dndEndMinute": "End minute",
|
||||||
|
"localPoolEndpoint": "Local Pool Endpoint"
|
||||||
},
|
},
|
||||||
"control": {
|
"control": {
|
||||||
"systemInfo": "System info",
|
"systemInfo": "System info",
|
||||||
|
@ -138,6 +139,12 @@
|
||||||
"viewRelease": "View Release",
|
"viewRelease": "View Release",
|
||||||
"autoUpdate": "Install update (experimental)",
|
"autoUpdate": "Install update (experimental)",
|
||||||
"autoUpdateInProgress": "Auto-update in progress, please wait..."
|
"autoUpdateInProgress": "Auto-update in progress, please wait..."
|
||||||
|
},
|
||||||
|
"system": {
|
||||||
|
"firmwareOutdated": "WebUI and firmware are out of sync. This might cause unexpected behavior."
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"title": "Home"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"colors": {
|
"colors": {
|
||||||
|
|
|
@ -118,6 +118,12 @@
|
||||||
"viewRelease": "Ver lanzamiento",
|
"viewRelease": "Ver lanzamiento",
|
||||||
"autoUpdate": "Instalar actualización (experimental)",
|
"autoUpdate": "Instalar actualización (experimental)",
|
||||||
"autoUpdateInProgress": "Actualización automática en progreso, espere..."
|
"autoUpdateInProgress": "Actualización automática en progreso, espere..."
|
||||||
|
},
|
||||||
|
"system": {
|
||||||
|
"firmwareOutdated": "La interfaz web y el firmware no están sincronizados. Esto podría causar un comportamiento inesperado."
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"title": "Inicio"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"button": {
|
"button": {
|
||||||
|
|
|
@ -108,6 +108,12 @@
|
||||||
"viewRelease": "Bekijk publicatie",
|
"viewRelease": "Bekijk publicatie",
|
||||||
"autoUpdate": "Update installeren (experimenteel)",
|
"autoUpdate": "Update installeren (experimenteel)",
|
||||||
"autoUpdateInProgress": "Automatische update wordt uitgevoerd. Even geduld a.u.b...."
|
"autoUpdateInProgress": "Automatische update wordt uitgevoerd. Even geduld a.u.b...."
|
||||||
|
},
|
||||||
|
"system": {
|
||||||
|
"firmwareOutdated": "WebUI en firmware zijn niet gesynchroniseerd. Dit kan onverwacht gedrag veroorzaken."
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"title": "Overzicht"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"colors": {
|
"colors": {
|
||||||
|
|
|
@ -46,7 +46,8 @@
|
||||||
"@fontsource-variable/oswald": "^5.2.5",
|
"@fontsource-variable/oswald": "^5.2.5",
|
||||||
"@fontsource/ubuntu": "^5.2.5",
|
"@fontsource/ubuntu": "^5.2.5",
|
||||||
"@inlang/paraglide-js": "^2.0.0",
|
"@inlang/paraglide-js": "^2.0.0",
|
||||||
"daisyui": "^5.0.35"
|
"daisyui": "^5.0.35",
|
||||||
|
"nostr-tools": "^2.12.0"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
|
|
82
pnpm-lock.yaml
generated
82
pnpm-lock.yaml
generated
|
@ -25,6 +25,9 @@ importers:
|
||||||
daisyui:
|
daisyui:
|
||||||
specifier: ^5.0.35
|
specifier: ^5.0.35
|
||||||
version: 5.0.35
|
version: 5.0.35
|
||||||
|
nostr-tools:
|
||||||
|
specifier: ^2.12.0
|
||||||
|
version: 2.12.0(typescript@5.8.3)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@eslint/compat':
|
'@eslint/compat':
|
||||||
specifier: ^1.2.5
|
specifier: ^1.2.5
|
||||||
|
@ -414,6 +417,23 @@ packages:
|
||||||
resolution: {integrity: sha512-k/1pb70eD638anoi0e8wUGAlbMJXyvdV4p62Ko+EZ7eBe1xMx8Uhak1R5DgfoofsK5IBBnRwsYGTaLZl+6/+RQ==}
|
resolution: {integrity: sha512-k/1pb70eD638anoi0e8wUGAlbMJXyvdV4p62Ko+EZ7eBe1xMx8Uhak1R5DgfoofsK5IBBnRwsYGTaLZl+6/+RQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
'@noble/ciphers@0.5.3':
|
||||||
|
resolution: {integrity: sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==}
|
||||||
|
|
||||||
|
'@noble/curves@1.1.0':
|
||||||
|
resolution: {integrity: sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==}
|
||||||
|
|
||||||
|
'@noble/curves@1.2.0':
|
||||||
|
resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==}
|
||||||
|
|
||||||
|
'@noble/hashes@1.3.1':
|
||||||
|
resolution: {integrity: sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==}
|
||||||
|
engines: {node: '>= 16'}
|
||||||
|
|
||||||
|
'@noble/hashes@1.3.2':
|
||||||
|
resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==}
|
||||||
|
engines: {node: '>= 16'}
|
||||||
|
|
||||||
'@nodelib/fs.scandir@2.1.5':
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
@ -534,6 +554,15 @@ packages:
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@scure/base@1.1.1':
|
||||||
|
resolution: {integrity: sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==}
|
||||||
|
|
||||||
|
'@scure/bip32@1.3.1':
|
||||||
|
resolution: {integrity: sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==}
|
||||||
|
|
||||||
|
'@scure/bip39@1.2.1':
|
||||||
|
resolution: {integrity: sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==}
|
||||||
|
|
||||||
'@sinclair/typebox@0.31.28':
|
'@sinclair/typebox@0.31.28':
|
||||||
resolution: {integrity: sha512-/s55Jujywdw/Jpan+vsy6JZs1z2ZTGxTmbZTPiuSL2wz9mfzA2gN1zzaqmvfi4pq+uOt7Du85fkiwv5ymW84aQ==}
|
resolution: {integrity: sha512-/s55Jujywdw/Jpan+vsy6JZs1z2ZTGxTmbZTPiuSL2wz9mfzA2gN1zzaqmvfi4pq+uOt7Du85fkiwv5ymW84aQ==}
|
||||||
|
|
||||||
|
@ -1569,6 +1598,17 @@ packages:
|
||||||
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
|
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
nostr-tools@2.12.0:
|
||||||
|
resolution: {integrity: sha512-pUWEb020gTvt1XZvTa8AKNIHWFapjsv2NKyk43Ez2nnvz6WSXsrTFE0XtkNLSRBjPn6EpxumKeNiVzLz74jNSA==}
|
||||||
|
peerDependencies:
|
||||||
|
typescript: '>=5.0.0'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
typescript:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
nostr-wasm@0.1.0:
|
||||||
|
resolution: {integrity: sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==}
|
||||||
|
|
||||||
nwsapi@2.2.20:
|
nwsapi@2.2.20:
|
||||||
resolution: {integrity: sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==}
|
resolution: {integrity: sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==}
|
||||||
|
|
||||||
|
@ -2473,6 +2513,20 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@noble/ciphers@0.5.3': {}
|
||||||
|
|
||||||
|
'@noble/curves@1.1.0':
|
||||||
|
dependencies:
|
||||||
|
'@noble/hashes': 1.3.1
|
||||||
|
|
||||||
|
'@noble/curves@1.2.0':
|
||||||
|
dependencies:
|
||||||
|
'@noble/hashes': 1.3.2
|
||||||
|
|
||||||
|
'@noble/hashes@1.3.1': {}
|
||||||
|
|
||||||
|
'@noble/hashes@1.3.2': {}
|
||||||
|
|
||||||
'@nodelib/fs.scandir@2.1.5':
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nodelib/fs.stat': 2.0.5
|
'@nodelib/fs.stat': 2.0.5
|
||||||
|
@ -2551,6 +2605,19 @@ snapshots:
|
||||||
'@rollup/rollup-win32-x64-msvc@4.40.1':
|
'@rollup/rollup-win32-x64-msvc@4.40.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@scure/base@1.1.1': {}
|
||||||
|
|
||||||
|
'@scure/bip32@1.3.1':
|
||||||
|
dependencies:
|
||||||
|
'@noble/curves': 1.1.0
|
||||||
|
'@noble/hashes': 1.3.1
|
||||||
|
'@scure/base': 1.1.1
|
||||||
|
|
||||||
|
'@scure/bip39@1.2.1':
|
||||||
|
dependencies:
|
||||||
|
'@noble/hashes': 1.3.1
|
||||||
|
'@scure/base': 1.1.1
|
||||||
|
|
||||||
'@sinclair/typebox@0.31.28': {}
|
'@sinclair/typebox@0.31.28': {}
|
||||||
|
|
||||||
'@sqlite.org/sqlite-wasm@3.48.0-build4': {}
|
'@sqlite.org/sqlite-wasm@3.48.0-build4': {}
|
||||||
|
@ -3589,6 +3656,21 @@ snapshots:
|
||||||
|
|
||||||
negotiator@1.0.0: {}
|
negotiator@1.0.0: {}
|
||||||
|
|
||||||
|
nostr-tools@2.12.0(typescript@5.8.3):
|
||||||
|
dependencies:
|
||||||
|
'@noble/ciphers': 0.5.3
|
||||||
|
'@noble/curves': 1.2.0
|
||||||
|
'@noble/hashes': 1.3.1
|
||||||
|
'@scure/base': 1.1.1
|
||||||
|
'@scure/bip32': 1.3.1
|
||||||
|
'@scure/bip39': 1.2.1
|
||||||
|
optionalDependencies:
|
||||||
|
nostr-wasm: 0.1.0
|
||||||
|
typescript: 5.8.3
|
||||||
|
|
||||||
|
nostr-wasm@0.1.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
nwsapi@2.2.20: {}
|
nwsapi@2.2.20: {}
|
||||||
|
|
||||||
object-assign@4.1.1: {}
|
object-assign@4.1.1: {}
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
@source "./safelist.txt";
|
||||||
|
|
||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
@plugin "daisyui" {
|
@plugin "daisyui" {
|
||||||
}
|
}
|
||||||
|
|
|
@ -109,3 +109,99 @@ export const toggleDoNotDisturb = (currentStatus: boolean) => (e: Event) => {
|
||||||
fetch(`${baseUrl}/api/dnd/disable`);
|
fetch(`${baseUrl}/api/dnd/disable`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads a file to a specified endpoint with progress, success and error callbacks
|
||||||
|
* @param file The file to upload
|
||||||
|
* @param endpoint The endpoint to upload to
|
||||||
|
* @param callbacks Optional callbacks for various events during upload
|
||||||
|
* @returns A promise that resolves when the upload is complete or fails
|
||||||
|
*/
|
||||||
|
export const uploadFile = async (
|
||||||
|
file: File | null,
|
||||||
|
endpoint: string,
|
||||||
|
callbacks?: {
|
||||||
|
onProgress?: (progress: number) => void;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
onError?: (error?: Error | string | unknown) => void;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', endpoint);
|
||||||
|
|
||||||
|
xhr.upload.onprogress = (event: ProgressEvent) => {
|
||||||
|
if (event.lengthComputable) {
|
||||||
|
const progress = Math.round((event.loaded * 100) / event.total);
|
||||||
|
callbacks?.onProgress?.(progress);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onload = () => {
|
||||||
|
if (xhr.status === 200 && xhr.responseText !== 'FAIL') {
|
||||||
|
callbacks?.onSuccess?.();
|
||||||
|
} else {
|
||||||
|
callbacks?.onError?.(xhr.responseText);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = () => {
|
||||||
|
callbacks?.onError?.('Network error');
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.send(formData);
|
||||||
|
} catch (error) {
|
||||||
|
callbacks?.onError?.(error);
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const uploadFirmwareFile = (
|
||||||
|
firmwareUploadFile: File,
|
||||||
|
callbacks?: {
|
||||||
|
onProgress?: (progress: number) => void;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
onError?: (error?: Error | string | unknown) => void;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
uploadFile(firmwareUploadFile, `${baseUrl}/upload/firmware`, callbacks);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const uploadWebUiFile = (
|
||||||
|
firmwareWebUiFile: File,
|
||||||
|
callbacks?: {
|
||||||
|
onProgress?: (progress: number) => void;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
onError?: (error?: Error | string | unknown) => void;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
uploadFile(firmwareWebUiFile, `${baseUrl}/upload/webui`, callbacks);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatically updates the firmware
|
||||||
|
*/
|
||||||
|
export const autoUpdate = async (callbacks?: {
|
||||||
|
onSuccess?: (message: string) => void;
|
||||||
|
onError?: (error?: Error | string | unknown) => void;
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${baseUrl}/api/firmware/auto_update`);
|
||||||
|
const data = await response.json();
|
||||||
|
const message = data.msg;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
callbacks?.onError?.(message);
|
||||||
|
} else {
|
||||||
|
callbacks?.onSuccess?.(message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
callbacks?.onError?.(error);
|
||||||
|
console.error('Error during auto-update:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
@ -1,7 +1,4 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import '@fontsource-variable/oswald'; // Import the Oswald variable font
|
|
||||||
import '@fontsource/ubuntu';
|
|
||||||
|
|
||||||
type DisplayMode = 'single' | 'medium' | 'split';
|
type DisplayMode = 'single' | 'medium' | 'split';
|
||||||
type DisplayTheme = 'light' | 'dark'; // New type for display theme
|
type DisplayTheme = 'light' | 'dark'; // New type for display theme
|
||||||
|
|
||||||
|
|
75
src/lib/components/form/NumericInput.svelte
Normal file
75
src/lib/components/form/NumericInput.svelte
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* NumericInput component with label and units display
|
||||||
|
*/
|
||||||
|
|
||||||
|
let {
|
||||||
|
// Core functionality
|
||||||
|
value = $bindable(0),
|
||||||
|
min = 0,
|
||||||
|
max = 100,
|
||||||
|
step = 1,
|
||||||
|
|
||||||
|
// Appearance
|
||||||
|
label = '',
|
||||||
|
unit = '',
|
||||||
|
displayVertical = false, // whether to stack label and input vertically
|
||||||
|
width = '20', // width of the input in Tailwind units (e.g. '20', 'full')
|
||||||
|
|
||||||
|
// Accessibility
|
||||||
|
id = '',
|
||||||
|
|
||||||
|
// Additional attributes
|
||||||
|
...restProps
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// Generate an ID if one wasn't provided
|
||||||
|
if (!id && label) {
|
||||||
|
id = `numeric-input-${Math.random().toString(36).substring(2, 11)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track the input element to access it when needed
|
||||||
|
let inputElement: HTMLInputElement;
|
||||||
|
|
||||||
|
function handleInput(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
// Handle empty input
|
||||||
|
if (input.value === '') return;
|
||||||
|
|
||||||
|
// Enforce min/max constraints
|
||||||
|
const numValue = parseFloat(input.value);
|
||||||
|
if (isNaN(numValue)) {
|
||||||
|
input.value = value.toString();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (numValue < min) input.value = min.toString();
|
||||||
|
if (numValue > max) input.value = max.toString();
|
||||||
|
value = parseFloat(input.value);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="form-control {displayVertical ? '' : 'flex justify-between'}">
|
||||||
|
<label for={id} class="label">
|
||||||
|
<span class="label-text">{label}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="input flex w-auto items-center gap-1">
|
||||||
|
<input
|
||||||
|
bind:this={inputElement}
|
||||||
|
{id}
|
||||||
|
type="number"
|
||||||
|
bind:value
|
||||||
|
{min}
|
||||||
|
{max}
|
||||||
|
{step}
|
||||||
|
oninput={handleInput}
|
||||||
|
class="w-{width}"
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if unit}
|
||||||
|
<span class="label ml-1">{unit}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
67
src/lib/components/form/Radio.svelte
Normal file
67
src/lib/components/form/Radio.svelte
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Radio component using DaisyUI styling with Svelte 5 Runes
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Define the allowed sizes
|
||||||
|
type RadioSize = 'xs' | 'sm' | 'md' | 'lg';
|
||||||
|
// Define the allowed colors based on DaisyUI themes
|
||||||
|
type RadioColor = 'primary' | 'secondary' | 'accent' | 'success' | 'warning' | 'info' | 'error';
|
||||||
|
|
||||||
|
let {
|
||||||
|
// Core functionality
|
||||||
|
group = $bindable(undefined as any),
|
||||||
|
value,
|
||||||
|
name = '',
|
||||||
|
checked = false,
|
||||||
|
disabled = false,
|
||||||
|
|
||||||
|
// Appearance
|
||||||
|
label = '',
|
||||||
|
size = 'md' as RadioSize,
|
||||||
|
color = 'primary' as RadioColor,
|
||||||
|
|
||||||
|
// Accessibility
|
||||||
|
id = '',
|
||||||
|
|
||||||
|
// Additional attributes
|
||||||
|
...restProps
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// Generate an ID if one wasn't provided
|
||||||
|
if (!id && label) {
|
||||||
|
id = `radio-${Math.random().toString(36).substring(2, 11)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute classes based on props
|
||||||
|
const radioClasses = $derived(() => {
|
||||||
|
const classes = ['radio'];
|
||||||
|
|
||||||
|
// Add size modifier
|
||||||
|
if (size) classes.push(`radio-${size}`);
|
||||||
|
|
||||||
|
// Add color modifier
|
||||||
|
if (color) classes.push(`radio-${color}`);
|
||||||
|
|
||||||
|
return classes.join(' ');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
class={radioClasses}
|
||||||
|
{id}
|
||||||
|
{name}
|
||||||
|
{value}
|
||||||
|
{disabled}
|
||||||
|
{checked}
|
||||||
|
bind:group
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
{#if label}
|
||||||
|
<label for={id} class="cursor-pointer">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
</div>
|
76
src/lib/components/form/RadioGroup.svelte
Normal file
76
src/lib/components/form/RadioGroup.svelte
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Radio } from '$lib/components';
|
||||||
|
|
||||||
|
// Define the orientation options
|
||||||
|
type Orientation = 'horizontal' | 'vertical';
|
||||||
|
// Define the allowed sizes
|
||||||
|
type RadioSize = 'xs' | 'sm' | 'md' | 'lg';
|
||||||
|
// Define the allowed colors based on DaisyUI themes
|
||||||
|
type RadioColor = 'primary' | 'secondary' | 'accent' | 'success' | 'warning' | 'info' | 'error';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options type for radio items
|
||||||
|
*/
|
||||||
|
type RadioOption = {
|
||||||
|
value: any;
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
// Core functionality
|
||||||
|
options = [] as RadioOption[],
|
||||||
|
selected = $bindable(undefined as any),
|
||||||
|
name = `radio-group-${Math.random().toString(36).substring(2, 11)}`,
|
||||||
|
|
||||||
|
// Appearance
|
||||||
|
orientation = 'vertical' as Orientation,
|
||||||
|
size = 'md' as RadioSize,
|
||||||
|
color = 'primary' as RadioColor,
|
||||||
|
|
||||||
|
// Container
|
||||||
|
legendLabel = '',
|
||||||
|
description = '',
|
||||||
|
|
||||||
|
// Additional props
|
||||||
|
...restProps
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// Compute container classes based on orientation
|
||||||
|
const containerClasses = $derived(() => {
|
||||||
|
const classes = ['space-y-4'];
|
||||||
|
|
||||||
|
if (orientation === 'horizontal') {
|
||||||
|
classes.push('flex flex-row gap-4');
|
||||||
|
} else {
|
||||||
|
classes.push('flex flex-col grid grid-cols-2');
|
||||||
|
}
|
||||||
|
|
||||||
|
return classes.join(' ');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<fieldset class="form-control flex justify-between">
|
||||||
|
{#if legendLabel}
|
||||||
|
<legend class="mb-2 font-medium">{legendLabel}</legend>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if description}
|
||||||
|
<p class="mb-3 text-xs">{description}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class={containerClasses()}>
|
||||||
|
{#each options as option}
|
||||||
|
<Radio
|
||||||
|
label={option.label}
|
||||||
|
value={option.value}
|
||||||
|
disabled={option.disabled}
|
||||||
|
{name}
|
||||||
|
{size}
|
||||||
|
{color}
|
||||||
|
bind:group={selected}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
134
src/lib/components/form/RangeSlider.svelte
Normal file
134
src/lib/components/form/RangeSlider.svelte
Normal file
|
@ -0,0 +1,134 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* RangeSlider component using DaisyUI styling with Svelte 5 Runes
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Define the allowed sizes for the slider
|
||||||
|
type SliderSize = 'xs' | 'sm' | 'md' | 'lg';
|
||||||
|
// Define the allowed colors based on DaisyUI themes
|
||||||
|
type SliderColor = 'primary' | 'secondary' | 'accent' | 'success' | 'warning' | 'info' | 'error';
|
||||||
|
|
||||||
|
let {
|
||||||
|
// Core functionality
|
||||||
|
value = $bindable(50),
|
||||||
|
min = 0,
|
||||||
|
max = 100,
|
||||||
|
step = 1,
|
||||||
|
|
||||||
|
// Appearance
|
||||||
|
label = '',
|
||||||
|
showValue = false,
|
||||||
|
showTicks = false,
|
||||||
|
showBubble = false,
|
||||||
|
valuePrefix = '',
|
||||||
|
valueSuffix = '',
|
||||||
|
size = 'md' as SliderSize,
|
||||||
|
color = 'primary' as SliderColor,
|
||||||
|
displayVertical = false, // whether to stack label and input vertically
|
||||||
|
tickCount = 5, // Number of ticks to display
|
||||||
|
|
||||||
|
// Accessibility
|
||||||
|
id = '',
|
||||||
|
|
||||||
|
// Additional attributes
|
||||||
|
...restProps
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// Generate an ID if one wasn't provided
|
||||||
|
if (!id && label) {
|
||||||
|
id = `range-slider-${Math.random().toString(36).substring(2, 11)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format displayed value
|
||||||
|
const formattedValue = $derived(() => {
|
||||||
|
return `${valuePrefix}${value}${valueSuffix}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate tick positions and values
|
||||||
|
function getTickPositions() {
|
||||||
|
const positions = [];
|
||||||
|
const range = max - min;
|
||||||
|
const interval = range / (tickCount - 1);
|
||||||
|
|
||||||
|
for (let i = 0; i < tickCount; i++) {
|
||||||
|
const tickValue = min + i * interval;
|
||||||
|
const percent = ((tickValue - min) / range) * 100;
|
||||||
|
positions.push({
|
||||||
|
value: Math.round(tickValue * 100) / 100, // Round to 2 decimal places
|
||||||
|
percent
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate bubble position
|
||||||
|
const bubblePosition = $derived(() => {
|
||||||
|
return ((value - min) / (max - min)) * 100;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Compute slider classes based on props
|
||||||
|
const sliderClasses = $derived(() => {
|
||||||
|
const classes = ['range'];
|
||||||
|
|
||||||
|
// Add size modifier
|
||||||
|
if (size) classes.push(`range-${size}`);
|
||||||
|
|
||||||
|
// Add color modifier
|
||||||
|
if (color) classes.push(`range-${color}`);
|
||||||
|
|
||||||
|
return classes.join(' ');
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleInput(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
value = parseFloat(input.value);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="form-control {displayVertical ? '' : 'justify flex'}">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<label for={id} class="label">
|
||||||
|
<span class="label-text">{label}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{#if showValue && !showBubble}
|
||||||
|
(<span class="label-text-alt">{formattedValue()}</span>)
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative flex grow justify-end">
|
||||||
|
<input
|
||||||
|
{id}
|
||||||
|
type="range"
|
||||||
|
bind:value
|
||||||
|
{min}
|
||||||
|
{max}
|
||||||
|
{step}
|
||||||
|
oninput={handleInput}
|
||||||
|
class={sliderClasses()}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if showBubble}
|
||||||
|
<div
|
||||||
|
class="badge absolute -top-8 -translate-x-1/2 transform badge-{color}"
|
||||||
|
style="left: {bubblePosition}%"
|
||||||
|
>
|
||||||
|
{formattedValue()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showTicks}
|
||||||
|
<div class="relative mt-1 h-4 w-full">
|
||||||
|
{#each getTickPositions() as { value: tickValue, percent }}
|
||||||
|
<div class="absolute h-2 w-px bg-gray-400" style="left: {percent}%">
|
||||||
|
<div class="mt-2 text-center text-xs" style="transform: translateX(-50%)">
|
||||||
|
{tickValue}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
90
src/lib/components/form/Select.svelte
Normal file
90
src/lib/components/form/Select.svelte
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Select component using DaisyUI styling with Svelte 5 Runes
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Define the allowed sizes for the select
|
||||||
|
type SelectSize = 'xs' | 'sm' | 'md' | 'lg';
|
||||||
|
// Define the allowed colors based on DaisyUI themes
|
||||||
|
type SelectColor = 'primary' | 'secondary' | 'accent' | 'success' | 'warning' | 'info' | 'error';
|
||||||
|
// Define the option type
|
||||||
|
type SelectOption = {
|
||||||
|
value: string | boolean;
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
// Core functionality
|
||||||
|
value = $bindable(''),
|
||||||
|
options = [] as SelectOption[],
|
||||||
|
|
||||||
|
// Appearance
|
||||||
|
label = '',
|
||||||
|
placeholder = 'Select an option',
|
||||||
|
size = 'md' as SelectSize,
|
||||||
|
color = '' as SelectColor,
|
||||||
|
bordered = true,
|
||||||
|
ghost = false,
|
||||||
|
displayVertical = false, // whether to stack label and input vertically
|
||||||
|
|
||||||
|
// State
|
||||||
|
disabled = false,
|
||||||
|
required = false,
|
||||||
|
|
||||||
|
// Accessibility
|
||||||
|
id = '',
|
||||||
|
name = '',
|
||||||
|
|
||||||
|
// Additional attributes
|
||||||
|
...restProps
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// Generate an ID if one wasn't provided
|
||||||
|
if (!id && label) {
|
||||||
|
id = `select-${Math.random().toString(36).substring(2, 11)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute select classes based on props
|
||||||
|
const selectClasses = $derived(() => {
|
||||||
|
const classes = ['select'];
|
||||||
|
|
||||||
|
// Add bordered style
|
||||||
|
if (bordered) classes.push('select-bordered');
|
||||||
|
|
||||||
|
// Add ghost style
|
||||||
|
if (ghost) classes.push('select-ghost');
|
||||||
|
|
||||||
|
// Add size modifier
|
||||||
|
if (size) classes.push(`select-${size}`);
|
||||||
|
|
||||||
|
// Add color modifier
|
||||||
|
if (color) classes.push(`select-${color}`);
|
||||||
|
|
||||||
|
return classes.join(' ');
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleChange(e: Event) {
|
||||||
|
const select = e.target as HTMLSelectElement;
|
||||||
|
value = select.value;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="form-control {displayVertical ? 'w-full' : 'flex items-center justify-between gap-2'}">
|
||||||
|
{#if label}
|
||||||
|
<label for={id} class="label">
|
||||||
|
<span class="label-text">{label}{required ? ' *' : ''}</span>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<select {id} {name} class={selectClasses()} {disabled} {required} bind:value {...restProps}>
|
||||||
|
{#if placeholder}
|
||||||
|
<option value="" disabled selected={!value}>{placeholder}</option>
|
||||||
|
{/if}
|
||||||
|
{#each options as option}
|
||||||
|
<option value={option.value} disabled={option.disabled} selected={value === option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
70
src/lib/components/form/SettingsInputField.svelte
Normal file
70
src/lib/components/form/SettingsInputField.svelte
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* SettingsInputField component using DaisyUI styling with Svelte 5 Runes
|
||||||
|
* A specialized input field with horizontal layout for settings pages
|
||||||
|
*/
|
||||||
|
|
||||||
|
let {
|
||||||
|
// Core functionality
|
||||||
|
value = $bindable(''),
|
||||||
|
|
||||||
|
// Appearance
|
||||||
|
label = '',
|
||||||
|
placeholder = '',
|
||||||
|
width = 'auto',
|
||||||
|
|
||||||
|
// State
|
||||||
|
disabled = false,
|
||||||
|
required = false,
|
||||||
|
|
||||||
|
// Input type
|
||||||
|
type = 'text',
|
||||||
|
|
||||||
|
// Accessibility
|
||||||
|
id = '',
|
||||||
|
name = '',
|
||||||
|
|
||||||
|
// Additional attributes
|
||||||
|
...restProps
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// Generate an ID if one wasn't provided
|
||||||
|
if (!id && label) {
|
||||||
|
id = `settings-input-${Math.random().toString(36).substring(2, 11)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute input classes based on props
|
||||||
|
const inputClasses = $derived(() => {
|
||||||
|
const classes = ['input', 'input-bordered', 'input', 'flex'];
|
||||||
|
|
||||||
|
if (width === 'auto') {
|
||||||
|
classes.push('w-auto');
|
||||||
|
} else if (width === 'full') {
|
||||||
|
classes.push('w-full');
|
||||||
|
} else {
|
||||||
|
classes.push(`w-${width}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return classes.join(' ');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="form-control flex items-center justify-between gap-2">
|
||||||
|
{#if label}
|
||||||
|
<label for={id} class="label">
|
||||||
|
<span class="label-text">{label}{required ? ' *' : ''}</span>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<input
|
||||||
|
{id}
|
||||||
|
{name}
|
||||||
|
{type}
|
||||||
|
class={inputClasses()}
|
||||||
|
{placeholder}
|
||||||
|
{disabled}
|
||||||
|
{required}
|
||||||
|
bind:value
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
</div>
|
51
src/lib/components/form/TimezoneSelector.svelte
Normal file
51
src/lib/components/form/TimezoneSelector.svelte
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages';
|
||||||
|
|
||||||
|
let {
|
||||||
|
selectedTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
label = m['section.settings.timezoneOffset'](),
|
||||||
|
helpText = m['section.settings.tzOffsetHelpText'](),
|
||||||
|
change = (_: string) => {}
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// Generate a unique ID for this component instance
|
||||||
|
const selectId = `timezone-select-${Math.random().toString(36).substring(2, 11)}`;
|
||||||
|
const timezones = Intl.supportedValuesOf('timeZone');
|
||||||
|
|
||||||
|
function autoDetectTimezone() {
|
||||||
|
const newTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
selectedTimezone = newTimezone;
|
||||||
|
change(newTimezone);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChange(event: Event) {
|
||||||
|
const select = event.target as HTMLSelectElement;
|
||||||
|
change(select.value);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="form-control flex flex-row justify-between">
|
||||||
|
<label for={selectId} class="label">
|
||||||
|
<span class="label-text">{label}</span>
|
||||||
|
</label>
|
||||||
|
<div class="flex w-auto items-center gap-2">
|
||||||
|
<div class="flex">
|
||||||
|
<select
|
||||||
|
id={selectId}
|
||||||
|
class="select select-bordered w-full"
|
||||||
|
bind:value={selectedTimezone}
|
||||||
|
onchange={handleChange}
|
||||||
|
>
|
||||||
|
{#each timezones as timezone}
|
||||||
|
<option value={timezone}>
|
||||||
|
{timezone}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-secondary" onclick={autoDetectTimezone}>{m['auto-detect']()}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if helpText}
|
||||||
|
<p class="mt-1 flex-1 justify-end text-sm">{helpText}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
|
@ -1,10 +1,42 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let { checked = $bindable(false), label = '', id = '', ...restProps } = $props();
|
let {
|
||||||
|
checked = $bindable(false),
|
||||||
|
group = $bindable(undefined),
|
||||||
|
value = undefined,
|
||||||
|
label = '',
|
||||||
|
id = '',
|
||||||
|
...restProps
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const isGroupMode = $derived(group !== undefined);
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
group = group.includes(value)
|
||||||
|
? group.filter((v) => v !== value) // New array without value
|
||||||
|
: [...group, value]; // New array with value
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<label class="flex cursor-pointer items-center justify-between gap-2">
|
<label class="label form-control flex cursor-pointer items-center justify-between gap-2">
|
||||||
{#if label}
|
{#if label}
|
||||||
<span class="label-text text-xs">{label}</span>
|
<span class="label-text">{label}</span>
|
||||||
|
{/if}
|
||||||
|
{#if isGroupMode}
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="toggle toggle-primary toggle-sm"
|
||||||
|
{id}
|
||||||
|
onchange={toggle}
|
||||||
|
checked={group && group.includes(value)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="toggle toggle-primary toggle-sm"
|
||||||
|
{id}
|
||||||
|
bind:checked
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<input type="checkbox" class="toggle toggle-primary toggle-xs" {id} bind:checked {...restProps} />
|
|
||||||
</label>
|
</label>
|
||||||
|
|
26
src/lib/components/form/ToggleWrapper.svelte
Normal file
26
src/lib/components/form/ToggleWrapper.svelte
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Toggle } from '$lib/components';
|
||||||
|
|
||||||
|
let {
|
||||||
|
label,
|
||||||
|
description = '',
|
||||||
|
value = $bindable(false),
|
||||||
|
group = $bindable(undefined),
|
||||||
|
id = '' // Pass-through for accessibility
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// Determine if we're in group mode based on whether group is undefined
|
||||||
|
const isGroupMode = $derived(group !== undefined);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
{#if isGroupMode}
|
||||||
|
<Toggle {label} bind:group {value} {id} />
|
||||||
|
{:else}
|
||||||
|
<!-- Value binding mode -->
|
||||||
|
<Toggle {label} bind:checked={value} {value} {id} />
|
||||||
|
{/if}
|
||||||
|
{#if description}
|
||||||
|
<p class="text-xs italic">{description}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
|
@ -4,11 +4,20 @@ export { default as TabButton } from './ui/TabButton.svelte';
|
||||||
export { default as Toast } from './ui/Toast.svelte';
|
export { default as Toast } from './ui/Toast.svelte';
|
||||||
export { default as Stat } from './ui/Stat.svelte';
|
export { default as Stat } from './ui/Stat.svelte';
|
||||||
export { default as Status } from './ui/Status.svelte';
|
export { default as Status } from './ui/Status.svelte';
|
||||||
|
export { default as Alert } from './ui/Alert.svelte';
|
||||||
|
|
||||||
// Form Components
|
// Form Components
|
||||||
export { default as InputField } from './form/InputField.svelte';
|
export { default as InputField } from './form/InputField.svelte';
|
||||||
export { default as Toggle } from './form/Toggle.svelte';
|
export { default as Toggle } from './form/Toggle.svelte';
|
||||||
export { default as CurrencyButton } from './form/CurrencyButton.svelte';
|
export { default as CurrencyButton } from './form/CurrencyButton.svelte';
|
||||||
|
export { default as TimezoneSelector } from './form/TimezoneSelector.svelte';
|
||||||
|
export { default as ToggleWrapper } from './form/ToggleWrapper.svelte';
|
||||||
|
export { default as Radio } from './form/Radio.svelte';
|
||||||
|
export { default as RadioGroup } from './form/RadioGroup.svelte';
|
||||||
|
export { default as NumericInput } from './form/NumericInput.svelte';
|
||||||
|
export { default as RangeSlider } from './form/RangeSlider.svelte';
|
||||||
|
export { default as Select } from './form/Select.svelte';
|
||||||
|
export { default as SettingsInputField } from './form/SettingsInputField.svelte';
|
||||||
|
|
||||||
// Layout Components
|
// Layout Components
|
||||||
export { default as Navbar } from './layout/Navbar.svelte';
|
export { default as Navbar } from './layout/Navbar.svelte';
|
||||||
|
@ -19,3 +28,4 @@ export { default as ControlSection } from './sections/ControlSection.svelte';
|
||||||
export { default as StatusSection } from './sections/StatusSection.svelte';
|
export { default as StatusSection } from './sections/StatusSection.svelte';
|
||||||
export { default as SettingsSection } from './sections/SettingsSection.svelte';
|
export { default as SettingsSection } from './sections/SettingsSection.svelte';
|
||||||
export { default as SystemSection } from './sections/SystemSection.svelte';
|
export { default as SystemSection } from './sections/SystemSection.svelte';
|
||||||
|
export { default as FirmwareUpdateSection } from './sections/FirmwareUpdateSection.svelte';
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let { title, open = $bindable(false), ...restProps } = $props();
|
let { title, open = $bindable(true), ...restProps } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="collapse-arrow bg-base-200 collapse mb-2 rounded-lg" {...restProps}>
|
<div class="collapse-arrow bg-base-200 collapse mb-2 rounded-lg" {...restProps}>
|
||||||
|
|
|
@ -3,19 +3,43 @@
|
||||||
|
|
||||||
import { setLocale, getLocale } from '$lib/paraglide/runtime';
|
import { setLocale, getLocale } from '$lib/paraglide/runtime';
|
||||||
import { locales } from '$lib/paraglide/runtime';
|
import { locales } from '$lib/paraglide/runtime';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/state';
|
||||||
|
import { settings, firmwareRelease } from '$lib/stores';
|
||||||
|
import { derived } from 'svelte/store';
|
||||||
|
|
||||||
// Navigation items
|
// Navigation items
|
||||||
|
import * as m from '$lib/paraglide/messages';
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: '/', label: 'Home' },
|
{ href: '/', label: m['section.home.title'](), indicator: false },
|
||||||
{ href: '/settings', label: 'Settings' },
|
{ href: '/settings', label: m['section.settings.title'](), indicator: false },
|
||||||
{ href: '/system', label: 'System' },
|
{ href: '/system', label: m['section.settings.section.system'](), indicator: false },
|
||||||
{ href: '/apidoc', label: 'API' }
|
{ href: '/apidoc', label: 'API', indicator: false }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Create a derived store that compares firmware version with settings
|
||||||
|
const updateAvailable = derived([firmwareRelease, settings], ([$firmwareRelease, $settings]) => {
|
||||||
|
return (
|
||||||
|
$firmwareRelease.tag_name &&
|
||||||
|
$settings.gitTag &&
|
||||||
|
$firmwareRelease.tag_name !== $settings.gitTag
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe only once to the derived store
|
||||||
|
updateAvailable.subscribe((isUpdateAvailable) => {
|
||||||
|
if (isUpdateAvailable) {
|
||||||
|
const systemNavItem = navItems.find((item) => item.href === '/system');
|
||||||
|
if (systemNavItem && !systemNavItem.indicator) {
|
||||||
|
systemNavItem.indicator = true;
|
||||||
|
console.log('Update available');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Helper function to check if a link is active
|
// Helper function to check if a link is active
|
||||||
function isActive(href: string) {
|
function isActive(href: string) {
|
||||||
return $page.url.pathname === href;
|
return page.url.pathname === href;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getLocaleName = (locale: string) => {
|
const getLocaleName = (locale: string) => {
|
||||||
|
@ -65,14 +89,26 @@
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
class="menu menu-sm dropdown-content bg-base-100 rounded-box z-[1] mt-3 w-52 p-2 shadow"
|
class="menu menu-sm dropdown-content bg-base-100 rounded-box z-[1] mt-3 w-52 p-2 shadow"
|
||||||
>
|
>
|
||||||
{#each navItems as { href, label } (href)}
|
{#each navItems as { href, label, indicator } (href)}
|
||||||
<li>
|
<li>
|
||||||
<a {href} class={isActive(href) ? 'menu-active' : ''}>{label}</a>
|
{#if indicator}
|
||||||
|
<div class="indicator">
|
||||||
|
<span class="indicator-item badge badge-primary badge-xs"></span>
|
||||||
|
<a {href} class={isActive(href) ? 'menu-active' : ''}>{label}</a>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<a {href} class={isActive(href) ? 'menu-active' : ''}>{label}</a>
|
||||||
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<a href="/" class="btn btn-ghost text-xl">BTClock</a>
|
<a href="/" class="btn btn-ghost text-xl">BTClock</a>
|
||||||
|
{#if $updateAvailable}
|
||||||
|
<span class="badge badge-xs badge-success"
|
||||||
|
>New update available: {$firmwareRelease.tag_name}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="navbar-center hidden lg:flex">
|
<div class="navbar-center hidden lg:flex">
|
||||||
<ul class="menu menu-horizontal px-1">
|
<ul class="menu menu-horizontal px-1">
|
||||||
|
|
259
src/lib/components/sections/FirmwareUpdateSection.svelte
Normal file
259
src/lib/components/sections/FirmwareUpdateSection.svelte
Normal file
|
@ -0,0 +1,259 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages';
|
||||||
|
import { CardContainer, Alert } from '$lib/components';
|
||||||
|
import { getFirmwareBinaryName, getWebUiBinaryName } from '$lib/utils';
|
||||||
|
import { status, settings, firmwareRelease } from '$lib/stores';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { uploadFirmwareFile, uploadWebUiFile, autoUpdate } from '$lib/clockControl';
|
||||||
|
|
||||||
|
// Format date to a more readable format
|
||||||
|
function formatDate(dateString: string): string {
|
||||||
|
if (!dateString) return '';
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload status
|
||||||
|
let firmwareUploadProgress = 0;
|
||||||
|
let webuiUploadProgress = 0;
|
||||||
|
let isUploading = false;
|
||||||
|
let uploadError: string | null = null;
|
||||||
|
let uploadSuccess = false;
|
||||||
|
let countdownValue = 0;
|
||||||
|
let countdownInterval: number | null = null;
|
||||||
|
|
||||||
|
// File selection status
|
||||||
|
let firmwareFileSelected = false;
|
||||||
|
let webuiFileSelected = false;
|
||||||
|
|
||||||
|
// Handle file selection change for firmware file
|
||||||
|
function handleFirmwareFileChange(event: Event) {
|
||||||
|
const fileInput = event.target as HTMLInputElement;
|
||||||
|
firmwareFileSelected = !!(fileInput.files && fileInput.files.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle file selection change for WebUI file
|
||||||
|
function handleWebUIFileChange(event: Event) {
|
||||||
|
const fileInput = event.target as HTMLInputElement;
|
||||||
|
webuiFileSelected = !!(fileInput.files && fileInput.files.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start countdown to reload page after successful upload
|
||||||
|
function startCountdownToReload(seconds: number) {
|
||||||
|
countdownValue = seconds;
|
||||||
|
if (countdownInterval) clearInterval(countdownInterval);
|
||||||
|
|
||||||
|
countdownInterval = setInterval(() => {
|
||||||
|
countdownValue--;
|
||||||
|
if (countdownValue <= 0) {
|
||||||
|
if (countdownInterval) clearInterval(countdownInterval);
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}, 1000) as unknown as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle firmware file upload
|
||||||
|
function handleFirmwareUpload() {
|
||||||
|
const fileInput = document.getElementById('firmwareFile') as HTMLInputElement;
|
||||||
|
if (!fileInput || !fileInput.files || fileInput.files.length === 0) {
|
||||||
|
uploadError = 'Please select a firmware file';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isUploading = true;
|
||||||
|
uploadError = null;
|
||||||
|
uploadSuccess = false;
|
||||||
|
firmwareUploadProgress = 0;
|
||||||
|
|
||||||
|
uploadFirmwareFile(fileInput.files[0], {
|
||||||
|
onProgress: (progress) => {
|
||||||
|
firmwareUploadProgress = progress;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
isUploading = false;
|
||||||
|
uploadSuccess = true;
|
||||||
|
startCountdownToReload(10);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
isUploading = false;
|
||||||
|
uploadError =
|
||||||
|
typeof error === 'string' && error !== 'FAIL' ? error : 'Failed to upload firmware';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle webUI file upload
|
||||||
|
function handleWebUIUpload() {
|
||||||
|
const fileInput = document.getElementById('webuiFile') as HTMLInputElement;
|
||||||
|
if (!fileInput || !fileInput.files || fileInput.files.length === 0) {
|
||||||
|
uploadError = 'Please select a WebUI file';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isUploading = true;
|
||||||
|
uploadError = null;
|
||||||
|
uploadSuccess = false;
|
||||||
|
webuiUploadProgress = 0;
|
||||||
|
|
||||||
|
uploadWebUiFile(fileInput.files[0], {
|
||||||
|
onProgress: (progress) => {
|
||||||
|
webuiUploadProgress = progress;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
isUploading = false;
|
||||||
|
uploadSuccess = true;
|
||||||
|
startCountdownToReload(10);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
isUploading = false;
|
||||||
|
uploadError = typeof error === 'string' ? error : 'Failed to upload WebUI';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const onAutoUpdate = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
autoUpdate({
|
||||||
|
onSuccess: () => {
|
||||||
|
// toast.push({
|
||||||
|
// type: 'success',
|
||||||
|
// message
|
||||||
|
// });
|
||||||
|
startCountdownToReload(10);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
// toast.push({
|
||||||
|
// type: 'error',
|
||||||
|
// message: error as string
|
||||||
|
// });
|
||||||
|
uploadError = typeof error === 'string' ? error : 'Failed to auto-update';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch firmware data when component is mounted
|
||||||
|
onMount(() => {
|
||||||
|
firmwareRelease.fetchLatest();
|
||||||
|
|
||||||
|
// Cleanup on component destroy
|
||||||
|
return () => {
|
||||||
|
if (countdownInterval) clearInterval(countdownInterval);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CardContainer title={m['section.control.firmwareUpdate']()} className="">
|
||||||
|
<div>
|
||||||
|
{#if $firmwareRelease.isLoading}
|
||||||
|
<div class="my-4 flex justify-center">
|
||||||
|
<span class="loading loading-spinner"></span>
|
||||||
|
</div>
|
||||||
|
{:else if $firmwareRelease.error}
|
||||||
|
<Alert type="error" className="mb-4" message={$firmwareRelease.error} />
|
||||||
|
{:else}
|
||||||
|
<p class="mb-2 text-sm">
|
||||||
|
{m['section.firmwareUpdater.latestVersion']()}
|
||||||
|
{$firmwareRelease.tag_name} - {m['section.firmwareUpdater.releaseDate']()}
|
||||||
|
{formatDate($firmwareRelease.published_at)} -
|
||||||
|
<a
|
||||||
|
href={$firmwareRelease.html_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="link link-primary"
|
||||||
|
>
|
||||||
|
{m['section.firmwareUpdater.viewRelease']()}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if !$status.isOTAUpdating && $settings.gitTag !== $firmwareRelease.tag_name}
|
||||||
|
<Alert type="success" className="mb-4">
|
||||||
|
{m['section.firmwareUpdater.swUpdateAvailable']()}
|
||||||
|
{$settings.gitTag} → {$firmwareRelease.tag_name} -
|
||||||
|
<a href="/" class="link link-primary" on:click={onAutoUpdate}
|
||||||
|
>{m['section.firmwareUpdater.autoUpdate']()}</a
|
||||||
|
>.
|
||||||
|
</Alert>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !$status.isOTAUpdating}
|
||||||
|
<div class="form-control mb-4">
|
||||||
|
<label class="label" for="firmwareFile">
|
||||||
|
<span class="label-text">Firmware File ({getFirmwareBinaryName($settings.hwRev)})</span>
|
||||||
|
</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
class="file-input file-input-bordered w-full"
|
||||||
|
id="firmwareFile"
|
||||||
|
accept=".bin"
|
||||||
|
disabled={isUploading}
|
||||||
|
on:change={handleFirmwareFileChange}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary w-1/4"
|
||||||
|
on:click={handleFirmwareUpload}
|
||||||
|
disabled={isUploading || !firmwareFileSelected}
|
||||||
|
>
|
||||||
|
{#if isUploading && firmwareUploadProgress > 0}
|
||||||
|
{firmwareUploadProgress}%
|
||||||
|
{:else}
|
||||||
|
{m['section.control.firmwareUpdate']()}
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if isUploading && firmwareUploadProgress > 0}
|
||||||
|
<progress class="progress progress-primary mt-2" value={firmwareUploadProgress} max="100"
|
||||||
|
></progress>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="webuiFile">
|
||||||
|
<span class="label-text">WebUI File ({getWebUiBinaryName($settings.hwRev)})</span>
|
||||||
|
</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
class="file-input file-input-bordered w-full"
|
||||||
|
id="webuiFile"
|
||||||
|
accept=".bin"
|
||||||
|
disabled={isUploading}
|
||||||
|
on:change={handleWebUIFileChange}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary w-1/4"
|
||||||
|
on:click={handleWebUIUpload}
|
||||||
|
disabled={isUploading || !webuiFileSelected}
|
||||||
|
>
|
||||||
|
{#if isUploading && webuiUploadProgress > 0}
|
||||||
|
{webuiUploadProgress}%
|
||||||
|
{:else}
|
||||||
|
Update WebUI
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if isUploading && webuiUploadProgress > 0}
|
||||||
|
<progress class="progress progress-primary mt-2" value={webuiUploadProgress} max="100"
|
||||||
|
></progress>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<Alert type="info" className="my-4">
|
||||||
|
<span class="loading loading-spinner text-neutral"></span>
|
||||||
|
{m['section.firmwareUpdater.autoUpdateInProgress']()}
|
||||||
|
</Alert>
|
||||||
|
{/if}
|
||||||
|
{#if uploadSuccess}
|
||||||
|
<Alert type="success" className="my-4">
|
||||||
|
{m['section.firmwareUpdater.fileUploadSuccess']({ countdown: countdownValue })}
|
||||||
|
</Alert>
|
||||||
|
{:else if uploadError}
|
||||||
|
<Alert type="error" className="my-4">
|
||||||
|
{uploadError}
|
||||||
|
</Alert>
|
||||||
|
{/if}
|
||||||
|
<Alert type="warning" className="mt-4">
|
||||||
|
{m['section.firmwareUpdater.firmwareUpdateText']()}
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
</CardContainer>
|
|
@ -1,233 +1,255 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { m } from '$lib/paraglide/messages';
|
import { m } from '$lib/paraglide/messages';
|
||||||
import { CardContainer, Toggle, CollapsibleSection } from '$lib/components';
|
import {
|
||||||
import { settings } from '$lib/stores';
|
CardContainer,
|
||||||
|
Toggle,
|
||||||
|
CollapsibleSection,
|
||||||
|
TimezoneSelector,
|
||||||
|
ToggleWrapper,
|
||||||
|
Radio,
|
||||||
|
NumericInput,
|
||||||
|
Select,
|
||||||
|
RadioGroup,
|
||||||
|
SettingsInputField
|
||||||
|
} from '$lib/components';
|
||||||
|
import { settings, status } from '$lib/stores';
|
||||||
|
import { DataSourceType } from '$lib/types';
|
||||||
|
import RangeSlider from '../form/RangeSlider.svelte';
|
||||||
|
import { miningPoolMap } from '$lib/utils';
|
||||||
let { ...restProps } = $props();
|
let { ...restProps } = $props();
|
||||||
|
|
||||||
// Show/hide toggles
|
const textColorOptions: [string, boolean][] = [
|
||||||
let showAll = $state(false);
|
[m['colors.black']() + ' on ' + m['colors.white'](), false],
|
||||||
let hideAll = $state(false);
|
[m['colors.white']() + ' on ' + m['colors.black'](), true]
|
||||||
|
];
|
||||||
|
|
||||||
function toggleShowAll() {
|
const fontPreferenceOptions: [string, string][] = $settings.availableFonts?.map((font) => {
|
||||||
showAll = true;
|
// Check if the translation key exists in messages
|
||||||
hideAll = false;
|
// If it exists, use the translation, otherwise capitalize the font name
|
||||||
}
|
const translationKey = `fonts.${font}` as keyof typeof m;
|
||||||
|
const hasTranslation = translationKey in m && typeof m[translationKey] === 'function';
|
||||||
|
|
||||||
function toggleHideAll() {
|
return [
|
||||||
hideAll = true;
|
hasTranslation
|
||||||
showAll = false;
|
? (m[translationKey] as Function)()
|
||||||
}
|
: font.charAt(0).toUpperCase() + font.slice(1),
|
||||||
|
font
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleColorChange = (e: Event) => {
|
||||||
|
const select = e.target as HTMLSelectElement;
|
||||||
|
$settings.invertedColor = select.value === 'true';
|
||||||
|
};
|
||||||
|
|
||||||
|
// // Show/hide toggles
|
||||||
|
// let showAll = $state(false);
|
||||||
|
// let hideAll = $state(false);
|
||||||
|
|
||||||
|
// function toggleShowAll() {
|
||||||
|
// showAll = true;
|
||||||
|
// hideAll = false;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// function toggleHideAll() {
|
||||||
|
// hideAll = true;
|
||||||
|
// showAll = false;
|
||||||
|
// }
|
||||||
|
|
||||||
|
const handleTimePerScreenChange = (e: Event) => {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
$settings.timePerScreen = Number(input.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
settings.reset();
|
||||||
|
console.log('formReset');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
console.log('formSave');
|
||||||
|
settings.update($settings);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dataSourceOptions = [
|
||||||
|
{ label: m['section.settings.dataSource.btclock'](), value: DataSourceType.BTCLOCK_SOURCE },
|
||||||
|
{
|
||||||
|
label: m['section.settings.dataSource.thirdParty'](),
|
||||||
|
value: DataSourceType.THIRD_PARTY_SOURCE
|
||||||
|
},
|
||||||
|
{ label: m['section.settings.dataSource.nostr'](), value: DataSourceType.NOSTR_SOURCE },
|
||||||
|
{ label: m['section.settings.dataSource.custom'](), value: DataSourceType.CUSTOM_SOURCE }
|
||||||
|
];
|
||||||
|
|
||||||
|
const miningPoolOptions = Array.from(miningPoolMap.entries()).map(([key, value]) => ({
|
||||||
|
label: value,
|
||||||
|
value: key
|
||||||
|
}));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CardContainer title={m['section.settings.title']()} {...restProps}>
|
<CardContainer {...restProps}>
|
||||||
<div class="mb-4 flex justify-end gap-2">
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
<button class="btn btn-sm" onclick={toggleShowAll}>{m['section.settings.showAll']()}</button>
|
<CollapsibleSection title={m['section.settings.screens']()}>
|
||||||
<button class="btn btn-sm" onclick={toggleHideAll}>{m['section.settings.hideAll']()}</button>
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<CollapsibleSection
|
|
||||||
title={m['section.settings.section.screenSettings']()}
|
|
||||||
open={showAll || !hideAll}
|
|
||||||
>
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<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 grid-cols-2 gap-4">
|
|
||||||
{#each $settings.screens as screen (screen.id)}
|
{#each $settings.screens as screen (screen.id)}
|
||||||
<div class="form-control">
|
<ToggleWrapper label={screen.name} bind:value={screen.enabled} />
|
||||||
<Toggle label={screen.name} checked={screen.enabled} />
|
|
||||||
</div>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
||||||
<CollapsibleSection title={m['section.settings.currencies']()} open={showAll || !hideAll}>
|
<CollapsibleSection title={m['section.settings.currencies']()}>
|
||||||
<div class="alert alert-warning">
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
<svg
|
{#each $settings.availableCurrencies as currency (currency)}
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<ToggleWrapper
|
||||||
class="h-6 w-6 shrink-0 stroke-current"
|
id={`currency-${currency}`}
|
||||||
fill="none"
|
label={currency}
|
||||||
viewBox="0 0 24 24"
|
bind:group={$settings.actCurrencies}
|
||||||
><path
|
value={currency}
|
||||||
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 grid-cols-2 gap-4">
|
|
||||||
{#each $settings.actCurrencies as currency (currency)}
|
|
||||||
<div class="form-control">
|
|
||||||
<Toggle label={currency} checked={$settings.actCurrencies.includes(currency)} />
|
|
||||||
</div>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
||||||
<CollapsibleSection
|
<CollapsibleSection title={m['section.settings.section.screenSettings']()}>
|
||||||
title={m['section.settings.section.displaysAndLed']()}
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
open={showAll || !hideAll}
|
<ToggleWrapper
|
||||||
>
|
label={m['section.settings.StealFocusOnNewBlock']()}
|
||||||
|
bind:value={$settings.stealFocus}
|
||||||
|
description="When a new block is mined, it will switch focus from the current screen."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ToggleWrapper
|
||||||
|
label={m['section.settings.useBigCharsMcap']()}
|
||||||
|
bind:value={$settings.mcapBigChar}
|
||||||
|
description="Use big characters for the market cap screen instead of using a suffix."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ToggleWrapper
|
||||||
|
label={m['section.settings.useBlkCountdown']()}
|
||||||
|
bind:value={$settings.useBlkCountdown}
|
||||||
|
description="When enabled it count down blocks instead of years/monts/days/hours/minutes."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ToggleWrapper
|
||||||
|
label={m['section.settings.useSatsSymbol']()}
|
||||||
|
bind:value={$settings.useSatsSymbol}
|
||||||
|
description="Prefix satoshi amounts with the sats symbol."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ToggleWrapper
|
||||||
|
label={m['section.settings.suffixPrice']()}
|
||||||
|
bind:value={$settings.suffixPrice}
|
||||||
|
description="Always use a suffix for the ticker screen."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ToggleWrapper
|
||||||
|
label={m['section.settings.verticalDesc']()}
|
||||||
|
bind:value={$settings.verticalDesc}
|
||||||
|
description="Rotate the description of the screen 90 degrees."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
|
||||||
|
<CollapsibleSection title={m['section.settings.section.displaysAndLed']()}>
|
||||||
<div class="grid gap-4">
|
<div class="grid gap-4">
|
||||||
<div class="form-control">
|
<Select
|
||||||
<label class="label">
|
label={m['section.settings.textColor']()}
|
||||||
<span class="label-text">{m['section.settings.textColor']()}</span>
|
bind:value={$settings.invertedColor}
|
||||||
</label>
|
options={textColorOptions.map(([label, value]) => ({ label, value: value }))}
|
||||||
<select class="select select-bordered w-full">
|
/>
|
||||||
<option>White on Black</option>
|
|
||||||
<option>Black on White</option>
|
<Select
|
||||||
</select>
|
label={m['section.settings.fontName']()}
|
||||||
</div>
|
bind:value={$settings.fontName}
|
||||||
|
options={fontPreferenceOptions.map(([label, value]) => ({ label, value }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumericInput
|
||||||
|
label={m['section.settings.timePerScreen']()}
|
||||||
|
value={$settings.timePerScreen ? $settings.timePerScreen : 1}
|
||||||
|
min={1}
|
||||||
|
required
|
||||||
|
step={1}
|
||||||
|
onchange={handleTimePerScreenChange}
|
||||||
|
unit={m['time.minutes']()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumericInput
|
||||||
|
label={m['section.settings.fullRefreshEvery']()}
|
||||||
|
bind:value={$settings.fullRefreshMin}
|
||||||
|
min={1}
|
||||||
|
required
|
||||||
|
step={1}
|
||||||
|
unit={m['time.minutes']()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumericInput
|
||||||
|
label={m['section.settings.timeBetweenPriceUpdates']()}
|
||||||
|
bind:value={$settings.minSecPriceUpd}
|
||||||
|
min={1}
|
||||||
|
required
|
||||||
|
step={1}
|
||||||
|
unit={m['time.seconds']()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RangeSlider
|
||||||
|
label={m['section.settings.ledBrightness']()}
|
||||||
|
bind:value={$settings.ledBrightness}
|
||||||
|
min={0}
|
||||||
|
max={255}
|
||||||
|
step={1}
|
||||||
|
showValue={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ToggleWrapper
|
||||||
|
label={m['section.settings.ledPowerOnTest']()}
|
||||||
|
bind:value={$settings.ledTestOnPower}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<ToggleWrapper
|
||||||
<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="input w-auto">
|
|
||||||
<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="input w-auto">
|
|
||||||
<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']()}
|
label={m['section.settings.ledFlashOnBlock']()}
|
||||||
checked={$settings.ledFlashOnUpd}
|
bind:value={$settings.ledFlashOnUpd}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<ToggleWrapper
|
||||||
<Toggle label={m['section.settings.disableLeds']()} checked={$settings.disableLeds} />
|
label={m['section.settings.disableLeds']()}
|
||||||
</div>
|
bind:value={$settings.disableLeds}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
||||||
{#if $settings.hasFrontlight}
|
{#if $settings.hasFrontlight}
|
||||||
<CollapsibleSection title="Frontlight Settings" open={showAll || !hideAll}>
|
<CollapsibleSection title="Frontlight Settings">
|
||||||
<div class="grid gap-4">
|
<div class="grid gap-4">
|
||||||
<div class="form-control">
|
<ToggleWrapper
|
||||||
<Toggle label="Disable Frontlight" checked={$settings.flDisable} />
|
label={m['section.settings.flDisable']()}
|
||||||
</div>
|
bind:value={$settings.flDisable}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="form-control">
|
<ToggleWrapper
|
||||||
<Toggle label="Always On" checked={$settings.flAlwaysOn} />
|
label={m['section.settings.flAlwaysOn']()}
|
||||||
</div>
|
bind:value={$settings.flAlwaysOn}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="form-control">
|
<ToggleWrapper
|
||||||
<Toggle label="Flash on Updates" checked={$settings.flFlashOnUpd} />
|
label={m['section.settings.flFlashOnUpd']()}
|
||||||
</div>
|
bind:value={$settings.flFlashOnUpd}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="form-control">
|
<ToggleWrapper
|
||||||
<Toggle label="Flash on Zaps" checked={$settings.flFlashOnZap} />
|
label={m['section.settings.flFlashOnZap']()}
|
||||||
</div>
|
bind:value={$settings.flFlashOnZap}
|
||||||
|
/>
|
||||||
|
|
||||||
{#if $settings.hasLightLevel}
|
{#if $settings.hasLightLevel}
|
||||||
<div class="form-control">
|
<ToggleWrapper
|
||||||
<Toggle label="Turn Off in Dark" checked={$settings.flOffWhenDark} />
|
label={m['section.settings.flOffWhenDark']()}
|
||||||
</div>
|
bind:value={$settings.flOffWhenDark}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
|
@ -238,124 +260,228 @@
|
||||||
min="0"
|
min="0"
|
||||||
max="255"
|
max="255"
|
||||||
class="range"
|
class="range"
|
||||||
value={$settings.luxLightToggle}
|
bind:value={$settings.luxLightToggle}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="form-control">
|
<RangeSlider
|
||||||
<label class="label">
|
label={m['section.settings.flMaxBrightness']()}
|
||||||
<span class="label-text">Maximum Brightness</span>
|
bind:value={$settings.flMaxBrightness}
|
||||||
</label>
|
min={0}
|
||||||
<input
|
max={4095}
|
||||||
type="range"
|
step={1}
|
||||||
min="0"
|
showValue={true}
|
||||||
max="4095"
|
/>
|
||||||
class="range"
|
|
||||||
value={$settings.flMaxBrightness}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
<NumericInput
|
||||||
<label class="label">
|
label={m['section.settings.flEffectDelay']()}
|
||||||
<span class="label-text">Effect Delay (ms)</span>
|
bind:value={$settings.flEffectDelay}
|
||||||
</label>
|
min={10}
|
||||||
<input
|
max={1000}
|
||||||
type="number"
|
step={1}
|
||||||
class="input input-bordered w-20"
|
unit="ms"
|
||||||
min="10"
|
/>
|
||||||
max="1000"
|
|
||||||
value={$settings.flEffectDelay}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<CollapsibleSection
|
<CollapsibleSection title={m['section.settings.section.dataSource']()}>
|
||||||
title={m['section.settings.section.dataSource']()}
|
|
||||||
open={showAll || !hideAll}
|
|
||||||
>
|
|
||||||
<div class="grid gap-4">
|
<div class="grid gap-4">
|
||||||
<div class="form-control">
|
<div class="form-control flex flex-col items-start justify-between md:flex-row">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text">{m['section.settings.dataSource.label']()}</span>
|
<span class="label-text">{m['section.settings.dataSource.label']()}</span>
|
||||||
</label>
|
</label>
|
||||||
<select class="select select-bordered w-full">
|
<div class="flex grid grid-cols-1 flex-row gap-2 md:grid-cols-2">
|
||||||
<option value="btclock">{m['section.settings.dataSource.btclock']()}</option>
|
{#each dataSourceOptions as option}
|
||||||
<option value="thirdparty">{m['section.settings.dataSource.thirdParty']()}</option>
|
<Radio
|
||||||
<option value="nostr">{m['section.settings.dataSource.nostr']()}</option>
|
label={option.label}
|
||||||
<option value="custom">{m['section.settings.dataSource.custom']()}</option>
|
value={option.value}
|
||||||
</select>
|
name="dataSource"
|
||||||
|
bind:group={$settings.dataSource}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control flex justify-between">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text">{m['section.settings.mempoolnstance']()}</span>
|
<span class="label-text">{m['section.settings.mempoolnstance']()}</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="text" class="input input-bordered w-full" value="mempool.space/coinlcp.io" />
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input flex w-auto"
|
||||||
|
bind:value={$settings.mempoolInstance}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control flex justify-between">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text">{m['section.settings.ceEndpoint']()}</span>
|
<span class="label-text">{m['section.settings.ceEndpoint']()}</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered w-full"
|
class="input input-bordered input flex w-auto"
|
||||||
placeholder="Custom Endpoint URL"
|
placeholder="Custom Endpoint URL"
|
||||||
|
bind:value={$settings.ceEndpoint}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
||||||
<CollapsibleSection
|
<CollapsibleSection title={m['section.settings.section.extraFeatures']()}>
|
||||||
title={m['section.settings.section.extraFeatures']()}
|
|
||||||
open={showAll || !hideAll}
|
|
||||||
>
|
|
||||||
<div class="grid gap-4">
|
<div class="grid gap-4">
|
||||||
<div class="form-control">
|
<ToggleWrapper
|
||||||
<Toggle label={m['section.settings.timeBasedDnd']()} checked={$settings.dnd.enabled} />
|
label={m['section.settings.timeBasedDnd']()}
|
||||||
</div>
|
bind:value={$settings.dnd.timeBasedEnabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h4 class="text-lg font-bold">Bitaxe</h4>
|
||||||
|
|
||||||
|
<ToggleWrapper
|
||||||
|
label={m['section.settings.bitaxeEnabled']()}
|
||||||
|
bind:value={$settings.bitaxeEnabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if $settings.bitaxeEnabled}
|
||||||
|
<SettingsInputField
|
||||||
|
label={m['section.settings.bitaxeHostname']()}
|
||||||
|
bind:value={$settings.bitaxeHostname}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<h4 class="text-lg font-bold">Mining Pool</h4>
|
||||||
|
|
||||||
|
<ToggleWrapper
|
||||||
|
label={m['section.settings.miningPoolStats']()}
|
||||||
|
bind:value={$settings.miningPoolStats}
|
||||||
|
/>
|
||||||
|
{#if $settings.miningPoolStats}
|
||||||
|
<div class="form-control flex flex-col items-start justify-between md:flex-row">
|
||||||
|
<label class="label" for="miningPoolName">
|
||||||
|
<span class="label-text">{m['section.settings.miningPoolName']()}</span>
|
||||||
|
</label>
|
||||||
|
<div class="flex grid grid-cols-1 flex-row gap-2 md:grid-cols-2">
|
||||||
|
{#each miningPoolOptions as option}
|
||||||
|
<Radio
|
||||||
|
label={option.label}
|
||||||
|
value={option.value}
|
||||||
|
name="miningPoolName"
|
||||||
|
bind:group={$settings.miningPoolName}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if $settings.miningPoolName === 'local_public_pool'}
|
||||||
|
<SettingsInputField
|
||||||
|
label={m['section.settings.localPoolEndpoint']()}
|
||||||
|
bind:value={$settings.localPoolEndpoint}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<SettingsInputField
|
||||||
|
label={m['section.settings.miningPoolUser']()}
|
||||||
|
bind:value={$settings.miningPoolUser}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<h4 class="text-lg font-bold">Nostr</h4>
|
||||||
|
|
||||||
|
<ToggleWrapper
|
||||||
|
label={m['section.settings.nostrZapNotify']()}
|
||||||
|
bind:value={$settings.nostrZapNotify}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if $settings.nostrZapNotify}
|
||||||
|
<SettingsInputField
|
||||||
|
label={m['section.settings.nostrRelay']()}
|
||||||
|
bind:value={$settings.nostrRelay}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SettingsInputField
|
||||||
|
label={m['section.settings.nostrZapPubkey']()}
|
||||||
|
bind:value={$settings.nostrZapPubkey}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
||||||
<CollapsibleSection title={m['section.settings.section.system']()} open={showAll || !hideAll}>
|
<CollapsibleSection title={m['section.settings.section.system']()}>
|
||||||
<div class="grid gap-4">
|
<div class="grid gap-4">
|
||||||
<div class="form-control">
|
<TimezoneSelector
|
||||||
<label class="label">
|
selectedTimezone={$settings.tzString}
|
||||||
<span class="label-text">{m['section.settings.timezoneOffset']()}</span>
|
change={(value: string) => ($settings.tzString = value)}
|
||||||
</label>
|
helpText={''}
|
||||||
<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="mt-1 text-sm">{m['section.settings.tzOffsetHelpText']()}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control flex justify-between">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text">{m['section.settings.hostnamePrefix']()}</span>
|
<span class="label-text">{m['section.settings.hostnamePrefix']()}</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="text" class="input input-bordered w-full" value="btclock" />
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input flex w-auto"
|
||||||
|
bind:value={$settings.hostnamePrefix}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<NumericInput
|
||||||
<label class="label">
|
label={m['section.settings.wpTimeout']()}
|
||||||
<span class="label-text">{m['section.settings.wpTimeout']()}</span>
|
bind:value={$settings.wpTimeout}
|
||||||
</label>
|
min={1}
|
||||||
<div class="flex items-center gap-2">
|
required
|
||||||
<input type="number" class="input input-bordered w-20" min="1" max="900" value="600" />
|
step={1}
|
||||||
<span>{m['time.seconds']()}</span>
|
unit={m['time.seconds']()}
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
|
<ToggleWrapper
|
||||||
|
label={m['section.settings.otaUpdates']()}
|
||||||
|
bind:value={$settings.otaEnabled}
|
||||||
|
/>
|
||||||
|
<ToggleWrapper
|
||||||
|
label={m['section.settings.enableMdns']()}
|
||||||
|
bind:value={$settings.mdnsEnabled}
|
||||||
|
/>
|
||||||
|
<ToggleWrapper
|
||||||
|
label={m['section.settings.httpAuthEnabled']()}
|
||||||
|
bind:value={$settings.httpAuthEnabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if $settings.httpAuthEnabled}
|
||||||
|
<SettingsInputField
|
||||||
|
label={m['section.settings.httpAuthUser']()}
|
||||||
|
bind:value={$settings.httpAuthUser}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SettingsInputField
|
||||||
|
label={m['section.settings.httpAuthPass']()}
|
||||||
|
bind:value={$settings.httpAuthPass}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<ToggleWrapper
|
||||||
|
label={m['section.settings.enableDebugLog']()}
|
||||||
|
bind:value={$settings.enableDebugLog}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-6 flex justify-between">
|
|
||||||
<button class="btn btn-error">{m['button.reset']()}</button>
|
|
||||||
<button class="btn btn-primary">{m['button.save']()}</button>
|
|
||||||
</div>
|
|
||||||
</CardContainer>
|
</CardContainer>
|
||||||
|
|
||||||
|
<div class="sticky right-0 bottom-0 left-0">
|
||||||
|
<div class="navbar bg-base-100 flex justify-around">
|
||||||
|
<button class="btn btn-error" type="button" onclick={handleReset}>{m['button.reset']()}</button>
|
||||||
|
{#if $status.isOTAUpdating}
|
||||||
|
<span class="text-center text-sm text-gray-500"
|
||||||
|
>OTA Update in progress... Please wait until the update is complete.</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
type="submit"
|
||||||
|
onclick={handleSave}
|
||||||
|
disabled={$status.isOTAUpdating}>{m['button.save']()}</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
|
@ -7,80 +7,100 @@
|
||||||
import { DataSourceType } from '$lib/types';
|
import { DataSourceType } from '$lib/types';
|
||||||
import { toUptimestring } from '$lib/utils';
|
import { toUptimestring } from '$lib/utils';
|
||||||
|
|
||||||
const screens = $settings.screens.map((screen) => ({
|
let screens: { id: number; label: string }[] = [];
|
||||||
id: screen.id,
|
|
||||||
label: screen.name
|
settings.subscribe((settings) => {
|
||||||
}));
|
screens = settings.screens.map((screen) => ({
|
||||||
|
id: screen.id,
|
||||||
|
label: screen.name
|
||||||
|
}));
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CardContainer title={m['section.status.title']()}>
|
<CardContainer title={m['section.status.title']()}>
|
||||||
<div class="mx-auto space-y-4">
|
{#if !$settings.isLoaded}
|
||||||
<div class="join">
|
<div class="flex justify-center">
|
||||||
{#each screens as screen (screen.id)}
|
<div class="mx-auto flex w-3/4 flex-col items-center justify-center gap-4">
|
||||||
<TabButton
|
<span class="loading loading-spinner loading-xl"></span>
|
||||||
active={$status.currentScreen === screen.id}
|
<p class="text-center text-lg font-bold">Loading...</p>
|
||||||
onClick={() => setActiveScreen(screen.id)}
|
|
||||||
>
|
|
||||||
{screen.label}
|
|
||||||
</TabButton>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="join flex justify-center">
|
|
||||||
{#each $settings.actCurrencies as currency (currency)}
|
|
||||||
<CurrencyButton
|
|
||||||
{currency}
|
|
||||||
active={$status.currency === currency}
|
|
||||||
onClick={() => setActiveCurrency(currency)}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-8 flex justify-center">
|
|
||||||
<div class="w-3/4">
|
|
||||||
<BTClock displays={$status.data} verticalDesc={$settings.verticalDesc} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="mx-auto space-y-4">
|
||||||
|
<div class="md:join">
|
||||||
|
{#each screens as screen (screen.id)}
|
||||||
|
<TabButton
|
||||||
|
active={$status.currentScreen === screen.id}
|
||||||
|
onClick={() => setActiveScreen(screen.id)}
|
||||||
|
>
|
||||||
|
{screen.label}
|
||||||
|
</TabButton>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="text-center text-sm text-gray-500">
|
<div class="join flex justify-center">
|
||||||
{m['section.status.screenCycle']()}: is {$status.timerRunning ? 'running' : 'stopped'}<br />
|
{#each $settings.actCurrencies as currency (currency)}
|
||||||
{m['section.status.doNotDisturb']()}: {$status.dnd.enabled ? m['on']() : m['off']()}
|
<CurrencyButton
|
||||||
<small>
|
{currency}
|
||||||
{#if $status.dnd?.timeBasedEnabled}
|
active={$status.currency === currency}
|
||||||
{m['section.status.timeBasedDnd']()} ( {$settings.dnd
|
onClick={() => setActiveCurrency(currency)}
|
||||||
.startHour}:{$settings.dnd.startMinute.toString().padStart(2, '0')} - {$settings.dnd
|
/>
|
||||||
.endHour}:{$settings.dnd.endMinute.toString().padStart(2, '0')} )
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 flex justify-center">
|
||||||
|
<div class="w-full md:w-3/4">
|
||||||
|
<BTClock
|
||||||
|
theme={$settings.invertColors ? 'light' : 'dark'}
|
||||||
|
displays={$status.data}
|
||||||
|
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.active ? 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.connectionStatus.nostr ? 'online' : 'offline'}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</small>
|
{#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>
|
||||||
|
<div class="stats mt-4 flex justify-center shadow">
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<Stat
|
||||||
{#if $settings.dataSource === DataSourceType.NOSTR_SOURCE || $settings.nostrZapNotify}
|
title={m['section.status.memoryFree']()}
|
||||||
<Status text="Nostr Relay connection" status={$status.nostr ? 'online' : 'offline'} />
|
value={`${Math.round($status.espFreeHeap / 1024)} / ${Math.round($status.espHeapSize / 1024)} KiB`}
|
||||||
{/if}
|
/>
|
||||||
{#if $settings.dataSource === DataSourceType.THIRD_PARTY_SOURCE}
|
<Stat title={m['section.status.wifiSignalStrength']()} value={`${$status.rssi} dBm`} />
|
||||||
<Status
|
<Stat title={m['section.status.uptime']()} value={`${toUptimestring($status.espUptime)}`} />
|
||||||
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>
|
{/if}
|
||||||
<div class="stats mt-4 flex justify-center shadow">
|
|
||||||
<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>
|
</CardContainer>
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { m } from '$lib/paraglide/messages';
|
import { m } from '$lib/paraglide/messages';
|
||||||
import { CardContainer } from '$lib/components';
|
import { CardContainer, Alert } from '$lib/components';
|
||||||
import { settings } from '$lib/stores';
|
import { settings } from '$lib/stores';
|
||||||
import { restartClock, forceFullRefresh } from '$lib/clockControl';
|
import { restartClock } from '$lib/clockControl';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CardContainer title="System Information">
|
<CardContainer title="System Information">
|
||||||
|
@ -22,7 +22,7 @@
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>{m['section.control.buildTime']()}</td>
|
<td>{m['section.control.buildTime']()}</td>
|
||||||
<td>{$settings.lastBuildTime}</td>
|
<td>{new Date($settings.lastBuildTime * 1000).toLocaleString()}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>IP</td>
|
<td>IP</td>
|
||||||
|
@ -36,6 +36,10 @@
|
||||||
<td>{m['section.control.fwCommit']()}</td>
|
<td>{m['section.control.fwCommit']()}</td>
|
||||||
<td class="text-xs">{$settings.gitRev}</td>
|
<td class="text-xs">{$settings.gitRev}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>WebUI commit</td>
|
||||||
|
<td class="text-xs">{$settings.fsRev}</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>{m['section.control.hostname']()}</td>
|
<td>{m['section.control.hostname']()}</td>
|
||||||
<td>{$settings.hostname}</td>
|
<td>{$settings.hostname}</td>
|
||||||
|
@ -43,57 +47,11 @@
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{#if $settings.fsRev !== $settings.gitRev}
|
||||||
|
<Alert type="warning" message={m['section.system.firmwareOutdated']()} />
|
||||||
|
{/if}
|
||||||
<div class="mt-4 flex gap-2">
|
<div class="mt-4 flex gap-2">
|
||||||
<button class="btn btn-error" onclick={restartClock}>{m['button.restart']()}</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</CardContainer>
|
</CardContainer>
|
||||||
|
|
65
src/lib/components/ui/Alert.svelte
Normal file
65
src/lib/components/ui/Alert.svelte
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
<script lang="ts">
|
||||||
|
const {
|
||||||
|
type = 'info',
|
||||||
|
message = null,
|
||||||
|
className = '',
|
||||||
|
style = 'default',
|
||||||
|
children = null
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// Maps for SVG icons based on type
|
||||||
|
const icons = {
|
||||||
|
info: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="h-6 w-6 shrink-0 stroke-current">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>`,
|
||||||
|
success: `<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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>`,
|
||||||
|
warning: `<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>`,
|
||||||
|
error: `<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="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>`
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compute alert classes based on type and style
|
||||||
|
const alertClass = $derived(() => {
|
||||||
|
const classes = ['alert'];
|
||||||
|
|
||||||
|
// Add type-based class
|
||||||
|
classes.push(`alert-${type}`);
|
||||||
|
|
||||||
|
// Add style-based class
|
||||||
|
if (style !== 'default') {
|
||||||
|
classes.push(`alert-${style}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add custom class if provided
|
||||||
|
if (className) {
|
||||||
|
classes.push(className);
|
||||||
|
}
|
||||||
|
|
||||||
|
return classes.join(' ');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Type-safe icon access
|
||||||
|
const getIcon = $derived(() => {
|
||||||
|
if (type === 'info' || type === 'success' || type === 'warning' || type === 'error') {
|
||||||
|
return icons[type];
|
||||||
|
}
|
||||||
|
return icons.info;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div role="alert" class={alertClass()}>
|
||||||
|
{@html getIcon()}
|
||||||
|
|
||||||
|
<span>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{:else if message}
|
||||||
|
{message}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let { title, className = '', ...restProps } = $props();
|
let { title = '', className = '', children, ...restProps } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card bg-base-100 w-full shadow-xl {className}" {...restProps}>
|
<div class="card bg-base-100 w-full shadow-xl {className}" {...restProps}>
|
||||||
|
@ -7,6 +7,6 @@
|
||||||
{#if title}
|
{#if title}
|
||||||
<h2 class="card-title">{title}</h2>
|
<h2 class="card-title">{title}</h2>
|
||||||
{/if}
|
{/if}
|
||||||
<slot />
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
82
src/lib/stores/firmware.ts
Normal file
82
src/lib/stores/firmware.ts
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import { settings } from './settings';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
|
export interface FirmwareReleaseAsset {
|
||||||
|
name: string;
|
||||||
|
browser_download_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirmwareRelease {
|
||||||
|
tag_name: string;
|
||||||
|
published_at: string;
|
||||||
|
html_url: string;
|
||||||
|
assets: FirmwareReleaseAsset[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultFirmwareRelease: FirmwareRelease = {
|
||||||
|
tag_name: '',
|
||||||
|
published_at: '',
|
||||||
|
html_url: '',
|
||||||
|
assets: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
|
||||||
|
function createFirmwareStore() {
|
||||||
|
const { subscribe, set, update } = writable<FirmwareRelease>(defaultFirmwareRelease);
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
fetchLatest: async () => {
|
||||||
|
update((state) => ({ ...state, isLoading: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settingsValue = get(settings);
|
||||||
|
const url =
|
||||||
|
settingsValue.gitReleaseUrl ||
|
||||||
|
'https://git.btclock.dev/api/v1/repos/btclock/btclock_v3/releases/latest';
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Error fetching firmware: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Format the data to match our store structure
|
||||||
|
const release: FirmwareRelease = {
|
||||||
|
tag_name: data.tag_name,
|
||||||
|
published_at: data.published_at,
|
||||||
|
html_url: data.html_url,
|
||||||
|
assets: data.assets.map((asset: { name: string; browser_download_url: string }) => ({
|
||||||
|
name: asset.name,
|
||||||
|
browser_download_url: asset.browser_download_url
|
||||||
|
})),
|
||||||
|
isLoading: false,
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
|
||||||
|
set(release);
|
||||||
|
return release;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
console.error('Failed to fetch firmware info:', errorMessage);
|
||||||
|
|
||||||
|
update((state) => ({
|
||||||
|
...state,
|
||||||
|
isLoading: false,
|
||||||
|
error: errorMessage
|
||||||
|
}));
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
reset: () => set(defaultFirmwareRelease)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const firmwareRelease = createFirmwareStore();
|
|
@ -1,2 +1,3 @@
|
||||||
export { settings, type Settings } from './settings';
|
export { settings, type Settings } from './settings';
|
||||||
export { status, type Status } from './status';
|
export { status, type Status } from './status';
|
||||||
|
export { firmwareRelease, type FirmwareRelease } from './firmware';
|
||||||
|
|
|
@ -118,6 +118,8 @@ function createSettingsStore() {
|
||||||
throw new Error(`Error fetching settings: ${response.statusText}`);
|
throw new Error(`Error fetching settings: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
data.timePerScreen = data.timerSeconds / 60;
|
||||||
|
data.isLoaded = true;
|
||||||
set(data);
|
set(data);
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -127,16 +129,23 @@ function createSettingsStore() {
|
||||||
},
|
},
|
||||||
update: async (newSettings: Partial<Settings>) => {
|
update: async (newSettings: Partial<Settings>) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${baseUrl}/api/settings`, {
|
const formSettings = { ...newSettings };
|
||||||
method: 'POST',
|
delete formSettings['gitRev'];
|
||||||
|
delete formSettings['ip'];
|
||||||
|
delete formSettings['lastBuildTime'];
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}/api/json/settings`, {
|
||||||
|
method: 'PATCH',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify(newSettings)
|
body: JSON.stringify(formSettings)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Error updating settings: ${response.statusText}`);
|
throw new Error(`Error updating settings: ${response.statusText}`);
|
||||||
|
} else {
|
||||||
|
console.log(formSettings, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the local store with the new settings
|
// Update the local store with the new settings
|
||||||
|
@ -149,7 +158,20 @@ function createSettingsStore() {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
set: (newSettings: Settings) => set(newSettings),
|
set: (newSettings: Settings) => set(newSettings),
|
||||||
reset: () => set(defaultSettings)
|
reset: 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -57,6 +57,7 @@ function createStatusStore() {
|
||||||
throw new Error(`Error fetching status: ${response.statusText}`);
|
throw new Error(`Error fetching status: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
data.isLoaded = true;
|
||||||
set(data);
|
set(data);
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
@ -47,6 +47,7 @@ export interface Status {
|
||||||
blue: number;
|
blue: number;
|
||||||
hex: string;
|
hex: string;
|
||||||
}>;
|
}>;
|
||||||
|
isLoaded?: boolean;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -103,6 +104,7 @@ export interface Settings {
|
||||||
httpAuthUser: string;
|
httpAuthUser: string;
|
||||||
httpAuthPass: string;
|
httpAuthPass: string;
|
||||||
hasFrontlight?: boolean;
|
hasFrontlight?: boolean;
|
||||||
|
timePerScreen?: number;
|
||||||
// Frontlight settings
|
// Frontlight settings
|
||||||
flDisable?: boolean;
|
flDisable?: boolean;
|
||||||
flMaxBrightness?: number;
|
flMaxBrightness?: number;
|
||||||
|
@ -137,5 +139,6 @@ export interface Settings {
|
||||||
endHour: number;
|
endHour: number;
|
||||||
endMinute: number;
|
endMinute: number;
|
||||||
};
|
};
|
||||||
|
isLoaded?: boolean;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,3 +20,83 @@ export const toUptimestring = (secs: number): string => {
|
||||||
|
|
||||||
return `${time.h}h ${time.m}m ${time.s}s`;
|
return `${time.h}h ${time.m}m ${time.s}s`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the appropriate firmware binary name based on hardware revision
|
||||||
|
* @param hwRev Hardware revision string
|
||||||
|
* @returns The firmware binary filename appropriate for this hardware
|
||||||
|
*/
|
||||||
|
export const getFirmwareBinaryName = (hwRev: string) => {
|
||||||
|
let binaryFilename = '';
|
||||||
|
switch (hwRev) {
|
||||||
|
case 'REV_V8_EPD_2_13':
|
||||||
|
binaryFilename = 'btclock_rev_v8_213epd_firmware.bin';
|
||||||
|
break;
|
||||||
|
case 'REV_B_EPD_2_13':
|
||||||
|
binaryFilename = 'btclock_rev_b_213epd_firmware.bin';
|
||||||
|
break;
|
||||||
|
case 'REV_A_EPD_2_13':
|
||||||
|
binaryFilename = 'lolin_s3_mini_213epd_firmware.bin';
|
||||||
|
break;
|
||||||
|
case 'REV_A_EPD_2_9':
|
||||||
|
binaryFilename = 'lolin_s3_mini_29epd_firmware.bin';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
binaryFilename = 'Unsupported hardware, unable to determine firmware binary filename';
|
||||||
|
}
|
||||||
|
|
||||||
|
return binaryFilename;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the appropriate WebUI binary name based on hardware revision
|
||||||
|
* @param hwRev Hardware revision string
|
||||||
|
* @returns The WebUI binary filename appropriate for this hardware
|
||||||
|
*/
|
||||||
|
export const getWebUiBinaryName = (hwRev: string) => {
|
||||||
|
let webuiFilename = '';
|
||||||
|
switch (hwRev) {
|
||||||
|
case 'REV_V8_EPD_2_13':
|
||||||
|
webuiFilename = 'littlefs_16MB.bin';
|
||||||
|
break;
|
||||||
|
case 'REV_B_EPD_2_13':
|
||||||
|
webuiFilename = 'littlefs_8MB.bin';
|
||||||
|
break;
|
||||||
|
case 'REV_A_EPD_2_13':
|
||||||
|
webuiFilename = 'littlefs_4MB.bin';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
webuiFilename = 'Unsupported hardware, unable to determine WebUI binary filename';
|
||||||
|
}
|
||||||
|
|
||||||
|
return webuiFilename;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const compareVersions = (version1: string, version2: string): number => {
|
||||||
|
if (!version2) return 0;
|
||||||
|
|
||||||
|
const parts1 = version1.split('.').map((part) => parseInt(part, 10));
|
||||||
|
const parts2 = version2.split('.').map((part) => parseInt(part, 10));
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
if (parts1[i] > parts2[i]) {
|
||||||
|
return 1;
|
||||||
|
} else if (parts1[i] < parts2[i]) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const miningPoolMap = new Map<string, string>([
|
||||||
|
['noderunners', 'Noderunners.network'],
|
||||||
|
['braiins', 'Braiins Pool'],
|
||||||
|
['ocean', 'ocean.xyz'],
|
||||||
|
['satoshi_radio', 'Satoshi Radio pool'],
|
||||||
|
['public_pool', 'public-pool.io'],
|
||||||
|
['gobrrr_pool', 'Go Brrr pool'],
|
||||||
|
['ckpool', 'CKPool'],
|
||||||
|
['eu_ckpool', 'EU CKPool'],
|
||||||
|
['local_public_pool', 'Public Pool (local)']
|
||||||
|
]);
|
||||||
|
|
46
src/lib/utils/index.ts
Normal file
46
src/lib/utils/index.ts
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
/**
|
||||||
|
* Determines the appropriate firmware binary name based on hardware revision
|
||||||
|
* @param hwRev Hardware revision string
|
||||||
|
* @returns The firmware binary filename appropriate for this hardware
|
||||||
|
*/
|
||||||
|
export function getFirmwareBinaryName(hwRev: string): string {
|
||||||
|
if (!hwRev) return 'blib_s3_mini_213epd_firmware.bin';
|
||||||
|
|
||||||
|
// Default binary for unknown hardware
|
||||||
|
let binaryName = 'blib_s3_mini_213epd_firmware.bin';
|
||||||
|
|
||||||
|
if (hwRev.includes('s3_mini')) {
|
||||||
|
if (hwRev.includes('29epd')) {
|
||||||
|
binaryName = 'lolin_s3_mini_29epd_firmware.bin';
|
||||||
|
} else {
|
||||||
|
binaryName = 'lolin_s3_mini_213epd_firmware.bin';
|
||||||
|
}
|
||||||
|
} else if (hwRev.includes('rev_b')) {
|
||||||
|
binaryName = 'btclock_rev_b_213epd_firmware.bin';
|
||||||
|
} else if (hwRev.includes('v8')) {
|
||||||
|
binaryName = 'btclock_v8_213epd_firmware.bin';
|
||||||
|
}
|
||||||
|
|
||||||
|
return binaryName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the appropriate WebUI binary name based on hardware revision
|
||||||
|
* @param hwRev Hardware revision string
|
||||||
|
* @returns The WebUI binary filename appropriate for this hardware
|
||||||
|
*/
|
||||||
|
export function getWebUiBinaryName(hwRev: string): string {
|
||||||
|
if (!hwRev) return 'littlefs_4MB.bin';
|
||||||
|
|
||||||
|
// Most devices use 4MB filesystem
|
||||||
|
let binaryName = 'littlefs_4MB.bin';
|
||||||
|
|
||||||
|
// Devices with larger flash might use 8MB or 16MB
|
||||||
|
if (hwRev.includes('v8')) {
|
||||||
|
binaryName = 'littlefs_8MB.bin';
|
||||||
|
} else if (hwRev.includes('16MB')) {
|
||||||
|
binaryName = 'littlefs_16MB.bin';
|
||||||
|
}
|
||||||
|
|
||||||
|
return binaryName;
|
||||||
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { Navbar } from '$lib/components';
|
import { Navbar } from '$lib/components';
|
||||||
import { settings, status } from '$lib/stores';
|
import { settings, status, firmwareRelease } from '$lib/stores';
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
@ -12,6 +12,8 @@
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
console.log('Initializing stores');
|
console.log('Initializing stores');
|
||||||
|
firmwareRelease.fetchLatest();
|
||||||
|
|
||||||
unsubscribeSettings = settings.subscribe(() => {});
|
unsubscribeSettings = settings.subscribe(() => {});
|
||||||
unsubscribeStatus = status.subscribe(() => {});
|
unsubscribeStatus = status.subscribe(() => {});
|
||||||
}
|
}
|
||||||
|
@ -27,6 +29,10 @@
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>BTClock</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<main class="bg-base-200">
|
<main class="bg-base-200">
|
||||||
|
|
|
@ -1,2 +1,2 @@
|
||||||
export const prerender = true;
|
export const prerender = true;
|
||||||
export const ssr = false;
|
export const ssr = false;
|
||||||
|
|
|
@ -1,8 +1,17 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { SettingsSection } from '$lib/components';
|
import { SettingsSection } from '$lib/components';
|
||||||
|
import { settings } from '$lib/stores';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container mx-auto p-4">
|
<div class="container mx-auto p-4">
|
||||||
<h1 class="mb-4 text-2xl font-bold">Settings</h1>
|
{#if !$settings.isLoaded}
|
||||||
<SettingsSection />
|
<div class="flex justify-center">
|
||||||
|
<div class="mx-auto flex w-3/4 flex-col items-center justify-center gap-4">
|
||||||
|
<span class="loading loading-spinner loading-xl"></span>
|
||||||
|
<p class="text-center text-lg font-bold">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<SettingsSection />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { SystemSection } from '$lib/components';
|
import { SystemSection, FirmwareUpdateSection } from '$lib/components';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container mx-auto p-4">
|
<div class="container mx-auto p-4">
|
||||||
<h1 class="mb-4 text-2xl font-bold">System Management</h1>
|
<h1 class="mb-4 text-2xl font-bold">System Management</h1>
|
||||||
<SystemSection />
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<SystemSection />
|
||||||
|
<FirmwareUpdateSection />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
1
src/safelist.txt
Normal file
1
src/safelist.txt
Normal file
|
@ -0,0 +1 @@
|
||||||
|
alert-success
|
Loading…
Add table
Add a link
Reference in a new issue