wip: Add testing buttons
Some checks failed
/ build (push) Failing after 1m28s
/ check-changes (push) Successful in 1m4s

This commit is contained in:
Djuri 2025-05-13 01:02:34 +02:00
parent 800881d348
commit 5ed4140b9c
Signed by: djuri
GPG key ID: 61B9B2DDE5AA3AC1
21 changed files with 1054 additions and 472 deletions

View file

@ -48,6 +48,7 @@
"@fontsource/ubuntu": "^5.2.5",
"@inlang/paraglide-js": "^2.0.0",
"daisyui": "^5.0.35",
"heroicons-svelte": "^2.0.2",
"nostr-tools": "^2.12.0",
"sass": "^1.87.0"
},

12
pnpm-lock.yaml generated
View file

@ -28,6 +28,9 @@ importers:
daisyui:
specifier: ^5.0.35
version: 5.0.35
heroicons-svelte:
specifier: ^2.0.2
version: 2.0.2(svelte@5.28.2)
nostr-tools:
specifier: ^2.12.0
version: 2.12.0(typescript@5.8.3)
@ -1409,6 +1412,11 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
heroicons-svelte@2.0.2:
resolution: {integrity: sha512-F0eMj7qQbEAd9NsTf/q6Hgn89zBT7Apf1XDOQkSHApK2A35Q/46WDiTR9ofTECUvVfSqb+XzfLDKmXDllasJ9A==}
peerDependencies:
svelte: ^4.0.0 || ^5.0.0-next || ^5.0.0
html-encoding-sniffer@4.0.0:
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
engines: {node: '>=18'}
@ -3594,6 +3602,10 @@ snapshots:
dependencies:
function-bind: 1.1.2
heroicons-svelte@2.0.2(svelte@5.28.2):
dependencies:
svelte: 5.28.2
html-encoding-sniffer@4.0.0:
dependencies:
whatwg-encoding: 3.1.1

View file

