wip: Add testing buttons
This commit is contained in:
parent
800881d348
commit
5ed4140b9c
21 changed files with 1054 additions and 472 deletions
|
@ -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
12
pnpm-lock.yaml
generated
|
@ -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
|
||||
|
|
87
src/app.css
87
src/app.css
|
@ -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;
|
||||
|
|
15
src/app.scss
15
src/app.scss
|
@ -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'
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
47
src/lib/components/form/TimeInput.svelte
Normal file
47
src/lib/components/form/TimeInput.svelte
Normal 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} />
|
|
@ -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}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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>
|
||||
|
|
12
src/lib/components/layout/SettingSection.svelte
Normal file
12
src/lib/components/layout/SettingSection.svelte
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
<CardContainer {...restProps}>
|
||||
<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>
|
||||
</CardContainer>
|
||||
|
||||
<div class="sticky right-0 bottom-0 left-0">
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
|
|
15
src/lib/components/sections/StatsSection.svelte
Normal file
15
src/lib/components/sections/StatsSection.svelte
Normal 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>
|
|
@ -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>
|
||||
|
|
54
src/lib/components/ui/ColorModeToggle.svelte
Normal file
54
src/lib/components/ui/ColorModeToggle.svelte
Normal 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>
|
85
src/lib/components/ui/LanguageSelector.svelte
Normal file
85
src/lib/components/ui/LanguageSelector.svelte
Normal 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>
|
|
@ -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
81
src/lib/utils/nostr.ts
Normal 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 };
|
|
@ -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>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue