Various improvements for usability and consistency and inclusion of PWA handling.

This commit is contained in:
2026-07-09 20:03:41 -05:00
parent 941ce78bfd
commit 0a400e3039
23 changed files with 439 additions and 133 deletions

View File

@@ -2,8 +2,17 @@
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ESP32-C3 Admin</title>
<meta name="theme-color" content="#1261a6">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="TSL-Embedded">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<title></title>
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/icons/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/icons/icon-192.png">
<link rel="stylesheet" href="/css/app.css">
<script defer src="/js/pwa.js"></script>
<script defer src="/js/app.js"></script>
<script type="module" src="/components/status-card.js"></script>
<script type="module" src="/components/uptime-card.js"></script>
@@ -11,8 +20,14 @@
<script type="module" src="/components/add-card.js"></script>
</head>
<body>
<div id="appTitle" class="top">ESP32-C3 Admin</div>
<div id="busyChip" class="busyChip hidden"><span class="spinner"></span>Working...</div>
<div id="appTitle" class="top"></div>
<div class="activityDock" aria-live="polite">
<div class="activityChips">
<div id="uiBusyChip" class="busyChip hidden"><span class="spinner"></span>UI request...</div>
<div id="serverBusyChip" class="busyChip serverBusy hidden"><span class="spinner"></span>Server event...</div>
</div>
<button id="logoutBtn" class="logoutBtn hidden">Logout</button>
</div>
<main>
<div id="loginPanel" class="box">
<h2>Login</h2>
@@ -43,7 +58,7 @@
<div id="users" class="subpanel active">
<section id="usersOverview">
<div class="toolbar"><h3>User Overview</h3><button id="usersRefresh">Refresh</button><button id="userNew" class="secondary">New</button></div>
<div class="toolbar"><h3>User Overview</h3><button id="userNew" class="secondary">New</button></div>
<div id="userList" class="list"></div>
</section>
<section id="userDetail" class="hidden">
@@ -59,7 +74,7 @@
<div id="roles" class="subpanel">
<section id="rolesOverview">
<div class="toolbar"><h3>Role Overview</h3><button id="rolesRefresh">Refresh</button><button id="roleNew" class="secondary">New</button></div>
<div class="toolbar"><h3>Role Overview</h3><button id="roleNew" class="secondary">New</button></div>
<div id="rolesOut" class="list"></div>
</section>
<section id="roleDetail" class="hidden">
@@ -98,7 +113,7 @@
<div id="ops" class="panel"><div class="grid">
<section><h3>Settings</h3><label>Log level</label><select id="logLevel"><option value="0">Error</option><option value="1">Warn</option><option value="2">SecurityAudit</option><option value="3" selected>Info</option><option value="4">Debug</option></select><label>Max log bytes</label><input id="logMax" type="number" value="51200"><label>Update package URL</label><input id="upd"><button id="settingsSave">Save</button><pre id="settingsOut"></pre></section>
<section><h3>Updates</h3><label>Update package</label><input id="pkg" type="file"><button id="uploadPkgBtn">Upload package</button><button id="otaCheck" class="secondary">Check URL</button><button id="otaRun" class="danger">Install from URL</button><pre id="fwOut"></pre></section>
<section><h3>Maintenance</h3><button id="configDownload">Download config</button><label>Restore configuration</label><input id="configFile" type="file" accept=".json,application/json"><button id="restoreConfigBtn" class="secondary">Restore config</button><button id="factoryReset" class="danger">Factory reset</button><pre id="maintOut"></pre></section>
<section><h3>Maintenance</h3><button id="configDownload">Download config</button><label>Restore configuration</label><input id="configFile" type="file" accept=".json,application/json"><button id="restoreConfigBtn" class="secondary">Restore config</button><button id="restartDevice" class="secondary">Restart</button><button id="factoryReset" class="danger">Factory reset</button><pre id="maintOut"></pre></section>
<section class="fullWidth"><div class="toolbar"><h3>Files</h3><span id="filesPath" class="filePathChip">/</span></div><div id="fileList" class="list"></div><pre id="filesOut"></pre></section>
<section class="fullWidth"><h3>Logs</h3><div class="row"><button id="logsLoad">Load</button><button id="logsClear" class="danger">Clear</button></div><pre id="logsOut"></pre></section>
</div></div>

