mirror of
https://github.com/mfactory-osaka/ESPTimeCast.git
synced 2026-02-19 11:54:56 -05:00
2858 lines
102 KiB
C++
2858 lines
102 KiB
C++
#pragma once
|
||
// index_html.h
|
||
|
||
const char index_html[] PROGMEM = R"rawliteral(
|
||
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<meta
|
||
http-equiv="Cache-Control"
|
||
content="no-cache, no-store, must-revalidate"
|
||
/>
|
||
<meta http-equiv="Pragma" content="no-cache" />
|
||
<meta http-equiv="Expires" content="0" />
|
||
<title>ESPTimeCast Settings</title>
|
||
<style>
|
||
:root {
|
||
--bg-gradient: linear-gradient(
|
||
135deg,
|
||
#081f56 0%,
|
||
#110f2e 50%,
|
||
#441a65 100%
|
||
);
|
||
--accent-color: #0ea5e9;
|
||
--glass-bg: rgba(255, 255, 255, 0.04);
|
||
--glass-border: rgba(255, 255, 255, 0.12);
|
||
}
|
||
|
||
.ssid-wrapper {
|
||
position: relative;
|
||
}
|
||
|
||
.combo-container {
|
||
display: flex;
|
||
box-sizing: border-box;
|
||
width: 100%;
|
||
border: 1.5px solid rgba(180, 230, 255, 0.08);
|
||
border-radius: 8px;
|
||
background-color: rgba(225, 245, 255, 0.07);
|
||
color: #ffffff;
|
||
font-size: 1rem;
|
||
appearance: none;
|
||
}
|
||
|
||
#ssid {
|
||
border-radius: 8px 0 0 8px;
|
||
flex: 1;
|
||
border: none;
|
||
outline: none;
|
||
background: transparent;
|
||
}
|
||
|
||
.icon-btn {
|
||
width: 40px;
|
||
border: none;
|
||
background: none;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #666;
|
||
transition: background 0.2s;
|
||
}
|
||
|
||
#arrowBtn {
|
||
background: transparent;
|
||
border: none;
|
||
width: 40px;
|
||
border-left: 1.5px solid rgba(180, 230, 255, 0.08);
|
||
}
|
||
|
||
#arrowBtn > svg {
|
||
position: relative;
|
||
top: 2px;
|
||
filter: invert(0);
|
||
opacity: 1;
|
||
}
|
||
|
||
#arrowBtn:disabled > svg {
|
||
position: relative;
|
||
top: 2px;
|
||
opacity: 0.25;
|
||
}
|
||
|
||
#arrowBtn:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 6px 16px rgba(0, 122, 255, 0.35);
|
||
}
|
||
|
||
#arrowBtn:disabled:hover {
|
||
transform: translateY(0px);
|
||
box-shadow: none;
|
||
}
|
||
|
||
#arrowBtn:disabled {
|
||
cursor: not-allowed;
|
||
background: none;
|
||
color: rgba(255, 255, 255, 0.25);
|
||
}
|
||
|
||
#scanBtn {
|
||
border-radius: 0 8px 8px 0;
|
||
width: 75px;
|
||
padding: 0;
|
||
text-align: center;
|
||
align-self: auto;
|
||
}
|
||
|
||
.icon-btn:hover {
|
||
background: #f5f5f5;
|
||
}
|
||
#scanBtn:disabled {
|
||
background: rgba(255, 255, 255, 0.5);
|
||
cursor: wait;
|
||
}
|
||
|
||
/* The Dropdown Menu */
|
||
#ssidList {
|
||
position: absolute;
|
||
width: 100%;
|
||
max-height: 50vh;
|
||
overflow-y: auto;
|
||
background: white;
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 6px;
|
||
display: none;
|
||
z-index: 1000;
|
||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.ssid-option {
|
||
padding: 10px 12px;
|
||
cursor: pointer;
|
||
color: black;
|
||
}
|
||
|
||
.ssid-option:hover {
|
||
background-color: var(--accent-color);
|
||
color: white;
|
||
}
|
||
|
||
* {
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
html {
|
||
background: var(--bg-gradient);
|
||
height: 100%;
|
||
background-attachment: fixed;
|
||
}
|
||
|
||
body {
|
||
font-family: Roboto, system-ui;
|
||
margin: 0;
|
||
padding: 2rem 1rem;
|
||
color: #ffffff;
|
||
background-repeat: no-repeat, repeat, repeat;
|
||
opacity: 0;
|
||
transition: opacity 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||
visibility: hidden;
|
||
height: 100%;
|
||
line-height: 1.5;
|
||
-webkit-font-smoothing: antialiased;
|
||
-moz-osx-font-smoothing: grayscale;
|
||
}
|
||
|
||
body.loaded {
|
||
visibility: visible;
|
||
opacity: 1;
|
||
}
|
||
|
||
body.modal-open {
|
||
overflow: hidden;
|
||
}
|
||
|
||
h1 {
|
||
text-align: center;
|
||
font-size: 1.5rem;
|
||
margin-bottom: 1.5rem;
|
||
color: #ffffff;
|
||
}
|
||
|
||
h2 {
|
||
margin-top: 4rem;
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
form:first-of-type > h2:first-of-type{
|
||
margin-top: 1.5rem;
|
||
}
|
||
|
||
.logo{
|
||
display: flex;
|
||
justify-content: center;
|
||
}
|
||
|
||
.logo svg {
|
||
filter: drop-shadow(0px 5px 10px black);
|
||
width: 90%;
|
||
height: auto;
|
||
margin: 0.5rem 0;
|
||
}
|
||
|
||
form {
|
||
background: var(--glass-bg);
|
||
border: 1px solid var(--glass-border);
|
||
display: flex;
|
||
flex-direction: column;
|
||
max-width: 500px;
|
||
margin: 0 auto;
|
||
padding: 1.5rem 1.5rem 2.5rem 1.5rem;
|
||
border-radius: 24px;
|
||
box-shadow:
|
||
0 10px 36px 0 rgba(40, 170, 255, 0.11),
|
||
0 2px 8px 0 rgba(44, 70, 110, 0.08);
|
||
}
|
||
|
||
label {
|
||
font-size: 0.9rem;
|
||
display: block;
|
||
margin-top: 0.75rem;
|
||
}
|
||
|
||
input[type="text"],
|
||
input[type="time"],
|
||
input[type="password"],
|
||
input[type="date"],
|
||
input[type="number"],
|
||
select {
|
||
width: 100%;
|
||
padding: 0.75rem;
|
||
border: 1.5px solid rgba(180, 230, 255, 0.08);
|
||
border-radius: 8px;
|
||
background-color: rgba(225, 245, 255, 0.07);
|
||
color: #ffffff;
|
||
font-size: 1rem;
|
||
appearance: none;
|
||
}
|
||
|
||
input[type="time"]:disabled,
|
||
input[type="text"]:disabled,
|
||
input[type="number"]:disabled,
|
||
input[type="date"]:disabled {
|
||
color: rgba(255, 255, 255, 0.25);
|
||
}
|
||
|
||
input[type="submit"] {
|
||
background-color: var(--accent-color);
|
||
color: white;
|
||
font-size: 1rem;
|
||
border: none;
|
||
border-radius: 999px;
|
||
cursor: pointer;
|
||
transition: background-color 0.2s ease-in-out;
|
||
padding: 0.9rem 1.8rem;
|
||
}
|
||
|
||
input[type="submit"]:hover {
|
||
background-color: var(--accent-color);
|
||
filter: brightness(1.2);
|
||
}
|
||
|
||
input[type="time"]::-webkit-calendar-picker-indicator,
|
||
input[type="date"]::-webkit-calendar-picker-indicator {
|
||
filter: invert(100%);
|
||
}
|
||
|
||
/* Enabled & checked toggle */
|
||
.toggle-switch input[type="checkbox"]:checked + .toggle-slider {
|
||
background-color: var(--accent-color);
|
||
}
|
||
|
||
.toggle-switch input[type="checkbox"]:disabled + .toggle-slider::before {
|
||
background-color: rgba(204, 204, 204, 0.5);
|
||
}
|
||
|
||
input:-webkit-autofill,
|
||
input:-webkit-autofill:focus,
|
||
input:-webkit-autofill:hover {
|
||
background: rgba(225, 245, 255, 0.07) !important;
|
||
color: #fff !important;
|
||
-webkit-box-shadow: 0 0 0 1000px rgba(225, 245, 255, 0.07) inset !important;
|
||
box-shadow: 0 0 0 1000px rgba(225, 245, 255, 0.07) inset !important;
|
||
-webkit-text-fill-color: #fff !important;
|
||
transition: background 9999s ease-in-out 0s;
|
||
}
|
||
|
||
input::placeholder,
|
||
textarea::placeholder {
|
||
color: hwb(0 100% 0% / 0.39); /* Example: light blue */
|
||
opacity: 1; /* Make sure it's not semi-transparent */
|
||
}
|
||
|
||
.form-row {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.form-row.two-col {
|
||
flex-direction: column;
|
||
}
|
||
.form-row.two-col > div {
|
||
flex: 1;
|
||
}
|
||
.primary-button {
|
||
background: var(--accent-color);
|
||
color: white;
|
||
padding: 0.9rem 1.8rem;
|
||
font-size: 1rem;
|
||
font-weight: 600;
|
||
border: none;
|
||
border-radius: 999px;
|
||
cursor: pointer;
|
||
text-align: center;
|
||
align-self: center;
|
||
width: fit-content;
|
||
transition:
|
||
background 0.25s,
|
||
transform 0.15s ease-in-out;
|
||
}
|
||
|
||
.primary-button:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 6px 16px rgba(0, 122, 255, 0.35);
|
||
filter: brightness(1.2);
|
||
}
|
||
|
||
.primary-button:active {
|
||
transform: scale(0.97);
|
||
}
|
||
|
||
.note {
|
||
font-size: 0.85rem;
|
||
text-align: center;
|
||
margin-top: 1rem;
|
||
}
|
||
|
||
#savingModal {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
display: none;
|
||
justify-content: center;
|
||
align-items: center;
|
||
z-index: 1000;
|
||
background: rgba(0, 0, 0, 0.45);
|
||
z-index: 1000;
|
||
transition: opacity 0.3s ease;
|
||
}
|
||
|
||
#savingModalContent {
|
||
background: var(--glass-bg);
|
||
border-radius: 24px;
|
||
box-shadow:
|
||
0 10px 36px 0 rgba(40, 170, 255, 0.11),
|
||
0 2px 8px 0 rgba(44, 70, 110, 0.08);
|
||
margin: 1.5rem;
|
||
padding: 2rem 2.5rem;
|
||
text-align: center;
|
||
border: 1px solid var(--glass-border);
|
||
backdrop-filter: blur(15px);
|
||
}
|
||
|
||
.spinner {
|
||
border: 4px solid rgba(255, 255, 255, 0.2);
|
||
border-top: 4px solid #007aff;
|
||
border-radius: 50%;
|
||
width: 36px;
|
||
height: 36px;
|
||
animation: spin 1s linear infinite;
|
||
margin: 0 auto 1rem;
|
||
}
|
||
|
||
.footer {
|
||
font-size: 0.8rem;
|
||
text-align: center;
|
||
margin-top: 1rem;
|
||
opacity: 0.8;
|
||
}
|
||
|
||
a {
|
||
color: white;
|
||
}
|
||
|
||
.small {
|
||
display: block;
|
||
font-size: 0.8rem;
|
||
margin-top: 0.25rem;
|
||
}
|
||
|
||
select option {
|
||
color: black;
|
||
}
|
||
|
||
.geo-disabled {
|
||
opacity: 0.5;
|
||
background: none !important;
|
||
color: white !important;
|
||
border: 0.1rem white solid;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.geo-disabled:hover {
|
||
transform: none;
|
||
box-shadow: none;
|
||
background-color: none;
|
||
background: none;
|
||
}
|
||
|
||
.button-row {
|
||
display: flex;
|
||
margin-top: 0.5rem;
|
||
gap: 1rem;
|
||
justify-content: space-evenly;
|
||
flex-direction: column;
|
||
}
|
||
|
||
@keyframes spin {
|
||
0% {
|
||
transform: rotate(0deg);
|
||
}
|
||
100% {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
#openWeatherCountry {
|
||
margin-top: 0.75rem;
|
||
}
|
||
|
||
@media (min-width: 361px) {
|
||
.form-row.two-col {
|
||
flex-direction: row;
|
||
gap: 1rem;
|
||
}
|
||
|
||
.button-row {
|
||
flex-direction: row;
|
||
gap: 0;
|
||
}
|
||
|
||
#openWeatherCountry {
|
||
margin-top: 0;
|
||
}
|
||
}
|
||
|
||
/* Make sure .details-content is visible only when open */
|
||
.animated-details[open] .details-content {
|
||
/* JS will animate the height */
|
||
}
|
||
|
||
/* Toggle Switch Styling for Flip Display */
|
||
.toggle-switch {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
cursor: pointer;
|
||
position: relative;
|
||
}
|
||
|
||
/* Hide default checkbox */
|
||
.toggle-switch input {
|
||
opacity: 0;
|
||
width: 0;
|
||
height: 0;
|
||
position: absolute;
|
||
}
|
||
|
||
/* Track */
|
||
.toggle-slider {
|
||
position: relative;
|
||
width: 48px;
|
||
height: 24px;
|
||
border-radius: 999px;
|
||
transition: all 0.18s cubic-bezier(0.4, 0, 0.2, 1);
|
||
|
||
/* OFF state (dark UI optimized) */
|
||
background: rgba(255, 255, 255, 0.2);
|
||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12);
|
||
}
|
||
|
||
/* Knob */
|
||
.toggle-slider::before {
|
||
content: "";
|
||
position: absolute;
|
||
height: 18px;
|
||
width: 18px;
|
||
left: 3px;
|
||
top: 3px;
|
||
border-radius: 50%;
|
||
background: #ffffff;
|
||
transition: all 0.18s cubic-bezier(0.4, 0, 0.2, 1);
|
||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
|
||
}
|
||
|
||
/* ON state */
|
||
.toggle-switch input:checked + .toggle-slider {
|
||
background: var(--accent);
|
||
box-shadow: 0 0 10px rgba(0, 191, 255, 0.35);
|
||
}
|
||
|
||
/* Slide knob */
|
||
.toggle-switch input:checked + .toggle-slider::before {
|
||
transform: translateX(24px);
|
||
}
|
||
|
||
/* Disabled */
|
||
.toggle-switch input:disabled + .toggle-slider {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
border: none;
|
||
background: transparent;
|
||
}
|
||
|
||
.accent {
|
||
accent-color: var(--accent-color);
|
||
}
|
||
|
||
.collapsible-toggle {
|
||
display: flex;
|
||
align-items: center;
|
||
cursor: pointer;
|
||
font-size: 1.1rem;
|
||
font-weight: normal;
|
||
background: none;
|
||
border: none;
|
||
color: white;
|
||
padding: 0;
|
||
margin: 0;
|
||
outline: none;
|
||
gap: 0.5em;
|
||
user-select: none;
|
||
margin-top: 6rem;
|
||
text-decoration: underline;
|
||
text-decoration-thickness: 1px;
|
||
text-underline-offset: 2px;
|
||
}
|
||
|
||
.collapsible-toggle .icon-area {
|
||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||
display: flex;
|
||
}
|
||
|
||
.collapsible-toggle.open .icon-area {
|
||
transform: rotate(90deg);
|
||
}
|
||
|
||
.collapsible-content {
|
||
overflow: hidden;
|
||
height: 0;
|
||
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||
color: #fff;
|
||
margin-bottom: 3rem;
|
||
}
|
||
|
||
.collapsible-content-inner {
|
||
padding: 1em 0;
|
||
}
|
||
|
||
input[type="range"] {
|
||
-webkit-appearance: none;
|
||
width: 100%;
|
||
height: 6px;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
}
|
||
|
||
/* Track (Chrome, Safari, Edge) */
|
||
input[type="range"]::-webkit-slider-runnable-track {
|
||
height: 6px;
|
||
background: rgba(255, 255, 255, 0.2);
|
||
border-radius: 6px;
|
||
}
|
||
|
||
/* Thumb (Chrome, Safari, Edge) */
|
||
input[type="range"]::-webkit-slider-thumb {
|
||
-webkit-appearance: none;
|
||
width: 18px;
|
||
height: 18px;
|
||
border-radius: 50%;
|
||
background: var(--accent-color);
|
||
border: none;
|
||
margin-top: -6px; /* center thumb */
|
||
box-shadow: 0 0 10px var(--accent-color);
|
||
transition: transform 0.15s ease;
|
||
}
|
||
|
||
input[type="range"]:hover::-webkit-slider-thumb {
|
||
transform: scale(1.1);
|
||
}
|
||
|
||
/* Track (Firefox) */
|
||
input[type="range"]::-moz-range-track {
|
||
height: 6px;
|
||
background: rgba(255, 255, 255, 0.2);
|
||
border-radius: 6px;
|
||
}
|
||
|
||
/* Thumb (Firefox) */
|
||
input[type="range"]::-moz-range-thumb {
|
||
width: 18px;
|
||
height: 18px;
|
||
border-radius: 50%;
|
||
background: var(--accent-color);
|
||
border: none;
|
||
box-shadow: 0 0 10px var(--accent-color);
|
||
}
|
||
|
||
/* Disabled base */
|
||
input[type="range"]:disabled {
|
||
cursor: not-allowed;
|
||
opacity: 0.6;
|
||
}
|
||
|
||
/* Disabled track */
|
||
input[type="range"]:disabled::-webkit-slider-runnable-track {
|
||
background: rgba(255, 255, 255, 0.08);
|
||
}
|
||
|
||
input[type="range"]:disabled::-moz-range-track {
|
||
background: rgba(255, 255, 255, 0.08);
|
||
}
|
||
|
||
/* Disabled thumb */
|
||
input[type="range"]:disabled::-webkit-slider-thumb {
|
||
background: #777;
|
||
box-shadow: none;
|
||
transform: none;
|
||
}
|
||
|
||
input[type="range"]:disabled::-moz-range-thumb {
|
||
background: #777;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.sub-collapsible {
|
||
color: inherit;
|
||
width: fit-content;
|
||
background: none;
|
||
border: none;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 1.5rem 1.5rem 1rem 2rem;
|
||
font-weight: 600;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
opacity: 0.65;
|
||
cursor: pointer;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.sub-collapsible.open {
|
||
opacity: 1;
|
||
}
|
||
|
||
.sub-collapsible-content {
|
||
overflow: hidden;
|
||
height: 0;
|
||
transition: height 0.3s ease;
|
||
}
|
||
|
||
.sub-collapsible-content .content-wrapper {
|
||
border-radius: 12px;
|
||
padding: 1rem 2rem 2rem 2rem;
|
||
background: #ffffff08;
|
||
margin-bottom: 2rem;
|
||
}
|
||
|
||
.sub-collapsible[aria-expanded="true"] + .sub-collapsible-content {
|
||
display: block;
|
||
}
|
||
|
||
.sub-collapsible::after {
|
||
padding-bottom: 0.2rem;
|
||
padding-left: 0.4rem;
|
||
line-height: 0;
|
||
font-size: 1.1rem;
|
||
content: "›";
|
||
transition: transform 0.25s ease;
|
||
}
|
||
|
||
.sub-collapsible.open::after {
|
||
transform: translateY(-0.1rem) rotate(90deg);
|
||
opacity: 1;
|
||
}
|
||
.content-wrapper > label:nth-child(1) {
|
||
margin-top: 0;
|
||
}
|
||
|
||
.content-wrapper > .toggle-padding > label:nth-child(1),
|
||
.content-wrapper > .form-group:nth-child(1) > label:nth-child(1) {
|
||
margin-top: 0.75rem;
|
||
}
|
||
|
||
#autoDimmingNote {
|
||
opacity: 0.5;
|
||
text-align: unset;
|
||
position: absolute;
|
||
padding-right: 3rem;
|
||
top: 0.9rem;
|
||
}
|
||
|
||
.content-wrapper .small {
|
||
text-align: center;
|
||
opacity: 0.75;
|
||
}
|
||
|
||
/* Shared flex toggle row */
|
||
.toggle-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
/* Same toggle rows that also had 1.75rem top margin */
|
||
.toggle-row-lg {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-top: 1.75rem;
|
||
}
|
||
|
||
/* Toggle rows with 0.75rem margin */
|
||
.toggle-row-sm {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-top: 0.75rem;
|
||
}
|
||
|
||
/* Span label spacing */
|
||
.label-text {
|
||
margin-right: 0.5em;
|
||
}
|
||
|
||
/* Slider full width */
|
||
.range-full {
|
||
width: 100%;
|
||
}
|
||
|
||
/* Top margin 1.75rem (used for labels) */
|
||
.mt-lg {
|
||
margin-top: 1.75rem;
|
||
}
|
||
|
||
#configForm > div.collapsible-content > div:nth-child(2) > div > div {
|
||
margin-top: 2rem;
|
||
}
|
||
|
||
#configForm
|
||
> div.collapsible-content
|
||
> div:nth-child(8)
|
||
> div
|
||
> div.toggle-padding
|
||
> div:nth-child(1)
|
||
> label {
|
||
margin-top: 0.75rem;
|
||
}
|
||
|
||
#configForm
|
||
> div.collapsible-content
|
||
> div:nth-child(6)
|
||
> div
|
||
> div.toggle-padding
|
||
> label:nth-child(4) {
|
||
position: relative;
|
||
}
|
||
|
||
#geo-button,
|
||
.primary-button.cmsg1,
|
||
.primary-button.cmsg2 {
|
||
background-color: white;
|
||
color: #1f1f1f;
|
||
font-weight: normal;
|
||
font-size: 0.75rem;
|
||
}
|
||
|
||
#geo-button:hover,
|
||
.primary-button.cmsg1:hover {
|
||
background-color: var(--accent-color);
|
||
color: white;
|
||
}
|
||
|
||
.primary-button.cmsg2:hover {
|
||
background-color: #ff3c3c;
|
||
color: white;
|
||
}
|
||
|
||
.device-info{
|
||
margin-top: 0.75rem;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<form id="configForm" onsubmit="submitConfig(event)">
|
||
<div class="logo">
|
||
<svg
|
||
width="450"
|
||
height="50"
|
||
viewBox="0 0 119.063 13.229"
|
||
xml:space="preserve"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
>
|
||
<path
|
||
style="
|
||
fill: currentColor;
|
||
font-variation-settings:
|
||
"wdth" 87,
|
||
"wght" 700;
|
||
paint-order: markers fill stroke;
|
||
"
|
||
d="M.75 0a.75.75 0 1 0-.002 1.495A.75.75 0 0 0 .75 0m1.957 0a.75.75 0 1 0-.001 1.499.75.75 0 0 0 0-1.499m1.956 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m1.956 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m1.957 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m6.082 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m1.957 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m1.956 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m6.083 0a.75.75 0 1 0-.002 1.495A.75.75 0 0 0 24.654 0m1.957 0a.75.75 0 1 0-.001 1.499.75.75 0 0 0 0-1.499m1.956 0a.75.75 0 1 0 0 1.499.75.75 0 0 0 0-1.499m1.956 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m6.083 0a.749.749 0 1 0-.001 1.498.749.749 0 0 0 0-1.498m1.956 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m1.957 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m1.956 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m1.957 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m4.126 0a.75.75 0 1 0-.002 1.495A.75.75 0 0 0 48.558 0m29.987 0a.75.75 0 1 0-.002 1.495A.75.75 0 0 0 78.545 0M80.5 0a.75.75 0 1 0 0 1.499.75.75 0 0 0 0-1.499m1.956 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498M114.4 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498M.75 1.955a.75.75 0 1 0-.002 1.5.75.75 0 0 0 .002-1.5m11.952 0a.749.749 0 1 0-.002 1.498.749.749 0 0 0 .002-1.498m7.826 0a.749.749 0 1 0-.001 1.498.749.749 0 0 0 .001-1.498m4.126 0a.75.75 0 1 0-.002 1.5.75.75 0 0 0 .002-1.5m7.826 0a.749.749 0 1 0-.001 1.498.749.749 0 0 0 .001-1.498m8.039 0a.749.749 0 1 0-.002 1.498.749.749 0 0 0 .002-1.498m36.069 0a.749.749 0 1 0-.002 1.498.749.749 0 0 0 .002-1.498m7.826 0a.749.749 0 1 0-.002 1.498.749.749 0 0 0 .002-1.498m29.986 0a.749.749 0 1 0-.002 1.498.749.749 0 0 0 .002-1.498M.75 3.91a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5m11.952 0a.749.749 0 1 0-.001 1.498.749.749 0 0 0 0-1.498m11.952 0a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5m7.826 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m8.039 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m8.04 0a.75.75 0 1 0-.002 1.5.75.75 0 0 0 .001-1.5m4.125 0a.75.75 0 1 0-.001 1.5.75.75 0 0 0 0-1.5m1.957 0a.75.75 0 1 0-.001 1.5.75.75 0 0 0 0-1.5m1.956 0a.749.749 0 1 0-.001 1.498.749.749 0 0 0 0-1.498m1.956 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m8.039 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m1.957 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m1.956 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m6.083 0a.75.75 0 1 0-.001 1.5.75.75 0 0 0 0-1.5m13.908 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m1.957 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m1.956 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m1.957 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m6.083 0a.75.75 0 1 0-.001 1.5.75.75 0 0 0 0-1.5m1.956 0a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5m1.957 0a.75.75 0 1 0-.001 1.5.75.75 0 0 0 .001-1.5m1.956 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m4.125 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m1.957 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m1.957 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m1.956 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498M.75 5.866a.75.75 0 1 0-.002 1.499.75.75 0 0 0 .002-1.5m1.957 0a.749.749 0 1 0-.002 1.498.749.749 0 0 0 .002-1.498m1.956 0a.749.749 0 1 0-.002 1.497.749.749 0 0 0 .002-1.497m1.956 0a.749.749 0 1 0 0 1.497.75.75 0 1 0 0-1.498m8.039 0a.749.749 0 1 0-.002 1.497.749.749 0 0 0 .002-1.497m1.957 0a.749.749 0 1 0-.002 1.497.749.749 0 0 0 .002-1.497m1.956 0a.749.749 0 1 0-.001 1.497.749.749 0 0 0 .001-1.497m6.083 0a.75.75 0 1 0-.002 1.499.75.75 0 0 0 .002-1.5m1.957 0a.749.749 0 1 0-.002 1.498.749.749 0 0 0 .002-1.498m1.956 0a.749.749 0 1 0-.001 1.498.749.749 0 0 0 .001-1.498m1.956 0a.749.749 0 1 0 0 1.497.75.75 0 1 0 0-1.498m9.996 0a.749.749 0 1 0-.002 1.497.749.749 0 0 0 .002-1.497m8.04 0a.75.75 0 1 0-.003 1.499.75.75 0 0 0 .002-1.5m4.125 0a.749.749 0 1 0-.002 1.498.749.749 0 0 0 .002-1.498m3.913 0a.749.749 0 1 0-.002 1.497.749.749 0 0 0 .002-1.497m3.913 0a.749.749 0 1 0-.002 1.497.749.749 0 0 0 .002-1.497m4.125 0a.749.749 0 1 0-.001 1.497.749.749 0 0 0 .001-1.497m7.827 0a.749.749 0 1 0-.001 1.497.749.749 0 0 0 .001-1.497m4.126 0a.749.749 0 1 0-.002 1.498.749.749 0 0 0 .002-1.498m11.951 0a.749.749 0 1 0-.001 1.497.749.749 0 0 0 .001-1.497m7.827 0a.749.749 0 1 0-.001 1.497.749.749 0 0 0 .001-1.497m4.126 0a.749.749 0 1 0-.002 1.498.749.749 0 0 0 .002-1.498m13.908 0a.749.749 0 1 0-.002 1.497.749.749 0 0 0 .002-1.497M.75 7.82a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5m19.778 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m4.126 0a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5m15.865 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m8.04 0a.75.75 0 1 0-.002 1.5.75.75 0 0 0 .001-1.5m4.125 0a.75.75 0 1 0-.001 1.499.75.75 0 0 0 0-1.5m3.913 0a.749.749 0 1 0-.001 1.498.749.749 0 0 0 0-1.498m3.913 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m4.125 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m1.957 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m1.957 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m1.956 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m1.957 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m4.126 0a.75.75 0 1 0-.001 1.499.75.75 0 0 0 0-1.5m11.951 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m7.827 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m6.083 0a.75.75 0 1 0-.001 1.5.75.75 0 0 0 0-1.5m1.956 0a.75.75 0 1 0 0 1.499.75.75 0 0 0 0-1.5m1.957 0a.75.75 0 1 0-.001 1.5.75.75 0 0 0 .001-1.5m8.038 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498M.75 9.776a.75.75 0 1 0-.002 1.499.75.75 0 0 0 .002-1.499m11.952 0a.749.749 0 1 0-.002 1.497.749.749 0 0 0 .002-1.497m7.826 0a.749.749 0 1 0-.001 1.497.749.749 0 0 0 .001-1.497m4.126 0a.75.75 0 1 0-.002 1.499.75.75 0 0 0 .002-1.499m15.865 0a.749.749 0 1 0-.002 1.497.749.749 0 0 0 .002-1.497m8.04 0a.75.75 0 1 0-.003 1.499.75.75 0 0 0 .002-1.499m4.125 0a.749.749 0 1 0-.002 1.498.749.749 0 0 0 .002-1.498m3.913 0a.749.749 0 1 0-.002 1.497.749.749 0 0 0 .002-1.497m3.913 0a.749.749 0 1 0-.002 1.497.749.749 0 0 0 .002-1.497m4.125 0a.749.749 0 1 0-.001 1.497.749.749 0 0 0 .001-1.497m11.953 0a.749.749 0 1 0-.002 1.498.749.749 0 0 0 .002-1.498m7.826 0a.749.749 0 1 0-.002 1.497.749.749 0 0 0 .002-1.497m4.125 0a.749.749 0 1 0-.001 1.497.749.749 0 0 0 .001-1.497m5.87 0a.749.749 0 1 0-.001 1.497.749.749 0 0 0 .001-1.497m1.957 0a.749.749 0 1 0-.001 1.497.749.749 0 0 0 .001-1.497m11.952 0a.749.749 0 1 0-.002 1.497.749.749 0 0 0 .002-1.497m6.082 0a.749.749 0 1 0-.002 1.497.749.749 0 0 0 .002-1.497M.75 11.731a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5m1.957 0a.75.75 0 1 0-.001 1.499.75.75 0 0 0 0-1.499m1.956 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m1.956 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m1.957 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m6.082 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m1.957 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m1.956 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m6.083 0a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5m15.865 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m8.04 0a.75.75 0 1 0-.002 1.5.75.75 0 0 0 .001-1.5m4.125 0a.75.75 0 1 0-.001 1.499.75.75 0 0 0 0-1.499m3.913 0a.749.749 0 1 0-.001 1.498.749.749 0 0 0 0-1.498m3.913 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m6.082 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m1.957 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m1.956 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m8.04 0a.75.75 0 1 0-.001 1.5.75.75 0 0 0 0-1.5m1.956 0a.75.75 0 1 0 0 1.499.75.75 0 0 0 0-1.499m1.956 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m8.039 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m1.957 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m3.913 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m4.126 0a.75.75 0 1 0-.001 1.499.75.75 0 0 0 0-1.499m1.957 0a.75.75 0 1 0-.001 1.5.75.75 0 0 0 0-1.5m1.956 0a.75.75 0 1 0 0 1.499.75.75 0 0 0 0-1.499m1.957 0a.75.75 0 1 0-.001 1.5.75.75 0 0 0 .001-1.5m9.995 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498m1.956 0a.749.749 0 1 0 0 1.498.749.749 0 0 0 0-1.498"
|
||
/>
|
||
</svg>
|
||
</div>
|
||
<h2>WiFi Settings</h2>
|
||
<label for="ssid">SSID</label>
|
||
<div class="ssid-wrapper">
|
||
<div class="combo-container">
|
||
<input type="text" id="ssid" name="ssid" required />
|
||
<button type="button" id="arrowBtn" title="Show scanned" disabled>
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
width="16"
|
||
height="16"
|
||
fill="none"
|
||
viewBox="0 0 0.48 0.48"
|
||
>
|
||
<g style="opacity: 1">
|
||
<path
|
||
d="M-24.679 12.619h.48v.48h-.48z"
|
||
style="
|
||
font-variation-settings:
|
||
"wdth" 87,
|
||
"wght" 700;
|
||
fill: none;
|
||
stroke-width: 0.0127174;
|
||
paint-order: markers fill stroke;
|
||
"
|
||
transform="translate(24.679 -12.62)"
|
||
/>
|
||
<path
|
||
d="M-24.661 12.732a.06.06 0 0 0 0 .085l.162.162a.086.086 0 0 0 .12 0l.163-.162a.06.06 0 0 0 0-.085.06.06 0 0 0-.085 0l-.138.138-.137-.138a.06.06 0 0 0-.085 0z"
|
||
style="
|
||
baseline-shift: baseline;
|
||
display: inline;
|
||
overflow: visible;
|
||
vector-effect: none;
|
||
fill: #fff;
|
||
stroke-width: 0.0600023;
|
||
stroke-linecap: round;
|
||
stroke-linejoin: round;
|
||
enable-background: accumulate;
|
||
stop-color: #000;
|
||
stop-opacity: 1;
|
||
"
|
||
transform="translate(24.679 -12.62)"
|
||
/>
|
||
</g>
|
||
</svg>
|
||
</button>
|
||
<button type="button" class="primary-button" id="scanBtn">
|
||
Scan
|
||
</button>
|
||
</div>
|
||
<div id="ssidList"></div>
|
||
</div>
|
||
<label for="password">Password</label>
|
||
<div style="position: relative">
|
||
<input type="password" id="password" name="password" required />
|
||
<label class="small">
|
||
<input
|
||
type="checkbox"
|
||
id="togglePassword"
|
||
style="margin-right: 0.3rem"
|
||
/>
|
||
Show Password
|
||
</label>
|
||
</div>
|
||
|
||
<h2>Time & Region</h2>
|
||
<label for="timeZone">Time Zone</label>
|
||
<select id="timeZone" name="timeZone" required>
|
||
<option value="" disabled selected>Select your time zone</option>
|
||
<option value="Africa/Cairo">Africa/Cairo</option>
|
||
<option value="Africa/Casablanca">Africa/Casablanca</option>
|
||
<option value="Africa/Johannesburg">Africa/Johannesburg</option>
|
||
<option value="America/Anchorage">America/Anchorage</option>
|
||
<option value="America/Argentina/Buenos_Aires">
|
||
America/Argentina/Buenos_Aires
|
||
</option>
|
||
<option value="America/Chicago">America/Chicago</option>
|
||
<option value="America/Denver">America/Denver</option>
|
||
<option value="America/Guatemala">America/Guatemala</option>
|
||
<option value="America/Halifax">America/Halifax</option>
|
||
<option value="America/Los_Angeles">America/Los_Angeles</option>
|
||
<option value="America/Mexico_City">America/Mexico_City</option>
|
||
<option value="America/New_York">America/New_York</option>
|
||
<option value="America/Phoenix">America/Phoenix</option>
|
||
<option value="America/Santiago">America/Santiago</option>
|
||
<option value="America/Sao_Paulo">America/Sao_Paulo</option>
|
||
<option value="America/St_Johns">America/St_Johns</option>
|
||
<option value="America/Toronto">America/Toronto</option>
|
||
<option value="America/Vancouver">America/Vancouver</option>
|
||
<option value="Asia/Almaty">Asia/Almaty</option>
|
||
<option value="Asia/Amman">Asia/Amman</option>
|
||
<option value="Asia/Baghdad">Asia/Baghdad</option>
|
||
<option value="Asia/Bangkok">Asia/Bangkok</option>
|
||
<option value="Asia/Beirut">Asia/Beirut</option>
|
||
<option value="Asia/Dhaka">Asia/Dhaka</option>
|
||
<option value="Asia/Dubai">Asia/Dubai</option>
|
||
<option value="Asia/Ho_Chi_Minh">Asia/Ho_Chi_Minh</option>
|
||
<option value="Asia/Hong_Kong">Asia/Hong_Kong</option>
|
||
<option value="Asia/Jakarta">Asia/Jakarta</option>
|
||
<option value="Asia/Jerusalem">Asia/Jerusalem</option>
|
||
<option value="Asia/Karachi">Asia/Karachi</option>
|
||
<option value="Asia/Kathmandu">Asia/Kathmandu</option>
|
||
<option value="Asia/Kolkata">Asia/Kolkata</option>
|
||
<option value="Asia/Kuala_Lumpur">Asia/Kuala_Lumpur</option>
|
||
<option value="Asia/Manila">Asia/Manila</option>
|
||
<option value="Asia/Seoul">Asia/Seoul</option>
|
||
<option value="Asia/Shanghai">Asia/Shanghai</option>
|
||
<option value="Asia/Singapore">Asia/Singapore</option>
|
||
<option value="Asia/Taipei">Asia/Taipei</option>
|
||
<option value="Asia/Tashkent">Asia/Tashkent</option>
|
||
<option value="Asia/Tokyo">Asia/Tokyo</option>
|
||
<option value="Asia/Ulaanbaatar">Asia/Ulaanbaatar</option>
|
||
<option value="Asia/Yekaterinburg">Asia/Yekaterinburg</option>
|
||
<option value="Atlantic/Azores">Atlantic/Azores</option>
|
||
<option value="Atlantic/Azores">Atlantic/Canary</option>
|
||
<option value="Atlantic/Reykjavik">Atlantic/Reykjavik</option>
|
||
<option value="Australia/Adelaide">Australia/Adelaide</option>
|
||
<option value="Australia/Brisbane">Australia/Brisbane</option>
|
||
<option value="Australia/Darwin">Australia/Darwin</option>
|
||
<option value="Australia/Hobart">Australia/Hobart</option>
|
||
<option value="Australia/Melbourne">Australia/Melbourne</option>
|
||
<option value="Australia/Perth">Australia/Perth</option>
|
||
<option value="Australia/Sydney">Australia/Sydney</option>
|
||
<option value="Europe/Amsterdam">Europe/Amsterdam</option>
|
||
<option value="Europe/Athens">Europe/Athens</option>
|
||
<option value="Europe/Belgrade">Europe/Belgrade</option>
|
||
<option value="Europe/Berlin">Europe/Berlin</option>
|
||
<option value="Europe/Brussels">Europe/Brussels</option>
|
||
<option value="Europe/Bucharest">Europe/Bucharest</option>
|
||
<option value="Europe/Copenhagen">Europe/Copenhagen</option>
|
||
<option value="Europe/Dublin">Europe/Dublin</option>
|
||
<option value="Europe/Helsinki">Europe/Helsinki</option>
|
||
<option value="Europe/Istanbul">Europe/Istanbul</option>
|
||
<option value="Europe/Kiev">Europe/Kiev</option>
|
||
<option value="Europe/Lisbon">Europe/Lisbon</option>
|
||
<option value="Europe/London">Europe/London</option>
|
||
<option value="Europe/Madrid">Europe/Madrid</option>
|
||
<option value="Europe/Moscow">Europe/Moscow</option>
|
||
<option value="Europe/Oslo">Europe/Oslo</option>
|
||
<option value="Europe/Paris">Europe/Paris</option>
|
||
<option value="Europe/Prague">Europe/Prague</option>
|
||
<option value="Europe/Rome">Europe/Rome</option>
|
||
<option value="Europe/Stockholm">Europe/Stockholm</option>
|
||
<option value="Europe/Warsaw">Europe/Warsaw</option>
|
||
<option value="Pacific/Auckland">Pacific/Auckland</option>
|
||
<option value="Pacific/Chatham">Pacific/Chatham</option>
|
||
<option value="Pacific/Fiji">Pacific/Fiji</option>
|
||
<option value="Pacific/Guam">Pacific/Guam</option>
|
||
<option value="Pacific/Honolulu">Pacific/Honolulu</option>
|
||
<option value="Pacific/Port_Moresby">Pacific/Port_Moresby</option>
|
||
<option value="Pacific/Tahiti">Pacific/Tahiti</option>
|
||
<option value="UTC">UTC</option>
|
||
<option value="Etc/GMT+1">Etc/GMT+1</option>
|
||
<option value="Etc/GMT-1">Etc/GMT-1</option>
|
||
</select>
|
||
|
||
<label for="language">Language (Day & Weather)</label>
|
||
<select id="language" name="language" onchange="setLanguage(this.value)">
|
||
<option value="" disabled selected>Select language</option>
|
||
<option value="af">Afrikaans</option>
|
||
<option value="hr">Croatian</option>
|
||
<option value="cs">Czech</option>
|
||
<option value="da">Danish</option>
|
||
<option value="nl">Dutch</option>
|
||
<option value="en">English</option>
|
||
<option value="eo">Esperanto</option>
|
||
<option value="et">Estonian</option>
|
||
<option value="fi">Finnish</option>
|
||
<option value="fr">French</option>
|
||
<option value="de">German</option>
|
||
<option value="hu">Hungarian</option>
|
||
<option value="it">Italian</option>
|
||
<option value="ga">Irish</option>
|
||
<option value="ja">Japanese</option>
|
||
<option value="lv">Latvian</option>
|
||
<option value="lt">Lithuanian</option>
|
||
<option value="no">Norwegian</option>
|
||
<option value="pl">Polish</option>
|
||
<option value="pt">Portuguese</option>
|
||
<option value="ro">Romanian</option>
|
||
<option value="ru">Russian</option>
|
||
<option value="sr">Serbian</option>
|
||
<option value="sk">Slovak</option>
|
||
<option value="sl">Slovenian</option>
|
||
<option value="es">Spanish</option>
|
||
<option value="sv">Swedish</option>
|
||
<option value="sw">Swahili</option>
|
||
<option value="tr">Turkish</option>
|
||
</select>
|
||
|
||
<div class="form-row two-col">
|
||
<div>
|
||
<label for="clockDuration">Clock Duration</label>
|
||
<input
|
||
type="number"
|
||
id="clockDuration"
|
||
name="clockDuration"
|
||
min="1"
|
||
required
|
||
/>
|
||
<label class="small">(Seconds)</label>
|
||
</div>
|
||
<div>
|
||
<label for="weatherDuration">Weather Duration</label>
|
||
<input
|
||
type="number"
|
||
id="weatherDuration"
|
||
name="weatherDuration"
|
||
min="1"
|
||
required
|
||
/>
|
||
<label class="small">(Seconds)</label>
|
||
</div>
|
||
</div>
|
||
|
||
<h2>Weather Settings</h2>
|
||
<label for="openWeatherApiKey">OpenWeather API Key</label>
|
||
<input
|
||
type="text"
|
||
id="openWeatherApiKey"
|
||
name="openWeatherApiKey"
|
||
placeholder="ADD-YOUR-API-KEY-32-CHARACTERS"
|
||
/>
|
||
<div class="small">
|
||
Required to fetch weather data.
|
||
<a href="https://home.openweathermap.org/users/sign_up" target="_blank"
|
||
>Get your API key here</a
|
||
>.
|
||
</div>
|
||
|
||
<label>Location</label>
|
||
<div class="form-row two-col">
|
||
<input
|
||
type="text"
|
||
id="openWeatherCity"
|
||
name="openWeatherCity"
|
||
placeholder="City / Zip / Lat."
|
||
/>
|
||
<input
|
||
type="text"
|
||
id="openWeatherCountry"
|
||
name="openWeatherCountry"
|
||
placeholder="Country Code / Long."
|
||
/>
|
||
</div>
|
||
|
||
<div class="small">
|
||
<strong>Location format examples:</strong> City, Country Code - Osaka,
|
||
JP | ZIP, Country Code - 94040, US | Latitude, Longitude - 34.6937,
|
||
135.5023
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
class="primary-button"
|
||
id="geo-button"
|
||
onclick="getLocation()"
|
||
style="margin-top: 1rem"
|
||
>
|
||
Get My Location
|
||
</button>
|
||
|
||
<div class="form-group">
|
||
<h2>Custom Message</h2>
|
||
<input
|
||
id="customMessage"
|
||
name="customMessage"
|
||
type="text"
|
||
maxlength="120"
|
||
placeholder="ENTER MESSAGE"
|
||
pattern="[A-Z0-9 :!'\-.,_\+%\/?]*"
|
||
title="Only uppercase letters, numbers, space, and : ! ' - . ? , _ + % / ? allowed"
|
||
/>
|
||
<div class="small">
|
||
Allowed characters: A–Z, 0–9, space, and : ! ' - . ? , _ + % /
|
||
</div>
|
||
</div>
|
||
<div class="button-row">
|
||
<button
|
||
type="button"
|
||
class="primary-button cmsg2"
|
||
onclick="clearCustomMessage()"
|
||
>
|
||
Clear Message
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="primary-button cmsg1"
|
||
onclick="sendCustomMessage()"
|
||
>
|
||
Send Message
|
||
</button>
|
||
</div>
|
||
|
||
<div class="geo-note" style="display: none">
|
||
<br />
|
||
<span class="small"
|
||
><strong>Note:</strong> External links, Custom Message and the "Get My
|
||
Location" button require internet access.</span
|
||
>
|
||
<span class="small"
|
||
>They won't work while the device is in AP Mode - connect to Wi-Fi
|
||
first.</span
|
||
>
|
||
</div>
|
||
|
||
<button type="button" class="collapsible-toggle" aria-expanded="false">
|
||
<span class="icon-area" aria-hidden="true">
|
||
<svg
|
||
width="20"
|
||
height="20"
|
||
viewBox="0 0 16 16"
|
||
version="1.1"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||
>
|
||
<path
|
||
fill="currentColor"
|
||
d="M16,9V7l-2.259-0.753c-0.113-0.372-0.262-0.728-0.441-1.066l1.066-2.131L12.95,1.634L10.819,2.7 c-0.338-0.178-0.694-0.325-1.066-0.441L9,0H7L6.247,2.259C5.875,2.372,5.519,2.522,5.181,2.7L3.05,1.638L1.638,3.05l1.066,2.131 C2.522,5.519,2.375,5.875,2.259,6.247L0,7v2l2.259,0.753c0.112,0.372,0.263,0.728,0.441,1.066L1.634,12.95l1.416,1.416L5.181,13.3 c0.338,0.178,0.694,0.328,1.066,0.441L7,16h2l0.753-2.259c0.372-0.113,0.728-0.262,1.066-0.441l2.131,1.066l1.416-1.416L13.3,10.819 c0.178-0.337,0.328-0.694,0.344-1.066L16,9z M8,11c-1.656,0-3-1.344-3-3s1.344-3,3-3s3,1.344,3,3S9.656,11,8,11z"
|
||
/>
|
||
</svg>
|
||
</span>
|
||
<span>Advanced Settings</span>
|
||
</button>
|
||
<div class="collapsible-content" aria-hidden="true">
|
||
<button type="button" class="sub-collapsible" aria-expanded="false">
|
||
Time & Date
|
||
</button>
|
||
<div class="sub-collapsible-content" aria-hidden="true">
|
||
<div class="content-wrapper">
|
||
<label>Primary NTP Server:</label>
|
||
<input
|
||
type="text"
|
||
name="ntpServer1"
|
||
id="ntpServer1"
|
||
placeholder="Enter NTP address"
|
||
/>
|
||
|
||
<label>Secondary NTP Server:</label>
|
||
<input
|
||
type="text"
|
||
name="ntpServer2"
|
||
id="ntpServer2"
|
||
placeholder="Enter NTP address"
|
||
/>
|
||
|
||
<div class="toggles toggle-padding">
|
||
<label class="toggle-row-lg">
|
||
<span class="label-text">Show Day Of The Week:</span>
|
||
<span class="toggle-switch">
|
||
<input
|
||
type="checkbox"
|
||
id="showDayOfWeek"
|
||
name="showDayOfWeek"
|
||
onchange="setShowDayOfWeek(this.checked)"
|
||
/>
|
||
<span class="toggle-slider"></span>
|
||
</span>
|
||
</label>
|
||
|
||
<label class="toggle-row-lg">
|
||
<span class="label-text">Animated Seconds:</span>
|
||
<span class="toggle-switch">
|
||
<input
|
||
type="checkbox"
|
||
id="colonBlinkEnabled"
|
||
name="colonBlinkEnabled"
|
||
onchange="setColonBlink(this.checked)"
|
||
/>
|
||
<span class="toggle-slider"></span>
|
||
</span>
|
||
</label>
|
||
|
||
<label class="toggle-row-lg">
|
||
<span class="label-text">Show Date:</span>
|
||
<span class="toggle-switch">
|
||
<input
|
||
type="checkbox"
|
||
id="showDate"
|
||
name="showDate"
|
||
onchange="setShowDate(this.checked)"
|
||
/>
|
||
<span class="toggle-slider"></span>
|
||
</span>
|
||
</label>
|
||
|
||
<label class="toggle-row-lg">
|
||
<span class="label-text">Display 12-hour Clock:</span>
|
||
<span class="toggle-switch">
|
||
<input
|
||
type="checkbox"
|
||
id="twelveHourToggle"
|
||
name="twelveHourToggle"
|
||
onchange="setTwelveHour(this.checked)"
|
||
/>
|
||
<span class="toggle-slider"></span>
|
||
</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<button type="button" class="sub-collapsible" aria-expanded="false">
|
||
Weather
|
||
</button>
|
||
<div class="sub-collapsible-content" aria-hidden="true">
|
||
<div class="content-wrapper">
|
||
<div class="toggle-padding">
|
||
<label class="toggle-row-lg">
|
||
<span class="label-text">Use Imperial Units (°F):</span>
|
||
<span class="toggle-switch">
|
||
<input
|
||
type="checkbox"
|
||
id="weatherUnits"
|
||
name="weatherUnits"
|
||
onchange="setWeatherUnits(this.checked)"
|
||
/>
|
||
<span class="toggle-slider"></span>
|
||
</span>
|
||
</label>
|
||
|
||
<label class="toggle-row-lg">
|
||
<span class="label-text">Show Humidity:</span>
|
||
<span class="toggle-switch">
|
||
<input
|
||
type="checkbox"
|
||
id="showHumidity"
|
||
name="showHumidity"
|
||
onchange="setShowHumidity(this.checked)"
|
||
/>
|
||
<span class="toggle-slider"></span>
|
||
</span>
|
||
</label>
|
||
|
||
<label class="toggle-row-lg">
|
||
<span class="label-text">Show Weather Description:</span>
|
||
<span class="toggle-switch">
|
||
<input
|
||
type="checkbox"
|
||
id="showWeatherDescription"
|
||
name="showWeatherDescription"
|
||
onchange="setShowWeatherDescription(this.checked)"
|
||
/>
|
||
<span class="toggle-slider"></span>
|
||
</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<button type="button" class="sub-collapsible" aria-expanded="false">
|
||
Display & Brightness
|
||
</button>
|
||
<div class="sub-collapsible-content" aria-hidden="true">
|
||
<div class="content-wrapper">
|
||
<div class="toggle-padding">
|
||
<label> Brightness: <span id="brightnessValue">10</span> </label>
|
||
<input
|
||
class="range-full"
|
||
type="range"
|
||
min="-1"
|
||
max="15"
|
||
name="brightness"
|
||
id="brightnessSlider"
|
||
value="10"
|
||
oninput="
|
||
brightnessValue.textContent =
|
||
this.value == -1 ? 'Off' : this.value;
|
||
setBrightnessLive(this.value);
|
||
"
|
||
/>
|
||
|
||
<label class="toggle-row-lg">
|
||
<span class="label-text">Flip Display (180°):</span>
|
||
<span class="toggle-switch">
|
||
<input
|
||
type="checkbox"
|
||
id="flipDisplay"
|
||
name="flipDisplay"
|
||
onchange="setFlipDisplay(this.checked)"
|
||
/>
|
||
<span class="toggle-slider"></span>
|
||
</span>
|
||
</label>
|
||
|
||
<label class="toggle-row-lg">
|
||
<span class="label-text">Automatic Dimming:</span>
|
||
<span class="toggle-switch">
|
||
<input
|
||
type="checkbox"
|
||
id="autoDimmingEnabled"
|
||
name="autoDimmingEnabled"
|
||
/>
|
||
<span class="toggle-slider"></span>
|
||
</span>
|
||
<div id="autoDimmingNote" class="small">
|
||
Requires a valid OpenWeather API key.
|
||
</div>
|
||
</label>
|
||
|
||
<label class="toggle-row-lg">
|
||
<span class="label-text">Custom Dimming:</span>
|
||
<span class="toggle-switch">
|
||
<input
|
||
type="checkbox"
|
||
id="dimmingEnabled"
|
||
name="dimmingEnabled"
|
||
/>
|
||
<span class="toggle-slider"></span>
|
||
</span>
|
||
</label>
|
||
|
||
<label class="toggle-row-lg">
|
||
<span class="label-text">Clock-Only Mode When Dimmed:</span>
|
||
<span class="toggle-switch">
|
||
<input type="checkbox" id="clockOnlyDuringDimming" />
|
||
<span class="toggle-slider"></span>
|
||
</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="form-row two-col">
|
||
<div>
|
||
<label for="dimStartTime">Start Time:</label>
|
||
<input type="time" id="dimStartTime" value="18:00" />
|
||
</div>
|
||
|
||
<div>
|
||
<label for="dimEndTime">End Time:</label>
|
||
<input type="time" id="dimEndTime" value="08:00" />
|
||
</div>
|
||
</div>
|
||
|
||
<label class="mt-lg" for="dimBrightness">
|
||
Dimming Brightness: <span id="dimmingBrightnessValue">2</span>
|
||
</label>
|
||
|
||
<input
|
||
class="range-full"
|
||
type="range"
|
||
min="-1"
|
||
max="15"
|
||
name="dimming_brightness"
|
||
id="dimBrightness"
|
||
value="2"
|
||
oninput="
|
||
dimmingBrightnessValue.textContent =
|
||
this.value == -1 ? 'Off' : this.value
|
||
"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<button type="button" class="sub-collapsible" aria-expanded="false">
|
||
Countdown
|
||
</button>
|
||
<div class="sub-collapsible-content" aria-hidden="true">
|
||
<div class="content-wrapper">
|
||
<div class="toggle-padding">
|
||
<div class="form-group">
|
||
<label class="toggle-row-lg">
|
||
<span class="label-text">Enable Countdown:</span>
|
||
<span class="toggle-switch">
|
||
<input
|
||
type="checkbox"
|
||
id="countdownEnabled"
|
||
name="countdownEnabled"
|
||
/>
|
||
<span class="toggle-slider"></span>
|
||
</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="toggle-row-lg">
|
||
<span class="label-text">Dramatic Countdown:</span>
|
||
<span class="toggle-switch">
|
||
<input
|
||
type="checkbox"
|
||
id="isDramaticCountdown"
|
||
name="isDramaticCountdown"
|
||
/>
|
||
<span class="toggle-slider"></span>
|
||
</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-row two-col">
|
||
<div class="form-group">
|
||
<label for="countdownDate">Date:</label>
|
||
<input
|
||
type="date"
|
||
id="countdownDate"
|
||
name="countdownDate"
|
||
class="form-control"
|
||
/>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="countdownTime">Time:</label>
|
||
<input
|
||
type="time"
|
||
id="countdownTime"
|
||
name="countdownTime"
|
||
class="form-control"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="countdownLabel">Countdown Label (Optional):</label>
|
||
<input
|
||
type="text"
|
||
id="countdownLabel"
|
||
name="countdownLabel"
|
||
class="form-control"
|
||
placeholder="e.g., BIRTHDAY, VACATION"
|
||
maxlength="24"
|
||
pattern="[A-Z0-9 :!'\-.,_\+%\/?]*"
|
||
title="Only uppercase letters, numbers, space, and : ! ' - . , _ + % / ? allowed"
|
||
/>
|
||
<div class="small">
|
||
Allowed characters: A–Z, 0–9, space, and : ! ' - . ? , _ + % /
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<button type="button" class="sub-collapsible" aria-expanded="false">
|
||
Device information
|
||
</button>
|
||
<div class="sub-collapsible-content" aria-hidden="true">
|
||
<div class="content-wrapper">
|
||
<div class="toggle-padding device-info">
|
||
<span>Firmware: <span id="fwVersion"></span></span><br><br>
|
||
<span>IP:
|
||
<span id="ipDisplay">Fetching...</span>
|
||
</span><br><br>
|
||
<span>Hostname:
|
||
<span id="hostnameDisplay">Fetching...</span>
|
||
</span><br><br>
|
||
<span>Uptime:
|
||
<span id="uptimeDisplay">Loading...</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<input type="submit" class="primary-button" value="Save Settings" />
|
||
</form>
|
||
|
||
<div class="footer">
|
||
ESPTimeCast<span class="tm">™</span> by
|
||
<a
|
||
href="https://github.com/mfactory-osaka/ESPTimeCast"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>M-Factory</a
|
||
>
|
||
</div>
|
||
|
||
<div id="savingMessage"></div>
|
||
<script>
|
||
let isSaving = false;
|
||
let isAPMode = false;
|
||
|
||
// Set initial value display for brightness
|
||
document.addEventListener("DOMContentLoaded", function () {
|
||
brightnessValue.textContent = brightnessSlider.value;
|
||
|
||
// Sanitize input LIVE for customMessage
|
||
var customMsgInput = document.getElementById("customMessage");
|
||
if (customMsgInput) {
|
||
customMsgInput.addEventListener("input", function () {
|
||
let before = this.value;
|
||
let after = before
|
||
.toUpperCase()
|
||
.replace(/[^A-Z0-9 :!'\-.,_\+%\/?]/g, "");
|
||
if (before !== after) {
|
||
this.value = after;
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
// Show/Hide Password toggle
|
||
document.addEventListener("DOMContentLoaded", function () {
|
||
const passwordInput = document.getElementById("password");
|
||
const toggleCheckbox = document.getElementById("togglePassword");
|
||
|
||
toggleCheckbox.addEventListener("change", function () {
|
||
if (this.checked) {
|
||
// Show password as text
|
||
passwordInput.type = "text";
|
||
|
||
// Only clear if it's the masked placeholder
|
||
if (passwordInput.value === "********") {
|
||
passwordInput.value = "";
|
||
passwordInput.placeholder = "Enter new password";
|
||
}
|
||
} else {
|
||
// Hide password as dots
|
||
passwordInput.type = "password";
|
||
|
||
// Remove placeholder only if it was set by show-password toggle
|
||
if (passwordInput.placeholder === "Enter new password") {
|
||
passwordInput.placeholder = "";
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
window.onbeforeunload = function () {
|
||
if (isSaving) {
|
||
return "Settings are being saved. Leaving now may interrupt the process.";
|
||
}
|
||
};
|
||
|
||
window.onload = function () {
|
||
fetch("/config.json")
|
||
.then((response) => response.json())
|
||
.then((data) => {
|
||
isAPMode = data.mode === "ap";
|
||
if (isAPMode) {
|
||
document.querySelector(".geo-note").style.display = "block";
|
||
document
|
||
.getElementById("geo-button")
|
||
.classList.add("geo-disabled");
|
||
document.getElementById("geo-button").disabled = true;
|
||
|
||
document.querySelector(".cmsg1").classList.add("geo-disabled");
|
||
document.querySelector(".cmsg1").disabled = true;
|
||
|
||
document.querySelector(".cmsg2").classList.add("geo-disabled");
|
||
document.querySelector(".cmsg2").disabled = true;
|
||
}
|
||
document.getElementById("ssid").value = data.ssid || "";
|
||
document.getElementById("password").value = data.password || "";
|
||
const apiInput = document.getElementById("openWeatherApiKey");
|
||
if (
|
||
data.openWeatherApiKey &&
|
||
data.openWeatherApiKey.trim() !== ""
|
||
) {
|
||
apiInput.value = MASK;
|
||
hasSavedKey = true;
|
||
} else {
|
||
apiInput.value = "";
|
||
hasSavedKey = false;
|
||
}
|
||
|
||
document.getElementById("openWeatherCity").value =
|
||
data.openWeatherCity || "";
|
||
document.getElementById("openWeatherCountry").value =
|
||
data.openWeatherCountry || "";
|
||
document.getElementById("weatherUnits").checked =
|
||
data.weatherUnits === "imperial";
|
||
document.getElementById("clockDuration").value =
|
||
(data.clockDuration || 10000) / 1000;
|
||
document.getElementById("weatherDuration").value =
|
||
(data.weatherDuration || 5000) / 1000;
|
||
document.getElementById("language").value = data.language || "";
|
||
|
||
// Advanced:
|
||
document.getElementById("brightnessSlider").value =
|
||
typeof data.brightness !== "undefined" ? data.brightness : 10;
|
||
document.getElementById("brightnessValue").textContent =
|
||
document.getElementById("brightnessSlider").value == -1
|
||
? "Off"
|
||
: document.getElementById("brightnessSlider").value;
|
||
document.getElementById("flipDisplay").checked = !!data.flipDisplay;
|
||
document.getElementById("ntpServer1").value = data.ntpServer1 || "";
|
||
document.getElementById("ntpServer2").value = data.ntpServer2 || "";
|
||
document.getElementById("twelveHourToggle").checked =
|
||
!!data.twelveHourToggle;
|
||
document.getElementById("showDayOfWeek").checked =
|
||
!!data.showDayOfWeek;
|
||
document.getElementById("showDate").checked = !!data.showDate;
|
||
document.getElementById("showHumidity").checked =
|
||
!!data.showHumidity;
|
||
document.getElementById("colonBlinkEnabled").checked =
|
||
!!data.colonBlinkEnabled;
|
||
document.getElementById("showWeatherDescription").checked =
|
||
!!data.showWeatherDescription;
|
||
|
||
// --- Dimming Controls ---
|
||
const autoDimmingEl = document.getElementById("autoDimmingEnabled");
|
||
const dimmingEnabledEl = document.getElementById("dimmingEnabled");
|
||
const apiInputEl = document.getElementById("openWeatherApiKey");
|
||
|
||
// Evaluate flags from config.json
|
||
const isAutoDimming =
|
||
data.autoDimmingEnabled === true ||
|
||
data.autoDimmingEnabled === "true" ||
|
||
data.autoDimmingEnabled === 1;
|
||
const isCustomDimming =
|
||
data.dimmingEnabled === true ||
|
||
data.dimmingEnabled === "true" ||
|
||
data.dimmingEnabled === 1;
|
||
|
||
// Set toggle states
|
||
autoDimmingEl.checked = isAutoDimming;
|
||
dimmingEnabledEl.checked = isCustomDimming;
|
||
|
||
// Apply initial dimming state
|
||
setDimmingFieldsEnabled();
|
||
|
||
// Initialize Clock-only-during-dimming control (depends on dimming fields)
|
||
initClockOnlyDuringDimming(data);
|
||
|
||
// Attach listeners (mutually exclusive + API dependency)
|
||
if (apiInputEl)
|
||
apiInputEl.addEventListener("input", setDimmingFieldsEnabled);
|
||
autoDimmingEl.addEventListener("change", () => {
|
||
if (autoDimmingEl.checked) dimmingEnabledEl.checked = false;
|
||
setDimmingFieldsEnabled();
|
||
clearClockOnlyIfNoDimming();
|
||
});
|
||
dimmingEnabledEl.addEventListener("change", () => {
|
||
if (dimmingEnabledEl.checked) autoDimmingEl.checked = false;
|
||
setDimmingFieldsEnabled();
|
||
clearClockOnlyIfNoDimming();
|
||
});
|
||
|
||
// Set field values from config
|
||
document.getElementById("dimStartTime").value =
|
||
(data.dimStartHour !== undefined
|
||
? String(data.dimStartHour).padStart(2, "0")
|
||
: "18") +
|
||
":" +
|
||
(data.dimStartMinute !== undefined
|
||
? String(data.dimStartMinute).padStart(2, "0")
|
||
: "00");
|
||
|
||
document.getElementById("dimEndTime").value =
|
||
(data.dimEndHour !== undefined
|
||
? String(data.dimEndHour).padStart(2, "0")
|
||
: "08") +
|
||
":" +
|
||
(data.dimEndMinute !== undefined
|
||
? String(data.dimEndMinute).padStart(2, "0")
|
||
: "00");
|
||
|
||
document.getElementById("dimBrightness").value =
|
||
data.dimBrightness !== undefined ? data.dimBrightness : 2;
|
||
document.getElementById("dimmingBrightnessValue").textContent =
|
||
document.getElementById("dimBrightness").value == -1
|
||
? "Off"
|
||
: document.getElementById("dimBrightness").value;
|
||
|
||
// --- Populate Countdown Fields ---
|
||
document.getElementById("isDramaticCountdown").checked = !!(
|
||
data.countdown && data.countdown.isDramaticCountdown
|
||
);
|
||
const countdownEnabledEl =
|
||
document.getElementById("countdownEnabled"); // Get reference
|
||
countdownEnabledEl.checked = !!(
|
||
data.countdown && data.countdown.enabled
|
||
);
|
||
if (data.countdown && data.countdown.targetTimestamp) {
|
||
const targetDate = new Date(
|
||
data.countdown.targetTimestamp * 1000,
|
||
);
|
||
const year = targetDate.getFullYear();
|
||
const month = (targetDate.getMonth() + 1)
|
||
.toString()
|
||
.padStart(2, "0");
|
||
const day = targetDate.getDate().toString().padStart(2, "0");
|
||
const hours = targetDate.getHours().toString().padStart(2, "0");
|
||
const minutes = targetDate
|
||
.getMinutes()
|
||
.toString()
|
||
.padStart(2, "0");
|
||
document.getElementById("countdownDate").value =
|
||
`${year}-${month}-${day}`;
|
||
document.getElementById("countdownTime").value =
|
||
`${hours}:${minutes}`;
|
||
} else {
|
||
document.getElementById("countdownDate").value = "";
|
||
document.getElementById("countdownTime").value = "";
|
||
}
|
||
|
||
// Countdown Label Input Validation
|
||
const countdownLabelInput =
|
||
document.getElementById("countdownLabel");
|
||
countdownLabelInput.addEventListener("input", function () {
|
||
this.value = this.value
|
||
.toUpperCase()
|
||
.replace(/[^A-Z0-9 :!'\-.,_\+%\/?]/g, "");
|
||
});
|
||
if (data.countdown && data.countdown.label) {
|
||
countdownLabelInput.value = data.countdown.label
|
||
.toUpperCase()
|
||
.replace(/[^A-Z0-9 :!'\-.,_\+%\/?]/g, "");
|
||
} else {
|
||
countdownLabelInput.value = "";
|
||
}
|
||
|
||
// Countdown Toggle Event Listener & Field Enabling
|
||
countdownEnabledEl.addEventListener("change", function () {
|
||
setCountdownEnabled(this.checked);
|
||
setCountdownFieldsEnabled(this.checked);
|
||
});
|
||
const dramaticCountdownEl = document.getElementById(
|
||
"isDramaticCountdown",
|
||
);
|
||
dramaticCountdownEl.addEventListener("change", function () {
|
||
setIsDramaticCountdown(this.checked);
|
||
});
|
||
setCountdownFieldsEnabled(countdownEnabledEl.checked);
|
||
|
||
if (data.customMessage !== undefined) {
|
||
document.getElementById("customMessage").value =
|
||
data.customMessage;
|
||
}
|
||
|
||
// Auto-detect browser's timezone if not set in config
|
||
if (!data.timeZone) {
|
||
try {
|
||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||
if (
|
||
tz &&
|
||
document
|
||
.getElementById("timeZone")
|
||
.querySelector(`[value="${tz}"]`)
|
||
) {
|
||
document.getElementById("timeZone").value = tz;
|
||
} else {
|
||
document.getElementById("timeZone").value = "";
|
||
}
|
||
} catch (e) {
|
||
document.getElementById("timeZone").value = "";
|
||
}
|
||
} else {
|
||
document.getElementById("timeZone").value = data.timeZone;
|
||
}
|
||
})
|
||
.catch((err) => {
|
||
console.error("Failed to load config:", err);
|
||
showSavingModal("");
|
||
updateSavingModal("⚠️ Failed to load configuration.", false);
|
||
// Show appropriate button for load error
|
||
removeReloadButton();
|
||
removeRestoreButton();
|
||
const errorMsg = (err.message || "").toLowerCase();
|
||
if (
|
||
errorMsg.includes("config corrupted") ||
|
||
errorMsg.includes("failed to write config") ||
|
||
errorMsg.includes("restore")
|
||
) {
|
||
ensureRestoreButton();
|
||
} else {
|
||
ensureReloadButton();
|
||
}
|
||
});
|
||
document.querySelector("html").style.height = "unset";
|
||
document.body.classList.add("loaded");
|
||
};
|
||
|
||
async function submitConfig(event) {
|
||
event.preventDefault();
|
||
isSaving = true;
|
||
|
||
const form = document.getElementById("configForm");
|
||
const formData = new FormData(form);
|
||
|
||
const clockDuration = parseInt(formData.get("clockDuration")) * 1000;
|
||
const weatherDuration =
|
||
parseInt(formData.get("weatherDuration")) * 1000;
|
||
formData.set("clockDuration", clockDuration);
|
||
formData.set("weatherDuration", weatherDuration);
|
||
|
||
let apiKeyToSend = apiInput.value;
|
||
|
||
// If the user left the masked key untouched, skip sending it
|
||
if (apiKeyToSend === MASK && hasSavedKey) {
|
||
formData.delete("openWeatherApiKey");
|
||
} else {
|
||
formData.set("openWeatherApiKey", apiKeyToSend);
|
||
}
|
||
|
||
// Advanced: ensure correct values are set for advanced fields
|
||
formData.set(
|
||
"brightness",
|
||
document.getElementById("brightnessSlider").value,
|
||
);
|
||
formData.set(
|
||
"flipDisplay",
|
||
document.getElementById("flipDisplay").checked ? "on" : "",
|
||
);
|
||
formData.set(
|
||
"twelveHourToggle",
|
||
document.getElementById("twelveHourToggle").checked ? "on" : "",
|
||
);
|
||
formData.set(
|
||
"showDayOfWeek",
|
||
document.getElementById("showDayOfWeek").checked ? "on" : "",
|
||
);
|
||
formData.set(
|
||
"showDate",
|
||
document.getElementById("showDate").checked ? "on" : "",
|
||
);
|
||
formData.set(
|
||
"showHumidity",
|
||
document.getElementById("showHumidity").checked ? "on" : "",
|
||
);
|
||
formData.set(
|
||
"colonBlinkEnabled",
|
||
document.getElementById("colonBlinkEnabled").checked ? "on" : "",
|
||
);
|
||
|
||
// --- Dimming ---
|
||
const autoDimmingChecked =
|
||
document.getElementById("autoDimmingEnabled").checked;
|
||
const customDimmingChecked =
|
||
document.getElementById("dimmingEnabled").checked;
|
||
|
||
// Mutual exclusivity (if both checked somehow, keep auto as priority)
|
||
if (autoDimmingChecked && customDimmingChecked) {
|
||
formData.set("autoDimmingEnabled", "true");
|
||
formData.set("dimmingEnabled", "false");
|
||
} else {
|
||
formData.set(
|
||
"autoDimmingEnabled",
|
||
autoDimmingChecked ? "true" : "false",
|
||
);
|
||
formData.set(
|
||
"dimmingEnabled",
|
||
customDimmingChecked ? "true" : "false",
|
||
);
|
||
}
|
||
|
||
const dimStart = document.getElementById("dimStartTime").value; // "18:45"
|
||
const dimEnd = document.getElementById("dimEndTime").value; // "08:30"
|
||
|
||
// Parse hour and minute
|
||
if (dimStart) {
|
||
const [startHour, startMin] = dimStart
|
||
.split(":")
|
||
.map((x) => parseInt(x, 10));
|
||
formData.set("dimStartHour", startHour);
|
||
formData.set("dimStartMinute", startMin);
|
||
}
|
||
if (dimEnd) {
|
||
const [endHour, endMin] = dimEnd
|
||
.split(":")
|
||
.map((x) => parseInt(x, 10));
|
||
formData.set("dimEndHour", endHour);
|
||
formData.set("dimEndMinute", endMin);
|
||
}
|
||
formData.set(
|
||
"dimBrightness",
|
||
document.getElementById("dimBrightness").value,
|
||
);
|
||
formData.set(
|
||
"showWeatherDescription",
|
||
document.getElementById("showWeatherDescription").checked ? "on" : "",
|
||
);
|
||
formData.set(
|
||
"weatherUnits",
|
||
document.getElementById("weatherUnits").checked
|
||
? "imperial"
|
||
: "metric",
|
||
);
|
||
|
||
// --- NEW: Countdown Form Data ---
|
||
formData.set(
|
||
"countdownEnabled",
|
||
document.getElementById("countdownEnabled").checked
|
||
? "true"
|
||
: "false",
|
||
);
|
||
formData.set(
|
||
"isDramaticCountdown",
|
||
document.getElementById("isDramaticCountdown").checked
|
||
? "true"
|
||
: "false",
|
||
);
|
||
// Date and Time inputs are already handled by formData if they have a 'name' attribute
|
||
// 'countdownDate' and 'countdownTime' are collected automatically
|
||
// Also apply the same validation for the label when submitting
|
||
const finalCountdownLabel = document
|
||
.getElementById("countdownLabel")
|
||
.value.toUpperCase()
|
||
.replace(/[^A-Z0-9 :!'\-.,_\+%\/?]/g, "");
|
||
formData.set("countdownLabel", finalCountdownLabel);
|
||
// --- END NEW ---
|
||
|
||
const params = new URLSearchParams();
|
||
for (const pair of formData.entries()) {
|
||
params.append(pair[0], pair[1]);
|
||
}
|
||
|
||
// Sanitize and set customMessage before sending
|
||
const customMsgInput = document.getElementById("customMessage");
|
||
if (customMsgInput) {
|
||
customMsgInput.value = customMsgInput.value
|
||
.toUpperCase()
|
||
.replace(/[^A-Z0-9 :!'\-.,_\+%\/?]/g, "")
|
||
.replace(/\s+/g, " ")
|
||
.trim()
|
||
.substring(0, 120);
|
||
}
|
||
|
||
// Check AP mode status
|
||
let isAPMode = false;
|
||
try {
|
||
const apStatusResponse = await fetch("/ap_status");
|
||
const apStatusData = await apStatusResponse.json();
|
||
isAPMode = apStatusData.isAP;
|
||
} catch (error) {
|
||
console.error("Error fetching AP status:", error);
|
||
// Handle error appropriately (e.g., assume not in AP mode)
|
||
}
|
||
|
||
if (isAPMode) {
|
||
showSavingModal("");
|
||
updateSavingModal(
|
||
"✅ Settings saved successfully!<br><br>" +
|
||
"Rebooting the device now...<br><br>" +
|
||
"Your device will connect to your Wi-Fi.<br>" +
|
||
"Its new IP address will appear on the display for future access.",
|
||
true, // show spinner
|
||
);
|
||
} else {
|
||
showSavingModal("");
|
||
}
|
||
|
||
await new Promise((resolve) =>
|
||
setTimeout(resolve, isAPMode ? 5000 : 0),
|
||
);
|
||
fetch("/save", {
|
||
method: "POST",
|
||
body: params,
|
||
})
|
||
.then((response) => {
|
||
if (!response.ok) {
|
||
return response.json().then((json) => {
|
||
throw new Error(
|
||
`Server error ${response.status}: ${json.error}`,
|
||
);
|
||
});
|
||
}
|
||
return response.json();
|
||
})
|
||
.then((json) => {
|
||
isSaving = false;
|
||
removeReloadButton();
|
||
removeRestoreButton();
|
||
|
||
if (isAPMode) {
|
||
setTimeout(() => {
|
||
document.getElementById("configForm").style.display = "none";
|
||
document.querySelector(".footer").style.display = "none";
|
||
document.querySelector("html").style.height = "100vh";
|
||
document.body.style.height = "100vh";
|
||
updateSavingModal(
|
||
"✅ All done!<br>You can now close this tab safely.<br><br>" +
|
||
"Your device has rebooted and is now connected to your Wi-Fi.<br>" +
|
||
"Check the display for the current IP address.",
|
||
false, // stop spinner
|
||
);
|
||
}, 5000);
|
||
return;
|
||
} else {
|
||
showSavingModal("");
|
||
updateSavingModal(
|
||
"✅ Configuration saved successfully.<br><br>Device will reboot",
|
||
false,
|
||
);
|
||
setTimeout(
|
||
() => (location.href = location.href.split("#")[0]),
|
||
3000,
|
||
);
|
||
}
|
||
})
|
||
.catch((err) => {
|
||
isSaving = false;
|
||
|
||
if (isAPMode && err.message.includes("Failed to fetch")) {
|
||
console.warn("Expected disconnect in AP mode after reboot.");
|
||
showSavingModal("");
|
||
updateSavingModal(
|
||
"✅ Settings saved successfully!<br><br>Rebooting the device now... ",
|
||
false,
|
||
);
|
||
setTimeout(() => {
|
||
document.getElementById("configForm").style.display = "none";
|
||
updateSavingModal(
|
||
"✅ All done!<br>You can now close this tab safely.<br><br>" +
|
||
"Your device has rebooted and is now connected to your Wi-Fi.<br>" +
|
||
"Check the display for the current IP address.",
|
||
false,
|
||
);
|
||
}, 5000);
|
||
removeReloadButton();
|
||
removeRestoreButton();
|
||
return;
|
||
}
|
||
|
||
console.error("Save error:", err);
|
||
let friendlyMessage =
|
||
"⚠️ Something went wrong while saving the configuration.";
|
||
if (err.message.includes("Failed to fetch")) {
|
||
friendlyMessage =
|
||
"⚠️ Cannot connect to the device.<br>Is it powered on and connected?";
|
||
}
|
||
|
||
updateSavingModal(
|
||
`${friendlyMessage}<br><br>Details: ${err.message}`,
|
||
false,
|
||
);
|
||
|
||
// Show only one action button, based on error content
|
||
removeReloadButton();
|
||
removeRestoreButton();
|
||
const errorMsg = (err.message || "").toLowerCase();
|
||
if (
|
||
errorMsg.includes("config corrupted") ||
|
||
errorMsg.includes("failed to write config") ||
|
||
errorMsg.includes("restore")
|
||
) {
|
||
ensureRestoreButton();
|
||
} else {
|
||
ensureReloadButton();
|
||
}
|
||
});
|
||
}
|
||
|
||
function showSavingModal(message) {
|
||
let modal = document.getElementById("savingModal");
|
||
if (!modal) {
|
||
modal = document.createElement("div");
|
||
modal.id = "savingModal";
|
||
modal.innerHTML = `
|
||
<div id="savingModalContent">
|
||
<div class="spinner"></div>
|
||
<div id="savingModalText">${message}</div>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(modal);
|
||
} else {
|
||
document.getElementById("savingModalText").innerHTML = message;
|
||
document.querySelector("#savingModal .spinner").style.display =
|
||
"block";
|
||
}
|
||
modal.style.display = "flex";
|
||
document.body.classList.add("modal-open");
|
||
}
|
||
|
||
function updateSavingModal(message, showSpinner = false) {
|
||
let modalText = document.getElementById("savingModalText");
|
||
modalText.innerHTML = message;
|
||
document.querySelector("#savingModal .spinner").style.display =
|
||
showSpinner ? "block" : "none";
|
||
|
||
// Remove reload/restore buttons if no longer needed
|
||
if (
|
||
message.includes("saved successfully") ||
|
||
message.includes("Backup restored") ||
|
||
message.includes("All done!")
|
||
) {
|
||
removeReloadButton();
|
||
removeRestoreButton();
|
||
}
|
||
}
|
||
|
||
function ensureReloadButton(options = {}) {
|
||
let modalContent = document.getElementById("savingModalContent");
|
||
if (!modalContent) return;
|
||
let btn = document.getElementById("reloadButton");
|
||
if (!btn) {
|
||
btn = document.createElement("button");
|
||
btn.id = "reloadButton";
|
||
btn.className = "primary-button";
|
||
btn.style.display = "inline-block";
|
||
btn.style.margin = "1rem 0.5rem 0 0";
|
||
modalContent.appendChild(btn);
|
||
}
|
||
btn.textContent = options.text || "Reload Page";
|
||
btn.onclick = options.onClick || (() => location.reload());
|
||
btn.style.display = "inline-block";
|
||
return btn;
|
||
}
|
||
|
||
function ensureRestoreButton(options = {}) {
|
||
let modalContent = document.getElementById("savingModalContent");
|
||
if (!modalContent) return;
|
||
let btn = document.getElementById("restoreButton");
|
||
if (!btn) {
|
||
btn = document.createElement("button");
|
||
btn.id = "restoreButton";
|
||
btn.className = "primary-button";
|
||
btn.style.display = "inline-block";
|
||
btn.style.margin = "1rem 0 0 0.5rem";
|
||
modalContent.appendChild(btn);
|
||
}
|
||
btn.textContent = options.text || "Restore Backup";
|
||
btn.onclick = options.onClick || restoreBackupConfig;
|
||
btn.style.display = "inline-block";
|
||
return btn;
|
||
}
|
||
|
||
function removeReloadButton() {
|
||
let btn = document.getElementById("reloadButton");
|
||
if (btn && btn.parentNode) btn.parentNode.removeChild(btn);
|
||
}
|
||
function removeRestoreButton() {
|
||
let btn = document.getElementById("restoreButton");
|
||
if (btn && btn.parentNode) btn.parentNode.removeChild(btn);
|
||
}
|
||
function restoreBackupConfig() {
|
||
showSavingModal("Restoring backup...");
|
||
removeReloadButton();
|
||
removeRestoreButton();
|
||
|
||
fetch("/restore", { method: "POST" })
|
||
.then((response) => {
|
||
if (!response.ok) {
|
||
throw new Error("Server returned an error");
|
||
}
|
||
return response.json();
|
||
})
|
||
.then((data) => {
|
||
updateSavingModal("✅ Backup restored! Device will now reboot.");
|
||
setTimeout(() => location.reload(), 1500);
|
||
})
|
||
.catch((err) => {
|
||
console.error("Restore error:", err);
|
||
updateSavingModal(
|
||
`❌ Failed to restore backup: ${err.message}`,
|
||
false,
|
||
);
|
||
|
||
// Show only one button, for backup restore failures show reload.
|
||
removeReloadButton();
|
||
removeRestoreButton();
|
||
ensureReloadButton();
|
||
});
|
||
}
|
||
|
||
function hideSavingModal() {
|
||
const modal = document.getElementById("savingModal");
|
||
if (modal) {
|
||
modal.style.display = "none";
|
||
document.body.classList.remove("modal-open");
|
||
}
|
||
}
|
||
|
||
const toggle = document.querySelector(".collapsible-toggle");
|
||
const content = document.querySelector(".collapsible-content");
|
||
toggle.addEventListener("click", function () {
|
||
const isOpen = toggle.classList.toggle("open");
|
||
toggle.setAttribute("aria-expanded", isOpen);
|
||
content.setAttribute("aria-hidden", !isOpen);
|
||
if (isOpen) {
|
||
content.style.height = content.scrollHeight + "px";
|
||
content.addEventListener("transitionend", function handler() {
|
||
content.style.height = "auto";
|
||
content.removeEventListener("transitionend", handler);
|
||
});
|
||
} else {
|
||
content.style.height = content.scrollHeight + "px";
|
||
// Force reflow to make sure the browser notices the height before transitioning to 0
|
||
void content.offsetHeight;
|
||
content.style.height = "0px";
|
||
}
|
||
});
|
||
// Optional: If open on load, set height to auto
|
||
if (toggle.classList.contains("open")) {
|
||
content.style.height = "auto";
|
||
}
|
||
|
||
document.querySelectorAll(".sub-collapsible").forEach((button) => {
|
||
const content = button.nextElementSibling;
|
||
|
||
button.addEventListener("click", () => {
|
||
const isOpen = button.classList.toggle("open");
|
||
button.setAttribute("aria-expanded", isOpen);
|
||
content.setAttribute("aria-hidden", !isOpen);
|
||
|
||
if (isOpen) {
|
||
content.style.height = content.scrollHeight + "px";
|
||
|
||
content.addEventListener("transitionend", function handler() {
|
||
content.style.height = "auto";
|
||
content.removeEventListener("transitionend", handler);
|
||
});
|
||
} else {
|
||
content.style.height = content.scrollHeight + "px";
|
||
void content.offsetHeight; // force reflow
|
||
content.style.height = "0px";
|
||
}
|
||
});
|
||
|
||
// Ensure collapsed on load
|
||
content.style.height = "0px";
|
||
});
|
||
|
||
let brightnessDebounceTimeout = null;
|
||
|
||
function setBrightnessLive(val) {
|
||
// Cancel the previous timeout if it exists
|
||
if (brightnessDebounceTimeout) {
|
||
clearTimeout(brightnessDebounceTimeout);
|
||
}
|
||
|
||
// Set a new timeout
|
||
brightnessDebounceTimeout = setTimeout(() => {
|
||
fetch("/set_brightness", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/x-www-form-urlencoded",
|
||
"X-Source": "UI",
|
||
},
|
||
body: "value=" + encodeURIComponent(val),
|
||
})
|
||
.then((res) => res.json())
|
||
.catch((e) => {}); // Optionally handle errors
|
||
}, 150); // 150ms debounce
|
||
}
|
||
|
||
function setFlipDisplay(val) {
|
||
fetch("/set_flip", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||
body: "value=" + (val ? 1 : 0),
|
||
});
|
||
}
|
||
|
||
function setTwelveHour(val) {
|
||
fetch("/set_twelvehour", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||
body: "value=" + (val ? 1 : 0),
|
||
});
|
||
}
|
||
|
||
function setShowDayOfWeek(val) {
|
||
fetch("/set_dayofweek", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||
body: "value=" + (val ? 1 : 0),
|
||
});
|
||
}
|
||
|
||
function setShowDate(val) {
|
||
fetch("/set_showdate", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||
body: "value=" + (val ? 1 : 0),
|
||
});
|
||
}
|
||
|
||
function setColonBlink(val) {
|
||
fetch("/set_colon_blink", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||
body: "value=" + (val ? 1 : 0),
|
||
});
|
||
}
|
||
|
||
function setShowHumidity(val) {
|
||
fetch("/set_humidity", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||
body: "value=" + (val ? 1 : 0),
|
||
});
|
||
}
|
||
|
||
function setLanguage(val) {
|
||
fetch("/set_language", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||
body: "value=" + encodeURIComponent(val),
|
||
});
|
||
}
|
||
|
||
function setShowWeatherDescription(val) {
|
||
fetch("/set_weatherdesc", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||
body: "value=" + (val ? 1 : 0),
|
||
});
|
||
}
|
||
|
||
function setWeatherUnits(val) {
|
||
fetch("/set_units", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||
body: "value=" + (val ? 1 : 0),
|
||
});
|
||
}
|
||
|
||
// --- Clock-only-during-dimming setter (no reboot) ---
|
||
function setClockOnlyDuringDimming(val) {
|
||
fetch("/set_clock_only_dimming", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||
body: "value=" + (val ? 1 : 0),
|
||
}).catch((e) => {
|
||
console.error("Failed to set clockOnlyDuringDimming:", e);
|
||
});
|
||
}
|
||
|
||
// Initialize the checkbox from cfg and wire up immediate save (no reboot)
|
||
function initClockOnlyDuringDimming(cfg) {
|
||
const el = document.getElementById("clockOnlyDuringDimming");
|
||
if (!el) return;
|
||
|
||
// Set initial checked value from config
|
||
el.checked = !!cfg.clockOnlyDuringDimming;
|
||
|
||
// Determine whether dimming is enabled (auto OR custom)
|
||
const autoDim =
|
||
cfg.autoDimmingEnabled === true ||
|
||
cfg.autoDimmingEnabled === "true" ||
|
||
cfg.autoDimmingEnabled === 1;
|
||
const manualDim =
|
||
cfg.dimmingEnabled === true ||
|
||
cfg.dimmingEnabled === "true" ||
|
||
cfg.dimmingEnabled === 1;
|
||
|
||
// Normalize dimBrightness from config (handle "Off" or "-1" string)
|
||
let db = cfg.dimBrightness;
|
||
if (typeof db === "string") {
|
||
if (db.toLowerCase() === "off") db = -1;
|
||
else db = parseInt(db, 10);
|
||
}
|
||
const dimBrightnessOk = typeof db === "number" ? db !== -1 : true;
|
||
|
||
// Enable only when some dimming mode is active and dimming does not fully turn display off
|
||
el.disabled = !(autoDim || manualDim) || !dimBrightnessOk;
|
||
|
||
// On change, persist immediately (no reboot)
|
||
el.addEventListener("change", function () {
|
||
const want = this.checked;
|
||
setClockOnlyDuringDimming(want);
|
||
// optimistic UI: leave checkbox as toggled; if server fails we don't roll back here
|
||
});
|
||
}
|
||
|
||
// --- Countdown Controls Logic ---
|
||
// NEW: Function to enable/disable countdown specific fields
|
||
function setCountdownFieldsEnabled(enabled) {
|
||
document.getElementById("countdownLabel").disabled = !enabled;
|
||
document.getElementById("countdownDate").disabled = !enabled;
|
||
document.getElementById("countdownTime").disabled = !enabled;
|
||
document.getElementById("isDramaticCountdown").disabled = !enabled;
|
||
}
|
||
|
||
// Existing function to send countdown enable/disable command to ESP
|
||
function setCountdownEnabled(val) {
|
||
fetch("/set_countdown_enabled", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||
body: "value=" + (val ? 1 : 0), // Send 1 for true, 0 for false
|
||
});
|
||
}
|
||
|
||
function setIsDramaticCountdown(val) {
|
||
fetch("/set_dramatic_countdown", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||
body: "value=" + (val ? 1 : 0), // Send 1 for true, 0 for false
|
||
});
|
||
}
|
||
// --- END Countdown Controls Logic ---
|
||
|
||
// --- Dimming Controls Logic ---
|
||
function setDimmingFieldsEnabled(enabled) {
|
||
document.getElementById("dimStartTime").disabled = !enabled;
|
||
document.getElementById("dimEndTime").disabled = !enabled;
|
||
document.getElementById("dimBrightness").disabled = !enabled;
|
||
}
|
||
|
||
async function getLocation() {
|
||
const normalize = (v) => {
|
||
if (v === null || v === undefined) return "";
|
||
const s = String(v).trim();
|
||
if (
|
||
!s ||
|
||
s.toLowerCase() === "null" ||
|
||
s.toLowerCase() === "none" ||
|
||
s === "-"
|
||
)
|
||
return "";
|
||
return s;
|
||
};
|
||
|
||
const setFields = (lat, lon, label) => {
|
||
if (lat) document.getElementById("openWeatherCity").value = lat;
|
||
if (lon) document.getElementById("openWeatherCountry").value = lon;
|
||
const btn = document.getElementById("geo-button");
|
||
btn.textContent = "Location: " + (label || "Location Found");
|
||
btn.disabled = true;
|
||
btn.classList.add("geo-disabled");
|
||
};
|
||
|
||
try {
|
||
// 1) get your public IP
|
||
const ipResp = await fetch("https://api.ipify.org?format=json");
|
||
if (!ipResp.ok) throw new Error("ipify failed: " + ipResp.status);
|
||
const { ip } = await ipResp.json();
|
||
if (!ip) throw new Error("no IP returned by ipify");
|
||
|
||
// 2) call HackerTarget GeoIP with JSON output
|
||
const geoResp = await fetch(
|
||
`https://api.hackertarget.com/geoip/?q=${encodeURIComponent(ip)}&output=json`,
|
||
);
|
||
if (!geoResp.ok)
|
||
throw new Error("HackerTarget returned " + geoResp.status);
|
||
const data = await geoResp.json();
|
||
|
||
// 3) extract and normalize fields
|
||
const lat = data.latitude;
|
||
const lon = data.longitude;
|
||
const city = normalize(data.city);
|
||
const state = normalize(data.state);
|
||
const country = normalize(data.country);
|
||
const label = city || state || country || "Location Found";
|
||
|
||
if (!lat || !lon) throw new Error("missing latitude/longitude");
|
||
|
||
setFields(lat, lon, label);
|
||
//console.log('Location fetched via HackerTarget. Label:', label);
|
||
} catch (err) {
|
||
console.error("HackerTarget geolocation failed:", err);
|
||
alert(
|
||
"Failed to guess your location using HackerTarget.\n\n" +
|
||
"Possible causes:\n" +
|
||
"- CORS blocking in browser (try server-side)\n" +
|
||
"- Network issue or rate limit\n\n" +
|
||
"You can manually search for coordinates on https://openweathermap.org/find",
|
||
);
|
||
}
|
||
}
|
||
|
||
// --- OpenWeather API Key field UX ---
|
||
const MASK_LENGTH = 32;
|
||
const MASK = "*".repeat(MASK_LENGTH);
|
||
const apiInput = document.getElementById("openWeatherApiKey");
|
||
let hasSavedKey = false;
|
||
|
||
// --- Initialize the field after config load ---
|
||
if (apiInput.value && apiInput.value.trim() !== "") {
|
||
apiInput.value = MASK; // show mask
|
||
hasSavedKey = true;
|
||
} else {
|
||
apiInput.value = "";
|
||
hasSavedKey = false;
|
||
}
|
||
|
||
// --- Detect user clearing intent ---
|
||
apiInput.addEventListener("input", () => {
|
||
apiInput.dataset.clearing = apiInput.value === "" ? "true" : "false";
|
||
});
|
||
|
||
// --- Handle Delete/Backspace when focused but empty ---
|
||
apiInput.addEventListener("keydown", (e) => {
|
||
if (
|
||
(e.key === "Backspace" || e.key === "Delete") &&
|
||
apiInput.value === ""
|
||
) {
|
||
apiInput.dataset.clearing = "true";
|
||
}
|
||
});
|
||
|
||
// --- Focus handler: clear mask for editing ---
|
||
apiInput.addEventListener("focus", () => {
|
||
if (apiInput.value === MASK) apiInput.value = "";
|
||
});
|
||
|
||
// --- Blur handler: restore mask if user didn’t clear the field ---
|
||
apiInput.addEventListener("blur", () => {
|
||
if (apiInput.value === "") {
|
||
if (hasSavedKey && apiInput.dataset.clearing !== "true") {
|
||
apiInput.value = MASK; // remask
|
||
} else {
|
||
hasSavedKey = false; // user cleared the key
|
||
apiInput.dataset.clearing = "false";
|
||
apiInput.value = ""; // leave blank
|
||
setDimmingFieldsEnabled();
|
||
}
|
||
}
|
||
});
|
||
|
||
// --- Uptime Tracker ---
|
||
|
||
let uptimeSeconds = 0;
|
||
let uptimeTimer;
|
||
|
||
// Fetch uptime from ESP
|
||
function fetchUptime() {
|
||
fetch("/uptime")
|
||
.then((res) => res.json())
|
||
.then((data) => {
|
||
// Get raw seconds from firmware
|
||
uptimeSeconds = data.uptime_seconds || 0;
|
||
// Update uptime display immediately
|
||
updateUptimeDisplay();
|
||
// Update firmware version in UI
|
||
const versionEl = document.getElementById("fwVersion");
|
||
if (versionEl) {
|
||
versionEl.textContent = "v" + data.version;
|
||
}
|
||
// Restart local increment timer
|
||
if (uptimeTimer) clearInterval(uptimeTimer);
|
||
uptimeTimer = setInterval(() => {
|
||
uptimeSeconds++;
|
||
updateUptimeDisplay();
|
||
}, 1000);
|
||
})
|
||
.catch((err) => console.error("Error fetching /uptime:", err));
|
||
}
|
||
|
||
// Format seconds → "2 days 04:09:31", "1 day 03:05:12", or "03:05:12"
|
||
function formatUptime(seconds) {
|
||
const days = Math.floor(seconds / 86400);
|
||
seconds %= 86400;
|
||
const h = Math.floor(seconds / 3600);
|
||
const m = Math.floor((seconds % 3600) / 60);
|
||
const s = seconds % 60;
|
||
|
||
const timePart = `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||
if (days > 1) return `${days} days ${timePart}`;
|
||
if (days === 1) return `1 day ${timePart}`;
|
||
return timePart;
|
||
}
|
||
|
||
// Update the text on screen
|
||
function updateUptimeDisplay() {
|
||
document.getElementById("uptimeDisplay").textContent =
|
||
formatUptime(uptimeSeconds);
|
||
}
|
||
|
||
// Start it up
|
||
fetchUptime();
|
||
|
||
function sendCustomMessage() {
|
||
const input = document.getElementById("customMessage");
|
||
let message = input.value
|
||
.toUpperCase()
|
||
.replace(/[^A-Z0-9 :!'\-.,_\+%\/?]/g, "")
|
||
.replace(/\s+/g, " ")
|
||
.trim()
|
||
.substring(0, 120);
|
||
|
||
fetch("/set_custom_message", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/x-www-form-urlencoded",
|
||
"X-Source": "UI",
|
||
},
|
||
body: "message=" + encodeURIComponent(message),
|
||
})
|
||
.then((res) => res.text())
|
||
.then((res) => {
|
||
showSavingModal("");
|
||
updateSavingModal(
|
||
"✅ Message sent successfully!<br><br>Now displaying your custom message.",
|
||
false,
|
||
);
|
||
setTimeout(hideSavingModal, 2000);
|
||
})
|
||
.catch((err) => {
|
||
console.error("Error sending custom message:", err);
|
||
showSavingModal("");
|
||
updateSavingModal(
|
||
"⚠️ Failed to send message.<br><br>Check connection.",
|
||
false,
|
||
);
|
||
setTimeout(hideSavingModal, 3000);
|
||
});
|
||
}
|
||
|
||
function clearCustomMessage() {
|
||
fetch("/set_custom_message", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/x-www-form-urlencoded",
|
||
"X-Source": "UI",
|
||
},
|
||
body: "message=",
|
||
})
|
||
.then((res) => res.text())
|
||
.then((res) => {
|
||
document.getElementById("customMessage").value = "";
|
||
showSavingModal("");
|
||
updateSavingModal(
|
||
"✅ Custom message cleared.<br><br>Display reverted to normal.",
|
||
false,
|
||
);
|
||
setTimeout(hideSavingModal, 2000);
|
||
})
|
||
.catch((err) => {
|
||
console.error("Error clearing custom message:", err);
|
||
showSavingModal("");
|
||
updateSavingModal(
|
||
"⚠️ Failed to clear message.<br><br>Check connection.",
|
||
false,
|
||
);
|
||
setTimeout(hideSavingModal, 3000);
|
||
});
|
||
}
|
||
|
||
// --- Dimming Controls Logic (The correct version) ---
|
||
function setDimmingFieldsEnabled() {
|
||
const apiKeyField = document.getElementById("openWeatherApiKey");
|
||
const autoDimming = document.getElementById("autoDimmingEnabled");
|
||
const dimmingEnabled = document.getElementById("dimmingEnabled");
|
||
const dimStart = document.getElementById("dimStartTime");
|
||
const dimEnd = document.getElementById("dimEndTime");
|
||
const dimBrightness = document.getElementById("dimBrightness");
|
||
const noteEl = document.getElementById("autoDimmingNote");
|
||
|
||
if (!apiKeyField || !autoDimming || !dimmingEnabled) return;
|
||
|
||
const currentApiKeyInput = apiKeyField.value.trim();
|
||
// Checks if a key is saved (hasSavedKey) OR if the user is currently typing a new one.
|
||
const isKeyPresent =
|
||
hasSavedKey ||
|
||
(currentApiKeyInput !== "" && currentApiKeyInput !== MASK);
|
||
|
||
// --- 1. Control Auto Dimming based on Key Presence ---
|
||
// Meets requirement: "when page load after autodim has been saved to json,
|
||
// if user removes the api key (masked) the toggle auto dim toggle should get disabled"
|
||
if (!isKeyPresent) {
|
||
autoDimming.checked = false;
|
||
autoDimming.disabled = true;
|
||
if (noteEl) noteEl.style.display = "block";
|
||
} else {
|
||
autoDimming.disabled = false;
|
||
if (noteEl) noteEl.style.display = "none";
|
||
}
|
||
|
||
// Custom Dimming toggle is always enabled (since it's not key-dependent)
|
||
dimmingEnabled.disabled = false;
|
||
|
||
// --- 2. Control Dependent Fields based on Active Mode ---
|
||
|
||
const isAutoDimmingActive = autoDimming.checked && isKeyPresent; // Auto is only active if checked AND key is present
|
||
const isCustomDimmingActive = dimmingEnabled.checked;
|
||
const isDimmingActive = isAutoDimmingActive || isCustomDimmingActive; // Brightness slider logic
|
||
|
||
// --- Update Clock-only-during-dimming checkbox state (if present) ---
|
||
const clockOnlyEl = document.getElementById("clockOnlyDuringDimming");
|
||
if (clockOnlyEl) {
|
||
// Read current brightness control value (string)
|
||
const dbEl = document.getElementById("dimBrightness");
|
||
const dbVal = dbEl ? dbEl.value : null;
|
||
const dbOk =
|
||
dbVal !== null
|
||
? !(
|
||
String(dbVal).toLowerCase() === "off" ||
|
||
String(dbVal) === "-1"
|
||
)
|
||
: true;
|
||
const currentlyDimEnabled =
|
||
isAutoDimmingActive || isCustomDimmingActive;
|
||
clockOnlyEl.disabled = !currentlyDimEnabled || !dbOk;
|
||
}
|
||
|
||
// BRIGHTNESS SLIDER: Enabled if EITHER mode is active.
|
||
if (dimBrightness) {
|
||
dimBrightness.disabled = !isDimmingActive;
|
||
}
|
||
|
||
// START/END TIME FIELDS: Enabled ONLY if Custom Dimming is checked (key not needed).
|
||
const isCustomTimeEnabled = dimmingEnabled.checked;
|
||
if (dimStart) {
|
||
dimStart.disabled = !isCustomTimeEnabled;
|
||
}
|
||
if (dimEnd) {
|
||
dimEnd.disabled = !isCustomTimeEnabled;
|
||
}
|
||
|
||
clearClockOnlyIfNoDimming();
|
||
}
|
||
|
||
// If both dimming modes are disabled, clear & persist the Clock-only-during-dimming flag
|
||
function clearClockOnlyIfNoDimming() {
|
||
const autoEl = document.getElementById("autoDimmingEnabled");
|
||
const dimEl = document.getElementById("dimmingEnabled");
|
||
const clockEl = document.getElementById("clockOnlyDuringDimming");
|
||
if (!autoEl || !dimEl || !clockEl) return;
|
||
|
||
if (!autoEl.checked && !dimEl.checked) {
|
||
// if currently checked, uncheck and persist change immediately
|
||
if (clockEl.checked) {
|
||
clockEl.checked = false;
|
||
// persist without reboot
|
||
fetch("/set_clock_only_dimming", {
|
||
method: "POST",
|
||
body: new URLSearchParams({ value: "false" }),
|
||
}).catch((e) =>
|
||
console.error(
|
||
"Failed to persist clockOnlyDuringDimming clear:",
|
||
e,
|
||
),
|
||
);
|
||
}
|
||
// also ensure it's disabled in the UI
|
||
clockEl.disabled = true;
|
||
}
|
||
}
|
||
|
||
window.addEventListener("DOMContentLoaded", () => {
|
||
const apiKeyEl = document.getElementById("openWeatherApiKey");
|
||
const autoEl = document.getElementById("autoDimmingEnabled");
|
||
const dimEl = document.getElementById("dimmingEnabled");
|
||
|
||
if (apiKeyEl) {
|
||
apiKeyEl.addEventListener("input", setDimmingFieldsEnabled);
|
||
apiKeyEl.addEventListener("change", setDimmingFieldsEnabled);
|
||
}
|
||
if (autoEl) autoEl.addEventListener("change", setDimmingFieldsEnabled);
|
||
if (dimEl) dimEl.addEventListener("change", setDimmingFieldsEnabled);
|
||
});
|
||
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
// --- IP ---
|
||
fetch("/ip")
|
||
.then((r) => r.text())
|
||
.then((ip) => {
|
||
const el = document.getElementById("ipDisplay");
|
||
if (el) el.textContent = ip || "—";
|
||
})
|
||
.catch(() => {
|
||
const el = document.getElementById("ipDisplay");
|
||
if (el) el.textContent = "—";
|
||
});
|
||
|
||
// --- Hostname ---
|
||
fetch("/hostname")
|
||
.then((r) => r.text())
|
||
.then((host) => {
|
||
const el = document.getElementById("hostnameDisplay");
|
||
if (el) el.textContent = host || "—";
|
||
})
|
||
.catch(() => {
|
||
const el = document.getElementById("hostnameDisplay");
|
||
if (el) el.textContent = "—";
|
||
});
|
||
});
|
||
|
||
const ssidInput = document.getElementById("ssid");
|
||
const list = document.getElementById("ssidList");
|
||
const scanBtn = document.getElementById("scanBtn");
|
||
const arrowBtn = document.getElementById("arrowBtn");
|
||
|
||
// Unlock the arrow button UI
|
||
function enableDropdown() {
|
||
arrowBtn.disabled = false;
|
||
arrowBtn.style.opacity = "1";
|
||
arrowBtn.style.cursor = "pointer";
|
||
}
|
||
|
||
// Show/Hide the dropdown list
|
||
function toggleList(e) {
|
||
if (e) e.stopPropagation();
|
||
if (list.children.length > 0) {
|
||
list.style.display =
|
||
list.style.display === "block" ? "none" : "block";
|
||
}
|
||
}
|
||
|
||
arrowBtn.onclick = toggleList;
|
||
|
||
// Close dropdown if user clicks away
|
||
window.onclick = (e) => {
|
||
if (!e.target.matches("#arrowBtn") && !e.target.matches("#ssid")) {
|
||
list.style.display = "none";
|
||
}
|
||
};
|
||
|
||
scanBtn.onclick = async function () {
|
||
// 1. Prepare UI
|
||
arrowBtn.disabled = true;
|
||
scanBtn.disabled = true;
|
||
list.style.display = "none";
|
||
|
||
// 2. Start Continuous Dot Animation
|
||
let dotCount = 0;
|
||
const dotInterval = setInterval(() => {
|
||
dotCount = (dotCount % 3) + 1;
|
||
scanBtn.innerText = ".".repeat(dotCount);
|
||
}, 850);
|
||
|
||
// 3. Define the recursive Polling Function
|
||
const performPolling = async () => {
|
||
try {
|
||
const resp = await fetch("/scan");
|
||
|
||
if (resp.status === 202) {
|
||
// ESP is still busy. Wait 1 second then try again.
|
||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||
return await performPolling();
|
||
}
|
||
|
||
if (resp.status === 200) {
|
||
const networks = await resp.json();
|
||
list.innerHTML = "";
|
||
|
||
if (networks && networks.length > 0) {
|
||
networks.forEach((net) => {
|
||
const div = document.createElement("div");
|
||
div.className = "ssid-option";
|
||
div.innerText = net.ssid;
|
||
div.onclick = () => {
|
||
ssidInput.value = net.ssid;
|
||
list.style.display = "none";
|
||
};
|
||
list.appendChild(div);
|
||
});
|
||
enableDropdown();
|
||
list.style.display = "block";
|
||
} else {
|
||
alert("No networks found.");
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error("Scan error:", err);
|
||
alert("Device connection lost.");
|
||
}
|
||
};
|
||
|
||
// 4. Run the polling chain
|
||
await performPolling();
|
||
|
||
// 5. Final Cleanup (Runs only AFTER polling is completely finished)
|
||
clearInterval(dotInterval);
|
||
scanBtn.disabled = false;
|
||
scanBtn.innerText = "Scan";
|
||
};
|
||
</script>
|
||
</body>
|
||
</html>
|
||
)rawliteral"; |