sd file manager ota system

This commit is contained in:
pelgraine
2026-03-27 03:36:20 +11:00
parent 2be399f65a
commit ce93cfa033
3 changed files with 290 additions and 179 deletions

View File

@@ -28,6 +28,7 @@
#include <SD.h> #include <SD.h>
#endif #endif
#include <WebServer.h> #include <WebServer.h>
#include <DNSServer.h>
#include <Update.h> #include <Update.h>
#include <esp_ota_ops.h> #include <esp_ota_ops.h>
#endif #endif
@@ -297,6 +298,7 @@ private:
// File manager state // File manager state
FmPhase _fmPhase; FmPhase _fmPhase;
const char* _fmError; const char* _fmError;
DNSServer* _dnsServer;
#endif #endif
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -416,12 +418,12 @@ private:
// Folder rows for sub-screens // Folder rows for sub-screens
addRow(ROW_CONTACTS_SUBMENU); addRow(ROW_CONTACTS_SUBMENU);
addRow(ROW_CHANNELS_SUBMENU); addRow(ROW_CHANNELS_SUBMENU);
// Info section (stays at top level)
addRow(ROW_INFO_HEADER);
#ifdef MECK_OTA_UPDATE #ifdef MECK_OTA_UPDATE
addRow(ROW_OTA_TOOLS_SUBMENU); addRow(ROW_OTA_TOOLS_SUBMENU);
#endif #endif
// Info section (stays at top level)
addRow(ROW_INFO_HEADER);
addRow(ROW_PUB_KEY); addRow(ROW_PUB_KEY);
addRow(ROW_FIRMWARE); addRow(ROW_FIRMWARE);
@@ -580,6 +582,7 @@ public:
_otaError = nullptr; _otaError = nullptr;
_fmPhase = FM_PHASE_CONFIRM; _fmPhase = FM_PHASE_CONFIRM;
_fmError = nullptr; _fmError = nullptr;
_dnsServer = nullptr;
#endif #endif
} }
@@ -1036,6 +1039,10 @@ public:
_otaServer->handleClient(); _otaServer->handleClient();
} }
} }
// Process DNS for captive portal redirect (file manager only)
if (_dnsServer && _editMode == EDIT_FILEMGR && _fmPhase == FM_PHASE_WAITING) {
_dnsServer->processNextRequest();
}
} }
// Called from main loop — detect upload completion and trigger flash. // Called from main loop — detect upload completion and trigger flash.
@@ -1132,52 +1139,67 @@ public:
Serial.printf("FM: AP '%s' started, IP: %s\n", Serial.printf("FM: AP '%s' started, IP: %s\n",
_otaApName, WiFi.softAPIP().toString().c_str()); _otaApName, WiFi.softAPIP().toString().c_str());
// Start DNS server — redirect ALL DNS lookups to our AP IP.
// This triggers captive portal detection on phones, which opens the
// page in a real browser instead of the restricted captive webview.
if (_dnsServer) { delete _dnsServer; }
_dnsServer = new DNSServer();
_dnsServer->start(53, "*", WiFi.softAPIP());
Serial.println("FM: DNS captive portal started");
// Start web server // Start web server
if (_otaServer) { _otaServer->stop(); delete _otaServer; } if (_otaServer) { _otaServer->stop(); delete _otaServer; }
_otaServer = new WebServer(80); _otaServer = new WebServer(80);
// --- Serve the file manager SPA --- // --- Captive portal detection handlers ---
_otaServer->on("/", HTTP_GET, [this]() { // Phones/OS probe these URLs to detect captive portals. Redirecting
_otaServer->send(200, "text/html", fileMgrPageHTML()); // them to our page causes the OS to open a real browser.
// iOS / macOS
_otaServer->on("/hotspot-detect.html", HTTP_GET, [this]() {
Serial.println("FM: captive probe (Apple)");
_otaServer->sendHeader("Location", "http://192.168.4.1/");
_otaServer->send(302, "text/plain", "");
});
// Android
_otaServer->on("/generate_204", HTTP_GET, [this]() {
Serial.println("FM: captive probe (Android)");
_otaServer->sendHeader("Location", "http://192.168.4.1/");
_otaServer->send(302, "text/plain", "");
});
_otaServer->on("/gen_204", HTTP_GET, [this]() {
_otaServer->sendHeader("Location", "http://192.168.4.1/");
_otaServer->send(302, "text/plain", "");
});
// Windows
_otaServer->on("/connecttest.txt", HTTP_GET, [this]() {
_otaServer->sendHeader("Location", "http://192.168.4.1/");
_otaServer->send(302, "text/plain", "");
});
_otaServer->on("/redirect", HTTP_GET, [this]() {
_otaServer->sendHeader("Location", "http://192.168.4.1/");
_otaServer->send(302, "text/plain", "");
});
// Firefox
_otaServer->on("/canonical.html", HTTP_GET, [this]() {
_otaServer->sendHeader("Location", "http://192.168.4.1/");
_otaServer->send(302, "text/plain", "");
});
_otaServer->on("/success.txt", HTTP_GET, [this]() {
_otaServer->send(200, "text/plain", "success");
}); });
// --- Directory listing: GET /api/ls?path=/ --- // --- Main page: server-rendered directory listing (no JS needed) ---
_otaServer->on("/api/ls", HTTP_GET, [this]() { _otaServer->on("/", HTTP_GET, [this]() {
String path = _otaServer->arg("path"); String path = _otaServer->arg("path");
if (path.isEmpty()) path = "/"; if (path.isEmpty()) path = "/";
File dir = SD.open(path); String msg = _otaServer->arg("msg");
if (!dir || !dir.isDirectory()) { Serial.printf("FM: page request path='%s'\n", path.c_str());
dir.close(); digitalWrite(SDCARD_CS, HIGH); String html = fmBuildPage(path, msg);
_otaServer->send(404, "application/json", "{\"error\":\"Not found\"}"); _otaServer->send(200, "text/html", html);
return;
}
String json = "[";
bool first = true;
File entry;
while ((entry = dir.openNextFile())) {
if (!first) json += ",";
first = false;
// Extract basename — entry.name() may return full path on some ESP32 cores
const char* fullName = entry.name();
const char* baseName = strrchr(fullName, '/');
baseName = baseName ? baseName + 1 : fullName;
json += "{\"n\":\"";
json += baseName;
json += "\",\"s\":";
json += String((unsigned long)entry.size());
json += ",\"d\":";
json += entry.isDirectory() ? "1" : "0";
json += "}";
entry.close();
}
dir.close();
digitalWrite(SDCARD_CS, HIGH);
json += "]";
_otaServer->send(200, "application/json", json);
}); });
// --- File download: GET /api/dl?path=/file.txt --- // --- File download: GET /dl?path=/file.txt ---
_otaServer->on("/api/dl", HTTP_GET, [this]() { _otaServer->on("/dl", HTTP_GET, [this]() {
String path = _otaServer->arg("path"); String path = _otaServer->arg("path");
File f = SD.open(path, FILE_READ); File f = SD.open(path, FILE_READ);
if (!f || f.isDirectory()) { if (!f || f.isDirectory()) {
@@ -1186,14 +1208,11 @@ public:
_otaServer->send(404, "text/plain", "Not found"); _otaServer->send(404, "text/plain", "Not found");
return; return;
} }
// Extract filename for Content-Disposition
String name = path; String name = path;
int lastSlash = name.lastIndexOf('/'); int lastSlash = name.lastIndexOf('/');
if (lastSlash >= 0) name = name.substring(lastSlash + 1); if (lastSlash >= 0) name = name.substring(lastSlash + 1);
_otaServer->sendHeader("Content-Disposition", _otaServer->sendHeader("Content-Disposition",
"attachment; filename=\"" + name + "\""); "attachment; filename=\"" + name + "\"");
// Stream file in chunks — no full-file RAM allocation
size_t fileSize = f.size(); size_t fileSize = f.size();
_otaServer->setContentLength(fileSize); _otaServer->setContentLength(fileSize);
_otaServer->send(200, "application/octet-stream", ""); _otaServer->send(200, "application/octet-stream", "");
@@ -1210,10 +1229,13 @@ public:
digitalWrite(SDCARD_CS, HIGH); digitalWrite(SDCARD_CS, HIGH);
}); });
// --- File upload: POST /api/upload?dir=/ --- // --- File upload: POST /upload?dir=/ → redirect back to listing ---
_otaServer->on("/api/upload", HTTP_POST, _otaServer->on("/upload", HTTP_POST,
[this]() { [this]() {
_otaServer->send(200, "application/json", "{\"ok\":true}"); String dir = _otaServer->arg("dir");
if (dir.isEmpty()) dir = "/";
_otaServer->sendHeader("Location", "/?path=" + dir + "&msg=Upload+complete");
_otaServer->send(303, "text/plain", "Redirecting...");
}, },
[this]() { [this]() {
HTTPUpload& upload = _otaServer->upload(); HTTPUpload& upload = _otaServer->upload();
@@ -1226,14 +1248,10 @@ public:
String fullPath = dir + upload.filename; String fullPath = dir + upload.filename;
Serial.printf("FM: Upload start: %s\n", fullPath.c_str()); Serial.printf("FM: Upload start: %s\n", fullPath.c_str());
fmUploadFile = SD.open(fullPath, FILE_WRITE); fmUploadFile = SD.open(fullPath, FILE_WRITE);
if (!fmUploadFile) { if (!fmUploadFile) Serial.println("FM: Failed to open file for write");
Serial.println("FM: Failed to open file for write");
}
} else if (upload.status == UPLOAD_FILE_WRITE) { } else if (upload.status == UPLOAD_FILE_WRITE) {
if (fmUploadFile) { if (fmUploadFile) fmUploadFile.write(upload.buf, upload.currentSize);
fmUploadFile.write(upload.buf, upload.currentSize);
}
} else if (upload.status == UPLOAD_FILE_END) { } else if (upload.status == UPLOAD_FILE_END) {
if (fmUploadFile) { if (fmUploadFile) {
@@ -1251,24 +1269,33 @@ public:
} }
); );
// --- Create directory: GET /api/mkdir?path=/newfolder --- // --- Create directory: GET /mkdir?name=xxx&dir=/path ---
_otaServer->on("/api/mkdir", HTTP_GET, [this]() { _otaServer->on("/mkdir", HTTP_GET, [this]() {
String path = _otaServer->arg("path"); String dir = _otaServer->arg("dir");
if (path.isEmpty()) { String name = _otaServer->arg("name");
_otaServer->send(400, "application/json", "{\"error\":\"No path\"}"); if (dir.isEmpty()) dir = "/";
if (name.isEmpty()) {
_otaServer->sendHeader("Location", "/?path=" + dir + "&msg=No+name");
_otaServer->send(303);
return; return;
} }
bool ok = SD.mkdir(path); String full = dir + (dir.endsWith("/") ? "" : "/") + name;
bool ok = SD.mkdir(full);
digitalWrite(SDCARD_CS, HIGH); digitalWrite(SDCARD_CS, HIGH);
_otaServer->send(ok ? 200 : 500, "application/json", Serial.printf("FM: mkdir '%s' %s\n", full.c_str(), ok ? "OK" : "FAIL");
ok ? "{\"ok\":true}" : "{\"error\":\"mkdir failed\"}"); _otaServer->sendHeader("Location",
"/?path=" + dir + "&msg=" + (ok ? "Folder+created" : "mkdir+failed"));
_otaServer->send(303);
}); });
// --- Delete file/folder: GET /api/rm?path=/file.txt --- // --- Delete file/folder: GET /rm?path=/file&ret=/parent ---
_otaServer->on("/api/rm", HTTP_GET, [this]() { _otaServer->on("/rm", HTTP_GET, [this]() {
String path = _otaServer->arg("path"); String path = _otaServer->arg("path");
String ret = _otaServer->arg("ret");
if (ret.isEmpty()) ret = "/";
if (path.isEmpty() || path == "/") { if (path.isEmpty() || path == "/") {
_otaServer->send(400, "application/json", "{\"error\":\"Bad path\"}"); _otaServer->sendHeader("Location", "/?path=" + ret + "&msg=Bad+path");
_otaServer->send(303);
return; return;
} }
File f = SD.open(path); File f = SD.open(path);
@@ -1279,8 +1306,44 @@ public:
ok = isDir ? SD.rmdir(path) : SD.remove(path); ok = isDir ? SD.rmdir(path) : SD.remove(path);
} }
digitalWrite(SDCARD_CS, HIGH); digitalWrite(SDCARD_CS, HIGH);
_otaServer->send(ok ? 200 : 500, "application/json", Serial.printf("FM: rm '%s' %s\n", path.c_str(), ok ? "OK" : "FAIL");
ok ? "{\"ok\":true}" : "{\"error\":\"Delete failed\"}"); _otaServer->sendHeader("Location",
"/?path=" + ret + "&msg=" + (ok ? "Deleted" : "Delete+failed"));
_otaServer->send(303);
});
// --- Confirm delete page: GET /confirm-rm?path=/file&ret=/parent ---
_otaServer->on("/confirm-rm", HTTP_GET, [this]() {
String path = _otaServer->arg("path");
String ret = _otaServer->arg("ret");
if (ret.isEmpty()) ret = "/";
String name = path;
int sl = name.lastIndexOf('/');
if (sl >= 0) name = name.substring(sl + 1);
String html = "<!DOCTYPE html><html><head>"
"<meta charset='UTF-8'>"
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
"<title>Confirm Delete</title>"
"<style>"
"body{font-family:-apple-system,sans-serif;max-width:480px;margin:40px auto;"
"padding:0 20px;background:#1a1a2e;color:#e0e0e0;text-align:center}"
".b{display:inline-block;padding:10px 24px;border-radius:6px;text-decoration:none;"
"font-weight:bold;margin:8px;font-size:1em}"
".br{background:#e74c3c;color:#fff}.bg{background:#4ecca3;color:#1a1a2e}"
"</style></head><body>"
"<h2 style='color:#e74c3c'>Delete?</h2>"
"<p style='font-size:1.1em'>" + fmHtmlEscape(name) + "</p>"
"<a class='b br' href='/rm?path=" + fmUrlEncode(path) + "&ret=" + fmUrlEncode(ret) + "'>Delete</a>"
"<a class='b bg' href='/?path=" + fmUrlEncode(ret) + "'>Cancel</a>"
"</body></html>";
_otaServer->send(200, "text/html", html);
});
// Catch-all: redirect unknown URLs to file manager (catches captive portal probes)
_otaServer->onNotFound([this]() {
Serial.printf("FM: redirect %s -> /\n", _otaServer->uri().c_str());
_otaServer->sendHeader("Location", "http://192.168.4.1/");
_otaServer->send(302, "text/plain", "");
}); });
_otaServer->begin(); _otaServer->begin();
@@ -1290,6 +1353,7 @@ public:
void stopFileMgr() { void stopFileMgr() {
if (_otaServer) { _otaServer->stop(); delete _otaServer; _otaServer = nullptr; } if (_otaServer) { _otaServer->stop(); delete _otaServer; _otaServer = nullptr; }
if (_dnsServer) { _dnsServer->stop(); delete _dnsServer; _dnsServer = nullptr; }
WiFi.softAPdisconnect(true); WiFi.softAPdisconnect(true);
WiFi.mode(WIFI_OFF); WiFi.mode(WIFI_OFF);
delay(100); delay(100);
@@ -1303,10 +1367,51 @@ public:
Serial.println("FM: Stopped, AP down, radio resumed"); Serial.println("FM: Stopped, AP down, radio resumed");
} }
// --- File manager SPA HTML --- // --- Helpers for server-rendered HTML ---
static const char* fileMgrPageHTML() {
return static String fmHtmlEscape(const String& s) {
"<!DOCTYPE html><html><head>" String r;
r.reserve(s.length());
for (unsigned int i = 0; i < s.length(); i++) {
char c = s[i];
if (c == '&') r += "&amp;";
else if (c == '<') r += "&lt;";
else if (c == '>') r += "&gt;";
else if (c == '"') r += "&quot;";
else r += c;
}
return r;
}
static String fmUrlEncode(const String& s) {
String r;
for (unsigned int i = 0; i < s.length(); i++) {
char c = s[i];
if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '/' || c == '~') {
r += c;
} else {
char hex[4];
snprintf(hex, sizeof(hex), "%%%02X", (uint8_t)c);
r += hex;
}
}
return r;
}
static String fmFormatSize(size_t bytes) {
if (bytes < 1024) return String(bytes) + " B";
if (bytes < 1048576) return String(bytes / 1024) + " KB";
return String(bytes / 1048576) + "." + String((bytes % 1048576) * 10 / 1048576) + " MB";
}
// Build the complete HTML page with inline directory listing
String fmBuildPage(const String& path, const String& msg) {
String html;
html.reserve(4096);
// --- Head + CSS ---
html += "<!DOCTYPE html><html><head>"
"<meta charset='UTF-8'>"
"<meta name='viewport' content='width=device-width,initial-scale=1'>" "<meta name='viewport' content='width=device-width,initial-scale=1'>"
"<title>Meck SD Files</title>" "<title>Meck SD Files</title>"
"<style>" "<style>"
@@ -1317,123 +1422,127 @@ public:
"font-family:monospace;font-size:0.9em;word-break:break-all}" "font-family:monospace;font-size:0.9em;word-break:break-all}"
".tb{display:flex;gap:6px;margin:8px 0;flex-wrap:wrap}" ".tb{display:flex;gap:6px;margin:8px 0;flex-wrap:wrap}"
".b{background:#4ecca3;color:#1a1a2e;border:none;padding:7px 14px;" ".b{background:#4ecca3;color:#1a1a2e;border:none;padding:7px 14px;"
"border-radius:5px;font-size:0.85em;font-weight:bold;cursor:pointer}" "border-radius:5px;font-size:0.85em;font-weight:bold;cursor:pointer;"
"text-decoration:none;display:inline-block}"
".b:active{background:#3ba88f}" ".b:active{background:#3ba88f}"
".br{background:#e74c3c;color:#fff}.br:active{background:#c0392b}" ".br{background:#e74c3c;color:#fff;padding:3px 8px;font-size:0.75em}.br:active{background:#c0392b}"
"ul{list-style:none;padding:0;margin:0}"
".it{display:flex;align-items:center;padding:8px 4px;border-bottom:1px solid #16213e;gap:6px}" ".it{display:flex;align-items:center;padding:8px 4px;border-bottom:1px solid #16213e;gap:6px}"
".ic{font-size:1.1em;width:22px;text-align:center}" ".ic{font-size:1.1em;width:22px;text-align:center}"
".nm{flex:1;word-break:break-all;cursor:pointer;color:#e0e0e0;text-decoration:none}" ".nm{flex:1;word-break:break-all;color:#e0e0e0;text-decoration:none}"
".nm:hover{color:#4ecca3}" ".nm:hover{color:#4ecca3}"
".sz{color:#888;font-size:0.8em;min-width:54px;text-align:right;margin-right:4px}" ".sz{color:#888;font-size:0.8em;min-width:54px;text-align:right;margin-right:4px}"
".up{background:#16213e;border:2px dashed #4ecca3;border-radius:8px;" ".up{background:#16213e;border:2px dashed #4ecca3;border-radius:8px;"
"padding:14px;margin:10px 0;text-align:center}" "padding:14px;margin:10px 0;text-align:center}"
".up.dg{border-color:#fff;background:#1f2847}"
"#pr{display:none;margin:8px 0}"
".ba{background:#16213e;border-radius:4px;height:18px;overflow:hidden}"
".fi{background:#4ecca3;height:100%;width:0%;transition:width 0.2s}"
".em{color:#888;text-align:center;padding:20px}" ".em{color:#888;text-align:center;padding:20px}"
".mo{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.7);" ".ms{background:#16213e;padding:8px 12px;border-radius:6px;margin:8px 0;"
"display:flex;align-items:center;justify-content:center;z-index:10}" "border-left:3px solid #4ecca3;font-size:0.9em}"
".mb{background:#16213e;padding:18px;border-radius:8px;max-width:280px;text-align:center}" "</style></head><body>";
".mb p{margin:10px 0}.mg{display:flex;gap:8px;justify-content:center}"
"</style></head><body>" // --- Title + path ---
"<h1>Meck SD File Manager</h1>" html += "<h1>Meck SD File Manager</h1>";
"<div class='pa' id='pa'>/</div>" html += "<div class='pa'>" + fmHtmlEscape(path) + "</div>";
"<div class='tb'>"
"<button class='b' onclick='U()'>.. Up</button>" // --- Status message (from redirects) ---
"<button class='b' onclick='MK()'>+ Folder</button>" if (msg.length() > 0) {
"<button class='b' onclick='R()'>Refresh</button>" html += "<div class='ms'>" + fmHtmlEscape(msg) + "</div>";
"</div>" }
"<ul id='ls'></ul>"
"<div class='up' id='dr'>" // --- Navigation buttons ---
"<p>Tap to select files or drag and drop</p>" html += "<div class='tb'>";
"<input type='file' id='fs' multiple onchange='UF(this.files)'>" if (path != "/") {
"</div>" // Compute parent
"<div id='pr'><div class='ba'><div class='fi' id='fi'></div></div>" String parent = path;
"<div id='pt' style='font-size:0.85em;margin-top:4px'></div></div>" if (parent.endsWith("/")) parent = parent.substring(0, parent.length() - 1);
"<div id='mo'></div>" int sl = parent.lastIndexOf('/');
"<script>" parent = (sl <= 0) ? "/" : parent.substring(0, sl);
"var D='/';" html += "<a class='b' href='/?path=" + fmUrlEncode(parent) + "'>.. Up</a>";
"function A(u){return fetch(u).then(function(r){return r.json()})}" }
"function R(){" html += "<a class='b' href='/?path=" + fmUrlEncode(path) + "'>Refresh</a>";
"A('/api/ls?path='+encodeURIComponent(D)).then(function(f){" html += "</div>";
"var l=document.getElementById('ls');"
"document.getElementById('pa').textContent=D;" // --- Directory listing (server-rendered) ---
"if(!f.length){l.innerHTML='<li class=\"em\">Empty folder</li>';return}" File dir = SD.open(path, FILE_READ);
"f.sort(function(a,b){return b.d-a.d||a.n.localeCompare(b.n)});" if (!dir || !dir.isDirectory()) {
"l.innerHTML=f.map(function(e){" if (dir) dir.close();
"var fp=D+(D.endsWith('/')?'':'/')+e.n;" digitalWrite(SDCARD_CS, HIGH);
"return '<li class=\"it\">'" html += "<div class='em'>Cannot open directory</div>";
"+'<span class=\"ic\">'+(e.d?'\\uD83D\\uDCC1':'\\uD83D\\uDCC4')+'</span>'" } else {
"+'<span class=\"nm\" onclick=\"'+(e.d?\"G('\"+E(fp)+\"')\":\"DL('\"+E(fp)+\"')\")+'\">'" // Collect entries into arrays for sorting (dirs first, then alpha)
"+E(e.n)+'</span>'" struct FmEntry { String name; size_t size; bool isDir; };
"+'<span class=\"sz\">'+(e.d?'':SZ(e.s))+'</span>'" FmEntry entries[128]; // max entries to display
"+'<button class=\"b br\" style=\"padding:3px 8px;font-size:0.75em\" " int count = 0;
"onclick=\"RM(\\''+E(fp)+'\\','+e.d+')\">Del</button>'" File entry = dir.openNextFile();
"+'</li>';" while (entry && count < 128) {
"}).join('');" const char* fullName = entry.name();
"}).catch(function(e){document.getElementById('ls').innerHTML=" const char* baseName = strrchr(fullName, '/');
"'<li class=\"em\">Error: '+e+'</li>'});" baseName = baseName ? baseName + 1 : fullName;
"}" entries[count].name = baseName;
"function E(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/'/g,\"\\\\'\")}" entries[count].size = entry.size();
"function SZ(b){" entries[count].isDir = entry.isDirectory();
"if(b<1024)return b+'B';" count++;
"if(b<1048576)return(b/1024).toFixed(1)+'K';" entry.close();
"return(b/1048576).toFixed(1)+'M';" entry = dir.openNextFile();
"}" }
"function G(p){D=p;R()}" dir.close();
"function U(){" digitalWrite(SDCARD_CS, HIGH);
"if(D==='/')return;"
"var p=D.split('/').filter(Boolean);p.pop();" Serial.printf("FM: listing %d entries for '%s'\n", count, path.c_str());
"D=p.length?'/'+p.join('/'):'/';"
"R();" // Sort: dirs first, then alphabetical
"}" for (int i = 0; i < count - 1; i++) {
"function DL(p){window.location='/api/dl?path='+encodeURIComponent(p)}" for (int j = i + 1; j < count; j++) {
"function RM(p,d){" bool swap = false;
"var n=p.split('/').pop();" if (entries[i].isDir != entries[j].isDir) {
"document.getElementById('mo').innerHTML=" swap = !entries[i].isDir && entries[j].isDir;
"'<div class=\"mo\"><div class=\"mb\"><p>Delete '+(d?'folder':'file')+" } else {
"':<br><b>'+E(n)+'</b>?</p><div class=\"mg\">" swap = entries[i].name.compareTo(entries[j].name) > 0;
"+'<button class=\"b br\" onclick=\"DR(\\''+E(p)+'\\')\">" }
"Delete</button><button class=\"b\" onclick=\"CM()\">Cancel</button>" if (swap) {
"+'</div></div></div>';" FmEntry tmp = entries[i];
"}" entries[i] = entries[j];
"function DR(p){CM();A('/api/rm?path='+encodeURIComponent(p)).then(R)}" entries[j] = tmp;
"function CM(){document.getElementById('mo').innerHTML=''}" }
"function MK(){" }
"var n=prompt('New folder name:');if(!n)return;" }
"var p=D+(D.endsWith('/')?'':'/')+n;"
"A('/api/mkdir?path='+encodeURIComponent(p)).then(R);" if (count == 0) {
"}" html += "<div class='em'>Empty folder</div>";
"function UF(fl){" } else {
"if(!fl.length)return;" for (int i = 0; i < count; i++) {
"var pr=document.getElementById('pr'),fi=document.getElementById('fi')," String fp = path + (path.endsWith("/") ? "" : "/") + entries[i].name;
"pt=document.getElementById('pt');" html += "<div class='it'>";
"pr.style.display='block';var i=0;" html += "<span class='ic'>" + String(entries[i].isDir ? "\xF0\x9F\x93\x81" : "\xF0\x9F\x93\x84") + "</span>";
"function nx(){" if (entries[i].isDir) {
"if(i>=fl.length){pr.style.display='none';fi.style.width='0%';" html += "<a class='nm' href='/?path=" + fmUrlEncode(fp) + "'>" + fmHtmlEscape(entries[i].name) + "</a>";
"document.getElementById('fs').value='';R();return;}" } else {
"var f=fl[i];" html += "<a class='nm' href='/dl?path=" + fmUrlEncode(fp) + "'>" + fmHtmlEscape(entries[i].name) + "</a>";
"pt.textContent='Uploading '+f.name+' ('+(i+1)+'/'+fl.length+')';" html += "<span class='sz'>" + fmFormatSize(entries[i].size) + "</span>";
"var fd=new FormData();fd.append('file',f);" }
"var x=new XMLHttpRequest();" html += "<a class='b br' href='/confirm-rm?path=" + fmUrlEncode(fp) + "&ret=" + fmUrlEncode(path) + "'>Del</a>";
"x.open('POST','/api/upload?dir='+encodeURIComponent(D));" html += "</div>";
"x.upload.onprogress=function(e){" }
"if(e.lengthComputable)fi.style.width=Math.round(e.loaded/e.total*100)+'%';" }
"};" }
"x.onload=function(){i++;fi.style.width='0%';nx()};"
"x.onerror=function(){pt.textContent='Error uploading '+f.name};" // --- Upload form (standard HTML form, no JS needed) ---
"x.send(fd);" html += "<div class='up'>"
"}" "<form method='POST' action='/upload?dir=" + fmUrlEncode(path) + "' enctype='multipart/form-data'>"
"nx();" "<p>Select files to upload</p>"
"}" "<input type='file' name='file' multiple><br><br>"
"var dr=document.getElementById('dr');" "<button class='b' type='submit'>Upload</button>"
"dr.ondragover=function(e){e.preventDefault();dr.classList.add('dg')};" "</form></div>";
"dr.ondragleave=function(){dr.classList.remove('dg')};"
"dr.ondrop=function(e){e.preventDefault();dr.classList.remove('dg');UF(e.dataTransfer.files)};" // --- New folder (tiny inline form) ---
"R();" html += "<form action='/mkdir' method='GET' style='margin:8px 0;display:flex;gap:6px'>"
"</script></body></html>"; "<input type='hidden' name='dir' value='" + fmHtmlEscape(path) + "'>"
"<input type='text' name='name' placeholder='New folder name' "
"style='flex:1;padding:7px;border-radius:5px;border:1px solid #4ecca3;"
"background:#16213e;color:#e0e0e0'>"
"<button class='b' type='submit'>Create</button>"
"</form>";
html += "</body></html>";
return html;
} }
#endif #endif
@@ -2121,10 +2230,10 @@ public:
display.print("Start WiFi file server?"); display.print("Start WiFi file server?");
oy += 10; oy += 10;
display.setCursor(bx + 4, oy); display.setCursor(bx + 4, oy);
display.print("Browse, upload and download"); display.print("Upload and download files");
oy += 8; oy += 8;
display.setCursor(bx + 4, oy); display.setCursor(bx + 4, oy);
display.print("SD card files via browser."); display.print("on SD card via browser.");
oy += 10; oy += 10;
display.setCursor(bx + 4, oy); display.setCursor(bx + 4, oy);
display.setColor(DisplayDriver::YELLOW); display.setColor(DisplayDriver::YELLOW);

View File

@@ -64,6 +64,7 @@ build_src_filter = ${esp32_base.build_src_filter}
lib_deps = lib_deps =
${esp32_base.lib_deps} ${esp32_base.lib_deps}
WebServer WebServer
DNSServer
Update Update
; --------------------------------------------------------------------------- ; ---------------------------------------------------------------------------

View File

@@ -97,6 +97,7 @@ lib_deps =
adafruit/Adafruit GFX Library@^1.11.0 adafruit/Adafruit GFX Library@^1.11.0
bitbank2/PNGdec@^1.0.1 bitbank2/PNGdec@^1.0.1
WebServer WebServer
DNSServer
Update Update
; --------------------------------------------------------------------------- ; ---------------------------------------------------------------------------