Files
esp32-Serial-Logger/web_html.h

710 lines
31 KiB
C++

#ifndef WEB_HTML_H
#define WEB_HTML_H
const char INDEX_HTML[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<title>ESP32 Serial Logger</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
:root{
--bg:#1a1a2e;--panel:#16213e;--accent:#0f3460;
--term-bg:#0a0a0a;--text:#e0e0e0;--rx:#00ff41;
--tx:#ffd700;--border:#2a2a4a;--btn:#e94560;
--btn-hover:#c81e45;--ok:#00c853;--warn:#ff9800;
--radius:6px;
}
html,body{height:100%;overflow:hidden;}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);display:flex;flex-direction:column;}
/* ===== NAVBAR ===== */
.nav{display:flex;align-items:center;background:var(--panel);padding:6px 12px;border-bottom:2px solid var(--accent);flex-shrink:0;gap:8px;min-height:42px;flex-wrap:wrap;}
.logo{font-weight:700;font-size:14px;color:var(--btn);white-space:nowrap;}
.conn{display:flex;align-items:center;gap:4px;font-size:11px;}
.dot{width:8px;height:8px;border-radius:50%;background:#555;flex-shrink:0;}
.dot.on{background:var(--ok);box-shadow:0 0 6px var(--ok);}
.tabs{display:flex;gap:2px;margin-left:auto;}
.tabs button{padding:6px 14px;background:var(--accent);border:none;color:var(--text);cursor:pointer;font-size:12px;font-weight:600;border-radius:4px 4px 0 0;touch-action:manipulation;}
.tabs button.act{background:var(--btn);color:#fff;}
/* ===== MAIN AREA ===== */
.main{flex:1;display:flex;overflow:hidden;position:relative;}
.page{display:none;position:absolute;inset:0;flex-direction:column;overflow:hidden;}
.page.act{display:flex;}
/* ===== TERMINAL ===== */
.t-bar{display:flex;align-items:center;padding:4px 8px;background:var(--panel);gap:6px;border-bottom:1px solid var(--border);flex-shrink:0;flex-wrap:wrap;}
.t-bar label{font-size:11px;color:#999;white-space:nowrap;}
.t-bar select{background:var(--accent);color:var(--text);border:1px solid var(--border);padding:2px 4px;font-size:11px;border-radius:3px;}
.t-bar input[type=checkbox]{width:16px;height:16px;accent-color:var(--btn);}
.t-bar .sp{flex:1;min-width:4px;}
.t-bar button,.ibtn{padding:4px 10px;background:var(--btn);color:#fff;border:none;border-radius:3px;cursor:pointer;font-size:11px;font-weight:600;touch-action:manipulation;}
.t-bar button:active,.ibtn:active{opacity:0.7;}
#term{flex:1;background:var(--term-bg);padding:6px 8px;overflow-y:auto;overflow-x:hidden;font-family:'Courier New',Consolas,monospace;font-size:13px;line-height:1.4;white-space:pre-wrap;word-break:break-all;-webkit-overflow-scrolling:touch;}
#term::-webkit-scrollbar{width:6px;}
#term::-webkit-scrollbar-thumb{background:var(--accent);border-radius:3px;}
.rx{color:var(--rx);}
.tx{color:var(--tx);}
.sys{color:#666;font-style:italic;}
.ts{color:#555;font-size:10px;margin-right:6px;}
/* Input Bar */
.ibar{display:flex;padding:4px 6px;background:var(--panel);border-top:1px solid var(--border);gap:4px;flex-shrink:0;align-items:center;flex-wrap:wrap;}
#txIn{flex:1;min-width:0;background:var(--term-bg);color:var(--tx);border:1px solid var(--border);padding:8px;font-family:monospace;font-size:14px;border-radius:var(--radius);}
#txIn:focus{border-color:var(--btn);outline:none;}
.ibar .opts{display:flex;align-items:center;gap:4px;flex-shrink:0;}
.ibar .opts label{font-size:10px;color:#888;white-space:nowrap;}
.ibar .opts select{background:var(--accent);color:var(--text);border:1px solid var(--border);padding:2px;font-size:10px;border-radius:3px;}
.sbtn{padding:8px 18px;background:var(--ok);color:#000;font-weight:700;border:none;border-radius:var(--radius);cursor:pointer;font-size:14px;touch-action:manipulation;flex-shrink:0;}
.sbtn:active{background:#00e65b;}
/* ===== SETTINGS ===== */
.spage{padding:12px;overflow-y:auto;-webkit-overflow-scrolling:touch;}
.sgrp{background:var(--panel);border:1px solid var(--border);border-radius:var(--radius);padding:14px;margin-bottom:12px;}
.sgrp h3{color:var(--btn);font-size:13px;margin-bottom:10px;padding-bottom:6px;border-bottom:1px solid var(--border);}
.fr{display:flex;align-items:center;margin-bottom:8px;gap:8px;}
.fr label{width:110px;font-size:12px;color:#bbb;flex-shrink:0;}
.fr select,.fr input{flex:1;background:var(--accent);color:var(--text);border:1px solid var(--border);padding:6px 8px;font-size:13px;border-radius:4px;max-width:220px;}
.abtn{display:inline-block;padding:8px 24px;background:var(--btn);color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:13px;font-weight:600;margin-top:6px;touch-action:manipulation;}
.abtn:active{opacity:0.7;}
/* ===== FILES ===== */
.fpage{display:flex;flex-direction:column;overflow:hidden;padding:8px;}
.ftool{display:flex;align-items:center;gap:6px;padding:6px;flex-shrink:0;flex-wrap:wrap;}
.ftool button{padding:6px 12px;border:none;border-radius:4px;cursor:pointer;font-size:11px;font-weight:600;touch-action:manipulation;}
.ftool .r{background:var(--accent);color:var(--text);}
.ftool .d{background:var(--btn);color:#fff;}
.ftool .dl{background:var(--ok);color:#000;}
.finfo{font-size:11px;color:#888;margin-left:auto;}
.ftable{flex:1;overflow-y:auto;-webkit-overflow-scrolling:touch;border:1px solid var(--border);border-radius:4px;}
table{width:100%;border-collapse:collapse;}
thead{background:var(--accent);position:sticky;top:0;z-index:1;}
th,td{padding:6px 8px;text-align:left;font-size:12px;border-bottom:1px solid var(--border);}
th{color:var(--btn);font-weight:600;font-size:11px;text-transform:uppercase;}
tr:active{background:rgba(233,69,96,0.1);}
.cba,.cbf{width:18px;height:18px;accent-color:var(--btn);}
.fl{color:var(--rx);text-decoration:none;}
.fs{color:#888;}
.fc{color:var(--ok);cursor:pointer;font-size:12px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
/* ===== STATUS BAR ===== */
.sbar{display:flex;align-items:center;padding:3px 10px;background:var(--panel);border-top:1px solid var(--border);font-size:10px;color:#777;gap:10px;flex-shrink:0;flex-wrap:wrap;}
.sbar .ld{width:6px;height:6px;border-radius:50%;background:#555;flex-shrink:0;}
.sbar .ld.rec{background:var(--btn);animation:bk 1s infinite;}
@keyframes bk{50%{opacity:0.2;}}
/* ===== MOBILE RESPONSIVE ===== */
@media(max-width:600px){
.nav{padding:4px 8px;gap:4px;}
.logo{font-size:12px;}
.tabs button{padding:5px 10px;font-size:11px;}
.t-bar{padding:3px 6px;gap:4px;}
.t-bar label{font-size:10px;}
#term{font-size:11px;padding:4px 6px;line-height:1.3;}
.ts{font-size:9px;}
.ibar{padding:4px;gap:3px;}
#txIn{padding:6px;font-size:13px;}
.sbtn{padding:6px 14px;font-size:13px;}
.ibar .opts{width:100%;justify-content:flex-start;gap:6px;order:3;}
.spage{padding:8px;}
.sgrp{padding:10px;}
.fr{flex-direction:column;align-items:flex-start;gap:4px;}
.fr label{width:auto;}
.fr select,.fr input{max-width:100%;width:100%;}
.ftool{gap:4px;}
.ftool button{padding:5px 10px;font-size:10px;}
th,td{padding:5px 6px;font-size:11px;}
.fc{max-width:100px;font-size:10px;}
.sbar{font-size:9px;gap:6px;padding:2px 6px;}
}
/* Very small screens */
@media(max-width:380px){
.tabs button{padding:4px 8px;font-size:10px;}
#term{font-size:10px;}
#txIn{font-size:12px;}
.conn{display:none;}
}
/* Landscape phone */
@media(max-height:500px){
.nav{min-height:36px;padding:2px 8px;}
.t-bar{padding:2px 6px;}
.ibar{padding:2px 4px;}
.sbar{padding:1px 6px;}
}
</style>
</head>
<body>
<!-- NAV -->
<div class="nav">
<span class="logo">ESP32 Logger</span>
<div class="conn"><span class="dot" id="wsDot"></span><span id="wsSt">OFF</span></div>
<div class="tabs">
<button class="act" data-tab="terminal">Terminal</button>
<button data-tab="settings">Settings</button>
<button data-tab="files">Files</button>
</div>
</div>
<!-- MAIN -->
<div class="main">
<!-- TERMINAL -->
<div class="page act" id="p-terminal">
<div class="t-bar">
<label>View:</label>
<select id="dMode"><option value="a">ASCII</option><option value="h">HEX</option><option value="b">Both</option></select>
<label>Auto&#8595;</label><input type="checkbox" id="aScr" checked>
<label>Time</label><input type="checkbox" id="sTs" checked>
<span class="sp"></span>
<button onclick="clrTerm()">Clear</button>
<button onclick="expTerm()">Export</button>
</div>
<div id="term"></div>
<div class="ibar">
<input type="text" id="txIn" placeholder="Send data..." enterkeyhint="send" inputmode="text" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false">
<button class="sbtn" id="sendBtn" onclick="txSend()">Send</button>
<div class="opts">
<label>End:</label>
<select id="lEnd"><option value="crlf">CR+LF</option><option value="cr">CR</option><option value="lf">LF</option><option value="none">None</option></select>
<label>Mode:</label>
<select id="sMode"><option value="ascii">ASCII</option><option value="hex">HEX</option></select>
</div>
</div>
</div>
<!-- SETTINGS -->
<div class="page" id="p-settings">
<div class="spage">
<div class="sgrp">
<h3>Serial Port</h3>
<div class="fr"><label>Monitor Port</label>
<select id="sPort" onchange="switchPort()">
<option value="0" selected>UART2 (GPIO16/17) - </option>
<option value="1">UART0 (USB) - </option>
</select>
</div>
<div id="portInfo" style="font-size:10px;color:#888;margin:-4px 0 4px 0;">
UART2: TX=GPIO17, RX=GPIO16 ( )
</div>
<div class="fr"><label>Baud Rate</label>
<select id="baud"><option value="1200">1200</option><option value="2400">2400</option><option value="4800">4800</option><option value="9600">9600</option><option value="19200">19200</option><option value="38400">38400</option><option value="57600">57600</option><option value="115200" selected>115200</option><option value="230400">230400</option><option value="460800">460800</option><option value="921600">921600</option><option value="1000000">1M</option><option value="2000000">2M</option></select>
</div>
<div class="fr"><label>Data Bits</label><select id="dBits"><option value="5">5</option><option value="6">6</option><option value="7">7</option><option value="8" selected>8</option></select></div>
<div class="fr"><label>Parity</label><select id="par"><option value="N" selected>None</option><option value="E">Even</option><option value="O">Odd</option></select></div>
<div class="fr"><label>Stop Bits</label><select id="sBits"><option value="1" selected>1</option><option value="2">2</option></select></div>
<button class="abtn" onclick="applySerial()">Apply</button>
</div>
<div class="sgrp">
<h3>System Info</h3>
<div class="fr"><label>AP IP</label><input id="sApIp" readonly></div>
<div class="fr"><label>AP Clients</label><input id="sApCli" readonly></div>
<div class="fr"><label>Free Heap</label><input id="sHeap" readonly></div>
<div class="fr"><label>Uptime</label><input id="sUp" readonly></div>
<button class="abtn" onclick="sendC('sysinfo')" style="background:var(--accent);">Refresh</button>
</div>
<div class="sgrp">
<h3>WiFi STA ( )</h3>
<div class="fr" style="flex-direction:row;align-items:center;gap:10px;">
<label>STA </label>
<label style="position:relative;display:inline-block;width:48px;height:26px;cursor:pointer;">
<input type="checkbox" id="staChk" style="opacity:0;width:0;height:0;" onchange="toggleSTA()">
<span style="position:absolute;inset:0;background:var(--border);border-radius:13px;transition:.3s;"></span>
<span id="staSlider" style="position:absolute;left:2px;top:2px;width:22px;height:22px;background:#fff;border-radius:50%;transition:.3s;"></span>
</label>
<span id="staLabel" style="font-size:12px;color:#888;">OFF</span>
</div>
<div id="staForm" style="display:none;">
<div class="fr"><label>SSID</label><input id="staSSID" type="text" placeholder="WiFi SSID" autocomplete="off"></div>
<div class="fr"><label>Password</label><input id="staPW" type="password" placeholder="WiFi Password" autocomplete="off"></div>
<button class="abtn" onclick="connectSTA()" id="staBtn" style="background:var(--ok);color:#000;">Connect</button>
</div>
<div id="staInfo" style="display:none;margin-top:6px;font-size:12px;">
<div class="fr"><label>STA Status</label><span id="staStatus" style="font-size:13px;">--</span></div>
<div class="fr"><label>STA IP</label><input id="staIpDisp" readonly style="font-size:12px;"></div>
<div class="fr"><label>RSSI</label><input id="staRssiDisp" readonly style="font-size:12px;"></div>
</div>
</div>
<div class="sgrp">
<h3>DS3231 RTC</h3>
<div class="fr"><label>RTC Status</label><span id="rtcSt" style="font-size:13px;color:#888;">--</span></div>
<div class="fr"><label>Time Synced</label><span id="rtcSynced" style="font-size:13px;">--</span></div>
<div class="fr"><label>Sync Count</label><span id="rtcSyncs" style="font-size:13px;color:#aaa;">0</span></div>
<div class="fr"><label>RTC Temp</label><span id="rtcTemp" style="font-size:13px;color:#aaa;">--</span></div>
</div>
<div class="sgrp">
<h3>SD Logging</h3>
<div class="fr"><label>Status</label><span id="logSt" style="color:var(--btn);font-size:13px;">Stopped</span></div>
<div class="fr"><label>Log File</label><input id="logFile" readonly style="font-size:11px;"></div>
<div style="display:flex;gap:8px;margin-top:6px;flex-wrap:wrap;">
<button class="abtn" onclick="sendC('toggle_log')" id="logBtn">Start Logging</button>
<button class="abtn" onclick="sendC('new_log')" style="background:var(--warn);color:#000;">New File</button>
</div>
</div>
<div class="sgrp" id="autoGrp" style="border:1px solid var(--border);">
<h3> (Auto-Start)</h3>
<p style="font-size:11px;color:#999;margin:0 0 8px 0;"> OFFON . .</p>
<div class="fr" style="flex-direction:row;align-items:center;gap:10px;">
<label></label>
<span id="autoSt" style="font-size:13px;color:#888;">OFF</span>
</div>
<button class="abtn" id="autoBtn" onclick="toggleAutoStart()" style="margin-top:8px;background:var(--border);color:var(--text);width:100%;">
</button>
<p id="autoDesc" style="font-size:10px;color:#666;margin:6px 0 0 0;display:none;">
: RTC
</p>
</div>
</div>
</div>
<!-- FILES -->
<div class="page" id="p-files">
<div class="fpage">
<div class="ftool">
<button class="r" onclick="loadFiles()">Refresh</button>
<button class="d" onclick="delSel()">Delete</button>
<button class="dl" onclick="dlSel()">Download</button>
<span class="finfo" id="fInfo">0 files</span>
</div>
<div class="ftable">
<table><thead><tr>
<th style="width:32px;"><input type="checkbox" class="cba" onchange="togAll(this)"></th>
<th>File</th><th>Size</th><th>Date</th><th>Comment</th>
</tr></thead><tbody id="fList"></tbody></table>
</div>
</div>
</div>
</div>
<!-- STATUS BAR -->
<div class="sbar">
<span class="ld" id="lDot"></span>
<span id="lInfo">Log OFF</span>
<span id="serInfo">UART2 115200 8N1</span>
<span id="rxC">RX:0</span>
<span id="txC">TX:0</span>
<span style="margin-left:auto;" id="tInfo">--</span>
</div>
<script>
// ===== Globals =====
let ws=null,rxB=0,txB=0,tLines=0;
const ML=5000;
// ===== Tab Switching =====
document.querySelectorAll('.tabs button').forEach(b=>{
b.addEventListener('click',function(){
document.querySelectorAll('.tabs button').forEach(x=>x.classList.remove('act'));
document.querySelectorAll('.page').forEach(x=>x.classList.remove('act'));
this.classList.add('act');
document.getElementById('p-'+this.dataset.tab).classList.add('act');
if(this.dataset.tab==='files') loadFiles();
if(this.dataset.tab==='settings') sendC('sysinfo');
});
});
// ===== WebSocket (port 81) =====
function wsConn(){
ws=new WebSocket('ws://'+location.hostname+':81');
ws.onopen=function(){
document.getElementById('wsDot').classList.add('on');
document.getElementById('wsSt').textContent='ON';
addSys('[Connected]');
// Send browser time to ESP32 for clock sync (silent - no terminal message)
let now=new Date();
let epoch=Math.floor(now.getTime()/1000);
let ms=now.getMilliseconds();
ws.send(JSON.stringify({cmd:'set_time',epoch:epoch,ms:ms}));
sendC('sysinfo');
sendC('get_serial_config');
sendC('get_wifi');
sendC('get_autostart');
};
ws.onmessage=function(e){
try{
let m=JSON.parse(e.data);
if(m.type==='rx'||m.type==='tx') addLine(m);
else if(m.type==='sysinfo') updSys(m);
else if(m.type==='serial_config') updSer(m);
else if(m.type==='log_status') updLog(m);
else if(m.type==='wifi_status') updWifi(m);
else if(m.type==='autostart_status') updAuto(m);
}catch(x){}
};
ws.onclose=function(){
document.getElementById('wsDot').classList.remove('on');
document.getElementById('wsSt').textContent='OFF';
setTimeout(wsConn,2000);
};
ws.onerror=function(){ws.close();};
}
function sendC(c){if(ws&&ws.readyState===1)ws.send(JSON.stringify({cmd:c}));}
// ===== Terminal =====
function addLine(m){
const t=document.getElementById('term');
const ts=document.getElementById('sTs').checked;
const dm=document.getElementById('dMode').value;
const dir=m.type==='rx'?'RX':'TX';
const cls=m.type==='rx'?'rx':'tx';
let txt=m.data||'';
if(dm==='h') txt=hex(txt);
else if(dm==='b') txt=txt+' ['+hex(txt)+']';
let d=document.createElement('div');
d.className=cls;
let h='';
if(ts&&m.ts) h+='<span class="ts">'+esc(m.ts)+'</span>';
h+='['+dir+'] '+esc(txt);
d.innerHTML=h;
t.appendChild(d);
if(m.type==='rx') rxB+=(m.data||'').length; else txB+=(m.data||'').length;
document.getElementById('rxC').textContent='RX:'+fB(rxB);
document.getElementById('txC').textContent='TX:'+fB(txB);
tLines++;
while(tLines>ML){t.removeChild(t.firstChild);tLines--;}
if(document.getElementById('aScr').checked) t.scrollTop=t.scrollHeight;
}
function addSys(s){
const t=document.getElementById('term');
let d=document.createElement('div');
d.className='sys';
d.textContent=s;
t.appendChild(d);
t.scrollTop=t.scrollHeight;
}
function clrTerm(){
document.getElementById('term').innerHTML='';
tLines=0;rxB=0;txB=0;
document.getElementById('rxC').textContent='RX:0';
document.getElementById('txC').textContent='TX:0';
}
function expTerm(){
const txt=document.getElementById('term').innerText;
const b=new Blob([txt],{type:'text/plain'});
const a=document.createElement('a');
a.href=URL.createObjectURL(b);
a.download='log_'+new Date().toISOString().slice(0,19).replace(/:/g,'')+'.txt';
a.click();
}
// ===== Send Data =====
function txSend(){
const inp=document.getElementById('txIn');
const txt=inp.value;
if(!txt||!ws||ws.readyState!==1) return;
ws.send(JSON.stringify({
cmd:'tx',
data:txt,
lineEnd:document.getElementById('lEnd').value,
mode:document.getElementById('sMode').value
}));
inp.value='';
inp.focus();
}
// Enter key handling (works on both desktop & mobile)
document.getElementById('txIn').addEventListener('keydown',function(e){
if(e.key==='Enter'||e.keyCode===13){
e.preventDefault();
txSend();
}
});
// iOS "Go" / Android "Send" button on virtual keyboard
document.getElementById('txIn').addEventListener('keypress',function(e){
if(e.key==='Enter'||e.keyCode===13){
e.preventDefault();
txSend();
}
});
// Fallback: form-like submit via compositionend (for IME)
document.getElementById('txIn').addEventListener('compositionend',function(e){
// Don't auto-send on composition end - user needs to press Enter
});
// ===== Serial Settings =====
function applySerial(){
if(!ws||ws.readyState!==1){alert('Not connected');return;}
ws.send(JSON.stringify({
cmd:'serial_config',
baud:parseInt(document.getElementById('baud').value),
dataBits:parseInt(document.getElementById('dBits').value),
parity:document.getElementById('par').value,
stopBits:parseInt(document.getElementById('sBits').value)
}));
addSys('[Serial config applied]');
}
function updSer(m){
if(m.baud) document.getElementById('baud').value=m.baud;
if(m.dataBits) document.getElementById('dBits').value=m.dataBits;
if(m.parity) document.getElementById('par').value=m.parity;
if(m.stopBits) document.getElementById('sBits').value=m.stopBits;
// Port selector
if(m.port!==undefined){
document.getElementById('sPort').value=m.port;
updPortInfo(m.port);
}
// Status bar: show port + config
let portLabel=((m.port||0)==1)?'USB':'UART2';
document.getElementById('serInfo').textContent=portLabel+' '+m.baud+' '+m.dataBits+m.parity+m.stopBits;
}
function updPortInfo(p){
let info=document.getElementById('portInfo');
if(p==1){
info.textContent='USB: PC ';
info.style.color='var(--ok)';
}else{
info.textContent='UART2: TX=GPIO17, RX=GPIO16 ( )';
info.style.color='#888';
}
}
function switchPort(){
let port=parseInt(document.getElementById('sPort').value);
if(port===1){
if(!confirm('USB ?\n\nPC의 (PuTTY )\nESP32 COM \n웹 .')) {
document.getElementById('sPort').value='0';
return;
}
}
updPortInfo(port);
if(ws&&ws.readyState===1) ws.send(JSON.stringify({cmd:'switch_port',port:port}));
}
// ===== System Info =====
function updSys(m){
document.getElementById('sApIp').value=m.apIp||'';
document.getElementById('sApCli').value=m.apClients||0;
document.getElementById('sHeap').value=fB(m.heap||0);
document.getElementById('sUp').value=fUp(m.uptime||0);
document.getElementById('tInfo').textContent=m.time||'--';
// STA info from sysinfo
if(m.staOn!==undefined) updWifi(m);
// RTC info
let rs=document.getElementById('rtcSt');
if(m.rtcOk){rs.textContent='OK (DS3231)';rs.style.color='var(--ok)';}
else{rs.textContent='Not found';rs.style.color='var(--btn)';}
let ry=document.getElementById('rtcSynced');
if(m.rtcSync){ry.textContent='Yes';ry.style.color='var(--ok)';}
else{ry.textContent='No';ry.style.color='var(--warn)';}
document.getElementById('rtcSyncs').textContent=m.rtcSyncs||0;
let t=m.rtcTemp;
document.getElementById('rtcTemp').textContent=(t&&t>-100)?(t.toFixed(1)+' °C'):'--';
}
// ===== WiFi STA Control =====
function updWifi(m){
let chk=document.getElementById('staChk');
let slider=document.getElementById('staSlider');
let label=document.getElementById('staLabel');
let form=document.getElementById('staForm');
let info=document.getElementById('staInfo');
chk.checked=m.staOn;
slider.style.transform=m.staOn?'translateX(22px)':'';
slider.parentElement.previousElementSibling.nextElementSibling.style.background=m.staOn?'var(--ok)':'var(--border)';
label.textContent=m.staOn?'ON':'OFF';
label.style.color=m.staOn?'var(--ok)':'#888';
form.style.display=m.staOn?'block':'none';
info.style.display=m.staOn?'block':'none';
if(m.staOn){
if(m.staSSID) document.getElementById('staSSID').value=m.staSSID;
let ss=document.getElementById('staStatus');
if(m.staConn){ss.textContent='Connected';ss.style.color='var(--ok)';}
else{ss.textContent='Disconnected';ss.style.color='var(--btn)';}
document.getElementById('staIpDisp').value=m.staIp||'--';
document.getElementById('staRssiDisp').value=m.staRssi?(m.staRssi+' dBm'):'--';
document.getElementById('staBtn').textContent='Disconnect';
document.getElementById('staBtn').style.background='var(--btn)';
document.getElementById('staBtn').style.color='#fff';
document.getElementById('staBtn').onclick=disconnectSTA;
}else{
document.getElementById('staBtn').textContent='Connect';
document.getElementById('staBtn').style.background='var(--ok)';
document.getElementById('staBtn').style.color='#000';
document.getElementById('staBtn').onclick=connectSTA;
}
}
function toggleSTA(){
let chk=document.getElementById('staChk');
let slider=document.getElementById('staSlider');
let form=document.getElementById('staForm');
let label=document.getElementById('staLabel');
slider.style.transform=chk.checked?'translateX(22px)':'';
slider.parentElement.previousElementSibling.nextElementSibling.style.background=chk.checked?'var(--ok)':'var(--border)';
label.textContent=chk.checked?'ON':'OFF';
label.style.color=chk.checked?'var(--ok)':'#888';
form.style.display=chk.checked?'block':'none';
if(!chk.checked) disconnectSTA();
}
function connectSTA(){
let ssid=document.getElementById('staSSID').value.trim();
let pw=document.getElementById('staPW').value;
if(!ssid){alert('SSID를 ');return;}
let ss=document.getElementById('staStatus');
if(ss) {ss.textContent='Connecting...';ss.style.color='var(--warn)';}
document.getElementById('staInfo').style.display='block';
if(ws&&ws.readyState===1) ws.send(JSON.stringify({cmd:'wifi_sta_on',ssid:ssid,pw:pw}));
}
function disconnectSTA(){
if(ws&&ws.readyState===1) ws.send(JSON.stringify({cmd:'wifi_sta_off'}));
document.getElementById('staInfo').style.display='none';
document.getElementById('staChk').checked=false;
toggleSTA();
}
// ===== Log Status =====
function updLog(m){
const dt=document.getElementById('lDot'),inf=document.getElementById('lInfo');
const st=document.getElementById('logSt'),btn=document.getElementById('logBtn');
if(m.active){
dt.classList.add('rec');inf.textContent='Logging: '+(m.file||'');
st.textContent='Active';st.style.color='var(--ok)';btn.textContent='Stop Logging';
}else{
dt.classList.remove('rec');inf.textContent='Stopped';
st.textContent='Stopped';st.style.color='var(--btn)';btn.textContent='Start Logging';
}
document.getElementById('logFile').value=m.file||'';
}
// ===== Autostart (Persistent Logging) =====
function updAuto(m){
let st=document.getElementById('autoSt');
let btn=document.getElementById('autoBtn');
let grp=document.getElementById('autoGrp');
let desc=document.getElementById('autoDesc');
if(m.enabled){
st.textContent='ON';st.style.color='var(--ok)';
btn.textContent=' ';
btn.style.background='var(--btn)';btn.style.color='#fff';
grp.style.borderColor='var(--ok)';
desc.style.display='block';
}else{
st.textContent='OFF';st.style.color='#888';
btn.textContent=' ';
btn.style.background='var(--border)';btn.style.color='var(--text)';
grp.style.borderColor='var(--border)';
desc.style.display='none';
}
}
function toggleAutoStart(){
let cur=document.getElementById('autoSt').textContent==='ON';
if(!cur){
if(!confirm(' ?\n\n전원이 .\n( )')) return;
}
sendC('toggle_autostart');
}
// ===== File Manager =====
function loadFiles(){
fetch('/api/files').then(r=>r.json()).then(d=>{
const tb=document.getElementById('fList');tb.innerHTML='';
if(!d.files||!d.files.length){
tb.innerHTML='<tr><td colspan="5" style="text-align:center;color:#666;padding:20px;">No files</td></tr>';
document.getElementById('fInfo').textContent='0 files';return;
}
let tot=0;
d.files.forEach(f=>{
tot+=f.size;
let cmt=f.comment||'';
let tr=document.createElement('tr');
tr.innerHTML='<td><input type="checkbox" class="cbf" value="'+esc(f.name)+'"></td>'
+'<td><a class="fl" href="/download?file='+encodeURIComponent(f.name)+'">'+esc(f.name)+'</a></td>'
+'<td class="fs">'+fB(f.size)+'</td>'
+'<td class="fs">'+(f.modified||'').slice(5)+'</td>'
+'<td class="fc" onclick="editCmt(this,\''+esc(f.name)+'\')" title="Click to edit">'
+(cmt?esc(cmt):'<span style="color:#555;font-style:italic;">+memo</span>')+'</td>';
tb.appendChild(tr);
});
document.getElementById('fInfo').textContent=d.files.length+' files ('+fB(tot)+')';
if(d.totalMB!==undefined){
let free=d.totalMB-d.usedMB;
document.getElementById('fInfo').textContent+=
' | SD: '+d.usedMB+'MB/'+d.totalMB+'MB ('+free+'MB free)';
}
}).catch(()=>addSys('[File list error]'));
}
function editCmt(td,fname){
if(td.querySelector('input')) return; // already editing
let old=td.textContent.trim();
if(old==='+memo') old='';
let inp=document.createElement('input');
inp.type='text';inp.value=old;inp.maxLength=100;
inp.style.cssText='width:100%;background:var(--term-bg);color:var(--text);border:1px solid var(--ok);padding:2px 4px;font-size:12px;border-radius:3px;';
inp.placeholder='Add comment...';
td.innerHTML='';td.appendChild(inp);inp.focus();
function save(){
let v=inp.value.trim();
fetch('/api/comment',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({file:fname,comment:v})})
.then(r=>r.json()).then(()=>{
td.innerHTML=v?esc(v):'<span style="color:#555;font-style:italic;">+memo</span>';
}).catch(()=>{td.textContent=old||'+memo';});
}
inp.onblur=save;
inp.onkeydown=function(e){if(e.key==='Enter'){e.preventDefault();inp.blur();}if(e.key==='Escape'){td.innerHTML=old?esc(old):'<span style="color:#555;font-style:italic;">+memo</span>';}};
}
function togAll(cb){document.querySelectorAll('.cbf').forEach(c=>c.checked=cb.checked);}
function getSel(){return Array.from(document.querySelectorAll('.cbf:checked')).map(c=>c.value);}
function delSel(){
const f=getSel();
if(!f.length){alert('Select files first');return;}
if(!confirm('Delete '+f.length+' file(s)?'))return;
fetch('/api/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({files:f})})
.then(r=>r.json()).then(d=>{addSys('[Deleted '+(d.deleted||0)+' files]');loadFiles();})
.catch(()=>alert('Delete failed'));
}
function dlSel(){
const f=getSel();
if(!f.length){alert('Select files first');return;}
f.forEach(n=>{let a=document.createElement('a');a.href='/download?file='+encodeURIComponent(n);a.download=n;a.click();});
}
// ===== Utility =====
function esc(s){let d=document.createElement('div');d.textContent=s;return d.innerHTML;}
function hex(s){return Array.from(s).map(c=>c.charCodeAt(0).toString(16).padStart(2,'0').toUpperCase()).join(' ');}
function fB(b){if(b<1024)return b+'B';if(b<1048576)return(b/1024).toFixed(1)+'K';return(b/1048576).toFixed(1)+'M';}
function fUp(s){let d=Math.floor(s/86400),h=Math.floor((s%86400)/3600),m=Math.floor((s%3600)/60);return d+'d '+h+'h '+m+'m';}
// ===== Init =====
wsConn();
setInterval(()=>sendC('sysinfo'),10000);
// Prevent pull-to-refresh on mobile (interferes with scrolling terminal)
document.body.addEventListener('touchmove',function(e){
if(e.target.closest('#term,.ftable,.spage')) return;
e.preventDefault();
},{passive:false});
</script>
</body>
</html>
)rawliteral";
#endif // WEB_HTML_H