feat: Most settings implemented
Some checks failed
/ build (push) Failing after 1m26s
/ check-changes (push) Successful in 8s

This commit is contained in:
Djuri 2025-05-04 02:12:17 +02:00
parent f8c2f4f228
commit 98ad7d1432
Signed by: djuri
GPG key ID: 61B9B2DDE5AA3AC1
41 changed files with 1976 additions and 421 deletions

2
.gitignore vendored
View file

@ -25,3 +25,5 @@ vite.config.ts.timestamp-*
# Paraglide
src/lib/paraglide
build
build_gz

View file

@ -37,7 +37,8 @@ export default ts.config(
},
{
rules: {
'svelte/no-at-html-tags': 'off'
'svelte/no-at-html-tags': 'off',
'@typescript-eslint/no-unsafe-function-type': 'off'
}
}
);

View file

@ -118,6 +118,12 @@
"viewRelease": "Veröffentlichung anzeigen",
"autoUpdate": "Update installieren (experimentell)",
"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": {

View file

@ -92,7 +92,8 @@
"dndStartHour": "Start hour",
"dndStartMinute": "Start minute",
"dndEndHour": "End hour",
"dndEndMinute": "End minute"
"dndEndMinute": "End minute",
"localPoolEndpoint": "Local Pool Endpoint"
},
"control": {
"systemInfo": "System info",
@ -138,6 +139,12 @@
"viewRelease": "View Release",
"autoUpdate": "Install update (experimental)",
"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": {

View file

@ -118,6 +118,12 @@
"viewRelease": "Ver lanzamiento",
"autoUpdate": "Instalar actualización (experimental)",
"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": {

View file

@ -108,6 +108,12 @@
"viewRelease": "Bekijk publicatie",
"autoUpdate": "Update installeren (experimenteel)",
"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": {

View file

@ -46,7 +46,8 @@
"@fontsource-variable/oswald": "^5.2.5",
"@fontsource/ubuntu": "^5.2.5",
"@inlang/paraglide-js": "^2.0.0",
"daisyui": "^5.0.35"
"daisyui": "^5.0.35",
"nostr-tools": "^2.12.0"
},
"pnpm": {
"onlyBuiltDependencies": [

82
pnpm-lock.yaml generated
View file

@ -25,6 +25,9 @@ importers:
daisyui:
specifier: ^5.0.35
version: 5.0.35
nostr-tools:
specifier: ^2.12.0
version: 2.12.0(typescript@5.8.3)
devDependencies:
'@eslint/compat':
specifier: ^1.2.5
@ -414,6 +417,23 @@ packages:
resolution: {integrity: sha512-k/1pb70eD638anoi0e8wUGAlbMJXyvdV4p62Ko+EZ7eBe1xMx8Uhak1R5DgfoofsK5IBBnRwsYGTaLZl+6/+RQ==}
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':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@ -534,6 +554,15 @@ packages:
cpu: [x64]
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':
resolution: {integrity: sha512-/s55Jujywdw/Jpan+vsy6JZs1z2ZTGxTmbZTPiuSL2wz9mfzA2gN1zzaqmvfi4pq+uOt7Du85fkiwv5ymW84aQ==}
@ -1569,6 +1598,17 @@ packages:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
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:
resolution: {integrity: sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==}
@ -2473,6 +2513,20 @@ snapshots:
transitivePeerDependencies:
- 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':
dependencies:
'@nodelib/fs.stat': 2.0.5
@ -2551,6 +2605,19 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.40.1':
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': {}
'@sqlite.org/sqlite-wasm@3.48.0-build4': {}
@ -3589,6 +3656,21 @@ snapshots:
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: {}
object-assign@4.1.1: {}

View file

@ -1,3 +1,5 @@
@source "./safelist.txt";
@import 'tailwindcss';
@plugin "daisyui" {
}

View file

@ -109,3 +109,99 @@ export const toggleDoNotDisturb = (currentStatus: boolean) => (e: Event) => {
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);
}
};

View file

@ -1,7 +1,4 @@
<script lang="ts">
import '@fontsource-variable/oswald'; // Import the Oswald variable font
import '@fontsource/ubuntu';
type DisplayMode = 'single' | 'medium' | 'split';
type DisplayTheme = 'light' | 'dark'; // New type for display theme

View 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>

View 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>

View 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>

View 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}
&nbsp;(<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>

View 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>

View 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>

View 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>

View file

@ -1,10 +1,42 @@
<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>
<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}
<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}
<input type="checkbox" class="toggle toggle-primary toggle-xs" {id} bind:checked {...restProps} />
</label>

View 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>

View file

@ -4,11 +4,20 @@ export { default as TabButton } from './ui/TabButton.svelte';
export { default as Toast } from './ui/Toast.svelte';
export { default as Stat } from './ui/Stat.svelte';
export { default as Status } from './ui/Status.svelte';
export { default as Alert } from './ui/Alert.svelte';
// Form Components
export { default as InputField } from './form/InputField.svelte';
export { default as Toggle } from './form/Toggle.svelte';
export { default as CurrencyButton } from './form/CurrencyButton.svelte';
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
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 SettingsSection } from './sections/SettingsSection.svelte';
export { default as SystemSection } from './sections/SystemSection.svelte';
export { default as FirmwareUpdateSection } from './sections/FirmwareUpdateSection.svelte';

View file

@ -1,5 +1,5 @@
<script lang="ts">
let { title, open = $bindable(false), ...restProps } = $props();
let { title, open = $bindable(true), ...restProps } = $props();
</script>
<div class="collapse-arrow bg-base-200 collapse mb-2 rounded-lg" {...restProps}>

View file

@ -3,19 +3,43 @@
import { setLocale, getLocale } 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
import * as m from '$lib/paraglide/messages';
const navItems = [
{ href: '/', label: 'Home' },
{ href: '/settings', label: 'Settings' },
{ href: '/system', label: 'System' },
{ href: '/apidoc', label: 'API' }
{ href: '/', label: m['section.home.title'](), indicator: false },
{ href: '/settings', label: m['section.settings.title'](), indicator: false },
{ href: '/system', label: m['section.settings.section.system'](), indicator: false },
{ 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
function isActive(href: string) {
return $page.url.pathname === href;
return page.url.pathname === href;
}
const getLocaleName = (locale: string) => {
@ -65,14 +89,26 @@
tabindex="-1"
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>
<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>
{/each}
</ul>
</div>
<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 class="navbar-center hidden lg:flex">
<ul class="menu menu-horizontal px-1">

View 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>

View file

@ -1,233 +1,255 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages';
import { CardContainer, Toggle, CollapsibleSection } from '$lib/components';
import { settings } from '$lib/stores';
import {
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();
// Show/hide toggles
let showAll = $state(false);
let hideAll = $state(false);
const textColorOptions: [string, boolean][] = [
[m['colors.black']() + ' on ' + m['colors.white'](), false],
[m['colors.white']() + ' on ' + m['colors.black'](), true]
];
function toggleShowAll() {
showAll = true;
hideAll = false;
}
const fontPreferenceOptions: [string, string][] = $settings.availableFonts?.map((font) => {
// Check if the translation key exists in messages
// 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() {
hideAll = true;
showAll = false;
}
return [
hasTranslation
? (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>
<CardContainer title={m['section.settings.title']()} {...restProps}>
<div class="mb-4 flex justify-end gap-2">
<button class="btn btn-sm" onclick={toggleShowAll}>{m['section.settings.showAll']()}</button>
<button class="btn btn-sm" onclick={toggleHideAll}>{m['section.settings.hideAll']()}</button>
</div>
<div class="grid 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">
<CardContainer {...restProps}>
<div class="grid gap-4 md:grid-cols-2">
<CollapsibleSection title={m['section.settings.screens']()}>
<div class="grid gap-4 md:grid-cols-2">
{#each $settings.screens as screen (screen.id)}
<div class="form-control">
<Toggle label={screen.name} checked={screen.enabled} />
</div>
<ToggleWrapper label={screen.name} bind:value={screen.enabled} />
{/each}
</div>
</CollapsibleSection>
<CollapsibleSection title={m['section.settings.currencies']()} open={showAll || !hideAll}>
<div class="alert alert-warning">
<svg
xmlns="http://www.w3.org/2000/svg"
class="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>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>
<CollapsibleSection title={m['section.settings.currencies']()}>
<div class="grid gap-4 md:grid-cols-2">
{#each $settings.availableCurrencies as currency (currency)}
<ToggleWrapper
id={`currency-${currency}`}
label={currency}
bind:group={$settings.actCurrencies}
value={currency}
/>
{/each}
</div>
</CollapsibleSection>
<CollapsibleSection
title={m['section.settings.section.displaysAndLed']()}
open={showAll || !hideAll}
>
<CollapsibleSection title={m['section.settings.section.screenSettings']()}>
<div class="grid gap-4 md:grid-cols-2">
<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="form-control">
<label class="label">
<span class="label-text">{m['section.settings.textColor']()}</span>
</label>
<select class="select select-bordered w-full">
<option>White on Black</option>
<option>Black on White</option>
</select>
</div>
<Select
label={m['section.settings.textColor']()}
bind:value={$settings.invertedColor}
options={textColorOptions.map(([label, value]) => ({ label, value: value }))}
/>
<Select
label={m['section.settings.fontName']()}
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">
<label class="label">
<span class="label-text">Font</span>
</label>
<select class="select select-bordered w-full">
<option>Oswald</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">{m['section.settings.timePerScreen']()}</span>
</label>
<div class="flex items-center gap-2">
<input type="number" class="input input-bordered w-20" min="1" max="60" value="1" />
<span>{m['time.minutes']()}</span>
</div>
</div>
<div class="form-control flex justify-between">
<label class="label">
<span class="label-text">{m['section.settings.fullRefreshEvery']()}</span>
</label>
<div class="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
<ToggleWrapper
label={m['section.settings.ledFlashOnBlock']()}
checked={$settings.ledFlashOnUpd}
bind:value={$settings.ledFlashOnUpd}
/>
</div>
<div class="form-control">
<Toggle label={m['section.settings.disableLeds']()} checked={$settings.disableLeds} />
</div>
<ToggleWrapper
label={m['section.settings.disableLeds']()}
bind:value={$settings.disableLeds}
/>
</div>
</CollapsibleSection>
{#if $settings.hasFrontlight}
<CollapsibleSection title="Frontlight Settings" open={showAll || !hideAll}>
<CollapsibleSection title="Frontlight Settings">
<div class="grid gap-4">
<div class="form-control">
<Toggle label="Disable Frontlight" checked={$settings.flDisable} />
</div>
<ToggleWrapper
label={m['section.settings.flDisable']()}
bind:value={$settings.flDisable}
/>
<div class="form-control">
<Toggle label="Always On" checked={$settings.flAlwaysOn} />
</div>
<ToggleWrapper
label={m['section.settings.flAlwaysOn']()}
bind:value={$settings.flAlwaysOn}
/>
<div class="form-control">
<Toggle label="Flash on Updates" checked={$settings.flFlashOnUpd} />
</div>
<ToggleWrapper
label={m['section.settings.flFlashOnUpd']()}
bind:value={$settings.flFlashOnUpd}
/>
<div class="form-control">
<Toggle label="Flash on Zaps" checked={$settings.flFlashOnZap} />
</div>
<ToggleWrapper
label={m['section.settings.flFlashOnZap']()}
bind:value={$settings.flFlashOnZap}
/>
{#if $settings.hasLightLevel}
<div class="form-control">
<Toggle label="Turn Off in Dark" checked={$settings.flOffWhenDark} />
</div>
<ToggleWrapper
label={m['section.settings.flOffWhenDark']()}
bind:value={$settings.flOffWhenDark}
/>
<div class="form-control">
<label class="label">
@ -238,124 +260,228 @@
min="0"
max="255"
class="range"
value={$settings.luxLightToggle}
bind:value={$settings.luxLightToggle}
/>
</div>
{/if}
<div class="form-control">
<label class="label">
<span class="label-text">Maximum Brightness</span>
</label>
<input
type="range"
min="0"
max="4095"
class="range"
value={$settings.flMaxBrightness}
/>
</div>
<RangeSlider
label={m['section.settings.flMaxBrightness']()}
bind:value={$settings.flMaxBrightness}
min={0}
max={4095}
step={1}
showValue={true}
/>
<div class="form-control">
<label class="label">
<span class="label-text">Effect Delay (ms)</span>
</label>
<input
type="number"
class="input input-bordered w-20"
min="10"
max="1000"
value={$settings.flEffectDelay}
/>
</div>
<NumericInput
label={m['section.settings.flEffectDelay']()}
bind:value={$settings.flEffectDelay}
min={10}
max={1000}
step={1}
unit="ms"
/>
</div>
</CollapsibleSection>
{/if}
<CollapsibleSection
title={m['section.settings.section.dataSource']()}
open={showAll || !hideAll}
>
<CollapsibleSection title={m['section.settings.section.dataSource']()}>
<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">
<span class="label-text">{m['section.settings.dataSource.label']()}</span>
</label>
<select class="select select-bordered w-full">
<option value="btclock">{m['section.settings.dataSource.btclock']()}</option>
<option value="thirdparty">{m['section.settings.dataSource.thirdParty']()}</option>
<option value="nostr">{m['section.settings.dataSource.nostr']()}</option>
<option value="custom">{m['section.settings.dataSource.custom']()}</option>
</select>
<div class="flex grid grid-cols-1 flex-row gap-2 md:grid-cols-2">
{#each dataSourceOptions as option}
<Radio
label={option.label}
value={option.value}
name="dataSource"
bind:group={$settings.dataSource}
/>
{/each}
</div>
</div>
<div class="form-control">
<div class="form-control flex justify-between">
<label class="label">
<span class="label-text">{m['section.settings.mempoolnstance']()}</span>
</label>
<input type="text" class="input input-bordered w-full" value="mempool.space/coinlcp.io" />
<input
type="text"
class="input input-bordered input flex w-auto"
bind:value={$settings.mempoolInstance}
/>
</div>
<div class="form-control">
<div class="form-control flex justify-between">
<label class="label">
<span class="label-text">{m['section.settings.ceEndpoint']()}</span>
</label>
<input
type="text"
class="input input-bordered w-full"
class="input input-bordered input flex w-auto"
placeholder="Custom Endpoint URL"
bind:value={$settings.ceEndpoint}
/>
</div>
</div>
</CollapsibleSection>
<CollapsibleSection
title={m['section.settings.section.extraFeatures']()}
open={showAll || !hideAll}
>
<CollapsibleSection title={m['section.settings.section.extraFeatures']()}>
<div class="grid gap-4">
<div class="form-control">
<Toggle label={m['section.settings.timeBasedDnd']()} checked={$settings.dnd.enabled} />
</div>
<ToggleWrapper
label={m['section.settings.timeBasedDnd']()}
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>
</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="form-control">
<label class="label">
<span class="label-text">{m['section.settings.timezoneOffset']()}</span>
</label>
<div class="flex items-center gap-2">
<select class="select select-bordered w-full">
<option>Europe/Amsterdam</option>
</select>
<button class="btn">{m['auto-detect']()}</button>
</div>
<p class="mt-1 text-sm">{m['section.settings.tzOffsetHelpText']()}</p>
</div>
<TimezoneSelector
selectedTimezone={$settings.tzString}
change={(value: string) => ($settings.tzString = value)}
helpText={''}
/>
<div class="form-control">
<div class="form-control flex justify-between">
<label class="label">
<span class="label-text">{m['section.settings.hostnamePrefix']()}</span>
</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 class="form-control">
<label class="label">
<span class="label-text">{m['section.settings.wpTimeout']()}</span>
</label>
<div class="flex items-center gap-2">
<input type="number" class="input input-bordered w-20" min="1" max="900" value="600" />
<span>{m['time.seconds']()}</span>
</div>
</div>
<NumericInput
label={m['section.settings.wpTimeout']()}
bind:value={$settings.wpTimeout}
min={1}
required
step={1}
unit={m['time.seconds']()}
/>
<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>
</CollapsibleSection>
</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>
<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>

View file

@ -7,80 +7,100 @@
import { DataSourceType } from '$lib/types';
import { toUptimestring } from '$lib/utils';
const screens = $settings.screens.map((screen) => ({
id: screen.id,
label: screen.name
}));
let screens: { id: number; label: string }[] = [];
settings.subscribe((settings) => {
screens = settings.screens.map((screen) => ({
id: screen.id,
label: screen.name
}));
});
</script>
<CardContainer title={m['section.status.title']()}>
<div class="mx-auto space-y-4">
<div class="join">
{#each screens as screen (screen.id)}
<TabButton
active={$status.currentScreen === screen.id}
onClick={() => setActiveScreen(screen.id)}
>
{screen.label}
</TabButton>
{/each}
</div>
<div class="join flex justify-center">
{#each $settings.actCurrencies as currency (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} />
{#if !$settings.isLoaded}
<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}
<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">
{m['section.status.screenCycle']()}: is {$status.timerRunning ? 'running' : 'stopped'}<br />
{m['section.status.doNotDisturb']()}: {$status.dnd.enabled ? m['on']() : m['off']()}
<small>
{#if $status.dnd?.timeBasedEnabled}
{m['section.status.timeBasedDnd']()} ( {$settings.dnd
.startHour}:{$settings.dnd.startMinute.toString().padStart(2, '0')} - {$settings.dnd
.endHour}:{$settings.dnd.endMinute.toString().padStart(2, '0')} )
<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-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}
</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 class="grid grid-cols-1 gap-4 md:grid-cols-2">
{#if $settings.dataSource === DataSourceType.NOSTR_SOURCE || $settings.nostrZapNotify}
<Status text="Nostr Relay connection" status={$status.nostr ? 'online' : 'offline'} />
{/if}
{#if $settings.dataSource === DataSourceType.THIRD_PARTY_SOURCE}
<Status
text={m['section.status.wsPriceConnection']()}
status={$status.connectionStatus.price ? 'online' : 'offline'}
/>
<Status
text={m['section.status.wsMempoolConnection']({ instance: $settings.mempoolInstance })}
status={$status.connectionStatus.blocks ? 'online' : 'offline'}
/>
{:else}
<Status
text={m['section.status.wsDataConnection']()}
status={$status.connectionStatus.V2 ? 'online' : 'offline'}
/>
{/if}
<div 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>
</div>
<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>
{/if}
</CardContainer>

View file

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

View file

@ -0,0 +1,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>

View file

@ -1,5 +1,5 @@
<script lang="ts">
let { title, className = '', ...restProps } = $props();
let { title = '', className = '', children, ...restProps } = $props();
</script>
<div class="card bg-base-100 w-full shadow-xl {className}" {...restProps}>
@ -7,6 +7,6 @@
{#if title}
<h2 class="card-title">{title}</h2>
{/if}
<slot />
{@render children?.()}
</div>
</div>

View 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();

View file

@ -1,2 +1,3 @@
export { settings, type Settings } from './settings';
export { status, type Status } from './status';
export { firmwareRelease, type FirmwareRelease } from './firmware';

View file

@ -118,6 +118,8 @@ function createSettingsStore() {
throw new Error(`Error fetching settings: ${response.statusText}`);
}
const data = await response.json();
data.timePerScreen = data.timerSeconds / 60;
data.isLoaded = true;
set(data);
return data;
} catch (error) {
@ -127,16 +129,23 @@ function createSettingsStore() {
},
update: async (newSettings: Partial<Settings>) => {
try {
const response = await fetch(`${baseUrl}/api/settings`, {
method: 'POST',
const formSettings = { ...newSettings };
delete formSettings['gitRev'];
delete formSettings['ip'];
delete formSettings['lastBuildTime'];
const response = await fetch(`${baseUrl}/api/json/settings`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(newSettings)
body: JSON.stringify(formSettings)
});
if (!response.ok) {
throw new Error(`Error updating settings: ${response.statusText}`);
} else {
console.log(formSettings, response);
}
// Update the local store with the new settings
@ -149,7 +158,20 @@ function createSettingsStore() {
}
},
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;
}
}
};
}

View file

@ -57,6 +57,7 @@ function createStatusStore() {
throw new Error(`Error fetching status: ${response.statusText}`);
}
const data = await response.json();
data.isLoaded = true;
set(data);
return data;
} catch (error) {

View file

@ -47,6 +47,7 @@ export interface Status {
blue: number;
hex: string;
}>;
isLoaded?: boolean;
[key: string]: unknown;
}
@ -103,6 +104,7 @@ export interface Settings {
httpAuthUser: string;
httpAuthPass: string;
hasFrontlight?: boolean;
timePerScreen?: number;
// Frontlight settings
flDisable?: boolean;
flMaxBrightness?: number;
@ -137,5 +139,6 @@ export interface Settings {
endHour: number;
endMinute: number;
};
isLoaded?: boolean;
[key: string]: unknown;
}

View file

@ -20,3 +20,83 @@ export const toUptimestring = (secs: number): string => {
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
View 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;
}

View file

@ -1,7 +1,7 @@
<script lang="ts">
import '../app.css';
import { Navbar } from '$lib/components';
import { settings, status } from '$lib/stores';
import { settings, status, firmwareRelease } from '$lib/stores';
import { onMount, onDestroy } from 'svelte';
let { children } = $props();
@ -12,6 +12,8 @@
onMount(() => {
if (typeof window !== 'undefined') {
console.log('Initializing stores');
firmwareRelease.fetchLatest();
unsubscribeSettings = settings.subscribe(() => {});
unsubscribeStatus = status.subscribe(() => {});
}
@ -27,6 +29,10 @@
});
</script>
<svelte:head>
<title>BTClock</title>
</svelte:head>
<Navbar />
<main class="bg-base-200">

View file

@ -1,2 +1,2 @@
export const prerender = true;
export const ssr = false;
export const ssr = false;

View file

@ -1,8 +1,17 @@
<script lang="ts">
import { SettingsSection } from '$lib/components';
import { settings } from '$lib/stores';
</script>
<div class="container mx-auto p-4">
<h1 class="mb-4 text-2xl font-bold">Settings</h1>
<SettingsSection />
{#if !$settings.isLoaded}
<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>

View file

@ -1,8 +1,11 @@
<script lang="ts">
import { SystemSection } from '$lib/components';
import { SystemSection, FirmwareUpdateSection } from '$lib/components';
</script>
<div class="container mx-auto p-4">
<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>

1
src/safelist.txt Normal file
View file

@ -0,0 +1 @@
alert-success