View File

@@ -1,7 +1,7 @@
class UptimeCard extends HTMLElement {
constructor() {
super();
this.source = null;
this.timer = null;
}
connectedCallback() {
@@ -11,11 +11,16 @@ class UptimeCard extends HTMLElement {
</style>
<section>
<h3>Live uptime</h3>
<div class="muted">Server-Sent Events</div>
<div class="muted">Auto refresh</div>
<div class="liveValue"><span id="seconds">--</span>s</div>
<div class="muted state"><span id="dot" class="dot"></span><span id="state">Disconnected</span></div>
</section>`;
window.addEventListener('app:login', event => this.start(event.detail.token));
window.addEventListener('app:logout', () => {
this.stop();
this.setState('Disconnected', false);
this.querySelector('#seconds').textContent = '--';
});
window.addEventListener('beforeunload', () => this.stop());
if (window.App && window.App.token()) this.start(window.App.token());
}
@@ -31,28 +36,32 @@ class UptimeCard extends HTMLElement {
start(token) {
this.stop();
if (!token || !window.EventSource) {
if (!token) {
this.setState('Unavailable', false);
return;
}
this.setState('Connecting', false);
this.source = new EventSource('/api/uptime/events?token=' + encodeURIComponent(token));
this.source.addEventListener('open', () => this.setState('Connected', true));
this.source.addEventListener('uptime', event => {
this.setState('Loading', false);
const load = async () => {
try {
const json = JSON.parse(event.data);
const text = await window.App.serverReq('GET', '/api/uptime');
const json = JSON.parse(text);
if (!json.success) {
this.setState('Unavailable', false);
return;
}
this.querySelector('#seconds').textContent = json.uptimeSeconds;
this.setState('Connected', true);
} catch (error) {
this.setState('Invalid event', false);
this.setState('Retrying', false);
}
});
this.source.addEventListener('error', () => this.setState('Reconnecting', false));
};
load();
this.timer = setInterval(load, 5000);
}
stop() {
if (this.source) this.source.close();
this.source = null;
if (this.timer) clearInterval(this.timer);
this.timer = null;
}
}

View File

@@ -20,7 +20,8 @@ pre{white-space:pre-wrap;background:#111;color:#d7ffd7;padding:10px;border-radiu
.pill{display:inline-flex;gap:6px;align-items:center;background:#eef3f6;border-radius:6px;padding:5px 8px;margin:4px 4px 0 0}.pill button{margin:0;padding:3px 7px;background:#b3261e}
.detailTitle{margin:0 0 8px}.inlineCheck{display:flex;gap:8px;align-items:center;margin-top:10px}.inlineCheck input{width:auto}
.toolbar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:10px}.toolbar button{margin-top:0}.detailActions{display:flex;gap:8px;flex-wrap:wrap}.detailActions button{flex:0 1 auto}
.fullWidth{grid-column:1/-1}.busyChip{position:fixed;right:14px;top:10px;z-index:10;background:#1261a6;color:white;border-radius:6px;padding:7px 10px;box-shadow:0 2px 10px rgba(0,0,0,.22);font-size:13px}
.fullWidth{grid-column:1/-1}.activityDock{position:fixed;right:14px;top:10px;z-index:10;display:flex;gap:8px;align-items:flex-start}.activityChips{display:flex;flex-direction:row;gap:6px;align-items:flex-start}.busyChip{background:#1261a6;color:white;border-radius:6px;padding:7px 10px;box-shadow:0 2px 10px rgba(0,0,0,.22);font-size:13px;white-space:nowrap}.serverBusy{background:#607d8b}.logoutBtn{margin:0;background:#607d8b;box-shadow:0 2px 10px rgba(0,0,0,.22);padding:7px 10px;font-size:13px}
.fileList{gap:3px}.fileRow{display:grid;grid-template-columns:28px minmax(0,1fr) auto;gap:8px;align-items:center;background:#eef3f6;border-radius:6px;color:#263238;min-height:30px;padding:3px 8px;box-sizing:border-box}.fileRow[data-path]{cursor:pointer}.fileRow:hover{background:#dbe7ed}.filePath{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:13px}.filePathChip{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:13px;color:#263238;background:#eef3f6;border-radius:6px;padding:6px 8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%}.fileSize{color:#607d8b;font-size:12px;white-space:nowrap}.iconBtn,.folderIcon{width:24px;height:24px;display:inline-grid;place-items:center}.iconBtn{border:0;border-radius:5px;background:transparent;color:#1261a6;margin:0;padding:0}.iconBtn:hover{background:#c9dbe5}.iconBtn svg,.folderIcon svg{width:16px;height:16px}.folderIcon{color:#607d8b}
.apiRow{display:grid;grid-template-columns:minmax(180px,1fr) minmax(220px,auto);gap:10px;align-items:center;background:#eef3f6;border-radius:6px;padding:7px 9px}.apiPath{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:13px}.apiVerbs{display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end}.verbBadge{display:inline-flex;gap:6px;align-items:center;background:white;color:#263238;border:1px solid #cfd8dc;border-radius:6px;margin:0;padding:5px 7px;font-size:12px}.verbBadge:hover{background:#dbe7ed}.verbBadge span{color:#607d8b}
.usageGrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;margin:12px 0}.usageGrid h3{font-size:14px;margin:0 0 6px}.usageList{display:flex;flex-direction:column;gap:4px}.usageItem{display:flex;gap:6px;align-items:center;background:#eef3f6;border-radius:6px;padding:6px 8px;min-height:28px}.usageItem code{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px}
@media (max-width:600px){main{padding:14px}.apiRow{grid-template-columns:1fr;gap:7px;align-items:start}.apiVerbs{justify-content:flex-start}.verbBadge{min-height:30px;box-sizing:border-box;flex:1 1 96px;justify-content:space-between}.activityDock{right:8px;top:8px;gap:6px}.busyChip,.logoutBtn{font-size:12px;padding:6px 8px}}

BIN
data/www/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
data/www/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

21
data/www/icons/icon.svg Normal file
View File

@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="TSL Embedded">
<rect width="512" height="512" rx="112" fill="#1261a6"/>
<rect x="116" y="116" width="280" height="280" rx="44" fill="#f5f7f8"/>
<rect x="164" y="164" width="184" height="184" rx="24" fill="#263238"/>
<path fill="#58c4b8" d="M210 233h92v46h-92z"/>
<path fill="#f5f7f8" d="M233 210h46v92h-46z"/>
<g fill="#58c4b8">
<rect x="152" y="68" width="24" height="64" rx="12"/>
<rect x="244" y="68" width="24" height="64" rx="12"/>
<rect x="336" y="68" width="24" height="64" rx="12"/>
<rect x="152" y="380" width="24" height="64" rx="12"/>
<rect x="244" y="380" width="24" height="64" rx="12"/>
<rect x="336" y="380" width="24" height="64" rx="12"/>
<rect x="68" y="152" width="64" height="24" rx="12"/>
<rect x="68" y="244" width="64" height="24" rx="12"/>
<rect x="68" y="336" width="64" height="24" rx="12"/>
<rect x="380" y="152" width="64" height="24" rx="12"/>
<rect x="380" y="244" width="64" height="24" rx="12"/>
<rect x="380" y="336" width="64" height="24" rx="12"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -7,47 +7,103 @@ let selectedRole = '';
let selectedApi = -1;
let currentFilePath = '/';
let filesLoaded = false;
let busyCount = 0;
let uiBusyCount = 0;
let serverBusyCount = 0;
let currentUserRoles = [];
const el = id => document.getElementById(id);
const hasCurrentRole = role => currentUserRoles.includes(role);
function setGlobalBusy(on) {
busyCount += on ? 1 : -1;
if (busyCount < 0) busyCount = 0;
el('busyChip').classList.toggle('hidden', busyCount == 0);
function setProjectName(projectName) {
if (!projectName) return;
document.title = projectName;
el('appTitle').textContent = projectName;
}
async function tracked(fn) {
setGlobalBusy(true);
async function loadAppInfo() {
try {
const response = await fetch('/api/app-info');
const json = await response.json();
setProjectName(json.projectName || '');
} catch (error) {}
}
function setActivityBusy(kind, on) {
if (kind == 'server') {
serverBusyCount += on ? 1 : -1;
if (serverBusyCount < 0) serverBusyCount = 0;
el('serverBusyChip').classList.toggle('hidden', serverBusyCount == 0);
return;
}
uiBusyCount += on ? 1 : -1;
if (uiBusyCount < 0) uiBusyCount = 0;
el('uiBusyChip').classList.toggle('hidden', uiBusyCount == 0);
}
async function tracked(fn, kind = 'ui') {
setActivityBusy(kind, true);
try {
return await fn();
} finally {
setGlobalBusy(false);
setActivityBusy(kind, false);
}
}
async function apiFetch(url, options) {
return await tracked(() => fetch(url, options));
async function apiFetch(url, options, activity = 'ui') {
return await tracked(() => fetch(url, options), activity);
}
async function req(method, url, body) {
async function req(method, url, body, activity = 'ui') {
const options = {method, headers: {'Content-Type': 'application/json'}};
if (token) options.headers.Authorization = 'Bearer ' + token;
if (body !== undefined && method != 'GET') options.body = JSON.stringify(body);
const response = await apiFetch(url, options);
const response = await apiFetch(url, options, activity);
return await response.text();
}
async function serverReq(method, url, body) {
return await req(method, url, body, 'server');
}
window.App = {
apiFetch,
req,
token: () => token
serverReq,
setActivityBusy,
token: () => token,
roles: () => [...currentUserRoles]
};
const topLevelTabs = [
{id: 'dashboard', role: 'WebUIConnect'},
{id: 'access', role: 'AccessAdmin'},
{id: 'network', role: 'NetworkAdmin'},
{id: 'ops', role: 'SystemAdmin'}
];
function visibleTopLevelTabs() {
return topLevelTabs.filter(tab => hasCurrentRole(tab.role)).map(tab => tab.id);
}
function applyRoleVisibility() {
const visible = visibleTopLevelTabs();
topLevelTabs.forEach(tab => {
const allowed = visible.includes(tab.id);
document.querySelectorAll('[data-tab="' + tab.id + '"]').forEach(button => button.classList.toggle('hidden', !allowed));
const panel = el(tab.id);
if (panel) panel.classList.toggle('hidden', !allowed);
});
const topTabs = document.querySelector('#app > .tabs');
if (topTabs) topTabs.classList.toggle('hidden', visible.length <= 1);
if (visible.length) showTab(visible[0]);
return visible;
}
function showTab(id) {
if (!visibleTopLevelTabs().includes(id)) return;
document.querySelectorAll('.tab[data-tab]').forEach(button => button.classList.toggle('active', button.dataset.tab == id));
document.querySelectorAll('.panel').forEach(panel => panel.classList.toggle('active', panel.id == id));
if (id == 'network') loadSettings();
if (id == 'network') loadNetworkSettings();
if (id == 'ops' && !filesLoaded) loadFiles('/');
}
@@ -65,12 +121,40 @@ async function doLogin() {
const json = JSON.parse(text);
if (!json.success) return;
token = json.token;
currentUserRoles = (json.roles || '').split('|').filter(Boolean);
el('loginPanel').classList.add('hidden');
el('app').classList.remove('hidden');
window.dispatchEvent(new CustomEvent('app:login', {detail: {token}}));
loadSettings();
loadAccess();
loadFiles('/');
el('logoutBtn').classList.remove('hidden');
window.dispatchEvent(new CustomEvent('app:login', {detail: {token, roles: currentUserRoles}}));
const visible = applyRoleVisibility();
if (hasCurrentRole('SystemAdmin')) loadSettings();
if (hasCurrentRole('AccessAdmin')) loadAccess();
if (hasCurrentRole('NetworkAdmin')) loadNetworkSettings();
if (visible.includes('ops')) loadFiles('/');
}
async function doLogout() {
if (token) {
try {
await req('POST', '/api/logout');
} catch (error) {}
}
token = '';
currentUserRoles = [];
roles = [];
users = [];
apis = [];
selectedUser = '';
selectedRole = '';
selectedApi = -1;
filesLoaded = false;
currentFilePath = '/';
el('app').classList.add('hidden');
el('loginPanel').classList.remove('hidden');
el('logoutBtn').classList.add('hidden');
el('pass').value = '';
el('loginOut').textContent = '';
window.dispatchEvent(new CustomEvent('app:logout'));
}
function esc(value) {
@@ -443,6 +527,16 @@ async function factoryResetDevice() {
el('maintOut').textContent = await req('POST', '/api/factory-reset');
}
async function restartDevice() {
if (!confirm('Restart this device now?')) return;
setBusy('restartDevice', true, 'Restarting');
try {
el('maintOut').textContent = await req('POST', '/api/restart');
} finally {
setBusy('restartDevice', false, 'Restart');
}
}
async function loadLogs() {
const text = await req('GET', '/api/logs');
try {
@@ -472,12 +566,20 @@ async function loadSettings() {
const json = JSON.parse(text);
if (!json.success) return;
const settings = json.settings;
const projectName = settings.projectName || 'ESP32-C3';
el('appTitle').textContent = projectName + ' Admin';
document.title = projectName + ' Admin';
setProjectName(settings.projectName);
el('logLevel').value = settings.logLevel;
el('logMax').value = settings.maxLogBytes;
el('upd').value = settings.updateUrl;
window.dispatchEvent(new CustomEvent('app:settings-loaded', {detail: {settings}}));
}
async function loadNetworkSettings() {
const text = await req('GET', '/api/network');
el('networkOut').textContent = text;
const json = JSON.parse(text);
if (!json.success) return;
const settings = json.settings;
setProjectName(settings.projectName);
el('netHost').value = settings.hostname || '';
el('netHost').placeholder = settings.defaultHostname || 'Default host name';
el('netDhcp').value = String(settings.dhcp !== false);
@@ -488,7 +590,6 @@ async function loadSettings() {
el('netDns2').value = settings.dns2 || '';
el('netCurrent').textContent = 'Current IP: ' + (settings.currentIp || 'not connected');
syncNetworkMode();
window.dispatchEvent(new CustomEvent('app:settings-loaded', {detail: {settings}}));
}
async function saveSettings() {
@@ -499,9 +600,9 @@ async function saveNetwork() {
setBusy('networkSaveBtn', true, 'Saving');
try {
const body = {hostname: el('netHost').value, dhcp: el('netDhcp').value == 'true', ip: el('netIp').value, gateway: el('netGateway').value, subnet: el('netSubnet').value, dns1: el('netDns1').value, dns2: el('netDns2').value};
const text = await req('POST', '/api/settings', body);
const text = await req('POST', '/api/network', body);
el('networkOut').textContent = text + '\nNetwork changes apply on the next WiFi reconnect or reboot.';
if (JSON.parse(text).success) await loadSettings();
if (JSON.parse(text).success) await loadNetworkSettings();
} catch (error) {
el('networkOut').textContent = 'Network save failed: ' + error.message;
} finally {
@@ -545,13 +646,12 @@ async function runOta() {
function bindEvents() {
el('loginBtn').addEventListener('click', doLogin);
el('logoutBtn').addEventListener('click', doLogout);
document.querySelectorAll('.tab[data-tab]').forEach(button => button.addEventListener('click', () => showTab(button.dataset.tab)));
document.querySelectorAll('.subtab').forEach(button => button.addEventListener('click', () => showAccessTab(button.dataset.subtab)));
el('usersRefresh').addEventListener('click', loadUsers);
el('userNew').addEventListener('click', newUserRecord);
el('userBack').addEventListener('click', showUserOverview);
el('userSave').addEventListener('click', saveSelectedUser);
el('rolesRefresh').addEventListener('click', loadRoles);
el('roleNew').addEventListener('click', newRoleRecord);
el('roleBack').addEventListener('click', showRoleOverview);
el('roleSave').addEventListener('click', addRole);
@@ -582,6 +682,7 @@ function bindEvents() {
el('otaRun').addEventListener('click', runOta);
el('configDownload').addEventListener('click', downloadConfig);
el('restoreConfigBtn').addEventListener('click', restoreConfig);
el('restartDevice').addEventListener('click', restartDevice);
el('factoryReset').addEventListener('click', factoryResetDevice);
el('fileList').addEventListener('click', event => {
const button = event.target.closest('[data-download]');
@@ -596,4 +697,5 @@ function bindEvents() {
el('logsClear').addEventListener('click', clearLogs);
}
loadAppInfo();
bindEvents();

9
data/www/js/pwa.js Normal file
View File

@@ -0,0 +1,9 @@
(function () {
if (!('serviceWorker' in navigator)) return;
window.addEventListener('load', function () {
navigator.serviceWorker.register('/service-worker.js').catch(function (error) {
console.warn('PWA service worker registration failed:', error);
});
});
})();

View File

@@ -4,6 +4,16 @@ const admin = document.getElementById('admin');
const adminPass = document.getElementById('adminPass');
const out = document.getElementById('out');
async function loadAppInfo() {
try {
const response = await fetch('/api/app-info');
const json = await response.json();
const projectName = json.projectName || '';
document.title = projectName;
document.getElementById('setupTitle').textContent = projectName;
} catch (error) {}
}
async function scan() {
const response = await fetch('/api/wifi/scan');
const json = await response.json();
@@ -17,4 +27,5 @@ async function save() {
}
document.getElementById('saveSetup').addEventListener('click', save);
loadAppInfo();
scan();

View File

@@ -0,0 +1,32 @@
{
"name": "TSL-Embedded Admin",
"short_name": "TSL Admin",
"description": "Installable administration app for the embedded ESP32-C3 device.",
"id": "/",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#f5f7f8",
"theme_color": "#1261a6",
"icons": [
{
"src": "/icons/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
},
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}

View File

@@ -0,0 +1,55 @@
const CACHE_NAME = 'tsl-embedded-pwa-v12';
const APP_SHELL = [
'/',
'/admin.html',
'/setup.html',
'/manifest.webmanifest',
'/css/app.css',
'/css/setup.css',
'/js/app.js',
'/js/setup.js',
'/js/pwa.js',
'/components/status-card.js',
'/components/uptime-card.js',
'/components/led-card.js',
'/components/add-card.js',
'/icons/icon.svg',
'/icons/icon-192.png',
'/icons/icon-512.png'
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(APP_SHELL))
.then(() => self.skipWaiting())
);
});
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys()
.then(names => Promise.all(names.filter(name => name !== CACHE_NAME).map(name => caches.delete(name))))
.then(() => self.clients.claim())
);
});
self.addEventListener('fetch', event => {
const requestUrl = new URL(event.request.url);
if (requestUrl.origin !== location.origin || requestUrl.pathname.startsWith('/api/')) return;
if (event.request.mode === 'navigate') {
event.respondWith(fetch(event.request).catch(() => caches.match('/admin.html')));
return;
}
event.respondWith(
caches.match(event.request).then(cached => cached || fetch(event.request).then(response => {
if (!response || response.status !== 200 || event.request.method !== 'GET') return response;
const copy = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(event.request, copy));
return response;
}))
);
});

View File

@@ -2,12 +2,12 @@
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ESP32-C3 first-run setup</title>
<title></title>
<link rel="stylesheet" href="/css/setup.css">
<script defer src="/js/setup.js"></script>
</head>
<body>
<div class="top">ESP32-C3 first-run setup</div>
<div id="setupTitle" class="top"></div>
<main>
<section class="box">
<h2>Network and admin setup</h2>