Initial commit

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

26
src/app.css Normal file
View file

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

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

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

12
src/app.html Normal file
View file

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

7
src/demo.spec.ts Normal file
View file

@ -0,0 +1,7 @@
import { describe, it, expect } from 'vitest';
describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
});

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

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

3
src/hooks.ts Normal file
View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

1
src/lib/index.ts Normal file
View file

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View file

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

View file

View file

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

View file

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

View file

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