@ -2,13 +2,96 @@
@import 'tailwindcss';
@plugin "daisyui" {
themes:
bitcoin-corporate --default,
bitcoin-corporate-dark --prefersdark,
bitcoin-corporate-light;
}
:root {
@plugin "daisyui/theme" {
name: bitcoin-corporate;
default: true;
prefersdark: false;
color-scheme: light;
--color-primary: #f7931a;
--color-primary-content: #ffffff;
--color-secondary: #0d579b;
--color-secondary-content: #ffffff;
--color-accent: #329239;
--color-accent-content: #ffffff;
--color-neutral: #4d4d4d;
--color-neutral-content: #ffffff;
--color-base-100: #ffffff;
--color-base-200: #f5f5f5;
--color-base-300: #e5e5e5;
--color-base-content: #000000;
--color-info: #0d579b;
--color-info-content: #ffffff;
--color-success: #329239;
--color-success-content: #ffffff;
--color-warning: #f7931a;
--color-warning-content: #4d4d4d;
--color-error: #d32f2f;
--color-error-content: #ffffff;
--radius-selector: 1rem;
--radius-field: 0.25rem;
--radius-box: 0.5rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 0;
--label-color: #000000;
}
@plugin "daisyui/theme" {
name: bitcoin-corporate-dark;
default: false;
prefersdark: true;
color-scheme: dark;
--color-primary: #f7931a;
--color-primary-content: #4d4d4d;
--color-secondary: #0d579b;
--color-secondary-content: #ffffff;
--color-accent: #329239;
--color-accent-content: #ffffff;
--color-neutral: #ffffff;
--color-neutral-content: #4d4d4d;
--color-base-100: #222222;
--color-base-200: #181818;
--color-base-300: #101010;
--color-base-content: #ffffff;
--color-info: #0d579b;
--color-info-content: #ffffff;
--color-success: #329239;
--color-success-content: #ffffff;
--color-warning: #f7931a;
--color-warning-content: #222222;
--color-error: #d32f2f;
--color-error-content: #ffffff;
--radius-selector: 1rem;
--radius-field: 0.25rem;
--radius-box: 0.5rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 0;
}
@theme {
--font-ubuntu: 'Ubuntu', sans-serif;
}
/* :root {
--primary: #3b82f6;
--secondary: #6b7280;
--accent: #f59e0b;
}
} */
html {
scroll-behavior: smooth;

View file

@ -1,8 +1,19 @@
@use '../node_modules/@fontsource-utils/scss' as fontsource;
@use '../node_modules/@fontsource-variable/oswald/scss/mixins' as Oswald;
@use '../node_modules/@fontsource/ubuntu/scss/mixins' as Ubuntu;
@include Oswald.faces(
@include fontsource.faces(
$metadata: Oswald.$metadata,
$subsets: latin,
$weights: 400,
$formats: 'woff2',
$formats: woff2,
$directory: '@fontsource-variable/oswald/files'
);
@include fontsource.faces(
$metadata: Ubuntu.$metadata,
$subsets: latin,
$weights: 400,
$formats: woff2,
$directory: '@fontsource/ubuntu/files'
);

View file

@ -102,7 +102,7 @@
<div class="btclock-label">BTClock</div>
</div>
<style>
<style scoped>
:root {
--font-family: 'Oswald Variable', sans-serif;
--primary-color: #0000ff;
@ -111,7 +111,6 @@
--display-light-bg: white;
--display-dark-text: white;
--display-light-text: black;
--label-color: rgba(0, 0, 0, 0.5);
--divider-light-color: #333;
--divider-dark-color: #fff;

View file

@ -7,7 +7,7 @@
// Core functionality
value = $bindable(0),
min = 0,
max = 100,
max = null,
step = 1,
// Appearance
@ -54,7 +54,7 @@
<span class="label-text">{label}</span>
</label>
<div class="input flex w-auto items-center gap-1">
<div class="input flex w-{width} items-center gap-1">
<input
bind:this={inputElement}
{id}
@ -64,7 +64,6 @@
{max}
{step}
oninput={handleInput}
class="w-{width}"
{...restProps}
/>

View file

@ -24,6 +24,9 @@
id = '',
name = '',
addon = null,
error = null,
extraClass = '',
// Additional attributes
...restProps
} = $props();
@ -33,29 +36,69 @@
id = `settings-input-${Math.random().toString(36).substring(2, 11)}`;
}
const widthClass = $derived(() => {
if (width === 'auto') {
return 'w-auto';
} else if (width === 'full') {
return 'w-full';
} else {
return `w-${width}`;
}
});
// Compute input classes based on props
const inputClasses = $derived(() => {
const classes = ['input', 'input-bordered', 'input', 'flex'];
const classes = ['input', 'input-bordered', 'input', 'flex', 'w-full'];
if (width === 'auto') {
classes.push('w-auto');
} else if (width === 'full') {
classes.push('w-full');
} else {
classes.push(`w-${width}`);
// if (!addon) {
// if (width === 'auto') {
// classes.push('w-auto');
// } else if (width === 'full') {
// classes.push('w-full');
// } else {
// classes.push(`w-${width}`);
// }
// }
if (error) {
classes.push('input-error');
}
return classes.join(' ');
});
</script>
<div class="form-control flex items-center justify-between gap-2">
<div class="form-control flex items-start justify-between gap-2">
{#if label}
<label for={id} class="label">
<span class="label-text">{label}{required ? ' *' : ''}</span>
<label for={id} class="label pt-2">
<span class="label-text">{label}</span>
</label>
{/if}
{#if addon}
<div class={widthClass()}>
<div class="join w-full">
<input
{id}
{name}
{type}
class={`${inputClasses()} join-item ${extraClass}`}
{placeholder}
{disabled}
{required}
bind:value
{...restProps}
/>
<div class="join-item">{@render addon()}</div>
</div>
{#if error}
<span class="text-error text-sm">{error}</span>
{/if}
</div>
{:else}
<div class={widthClass()}>
<div class="flex-1">
<input
{id}
{name}
@ -67,4 +110,10 @@
bind:value
{...restProps}
/>
{#if error}
<span class="text-error text-sm">{error}</span>
{/if}
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,47 @@
<script lang="ts" context="module">
export interface TimeInputProps {
hours: number;
minutes: number;
}
</script>
<script lang="ts">
import { createEventDispatcher } from 'svelte';
export let hours: number = 0;
export let minutes: number = 0;
// Allow 'change' event
const dispatch = createEventDispatcher<{ change: { hours: number; minutes: number } }>();
// Helper to format as HH:MM
function toTimeString(h: number, m: number) {
const hh = String(h).padStart(2, '0');
const mm = String(m).padStart(2, '0');
return `${hh}:${mm}`;
}
// Helper to parse time string (HH:MM)
function fromTimeString(val: string) {
const parts = val.split(':').map(Number);
let h = 0,
m = 0;
if (parts.length === 2) {
h = parts[0];
m = parts[1];
}
return { h, m };
}
let timeValue = '';
$: timeValue = toTimeString(hours, minutes);
function onInput(e: Event) {
const val = (e.target as HTMLInputElement).value;
const { h, m } = fromTimeString(val);
hours = h;
minutes = m;
dispatch('change', { hours, minutes });
}
</script>
<input type="time" step="60" bind:value={timeValue} on:input={onInput} />

View file

@ -28,11 +28,11 @@
<label for={selectId} class="label">
<span class="label-text">{label}</span>
</label>
<div class="flex w-auto items-center gap-2">
<div class="flex">
<div class="flex w-auto items-center justify-end gap-2">
<div class="join flex w-5/6">
<select
id={selectId}
class="select select-bordered w-full"
class="select select-bordered join-item w-full"
bind:value={selectedTimezone}
onchange={handleChange}
>
@ -42,7 +42,9 @@
</option>
{/each}
</select>
<button class="btn btn-secondary" onclick={autoDetectTimezone}>{m['auto-detect']()}</button>
<button class="btn btn-secondary join-item" onclick={autoDetectTimezone}
>{m['auto-detect']()}</button
>
</div>
</div>
{#if helpText}

View file

@ -5,6 +5,8 @@ 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';
export { default as ColorModeToggle } from './ui/ColorModeToggle.svelte';
export { default as LanguageSelector } from './ui/LanguageSelector.svelte';
// Form Components
export { default as InputField } from './form/InputField.svelte';
@ -18,14 +20,16 @@ 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';
export { default as TimeInput } from './form/TimeInput.svelte';
// Layout Components
export { default as Navbar } from './layout/Navbar.svelte';
export { default as CollapsibleSection } from './layout/CollapsibleSection.svelte';
export { default as SettingSection } from './layout/SettingSection.svelte';
// Section Components
export { default as ControlSection } from './sections/ControlSection.svelte';
export { default as StatusSection } from './sections/StatusSection.svelte';
export { default as SettingsSection } from './sections/SettingsSection.svelte';
export { default as SystemSection } from './sections/SystemSection.svelte';
export { default as FirmwareUpdateSection } from './sections/FirmwareUpdateSection.svelte';
export { default as StatsSection } from './sections/StatsSection.svelte';

View file

@ -1,8 +1,9 @@
<script lang="ts">
import { LanguageSelector, ColorModeToggle } from '$lib/components';
let { ...restProps } = $props();
import { setLocale, getLocale } from '$lib/paraglide/runtime';
import { locales } from '$lib/paraglide/runtime';
import { getLocale } from '$lib/paraglide/runtime';
import { page } from '$app/state';
import { settings, firmwareRelease } from '$lib/stores';
import { derived } from 'svelte/store';
@ -41,29 +42,6 @@
function isActive(href: string) {
return page.url.pathname === href;
}
const getLocaleName = (locale: string) => {
return new Intl.DisplayNames([locale], { type: 'language' }).of(locale);
};
const getLanguageName = (locale: string) => {
return getLocaleName(locale.split('-')[0]);
};
const getEmojiFlag = (locale: string) => {
const countryCode = locale.split('-')[1];
if (!countryCode || countryCode === 'US') {
return '🇺🇸';
}
return [...countryCode.toUpperCase()]
.map((char) => String.fromCodePoint(127397 + char.charCodeAt(0)))
.join('');
};
// Function to get the current flag
const getCurrentFlag = () => getEmojiFlag(getLocale()) || '🇬🇧';
</script>
<div class="navbar bg-base-100 fixed top-0 z-50 w-full shadow-sm" {...restProps}>
@ -103,7 +81,7 @@
{/each}
</ul>
</div>
<a href="/" class="btn btn-ghost text-xl">BTClock</a>
<a href="/" class="btn btn-ghost font-ubuntu text-xl italic">BTClock</a>
{#if $updateAvailable}
<span class="badge badge-xs badge-success"
>New update available: {$firmwareRelease.tag_name}</span
@ -120,22 +98,7 @@
</ul>
</div>
<div class="navbar-end">
<div class="dropdown dropdown-end mr-2">
<div tabindex="0" role="button" class="btn btn-ghost">
<span class="text-sm">{getCurrentFlag()} {getLanguageName(getLocale())}</span>
</div>
<ul
tabindex="-1"
class="menu dropdown-content bg-base-100 rounded-box z-[1] mt-3 w-auto p-2 shadow"
>
{#each locales as locale (locale)}
<li>
<button onclick={() => setLocale(locale)} class="flex items-center gap-2 text-nowrap"
>{getEmojiFlag(locale)} {getLanguageName(locale)}</button
>
</li>
{/each}
</ul>
</div>
<ColorModeToggle />
<LanguageSelector></LanguageSelector>
</div>
</div>

View file

@ -0,0 +1,12 @@
<script lang="ts">
let { title, children, ...restProps } = $props();
</script>
<div class="card bg-base-200 mb-2 rounded-lg" {...restProps}>
<div class="card-body">
<h2 class="card-title mb-2">
{title}
</h2>
{@render children()}
</div>
</div>

View file

@ -62,7 +62,7 @@
<label class="label" for="customText">
<span class="label-text">{m['section.control.text']()}</span>
</label>
<div class="flex gap-2">
<div class="flex flex-col gap-2 md:flex-row">
<InputField
id="customText"
maxLength="7"
@ -78,8 +78,9 @@
<div class="">
<h3 class="mb-2 font-medium">{m['section.control.ledColor']()}</h3>
<div class="flex justify-between gap-2">
<div class="mb-4 flex flex-wrap gap-2">
<div class="flex flex-col justify-between md:flex-row">
<div class="mb-4 flex flex-col flex-wrap gap-2">
<div class="flex gap-2">
{#if ledStatus.length > 0}
{#each ledStatus as led (led)}
<input
@ -90,7 +91,13 @@
/>
{/each}
{/if}
<Toggle label={m['sections.control.keepSameColor']()} bind:checked={keepLedsSameColor} />
</div>
<div class="flex gap-2">
<Toggle
label={m['sections.control.keepSameColor']()}
bind:checked={keepLedsSameColor}
/>
</div>
</div>
<div class="flex gap-2">
<button class="btn btn-secondary" onclick={turnOffLeds}
@ -121,7 +128,7 @@
{/if}
<div>
<div class="flex justify-end gap-2">
<div class="flex gap-2 md:justify-end">
<button class="btn btn-error" onclick={restartClock}>{m['button.restart']()}</button>
<button class="btn" onclick={forceFullRefresh}>{m['button.forceFullRefresh']()}</button>
</div>

View file

@ -2,20 +2,20 @@
import { m } from '$lib/paraglide/messages';
import {
CardContainer,
Toggle,
CollapsibleSection,
TimezoneSelector,
ToggleWrapper,
Radio,
NumericInput,
Select,
RadioGroup,
SettingsInputField
SettingsInputField,
TimeInput,
SettingSection
} 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';
import { getPubKey, isValidNpub, isValidHexPubKey, isValidNostrRelay } from '$lib/utils/nostr';
let { ...restProps } = $props();
const textColorOptions: [string, boolean][] = [
@ -87,19 +87,121 @@
label: value,
value: key
}));
const errors = $state(Object.fromEntries(Object.keys($settings).map((key) => [key, null])));
let isValidRelay = $state(false);
let isValidBitaxe = $state(false);
const checkValidNostrPubkey = (key: string) => {
$settings[key] = $settings[key]?.trim();
if (isValidNpub($settings[key])) {
$settings[key] = getPubKey($settings[key]);
errors[key] = null;
return;
} else if (isValidHexPubKey($settings[key])) {
errors[key] = null;
return;
} else {
errors[key] = 'Not a valid npub or hex pubkey';
}
};
const checkValidNostrRelay = (key: string) => {
isValidRelay = false;
$settings[key] = $settings[key]?.trim();
if (/\s/.test($settings[key])) {
errors[key] = 'No spaces allowed';
return false;
}
try {
let relay = new URL($settings[key]);
if (relay.protocol !== 'wss:') {
errors[key] = 'Must be a valid nostr relay';
return false;
}
} catch (e) {
errors[key] = e.message;
return false;
}
errors[key] = null;
return true;
};
const testNostrRelay = (key: string) => {
if (!checkValidNostrRelay(key)) {
return;
}
isValidNostrRelay($settings[key]).then((isValid) => {
if (isValid) {
errors[key] = null;
isValidRelay = true;
console.log('isValidRelay', isValidRelay);
} else {
errors[key] = 'Could not connect to relay';
}
});
};
const dispatch = (type: string, payload: any) => {
console.log('dispatch', type, payload);
};
const testBitaxe = async () => {
try {
const response = await fetch(`http://${$settings.bitaxeHostname}/api/system/info`, {
mode: 'cors',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' }
});
if (!response.ok) {
console.log('response', response);
dispatch('showToast', {
color: 'danger',
text: `Failed to connect to BitAxe HTTP error! status: ${response.status}`
});
isValidBitaxe = false;
throw new Error();
}
const systemInfo = await response.json();
dispatch('showToast', {
color: 'success',
text: `Connected to BitAxe ${systemInfo.ASICModel} (Board version ${systemInfo.boardVersion}) running firmware ${systemInfo.version}.\r\nCurrent hashrate ${Math.round(systemInfo.hashRate)} GH/s`
});
isValidBitaxe = true;
} catch (error) {
if (error instanceof TypeError && error.message.includes('Failed to fetch')) {
dispatch('showToast', {
color: 'danger',
text: `Failed to connect to BitAxe, make sure you are connected to the same network.`
});
}
console.error('Failed to fetch Bitaxe system info:', error);
isValidBitaxe = false;
}
};
</script>
<form onsubmit={handleSave}>
<CardContainer {...restProps}>
<div class="grid gap-4 md:grid-cols-2">
<CollapsibleSection title={m['section.settings.screens']()}>
<SettingSection title={m['section.settings.screens']()}>
<div class="grid gap-4 md:grid-cols-2">
{#each $settings.screens as screen (screen.id)}
<ToggleWrapper label={screen.name} bind:value={screen.enabled} />
{/each}
</div>
</CollapsibleSection>
</SettingSection>
<CollapsibleSection title={m['section.settings.currencies']()}>
<SettingSection title={m['section.settings.currencies']()}>
<div class="grid gap-4 md:grid-cols-2">
{#each $settings.availableCurrencies as currency (currency)}
<ToggleWrapper
@ -110,9 +212,9 @@
/>
{/each}
</div>
</CollapsibleSection>
</SettingSection>
<CollapsibleSection title={m['section.settings.section.screenSettings']()}>
<SettingSection title={m['section.settings.section.screenSettings']()}>
<div class="grid gap-4 md:grid-cols-2">
<ToggleWrapper
label={m['section.settings.StealFocusOnNewBlock']()}
@ -150,9 +252,58 @@
description="Rotate the description of the screen 90 degrees."
/>
</div>
</CollapsibleSection>
</SettingSection>
<CollapsibleSection title={m['section.settings.section.displaysAndLed']()}>
<SettingSection title={m['section.settings.section.dataSource']()}>
<div class="grid gap-4">
<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>
<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>
{#if $settings.dataSource === DataSourceType.THIRD_PARTY_SOURCE}
<div class="form-control flex items-center justify-between gap-2">
<label class="label" for="mempoolInstance">
<span class="label-text">{m['section.settings.mempoolnstance']()}</span>
</label>
<input
id="mempoolInstance"
type="text"
class="input input-bordered input flex"
bind:value={$settings.mempoolInstance}
/>
</div>
{/if}
{#if $settings.dataSource === DataSourceType.CUSTOM_SOURCE}
<div class="form-control flex items-center justify-between gap-2">
<label class="label" for="ceEndpoint">
<span class="label-text">{m['section.settings.ceEndpoint']()}</span>
</label>
<input
id="ceEndpoint"
type="text"
class="input input-bordered input flex"
placeholder="Custom Endpoint URL"
bind:value={$settings.ceEndpoint}
/>
</div>
{/if}
</div>
</SettingSection>
<SettingSection title={m['section.settings.section.displaysAndLed']()}>
<div class="grid gap-4">
<Select
label={m['section.settings.textColor']()}
@ -192,6 +343,7 @@
required
step={1}
unit={m['time.seconds']()}
id="minSecPriceUpd"
/>
<RangeSlider
@ -220,10 +372,10 @@
bind:value={$settings.disableLeds}
/>
</div>
</CollapsibleSection>
</SettingSection>
{#if $settings.hasFrontlight}
<CollapsibleSection title="Frontlight Settings">
<SettingSection title="Frontlight Settings">
<div class="grid gap-4">
<ToggleWrapper
label={m['section.settings.flDisable']()}
@ -252,10 +404,11 @@
/>
<div class="form-control">
<label class="label">
<label class="label" for="luxLightToggle">
<span class="label-text">Light Level Threshold</span>
</label>
<input
id="luxLightToggle"
type="range"
min="0"
max="255"
@ -283,59 +436,40 @@
unit="ms"
/>
</div>
</CollapsibleSection>
</SettingSection>
{/if}
<CollapsibleSection title={m['section.settings.section.dataSource']()}>
<div class="grid gap-4">
<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>
<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 flex justify-between">
<label class="label">
<span class="label-text">{m['section.settings.mempoolnstance']()}</span>
</label>
<input
type="text"
class="input input-bordered input flex w-auto"
bind:value={$settings.mempoolInstance}
/>
</div>
<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 input flex w-auto"
placeholder="Custom Endpoint URL"
bind:value={$settings.ceEndpoint}
/>
</div>
</div>
</CollapsibleSection>
<CollapsibleSection title={m['section.settings.section.extraFeatures']()}>
<SettingSection title={m['section.settings.section.extraFeatures']()}>
<div class="grid gap-4">
<ToggleWrapper
label={m['section.settings.timeBasedDnd']()}
bind:value={$settings.dnd.timeBasedEnabled}
/>
{#if $settings.dnd.timeBasedEnabled}
<div class="form-control flex flex-col items-center justify-between md:flex-row">
<label class="label" for="startTime">
<span class="label-text">Start time</span>
</label>
<TimeInput
id="startTime"
bind:minutes={$settings.dnd.startMinute}
bind:hours={$settings.dnd.startHour}
/>
</div>
<div class="form-control flex flex-col items-center justify-between md:flex-row">
<label class="label" for="endTime">
<span class="label-text">End time</span>
</label>
<TimeInput
id="endTime"
bind:minutes={$settings.dnd.endMinute}
bind:hours={$settings.dnd.endHour}
/>
</div>
{/if}
<h4 class="text-lg font-bold">Bitaxe</h4>
<ToggleWrapper
@ -347,7 +481,15 @@
<SettingsInputField
label={m['section.settings.bitaxeHostname']()}
bind:value={$settings.bitaxeHostname}
/>
>
{#snippet addon()}
<button
class="btn btn-secondary join-item"
onclick={() => testBitaxe('bitaxeHostname')}
type="button">Test</button
>
{/snippet}
</SettingsInputField>
{/if}
<h4 class="text-lg font-bold">Mining Pool</h4>
@ -397,17 +539,40 @@
<SettingsInputField
label={m['section.settings.nostrRelay']()}
bind:value={$settings.nostrRelay}
/>
width="1/2"
onchange={() => checkValidNostrRelay('nostrRelay')}
oninput={() => checkValidNostrRelay('nostrRelay')}
bind:error={errors.nostrRelay}
extraClass={isValidRelay ? 'input-success' : ''}
>
{#snippet addon()}
<button
class="btn btn-secondary join-item"
onclick={() => testNostrRelay('nostrRelay')}
type="button">Test</button
>
{/snippet}
</SettingsInputField>
<SettingsInputField
label={m['section.settings.nostrZapPubkey']()}
bind:value={$settings.nostrZapPubkey}
/>
width="1/2"
minlength="64"
required
onchange={() => checkValidNostrPubkey('nostrZapPubkey')}
oninput={() => checkValidNostrPubkey('nostrZapPubkey')}
bind:error={errors.nostrZapPubkey}
>
{#snippet hint()}
<p class="validator-hint">Must be 66 characters</p>
{/snippet}
</SettingsInputField>
{/if}
</div>
</CollapsibleSection>
</SettingSection>
<CollapsibleSection title={m['section.settings.section.system']()}>
<SettingSection title={m['section.settings.section.system']()}>
<div class="grid gap-4">
<TimezoneSelector
selectedTimezone={$settings.tzString}
@ -416,12 +581,13 @@
/>
<div class="form-control flex justify-between">
<label class="label">
<label class="label" for="hostnamePrefix">
<span class="label-text">{m['section.settings.hostnamePrefix']()}</span>
</label>
<input
id="hostnamePrefix"
type="text"
class="input input-bordered input flex w-auto"
class="input input-bordered input flex"
bind:value={$settings.hostnamePrefix}
/>
</div>
@ -465,23 +631,23 @@
bind:value={$settings.enableDebugLog}
/>
</div>
</CollapsibleSection>
</SettingSection>
</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>
<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
<button class="btn btn-primary" type="submit" disabled={$status.isOTAUpdating}
>{m['button.save']()}</button
>
</div>
</div>
</form>

View file

@ -0,0 +1,15 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages';
import { toUptimestring } from '$lib/utils';
import { status } from '$lib/stores';
import Stat from '$lib/components/ui/Stat.svelte';
</script>
<div class="stats bg-base-100 mt-4 flex justify-center shadow-xl">
<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>

View file

@ -1,11 +1,10 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages';
import { CardContainer, TabButton, CurrencyButton, Stat, Status } from '$lib/components';
import { CardContainer, TabButton, CurrencyButton, Status } from '$lib/components';
import { status, settings } from '$lib/stores';
import { setActiveScreen, setActiveCurrency } from '$lib/clockControl';
import BTClock from '../BTClock.svelte';
import { DataSourceType } from '$lib/types';
import { toUptimestring } from '$lib/utils';
let screens: { id: number; label: string }[] = [];
@ -27,7 +26,7 @@
</div>
{:else}
<div class="mx-auto space-y-4">
<div class="md:join">
<div class="md:join flex flex-wrap justify-center gap-1 md:gap-0">
{#each screens as screen (screen.id)}
<TabButton
active={$status.currentScreen === screen.id}
@ -94,13 +93,5 @@
{/if}
</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

@ -0,0 +1,54 @@
<script lang="ts">
import { onMount } from 'svelte';
import { SunIcon, MoonIcon } from 'heroicons-svelte/24/outline';
let isDark = false;
let userOverride = false;
const theme = {
light: 'bitcoin-corporate',
dark: 'bitcoin-corporate-dark'
};
// Set theme on toggle
function setTheme(dark: boolean) {
userOverride = true;
document.documentElement.setAttribute('data-theme', dark ? theme.dark : theme.light);
document.documentElement.setAttribute('class', dark ? 'dark' : 'light');
document.body.setAttribute('data-theme', dark ? theme.dark : theme.light);
isDark = dark;
}
onMount(() => {
if (typeof window !== 'undefined' && window.matchMedia) {
const media = window.matchMedia('(prefers-color-scheme: dark)');
// Only set from system if not overridden
if (!userOverride) {
isDark = media.matches;
document.documentElement.setAttribute('data-theme', isDark ? theme.dark : theme.light);
}
const handler = (event: MediaQueryListEvent) => {
if (!userOverride) {
isDark = event.matches;
document.documentElement.setAttribute('data-theme', isDark ? theme.dark : theme.light);
}
};
media.addEventListener('change', handler);
return () => {
media.removeEventListener('change', handler);
};
}
});
</script>
<label class="swap swap-rotate">
<input
type="checkbox"
class="theme-controller"
aria-label="Toggle dark mode"
bind:checked={isDark}
on:change={() => setTheme(isDark)}
value={isDark ? 'dark' : 'light'}
/>
<SunIcon class="swap-off h-5 w-5" />
<MoonIcon class="swap-on h-5 w-5" />
</label>

View file

@ -0,0 +1,85 @@
<script lang="ts">
let { ...restProps } = $props();
import { setLocale, getLocale } from '$lib/paraglide/runtime';
import { locales } from '$lib/paraglide/runtime';
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: 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;
}
const getLocaleName = (locale: string) => {
return new Intl.DisplayNames([locale], { type: 'language' }).of(locale);
};
const getLanguageName = (locale: string) => {
return getLocaleName(locale.split('-')[0]);
};
const getEmojiFlag = (locale: string) => {
const countryCode = locale.split('-')[1];
if (!countryCode || countryCode === 'US') {
return '🇺🇸';
}
return [...countryCode.toUpperCase()]
.map((char) => String.fromCodePoint(127397 + char.charCodeAt(0)))
.join('');
};
// Function to get the current flag
const getCurrentFlag = () => getEmojiFlag(getLocale()) || '🇬🇧';
</script>
<div class="dropdown dropdown-end mr-2">
<div tabindex="0" role="button" class="btn btn-ghost">
<span class="text-sm">{getCurrentFlag()} {getLanguageName(getLocale())}</span>
</div>
<ul
tabindex="-1"
class="menu dropdown-content bg-base-100 rounded-box z-[1] mt-3 w-auto p-2 shadow"
>
{#each locales as locale (locale)}
<li>
<button onclick={() => setLocale(locale)} class="flex items-center gap-2 text-nowrap"
>{getEmojiFlag(locale)} {getLanguageName(locale)}</button
>
</li>
{/each}
</ul>
</div>

View file

@ -9,7 +9,7 @@
</div>
{/if}
<div class="stat-title">{title}</div>
<div class="stat-value">{value}</div>
<div class="stat-value text-xs md:text-sm">{value}</div>
{#if desc}
<div class="stat-desc">{desc}</div>
{/if}

81
src/lib/utils/nostr.ts Normal file
View file

@ -0,0 +1,81 @@
import * as nip19 from 'nostr-tools/nip19';
import { Relay } from 'nostr-tools';
/**
* Validates if the given npub is a valid Nostr Public Key.
* @param npub - The npub (Nostr Public Key) to validate.
* @returns A boolean indicating if the npub is valid.
*/
const isValidNpub = (npub: string): boolean => {
try {
// Decode the npub using NIP-19
const { type, data } = nip19.decode(npub);
// Check if the type is 'npub' and the data length is 32 bytes
return type === 'npub' && data.length === 64;
} catch {
// If any error is thrown, the npub is not valid
return false;
}
};
/**
* Validates if the given URL is a valid Nostr relay.
* @param url - The URL of the Nostr relay to validate.
* @returns A Promise<boolean> indicating if the URL is a valid Nostr relay.
*/
const isValidNostrRelay = async (url: string): Promise<boolean> => {
try {
const relay: Relay = await Relay.connect(url);
// If the relay is successfully connected, it's a valid Nostr relay
if (relay.connected) {
// Close the connection to clean up
relay.close();
return true;
}
return false;
} catch {
// If any error is thrown, the URL is not a valid Nostr relay
return false;
}
};
/**
* Validates if the given parameter is a valid hex public key.
* @param pubkey - The public key to validate.
* @returns A boolean indicating if the public key is valid.
*/
const isValidHexPubKey = (pubkey: string): boolean => {
return /^[0-9a-f]{64}$/i.test(pubkey);
};
/**
* Checks if a parameter is a valid pubkey or npub and converts npub to pubkey.
* @param input - The input string to check and convert.
* @returns The pubkey if valid, otherwise null.
*/
const getPubKey = (input: string): string | null => {
try {
// If input is a valid hex public key
if (isValidHexPubKey(input)) {
return input;
}
// Try to decode the input as npub
const { type, data } = nip19.decode(input);
// Check if the decoded type is 'npub' and the data length is 64 characters (32 bytes in hex)
if (type === 'npub' && data.length === 64) {
return data;
}
return null;
} catch {
// If any error is thrown, the input is not valid
return null;
}
};
export { isValidNpub, isValidNostrRelay, isValidHexPubKey, getPubKey };

View file

@ -1,14 +1,15 @@
<script lang="ts">
import { ControlSection, StatusSection } from '$lib/components';
import { ControlSection, StatusSection, StatsSection } from '$lib/components';
</script>
<div class=" mx-auto px-2 py-4">
<div class="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
<div class="grid grid-cols-1 gap-3 lg:grid-cols-3">
<div>
<ControlSection />
<StatsSection />
</div>
<div class="col-span-2">
<div class="md:col-span-2">
<StatusSection />
</div>
</div>