3 Commity 4265291efb ... ec772c5bb1

Autor SHA1 Wiadomość Data
  Toby Chui ec772c5bb1 Added wip blog engine 3 dni temu
  Toby Chui eec55fbf75 Updated part of the homepage to tocas 3 dni temu
  Toby Chui 8f1c7a0e91 Fixed user cannot login bug 5 dni temu
40 zmienionych plików z 2974 dodań i 332 usunięć
  1. BIN
      firmware/v3/web-server/web-server.ino.d1_mini.bin
  2. BIN
      firmware/v3/web-server/web-server.ino.d1_mini.zip
  3. 492 0
      firmware/v4/web-server/api.ino
  4. 274 0
      firmware/v4/web-server/internal.ino
  5. 121 0
      firmware/v4/web-server/kvdb.ino
  6. BIN
      firmware/v4/web-server/lib/WakeOnLan.zip
  7. 94 0
      firmware/v4/web-server/router.ino
  8. 177 0
      firmware/v4/web-server/server.ino
  9. 240 0
      firmware/v4/web-server/share.ino
  10. 156 0
      firmware/v4/web-server/upload.ino
  11. 216 0
      firmware/v4/web-server/users.ino
  12. 260 0
      firmware/v4/web-server/utils.ino
  13. 138 0
      firmware/v4/web-server/web-server.ino
  14. BIN
      firmware/v4/web-server/web-server.ino.d1_mini.bin
  15. BIN
      firmware/v4/web-server/web-server.ino.d1_mini.zip
  16. BIN
      pcb/v4/BOM_Website-Stick_2025-02-28.csv
  17. BIN
      pcb/v4/Gerber_Website-Stick_webstick-v4_2025-02-28.zip
  18. 22 0
      pcb/v4/PCB_webstick-v4_2025-02-28.json
  19. BIN
      pcb/v4/PickAndPlace_webstick-v4_2025-02-28.csv
  20. 60 100
      sd_card/www/about.html
  21. 88 0
      sd_card/www/admin/blog.html
  22. 4 0
      sd_card/www/admin/fs.html
  23. 82 0
      sd_card/www/admin/img/post-engine.svg
  24. 17 1
      sd_card/www/admin/index.html
  25. 58 0
      sd_card/www/admin/posteng/all.html
  26. BIN
      sd_card/www/down/Rise Above.mp3
  27. BIN
      sd_card/www/down/minecraft.webm
  28. BIN
      sd_card/www/down/music.mp3
  29. BIN
      sd_card/www/down/video.webm
  30. BIN
      sd_card/www/favicon.ico
  31. BIN
      sd_card/www/favicon.png
  32. BIN
      sd_card/www/favicon.psd
  33. BIN
      sd_card/www/img/logo.png
  34. BIN
      sd_card/www/img/logo.psd
  35. BIN
      sd_card/www/img/wallpaper.jpg
  36. 76 121
      sd_card/www/index.html
  37. 74 54
      sd_card/www/login.html
  38. 0 7
      sd_card/www/navi.html
  39. 301 0
      sd_card/www/posts.html
  40. 24 49
      sd_card/www/tool/qr.html

BIN
firmware/v3/web-server/web-server.ino.d1_mini.bin


BIN
firmware/v3/web-server/web-server.ino.d1_mini.zip


+ 492 - 0
firmware/v4/web-server/api.ino

@@ -0,0 +1,492 @@
+/*
+
+    API.ino
+
+    This script handle API requests
+    functions.
+*/
+
+/* Utilities Functions */
+String GetPara(AsyncWebServerRequest *request, String key) {
+  if (request->hasParam(key)) {
+    return request->getParam(key)->value();
+  }
+  return "";
+}
+
+void SendErrorResp(AsyncWebServerRequest *r, String errorMessage) {
+  //Parse the error message into json
+  StaticJsonDocument<200> jsonDocument;
+  JsonObject root = jsonDocument.to<JsonObject>();
+  root["error"] = errorMessage;
+  String jsonString;
+  serializeJson(root, jsonString);
+
+  //Send it out with request handler
+  r->send(200, "application/json", jsonString);
+}
+
+void SendJsonResp(AsyncWebServerRequest *r, String jsonString) {
+  r->send(200, "application/json", jsonString);
+}
+
+void SendOK(AsyncWebServerRequest *r) {
+  r->send(200, "application/json", "\"ok\"");
+}
+
+//Handle auth check if the request has been authed.
+//Return false if user is not authed
+bool HandleAuth(AsyncWebServerRequest *request) {
+  //Handle for API calls authentication validate
+  if (!IsUserAuthed(request)) {
+    //user not logged in
+    request->send(401, "text/html", "401 - Unauthorized");
+    return false;
+  }
+
+  return true;
+}
+
+/*
+
+    Handler Functions
+
+    These are API endpoints handler
+    which handle special API call
+    for backend operations
+
+*/
+
+/* Authentications */
+//Check if the user has logged in
+void HandleCheckAuth(AsyncWebServerRequest *r) {
+  if (IsUserAuthed(r)) {
+    SendJsonResp(r, "true");
+  } else {
+    SendJsonResp(r, "false");
+  }
+}
+
+//Handle login request
+void HandleLogin(AsyncWebServerRequest *r) {
+  String username = GetPara(r, "username");
+  String password = GetPara(r, "password");
+  if (adminUsername == "") {
+    SendErrorResp(r, "admin account not enabled");
+    return;
+  }
+  if (username.equals(adminUsername) && password.equals(adminPassword)) {
+    //Username and password correct. Set a cookie for this login.
+    //Generate a unique cookie for this login session
+    String cookieId = GeneratedRandomHex();
+    Serial.print("Generating new cookie ID ");
+    Serial.println(cookieId);
+
+    String expireUTC = getUTCTimeString(getTime() + 604800);
+    Serial.print("Generating expire UTC timestamp ");
+    Serial.println(expireUTC);
+
+    AsyncWebServerResponse *response = r->beginResponse(200, "application/json", "\"ok\"");
+    response->addHeader("Server", mdnsName);
+    response->addHeader("Cache-Control", "no-cache");
+    response->addHeader("Set-Cookie", "web-auth=" + cookieId + "; Path=/; Expires=" + expireUTC + "; Max-Age=604800");
+
+    //Save the cookie id
+    DBWrite("auth", "cookie", cookieId);
+    authSession = cookieId;
+
+    //Return login succ
+    r->send(response);
+
+    Serial.println(username + " logged in");
+    return;
+  } else if (UserCheckAuth(username, password)) {
+    //User Login. Generate a session for this user
+    String cookieId = GeneratedRandomHex();
+    Serial.print("Generating new cookie ID ");
+    Serial.println(cookieId);
+
+    String expireUTC = getUTCTimeString(getTime() + 604800);
+    Serial.print("Generating expire UTC timestamp ");
+    Serial.println(expireUTC);
+
+    AsyncWebServerResponse *response = r->beginResponse(200, "application/json", "\"ok\"");
+    response->addHeader("Server", mdnsName);
+    response->addHeader("Cache-Control", "no-cache");
+    response->addHeader("Set-Cookie", "web-auth=" + cookieId + "; Path=/; Expires=" + expireUTC + "; Max-Age=604800");
+
+    //Save the cookie id
+    DBWrite("sess", cookieId, username);
+    //Return login succ
+    r->send(response);
+
+    Serial.println(username + " logged in");
+    return;
+  } else {
+    SendErrorResp(r, "invalid username or password");
+    return;
+  }
+  SendOK(r);
+}
+
+//Handle logout request, or you can logout with
+//just front-end by going to log:out@{ip_addr}/api/auth/logout
+void HandleLogout(AsyncWebServerRequest *r) {
+  if (!IsUserAuthed(r)) {
+    SendErrorResp(r, "not logged in");
+    return;
+  }
+
+  //Delete the server side cookie
+  if (IsAdmin(r)) {
+    DBRemove("auth", "cookie");
+    authSession = "";
+  } else {
+    //Get the session from user
+    String authCookie = GetCookieValueByKey(r, "web-auth");
+    if (authCookie == "") {
+      SendErrorResp(r, "unknown error: unable to read cookie from header");
+      return;
+    }
+    //Remove the session map
+    DBRemove("sess", authCookie);
+  }
+
+  //Remove the cookie on client side
+  AsyncWebServerResponse *response = r->beginResponse(200, "application/json", "\"ok\"");
+  response->addHeader("Server", mdnsName);
+  response->addHeader("Cache-Control", "no-cache");
+  response->addHeader("Set-Cookie", "web-auth=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT");
+  r->send(response);
+}
+
+/* File System Functions */
+//HandleListDir handle the listing of directory under /www/
+void HandleListDir(AsyncWebServerRequest *r) {
+  if (!HandleAuth(r)) {
+    return;
+  }
+
+  //Get the folder path to be listed
+  //As ESP8266 dont have enough memory for proper struct to json conv, we are hacking a json string out of a single for-loop
+  String jsonString = "[";
+  String folderSubPath = GetPara(r, "dir");
+  String folderPath = "/www" + folderSubPath;
+  if (SD.exists(folderPath)) {
+    File root = SD.open(folderPath);
+    bool firstObject = true;
+    if (root) {
+      while (true) {
+        File entry = root.openNextFile();
+        if (!entry) {
+          // No more files
+          break;
+        } else {
+          //There are more lines. Add a , to the end of the previous json object
+          if (!firstObject) {
+            jsonString = jsonString + ",";
+          } else {
+            firstObject = false;
+          }
+        }
+        
+        String isDirString = "true";
+        if (!entry.isDirectory()) {
+          isDirString = "false";
+        }
+
+        //Get share UUID, return empty string if not shared
+        //prefix "/www" have to be removed for share entry lookup
+        String shareUUID = GetFileShareIDByFilename(folderSubPath + entry.name());
+        
+        jsonString = jsonString + "{\"Filename\":\"" + entry.name() + "\",\"Filesize\":" + String(entry.size()) + ",\"IsDir\":" + isDirString + ",\"Share\":\"" + shareUUID + "\"}";
+        entry.close();
+      }
+      root.close();
+
+      jsonString += "]";
+      SendJsonResp(r, jsonString);
+    } else {
+      SendErrorResp(r, "path read failed");
+    }
+  } else {
+    SendErrorResp(r, "path not found");
+  }
+  Serial.println(folderPath);
+}
+
+//Handle file rename on SD card
+void HandleFileRename(AsyncWebServerRequest *r) {
+  if (!HandleAuth(r)) {
+    return;
+  }
+  String srcFile = GetPara(r, "src");
+  String destFile = GetPara(r, "dest");
+  srcFile.trim();
+  destFile.trim();
+  srcFile = "/www" + srcFile;
+  destFile = "/www" + destFile;
+  if (!SD.exists(srcFile)) {
+    SendErrorResp(r, "source file not exists");
+    return;
+  }
+
+  if (SD.exists(destFile)) {
+    SendErrorResp(r, "destination file already exists");
+    return;
+  }
+
+  SD.rename(srcFile, destFile);
+  SendOK(r);
+}
+
+
+//Handle file delete on SD card
+void HandleFileDel(AsyncWebServerRequest *r) {
+  if (!HandleAuth(r)) {
+    return;
+  }
+  String targetFile = GetPara(r, "target");
+  targetFile.trim();
+  if (targetFile.equals("/")) {
+    //Do not allow remove the whole root folder
+    SendErrorResp(r, "you cannot remove the root folder");
+    return;
+  }
+  targetFile = "/www" + targetFile;
+  if (!SD.exists(targetFile)) {
+    SendErrorResp(r, "target file not exists");
+    return;
+  }
+
+
+
+  if (!SD.remove(targetFile)) {
+    if (IsDir(targetFile)) {
+      if (!targetFile.endsWith("/")) {
+        //pad the tailing slash
+        targetFile = targetFile + "/";
+      }
+
+      //Try remove dir
+      if (!SD.rmdir(targetFile)) {
+        //The folder might contain stuffs. Do recursive delete
+        Serial.println("rmdir failed. Trying recursive delete");
+        if (recursiveDirRemove(targetFile)) {
+          SendOK(r);
+        } else {
+          SendErrorResp(r, "folder delete failed");
+        }
+
+      } else {
+        SendOK(r);
+      }
+    } else {
+      SendErrorResp(r, "file delete failed");
+    }
+    return;
+  }
+
+  SendOK(r);
+}
+
+//Hanle creating new Folder
+void HandleNewFolder(AsyncWebServerRequest *r) {
+  if (!HandleAuth(r)) {
+    return;
+  }
+
+  //Get and clean the folder path
+  String folderPath = GetPara(r, "path");
+  folderPath.trim();
+  if (folderPath == "") {
+    SendErrorResp(r, "invalid folder path given");
+    return;
+  }
+
+  folderPath = "/www" + folderPath;
+
+  if (SD.exists(folderPath)) {
+    //Already exists
+    SendErrorResp(r, "folder with given path already exists");
+    return;
+  }
+
+  SD.mkdir(folderPath);
+  SendOK(r);
+}
+
+//Handle download file from any path, including the private store folder
+void HandleFileDownload(AsyncWebServerRequest *r) {
+  if (!HandleAuth(r)) {
+    return;
+  }
+  String targetFile = GetPara(r, "file");
+  String preview = GetPara(r, "preview");
+  if (!SD.exists("/www" + targetFile)) {
+    r->send(404, "text/html", "404 - File Not Found");
+    return;
+  }
+
+  //Check if it is dir, ESP8266 have no power of creating zip file
+  if (IsDir("/www" + targetFile)) {
+    r->send(500, "text/html", "500 - Internal Server Error: Target is a folder");
+    return;
+  }
+
+  //Ok. Proceed with file serving
+  if (preview == "true") {
+    //Serve
+    r->send(SDFS, "/www" + targetFile, getMime(targetFile), false);
+  } else {
+    //Download
+    r->send(SDFS, "/www" + targetFile, "application/octet-stream", false);
+  }
+}
+
+//Get the file / folder properties
+void HandleFileProp(AsyncWebServerRequest *r) {
+  if (!HandleAuth(r)) {
+    return;
+  }
+  String filepath = GetPara(r, "file");
+  filepath.trim();
+  String realFilepath = "/www" + filepath;
+
+  String resp = "";
+  if (IsDir(realFilepath)) {
+    //target is a folder
+    uint32_t totalSize = 0;
+    uint16_t fileCount = 0;
+    uint16_t folderCount = 0;
+    analyzeDirectory(realFilepath, totalSize, fileCount, folderCount);
+    resp = "{\"filename\":\"" + basename(filepath) + "\",\"filepath\":\"" + filepath + "\",\"isDir\":true,\"filesize\":" + String(totalSize) + ",\"fileCounts\":" + String(fileCount) + ",\"folderCounts\":" + String(folderCount) + "}";
+
+  } else {
+    //target is a file
+    File targetFile = SD.open(realFilepath);
+    if (!targetFile) {
+      SendErrorResp(r, "File open failed");
+      return;
+    }
+
+    String shareID = GetFileShareIDByFilename(filepath);
+    resp = "{\"filename\":\"" + basename(filepath) + "\",\"filepath\":\"" + filepath + "\",\"isDir\":false,\"filesize\":" + String(targetFile.size()) + ",\"shareid\":\"" + shareID + "\"}";
+    targetFile.close();
+  }
+
+  SendJsonResp(r, resp);
+}
+
+//Search with basic keyword match
+void HandleFileSearch(AsyncWebServerRequest *r) {
+  if (!HandleAuth(r)) {
+    return;
+  }
+  String keyword = GetPara(r, "keyword");
+  keyword.trim();
+  if (keyword.equals("")) {
+    SendErrorResp(r, "keyword cannot be empty");
+    return;
+  }
+
+  //Prepare for the resp
+  AsyncResponseStream *response = r->beginResponseStream("application/json");
+  response->print("[");
+
+  //Recursive search for the whole /www/ directory (Require tailing slash)
+  int foundCounter = 0;
+  scanSDCardForKeyword("/www/", keyword, &foundCounter, response);
+
+  //Send the resp
+  response->print("]");
+  r->send(response);
+}
+/* Handle Load and Set of Prefernece Value */
+//Set Preference, auth user only
+void HandleSetPref(AsyncWebServerRequest *r) {
+  if (!HandleAuth(r)) {
+    return;
+  }
+  String key = GetPara(r, "key");
+  String val = GetPara(r, "value");
+  key.trim();
+  val.trim();
+
+  if (key == "" || val == "") {
+    SendErrorResp(r, "invalid key or value given");
+    return;
+  }
+
+  DBWrite("pref", key, val);
+  SendOK(r);
+}
+
+//Load Prefernece, allow public access
+void HandleLoadPref(AsyncWebServerRequest *r) {
+  String key = GetPara(r, "key");
+  key.trim();
+  if (!DBKeyExists("pref", key)) {
+    SendErrorResp(r, "preference with given key not found");
+    return;
+  }
+
+  String prefValue = DBRead("pref", key);
+  r->send(200, "application/json", "\"" + prefValue + "\"");
+}
+
+/* Handle System Info */
+void HandleLoadSpaceInfo(AsyncWebServerRequest *r) {
+  String jsonResp = "{\
+    \"diskSpace\":" + String(getSDCardTotalSpace())
+                    + ",\
+    \"usedSpace\": " + String(getSDCardUsedSpace())
+                    + "\
+  }";
+
+  SendJsonResp(r, jsonResp);
+}
+
+/* Handle Wake On Lan Request */
+void HandleWakeOnLan(AsyncWebServerRequest *r) {
+  if (!IsUserAuthed(r)) {
+    SendErrorResp(r, "not logged in");
+    return;
+  }
+
+  if (r->hasArg("mac")) {
+    String macAddress = r->arg("mac");
+    Serial.print("Sending WoL packet to: ");
+    Serial.println(macAddress);
+
+    //Send MAC address to both port 9 and 7
+    WOL.sendMagicPacket(macAddress);
+    WOL.sendMagicPacket(macAddress, 7);
+
+    r->send(200, "text/plain", "Received MAC Address: " + macAddress);
+    return;
+  } else {
+    r->send(400, "text/plain", "Missing 'mac' parameter");
+    return;
+  }
+}
+
+//Get the current connected WiFi info
+void HandleWiFiInfo(AsyncWebServerRequest *r) {
+  StaticJsonDocument<256> jsonBuffer;
+  jsonBuffer["SSID"] = WiFi.SSID();
+  jsonBuffer["WifiStatus"] = WiFi.status();
+  jsonBuffer["WifiStrength"] = WiFi.RSSI();
+  jsonBuffer["MAC"] = WiFi.macAddress();
+  jsonBuffer["IP"] = WiFi.localIP().toString();
+  jsonBuffer["Subnet"] = WiFi.subnetMask().toString();
+  jsonBuffer["Gateway"] = WiFi.gatewayIP().toString();
+  jsonBuffer["DNS1"] = WiFi.dnsIP(0).toString();
+  jsonBuffer["DNS2"] = WiFi.dnsIP(1).toString();
+  jsonBuffer["DNS3"] = WiFi.dnsIP(2).toString();
+
+  // Serialize the JSON buffer to a string
+  String jsonString;
+  serializeJson(jsonBuffer, jsonString);
+  SendJsonResp(r, jsonString);
+}

+ 274 - 0
firmware/v4/web-server/internal.ino

@@ -0,0 +1,274 @@
+/*
+
+    WiFi Setup Functions
+
+    To setup WiFi on your Web-stick
+    put a file in your SD card with filename /cfg/wifi.txt
+    In the text file, write two lines which containing the
+    WiFi ssid and password, seperated by a linux new linx \n
+    as follows:
+
+    [WIFI SSID]
+    [WiFi Password]
+*/
+String loadWiFiInfoFromSD() {
+  if (SD.exists("/cfg/wifi.txt")) {
+    String fileContent = "";
+    File configFile = SD.open("/cfg/wifi.txt", FILE_READ);
+
+    if (configFile) {
+      while (configFile.available()) {
+        fileContent += char(configFile.read());
+      }
+      configFile.close();
+    }
+
+    return fileContent;
+  }
+
+  return "";
+}
+
+void splitWiFiFileContent(const String& fileContent, String& ssid, String& password) {
+  int newlineIndex = fileContent.indexOf('\n');
+  if (newlineIndex != -1) {
+    ssid = fileContent.substring(0, newlineIndex);
+    password = fileContent.substring(newlineIndex + 1);
+  }
+}
+
+void initWiFiConn() {
+  String wifiFileContent = loadWiFiInfoFromSD();
+  if (wifiFileContent.equals("")) {
+    while (1) {
+      Serial.println("WiFi info is not set. Please create a file in the SD card named /cfg/wifi.txt with first line as WiFi SSID and 2nd line as Password");
+      delay(3000);
+    }
+  }
+
+  // Split the contents into SSID and password
+  String ssid, password;
+  splitWiFiFileContent(wifiFileContent, ssid, password);
+  ssid.trim();
+  password.trim();
+  WiFi.begin(ssid, password);
+  Serial.print("Connecting to WiFi...");
+  while (WiFi.status() != WL_CONNECTED) {
+    Serial.print(".");
+    delay(1000);
+  }
+  Serial.println(".");
+  Serial.println("WiFi Connected: ");
+  Serial.println("\n\nNetwork Configuration:");
+  Serial.println("----------------------");
+  Serial.print("         SSID: "); Serial.println(WiFi.SSID());
+  Serial.print("  Wifi Status: "); Serial.println(WiFi.status());
+  Serial.print("Wifi Strength: "); Serial.print(WiFi.RSSI()); Serial.println(" dBm");
+  Serial.print("          MAC: "); Serial.println(WiFi.macAddress());
+  Serial.print("           IP: "); Serial.println(WiFi.localIP());
+  Serial.print("       Subnet: "); Serial.println(WiFi.subnetMask());
+  Serial.print("      Gateway: "); Serial.println(WiFi.gatewayIP());
+  Serial.print("        DNS 1: "); Serial.println(WiFi.dnsIP(0));
+  Serial.print("        DNS 2: "); Serial.println(WiFi.dnsIP(1));
+  Serial.print("        DNS 3: "); Serial.println(WiFi.dnsIP(2));
+  Serial.println("----------------------");
+  Serial.println();
+}
+
+/*
+
+    Admin Credential Setup
+
+    For management portal functions, you will
+    need to setup the admin account in order to
+    use it. Place a text file at cfg/admin.txt
+    with the following information
+
+    [admin_username]
+    [admin_password]
+
+    Currently only 1 admin user is supported
+
+*/
+
+String loadAdminCredFromSD() {
+  if (SD.exists("/cfg/admin.txt")) {
+    String fileContent = "";
+    File configFile = SD.open("/cfg/admin.txt", FILE_READ);
+
+    if (configFile) {
+      while (configFile.available()) {
+        fileContent += char(configFile.read());
+      }
+      configFile.close();
+    }
+
+    return fileContent;
+  }
+
+  return "";
+}
+
+void splitAdminCreds(const String& fileContent, String& username, String& password) {
+  int newlineIndex = fileContent.indexOf('\n');
+  if (newlineIndex != -1) {
+    username = fileContent.substring(0, newlineIndex);
+    password = fileContent.substring(newlineIndex + 1);
+  }
+}
+
+void initAdminCredentials() {
+  String adminCredentials = loadAdminCredFromSD();
+  if (adminCredentials.equals("")) {
+    //Disable authentications on API calls
+    return;
+  }
+
+  // Split the contents into username and password
+  splitWiFiFileContent(adminCredentials, adminUsername, adminPassword);
+  adminUsername.trim();
+  adminPassword.trim();
+  Serial.println("Admin user loaded: " + adminUsername);
+}
+
+void initmDNSName() {
+  if (SD.exists("/cfg/mdns.txt")) {
+    String fileContent = "";
+    File configFile = SD.open("/cfg/mdns.txt", FILE_READ);
+
+    if (configFile) {
+      while (configFile.available()) {
+        fileContent += char(configFile.read());
+      }
+      configFile.close();
+    }
+
+    fileContent.trim();
+    mdnsName = fileContent;
+  }
+}
+
+//Load the previous login session key from database
+//for resuming login session after poweroff
+void initLoginSessionKey() {
+  String serverCookie = DBRead("auth", "cookie");
+  if (serverCookie != "") {
+    authSession = serverCookie;
+  }
+}
+
+/*
+
+    MIME Utils
+
+*/
+
+String getMime(const String& path) {
+  String _contentType = "text/plain";
+  if (path.endsWith(".html")) _contentType = "text/html";
+  else if (path.endsWith(".htm")) _contentType = "text/html";
+  else if (path.endsWith(".css")) _contentType = "text/css";
+  else if (path.endsWith(".json")) _contentType = "text/json";
+  else if (path.endsWith(".js")) _contentType = "application/javascript";
+  else if (path.endsWith(".png")) _contentType = "image/png";
+  else if (path.endsWith(".gif")) _contentType = "image/gif";
+  else if (path.endsWith(".jpg")) _contentType = "image/jpeg";
+  else if (path.endsWith(".ico")) _contentType = "image/x-icon";
+  else if (path.endsWith(".svg")) _contentType = "image/svg+xml";
+  else if (path.endsWith(".eot")) _contentType = "font/eot";
+  else if (path.endsWith(".woff")) _contentType = "font/woff";
+  else if (path.endsWith(".woff2")) _contentType = "font/woff2";
+  else if (path.endsWith(".ttf")) _contentType = "font/ttf";
+  else if (path.endsWith(".xml")) _contentType = "text/xml";
+  else if (path.endsWith(".pdf")) _contentType = "application/pdf";
+  else if (path.endsWith(".zip")) _contentType = "application/zip";
+  else if (path.endsWith(".gz")) _contentType = "application/x-gzip";
+  else if (path.endsWith(".mp3")) _contentType = "audio/mpeg";
+  else if (path.endsWith(".mp4")) _contentType = "video/mp4";
+  else if (path.endsWith(".aac")) _contentType = "audio/aac";
+  else if (path.endsWith(".ogg")) _contentType = "audio/ogg";
+  else if (path.endsWith(".wav")) _contentType = "audio/wav";
+  else if (path.endsWith(".m4v")) _contentType = "video/x-m4v";
+  else if (path.endsWith(".webm")) _contentType = "video/webm";
+  else _contentType = "text/plain";
+  return _contentType;
+}
+
+/*
+
+    Get ESP Info
+*/
+
+void printESPInfo() {
+  Serial.println(ESP.getBootMode());
+  Serial.print("ESP.getSdkVersion(); ");
+  Serial.println(ESP.getSdkVersion());
+  Serial.print("ESP.getBootVersion(); ");
+  Serial.println(ESP.getBootVersion());
+  Serial.print("ESP.getChipId(); ");
+  Serial.println(ESP.getChipId());
+  Serial.print("ESP.getFlashChipSize(); ");
+  Serial.println(ESP.getFlashChipSize());
+  Serial.print("ESP.getFlashChipRealSize(); ");
+  Serial.println(ESP.getFlashChipRealSize());
+  Serial.print("ESP.getFlashChipSizeByChipId(); ");
+  Serial.println(ESP.getFlashChipSizeByChipId());
+  Serial.print("ESP.getFlashChipId(); ");
+  Serial.println(ESP.getFlashChipId());
+}
+
+/*
+
+    Get SD card info
+
+*/
+
+uint32_t getTotalUsedSpace(const String& directory) {
+  uint32_t totalUsedSpace = 0;
+
+  File root = SD.open(directory);
+  if (root) {
+    while (true) {
+      File entry = root.openNextFile();
+      if (!entry) {
+        // No more files
+        break;
+      }
+
+      if (entry.isDirectory()) {
+        // Recursive call for subdirectory
+        totalUsedSpace += getTotalUsedSpace(directory + "/" + entry.name());
+      } else {
+        //Serial.print(entry.name());
+        //Serial.print(" | Size: ");
+        //Serial.println(humanReadableSize(entry.size()));
+        totalUsedSpace += entry.size();
+      }
+
+      entry.close();
+    }
+    root.close();
+  }
+
+  return totalUsedSpace;
+}
+
+//Return the total used space on SD card
+long getSDCardUsedSpace() {
+  uint32_t totalUsedSpace = getTotalUsedSpace("/");
+  Serial.println("Total used space: " + humanReadableSize(totalUsedSpace));
+  return totalUsedSpace;
+}
+
+//Get the total SD card size
+long getSDCardTotalSpace() {
+  Serial.println("SD card size: " + humanReadableSize(SD.size64()));
+  return SD.size64();
+}
+
+String humanReadableSize(const int bytes) {
+  if (bytes < 1024) return String(bytes) + " B";
+  else if (bytes < (1024 * 1024)) return String(bytes / 1024.0) + " KB";
+  else if (bytes < (1024 * 1024 * 1024)) return String(bytes / 1024.0 / 1024.0) + " MB";
+  else return String(bytes / 1024.0 / 1024.0 / 1024.0) + " GB";
+}

+ 121 - 0
firmware/v4/web-server/kvdb.ino

@@ -0,0 +1,121 @@
+/*
+
+   Key Value database
+
+   This is a file system based database
+   that uses foldername as table name,
+   filename as key and content as value
+
+   Folder name and filename are limited to
+   5 characters as SDFS requirements.
+*/
+
+//Root of the db on SD card, **must have tailing slash**
+const String DB_root = "/db/";
+
+//Clean the input for any input string
+String DBCleanInput(const String& inputString) {
+  String trimmedString = inputString;
+  //Replae all the slash that might breaks the file system
+  trimmedString.replace("/", "");
+  //Trim off the space before and after the string
+  trimmedString.trim();
+  return trimmedString;
+}
+
+//Database init create all the required table for basic system operations
+void DBInit() {
+  /* Preference persistent store */
+  DBNewTable("pref"); //Preference settings
+  /* User Authentications Tables */
+  DBNewTable("auth"); //Auth session store
+  DBNewTable("user"); //User accounts store
+  DBNewTable("sess"); //Session store
+  /* Share System Tables */
+  DBNewTable("shln");//Shared links to filename map
+  DBNewTable("shfn");//Shared filename to links map
+}
+
+//Create a new Database table
+void DBNewTable(String tableName) {
+  tableName = DBCleanInput(tableName);
+  if (!SD.exists(DB_root + tableName)) {
+    SD.mkdir(DB_root + tableName);
+    Serial.println("KVDB table created "+ tableName);
+  }
+}
+
+
+//Check if a database table exists
+bool DBTableExists(String tableName) {
+  tableName = DBCleanInput(tableName);
+  return SD.exists(DB_root + tableName);
+}
+
+//Write a key to a table, return true if succ
+bool DBWrite(String tableName, String key, String value) {
+  if (!DBTableExists(tableName)) {
+    Serial.println("KVDB table name "+ tableName + " not exists!");
+    return false;
+  }
+  tableName = DBCleanInput(tableName);
+  key = DBCleanInput(key);
+  String fsDataPath = DB_root + tableName + "/" + key;
+  if (SD.exists(fsDataPath)) {
+    //Entry already exists. Delete it
+    SD.remove(fsDataPath);
+  }
+
+  //Write new data to it
+  File targetEntry = SD.open(fsDataPath, FILE_WRITE);
+  targetEntry.print(value);
+  targetEntry.close();
+  Serial.println("KVDB Entry written to: "+ fsDataPath);
+  return true;
+}
+
+//Read from database, require table name and key
+String DBRead(String tableName, String key) {
+  if (!DBTableExists(tableName)) {
+    return "";
+  }
+  tableName = DBCleanInput(tableName);
+  key = DBCleanInput(key);
+  String fsDataPath = DB_root + tableName + "/" + key;
+  if (!SD.exists(fsDataPath)) {
+    //Target not exists. Return empty string
+    return "";
+  }
+
+  String value = "";
+  File targetEntry = SD.open(fsDataPath, FILE_READ);
+  while (targetEntry.available()) {
+    value = value + targetEntry.readString();
+  }
+
+  targetEntry.close();
+  return value;
+}
+
+//Check if a given key exists in the database
+bool DBKeyExists(String tableName, String key) {
+  if (!DBTableExists(tableName)) {
+    return false;
+  }
+  tableName = DBCleanInput(tableName);
+  key = DBCleanInput(key);
+  String fsDataPath = DB_root + tableName + "/" + key;
+  return SD.exists(fsDataPath);
+}
+
+//Remove the key-value item from db, return true if succ
+bool DBRemove(String tableName, String key) {
+  if (!DBKeyExists(tableName, key)){
+    return false;
+  }
+  tableName = DBCleanInput(tableName);
+  key = DBCleanInput(key);
+  String fsDataPath = DB_root + tableName + "/" + key;
+  SD.remove(fsDataPath);
+  return true;
+}

BIN
firmware/v4/web-server/lib/WakeOnLan.zip


+ 94 - 0
firmware/v4/web-server/router.ino

@@ -0,0 +1,94 @@
+/*
+
+    Router.ino
+
+    This is the main router of the whole web server.
+    It is like the apache.conf where you handle routing
+    to different services.
+
+    By default, all route will go to the SD card /www/ folder
+*/
+class MainRouter : public AsyncWebHandler {
+public:
+  MainRouter() {}
+  virtual ~MainRouter() {}
+
+  bool canHandle(AsyncWebServerRequest *request) {
+    String requestURI = request->url().c_str();
+    if (requestURI.equals("/upload")) {
+      //File Upload Endpoint
+      return false;
+    } else if (requestURI.startsWith("/api/")) {
+      //API paths
+      return false;
+    } else if (requestURI.startsWith("/share/")) {
+      //Share paths
+      return false;
+    } 
+    return true;
+  }
+
+  //Main Routing Logic Here
+  void handleRequest(AsyncWebServerRequest *request) {
+    String requestURI = request->url().c_str();
+
+    /* Rewrite the request path if URI contains ./ */
+    if (requestURI.indexOf("./") > 0) {
+      requestURI.replace("./", "");
+      AsyncWebServerResponse *response = request->beginResponse(307);
+      response->addHeader("Cache-Control", "no-cache");
+      response->addHeader("Location", requestURI);
+      request->send(response);
+      return;
+    }
+
+    /* Special Routing Rules */
+    //Redirect / back to index.html
+    if (requestURI == "/") {
+      request->redirect("/index.html");
+      return;
+    }
+
+    //Special interfaces that require access controls
+    if (requestURI.startsWith("/store/")) {
+      //Private file storage. Not allow access
+      AsyncWebServerResponse *response = request->beginResponse(401, "text/html", "403 - Forbidden");
+      request->send(response);
+      return;
+    }
+
+    /* Default Routing Rules */
+    Serial.println("URI: " + requestURI + " | MIME: " + getMime(requestURI));
+    //Check if the file exists on the SD card
+    if (SD.exists("/www" + requestURI)) {
+      // File exists on SD card web root
+      if (IsDir("/www" + requestURI)) {
+        //Requesting a directory
+        if (!requestURI.endsWith("/")) {
+          //Missing tailing slash
+          request->redirect(requestURI + "/");
+          return;
+        }
+
+        if (SD.exists("/www" + requestURI + "index.html")) {
+          request->send(SDFS, "/www" + requestURI + "/index.html", "text/html", false);
+        } else {
+          HandleDirRender(request, requestURI, "/www" + requestURI);
+        }
+
+      } else {
+        request->send(SDFS, "/www" + requestURI, getMime(requestURI), false);
+      }
+    } else {
+      // File does not exist in web root
+      AsyncResponseStream *response = request->beginResponseStream("text/html");
+      Serial.println("NOT FOUND: " + requestURI);
+      prettyPrintRequest(request);
+      response->print("<!DOCTYPE html><html><head><title>Not Found</title></head><body>");
+      response->print("<p>404 - Not Found</p>");
+      response->printf("<p>Requesting http://%s with URI: %s</p>", request->host().c_str(), request->url().c_str());
+      response->print("</body></html>");
+      request->send(response);
+    }
+  }
+};

+ 177 - 0
firmware/v4/web-server/server.ino

@@ -0,0 +1,177 @@
+/*
+
+    Web Server
+
+    This is the main entry point of the WebStick bare metal
+    web server. If you have exception rules that shall not
+    be handled by the main router, you can do them here.
+
+*/
+
+//Check if a user is authenticated / logged in
+bool IsUserAuthed(AsyncWebServerRequest *request) {
+  if (request->hasHeader("Cookie")) {
+    //User cookie from browser
+    String authCookie = GetCookieValueByKey(request, "web-auth");
+    if (authCookie == "") {
+      return false;
+    }
+    
+    //Check if it is user login (no state keeping)
+    bool isUserLogin = DBKeyExists("sess", authCookie);
+    if (isUserLogin){
+      //User login
+      return true;
+    }
+
+    //Check if it is admin login (state keeping)
+    if (authSession == "") {
+      //Server side has no resumable login session
+      return false;
+    }
+
+    bool isAdminLogin = authCookie.equals(authSession);
+    if (isAdminLogin) {
+      //Admin login
+      return true;
+    }
+    
+    return false;
+  } else {
+    Serial.println("Cookie Missing");
+    return false;
+  }
+}
+
+//Check if a user is authenticated and is Admin
+bool IsAdmin(AsyncWebServerRequest *request) {
+  if (request->hasHeader("Cookie")) {
+    //User cookie from browser
+    String authCookie = GetCookieValueByKey(request, "web-auth");
+    if (authCookie == "") {
+      return false;
+    }
+
+    //Match it to the server side value in kvdb
+    if (authSession == "") {
+      //Server side has no resumable login session
+      return false;
+    }
+    if (authCookie.equals(authSession)) {
+      return true;
+    }
+    return false;
+  } else {
+    return false;
+  }
+}
+
+
+//Reply the request by a directory list
+void HandleDirRender(AsyncWebServerRequest *r, String dirName, String dirToList) {
+  AsyncResponseStream *response = r->beginResponseStream("text/html");
+  //Serve directory entries
+  File directory = SD.open(dirToList);
+
+  // Check if the directory is open
+  if (!directory) {
+    SendErrorResp(r, "unable to open directory");
+    return;
+  }
+
+  response->print("<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>Content of " + dirName + "</title></head><body style=\"margin: 3em;font-family: Arial;\">");
+  response->print("<h3>Content of " + dirName + "</h3><div style=\"width: 100%;border-bottom: 1px solid #d9d9d9;\"></div><ul>");
+  // List the contents of the directory
+  while (true) {
+    File entry = directory.openNextFile();
+    if (!entry) {
+      // No more files
+      break;
+    }
+
+    // Print the file name
+    response->print("<li><a href=\"./" + String(entry.name()) + "\">");
+    response->print(entry.name());
+    response->print(" (" + humanReadableSize(entry.size()) + ")</a></li>");
+    Serial.println(entry.name());
+
+    entry.close();
+  }
+
+  // Close the directory
+  directory.close();
+
+  response->print("</ul><div style=\"width: 100%;border-bottom: 1px solid #d9d9d9;\"></div><br><a href=\"../\">Back</a>");
+  response->print("<br><br><body></html>");
+  r->send(response);
+}
+
+
+void initWebServer() {
+  /*
+     Other handles here, like this
+    server.on("/logout", HTTP_GET, [](AsyncWebServerRequest *request){
+    request->send(401);
+    });
+  */
+
+  /*
+  server.on("/test", HTTP_GET, [](AsyncWebServerRequest * request) {
+    getSDCardUsedSpace();
+    request->send(200);
+  });
+  */
+
+  /* Authentication Functions */
+  server.on("/api/auth/chk", HTTP_GET, HandleCheckAuth);
+  server.on("/api/auth/login", HTTP_POST, HandleLogin);
+  server.on("/api/auth/logout", HTTP_GET, HandleLogout);
+
+  /* User System Functions */
+  server.on("/api/user/info", HTTP_GET, HandleGetUserinfo);
+  server.on("/api/user/new", HTTP_POST, HandleNewUser);
+  server.on("/api/user/chpw", HTTP_POST, HandleUserChangePassword);
+  server.on("/api/user/del", HTTP_POST, HandleRemoveUser);
+  server.on("/api/user/list", HTTP_GET, HandleUserList);
+
+  /* File System Functions */
+  server.on("/api/fs/list", HTTP_GET, HandleListDir);
+  server.on("/api/fs/del", HTTP_POST, HandleFileDel);
+  server.on("/api/fs/move", HTTP_POST, HandleFileRename);
+  server.on("/api/fs/download", HTTP_GET, HandleFileDownload);
+  server.on("/api/fs/newFolder", HTTP_POST, HandleNewFolder);
+  server.on("/api/fs/disk", HTTP_GET, HandleLoadSpaceInfo);
+  server.on("/api/fs/properties", HTTP_GET, HandleFileProp);
+  server.on("/api/fs/search", HTTP_GET, HandleFileSearch);
+
+  /* File Share Functions */
+  server.on("/api/share/new", HTTP_POST, HandleCreateShare);
+  server.on("/api/share/del", HTTP_POST, HandleRemoveShare);
+  server.on("/api/share/list", HTTP_GET, HandleShareList);
+  server.on("/api/share/clean", HTTP_GET, HandleShareListCleaning);
+  server.on("/share", HTTP_GET, HandleShareAccess);
+
+  /* Preference */
+  server.on("/api/pref/set", HTTP_GET, HandleSetPref);
+  server.on("/api/pref/get", HTTP_GET, HandleLoadPref);
+
+  /* Others */
+  server.on("/api/info/wifi", HTTP_GET, HandleWiFiInfo); //Show WiFi Information
+  server.on("/api/wol", HTTP_GET, HandleWakeOnLan); //Handle WoL request
+
+  //File upload handler. see upload.ino
+  server.onFileUpload(handleFileUpload);
+
+  //Not found handler
+  server.onNotFound([](AsyncWebServerRequest *request) {
+    //Generally it will not arrive here as NOT FOUND is also handled in the main router.
+    //See router.ino for implementation details.
+    prettyPrintRequest(request);
+    request->send(404, "text/plain", "Not Found");
+  });
+
+  //Main Router, see router.ino
+  server.addHandler(new MainRouter());
+
+  server.begin();
+}

+ 240 - 0
firmware/v4/web-server/share.ino

@@ -0,0 +1,240 @@
+/*
+    Share.ino
+
+    This module handle file sharing on the WebSticks
+    Recommended file size <= 5MB
+
+*/
+
+//Create a file share, must be logged in
+void HandleCreateShare(AsyncWebServerRequest *r) {
+  if (!HandleAuth(r)) {
+    return;
+  }
+
+  //Get filename from parameters
+  String filepath = GetPara(r, "filename");
+  filepath.trim();
+  //filepath is the subpath under the www folder
+  // e.g. "/www/myfile.txt" filepath will be "/myfile.txt"
+  if (filepath == "") {
+    SendErrorResp(r, "invalid filename given");
+    return;
+  }
+
+  if (IsFileShared(filepath)) {
+    SendErrorResp(r, "target file already shared");
+    return;
+  }
+
+  //Add a share entry for this file
+  String shareID = GeneratedRandomHex();
+  bool succ = DBWrite("shln", shareID, filepath);
+  if (!succ) {
+    SendErrorResp(r, "unable to save share entry");
+    return;
+  }
+  succ = DBWrite("shfn", filepath, shareID);
+  if (!succ) {
+    SendErrorResp(r, "unable to save share entry");
+    return;
+  }
+
+  Serial.println("Shared: " + filepath + " with ID: " + shareID);
+  SendOK(r);
+}
+
+//Remove a file share
+void HandleRemoveShare(AsyncWebServerRequest *r) {
+  if (!HandleAuth(r)) {
+    return;
+  }
+
+  //Get filename from parameters
+  String filepath = GetPara(r, "filename");
+  filepath.trim();
+  if (filepath == "") {
+    SendErrorResp(r, "invalid filename given");
+    return;
+  }
+
+  if (!IsFileShared(filepath)) {
+    SendErrorResp(r, "target file is not shared");
+    return;
+  }
+
+  //Get the share ID of this entry
+  String shareId = DBRead("shfn", filepath);
+  if (shareId == "") {
+    SendErrorResp(r, "unable to load share entry");
+    return;
+  }
+
+  //Remove share entry in both tables
+  bool succ = DBRemove("shln", shareId);
+  if (!succ) {
+    SendErrorResp(r, "unable to remove share entry");
+    return;
+  }
+  succ = DBRemove("shfn", filepath);
+  if (!succ) {
+    SendErrorResp(r, "unable to remove share entry");
+    return;
+  }
+
+  Serial.println("Removed shared file " + filepath + " (share ID: " + shareId + ")");
+  SendOK(r);
+}
+
+//List all shared files
+void HandleShareList(AsyncWebServerRequest *r) {
+  if (!HandleAuth(r)) {
+    return;
+  }
+
+  //Build the json with brute force
+  String jsonString = "[";
+  //As the DB do not support list, it directly access the root of the folder where the kvdb stores the entries
+  File root = SD.open(DB_root + "shln/");
+  bool firstObject = true;
+  if (root) {
+    while (true) {
+      File entry = root.openNextFile();
+      if (!entry) {
+        // No more files
+        break;
+      } else {
+        //There are more lines. Add a , to the end of the previous json object
+        if (!firstObject) {
+          jsonString = jsonString + ",";
+        } else {
+          firstObject = false;
+        }
+
+        //Filter out all the directory if any
+        if (entry.isDirectory()) {
+          continue;
+        }
+
+        //Read the filename from file
+        String filename = "";
+        while (entry.available()) {
+          filename = filename + entry.readString();
+        }
+
+        //Append to the JSON line
+        jsonString = jsonString + "{\"filename\":\"" + basename(filename) + "\", \"filepath\":\"" + filename + "\", \"shareid\":\"" + entry.name() + "\"}";
+      }
+    }
+  }
+  jsonString += "]";
+
+  r->send(200, "application/json", jsonString);
+}
+
+//Serve a shared file, do not require login
+void HandleShareAccess(AsyncWebServerRequest *r) {
+  String shareID = GetPara(r, "id");
+  if (shareID == "") {
+    r->send(404, "text/plain", "Not Found");
+    return;
+  }
+
+  //Download request
+  String sharedFilename = GetFilenameFromShareID(shareID);
+  if (sharedFilename == "") {
+    r->send(404, "text/plain", "Share not found");
+    return;
+  }
+
+  //Check if the file still exists on SD card
+  String realFilepath = "/www" + sharedFilename;
+  File targetFile = SD.open(realFilepath);
+  if (!targetFile) {
+    r->send(404, "text/plain", "Shared file no longer exists");
+    return;;
+  }
+
+
+  if (r->hasParam("download")) {
+    //Serve the file
+    r->send(SDFS, realFilepath, getMime(sharedFilename), false);
+  } else if (r->hasParam("prop")) {
+    //Serve the file properties
+    File targetFile = SD.open(realFilepath);
+    if (!targetFile) {
+      SendErrorResp(r, "File open failed");
+      return;
+    }
+
+    String resp = "{\"filename\":\"" + basename(sharedFilename) + "\",\"filepath\":\"" + sharedFilename + "\",\"isDir\":false,\"filesize\":" + String(targetFile.size()) + ",\"shareid\":\"" + shareID + "\"}";
+    targetFile.close();
+    SendJsonResp(r, resp);
+  } else {
+    //serve download UI template
+    r->send(SDFS, "/www/admin/share.html", "text/html", false);
+    return;
+  }
+}
+
+//Clear the shares that no longer exists
+void HandleShareListCleaning(AsyncWebServerRequest *r) {
+  if (!HandleAuth(r)) {
+    return;
+  }
+
+  File root = SD.open(DB_root + "shln/");
+  bool firstObject = true;
+  if (root) {
+    while (true) {
+      File entry = root.openNextFile();
+      if (!entry) {
+        // No more files
+        break;
+      } else {
+        //Filter out all the directory if any
+        if (entry.isDirectory()) {
+          continue;
+        }
+
+        //Read the filename from file
+        String filename = "";
+        while (entry.available()) {
+          filename = filename + entry.readString();
+        }
+
+        //Check if the target file still exists
+        File targetFile = SD.open("/www" + filename);
+        if (!targetFile) {
+          //File no longer exists. Remove this share entry
+          DBRemove("shln", entry.name());
+          DBRemove("shfn", filename);
+        } else {
+          //File still exists.
+          targetFile.close();
+        }
+      }
+    }
+  }
+
+  SendOK(r);
+}
+
+
+//Get the file share ID from filename, return empty string if not shared
+String GetFileShareIDByFilename(String filepath) {
+  return DBRead("shfn", filepath);
+}
+
+//Get the filename (without /www prefix) from share id
+// return empty string if not found
+String GetFilenameFromShareID(String shareid) {
+  return DBRead("shln", shareid);
+}
+
+
+//Check if a file is shared
+bool IsFileShared(String filepath) {
+  //Check if the file is shared
+  return DBKeyExists("shfn", filepath);
+}

+ 156 - 0
firmware/v4/web-server/upload.ino

@@ -0,0 +1,156 @@
+/*
+
+    Upload.ino
+
+    This script handles file upload to the web-stick
+    by default this function require authentication.
+    Hence, admin.txt must be set before use
+
+*/
+
+void handleFileUpload(AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) {
+  // make sure authenticated before allowing upload
+  if (IsUserAuthed(request)) {
+    String logmessage = "";
+    //String logmessage = "Client:" + request->client()->remoteIP().toString() + " " + request->url();
+    //Serial.println(logmessage);
+
+    //Rewrite the filename if it is too long
+    filename = trimFilename(filename);
+
+    //Get the dir to store the file
+    String dirToStore = GetPara(request, "dir");
+    if (!dirToStore.startsWith("/")) {
+      dirToStore = "/" + dirToStore;
+    }
+
+    if (!dirToStore.endsWith("/")) {
+      dirToStore = dirToStore + "/";
+    }
+    dirToStore = "/www" + dirToStore;
+
+    if (!index) {
+      Serial.println("Selected Upload Dir: " + dirToStore);
+      logmessage = "Upload Start: " + String(filename) + " by " + request->client()->remoteIP().toString();
+      // open the file on first call and store the file handle in the request object
+      if (!SD.exists(dirToStore)) {
+        SD.mkdir(dirToStore);
+      }
+
+      //Already exists. Overwrite
+      if (SD.exists(dirToStore + filename)) {
+        SD.remove(dirToStore + filename);
+      }
+      request->_tempFile = SD.open(dirToStore + filename, FILE_WRITE);
+      Serial.println(logmessage);
+    }
+
+    if (len) {
+      // stream the incoming chunk to the opened file
+      request->_tempFile.write(data, len);
+      //logmessage = "Writing file: " + String(filename) + " index=" + String(index) + " len=" + String(len);
+      //Serial.println(logmessage);
+    }
+
+    if (final) {
+      logmessage = "Upload Complete: " + String(filename) + ",size: " + String(index + len);
+      // close the file handle as the upload is now done
+      request->_tempFile.close();
+      Serial.println(logmessage);
+
+      //Check if the file actually exists on SD card
+      if (!SD.exists(String(dirToStore + filename))) {
+        //Not found!
+        SendErrorResp(request, "Write failed for " + String(filename) + ". Try a shorter name!");
+        return;
+      }
+      request->send(200, "application/json", "ok");
+    }
+  } else {
+    Serial.println("Auth: Failed");
+    SendErrorResp(request, "unauthorized");
+  }
+}
+
+/*
+    Upload File Trimming
+
+    This trim the given uploading filename to less than 32 chars
+    if the filename is too long to fit on the SD card.
+
+    The code handle UTF-8 trimming at the bytes level.
+*/
+
+//UTF-8 is varaible in length, this get how many bytes in the coming sequences
+//are part of this UTF-8 word
+uint8_t getUtf8CharLength(const uint8_t firstByte) {
+  if ((firstByte & 0x80) == 0) {
+    // Single-byte character
+    return 1;
+  } else if ((firstByte & 0xE0) == 0xC0) {
+    // Two-byte character
+    return 2;
+  } else if ((firstByte & 0xF0) == 0xE0) {
+    // Three-byte character
+    return 3;
+  } else if ((firstByte & 0xF8) == 0xF0) {
+    // Four-byte character
+    return 4;
+  } else {
+    // Invalid UTF-8 character
+    return 0;
+  }
+}
+
+String filterBrokenUtf8(const String& input) {
+  String result;
+  size_t inputLength = input.length();
+  size_t i = 0;
+  while (i < inputLength) {
+    uint8_t firstByte = input[i];
+    uint8_t charLength = getUtf8CharLength(firstByte);
+
+    if (charLength == 0){
+       //End of filter
+       break;
+    }
+    
+    // Check if the character is complete (non-broken UTF-8)
+    if (i + charLength <= inputLength) {
+      // Check for invalid UTF-8 continuation bytes in the character
+      for (size_t j = 0; j < charLength; j++) {
+        result += input[i];
+        i++;
+      }
+    }else{
+      //End of valid UTF-8 segment
+      break;
+    }
+  }
+  return result;
+}
+
+String trimFilename(String& filename) {
+  //Replace all things that is not suppose to be in the filename
+  filename.replace("#","");
+  filename.replace("?","");
+  filename.replace("&","");
+  
+  // Find the position of the last dot (file extension)
+  int dotIndex = filename.lastIndexOf('.');
+
+  // Check if the filename contains a dot and the extension is not at the beginning or end
+  if (dotIndex > 0 && dotIndex < filename.length() - 1) {
+    // Calculate the maximum length for the filename (excluding extension)
+    int maxLength = 32 - (filename.length() - dotIndex - 1);
+
+    // Truncate the filename if it's longer than the maximum length
+    if (filename.length() > maxLength) {
+      String trimmedFilename = filterBrokenUtf8(filename.substring(0, maxLength)) + filename.substring(dotIndex);
+      return trimmedFilename;
+    }
+  }
+
+  // If no truncation is needed, return the original filename
+  return filename;
+}

+ 216 - 0
firmware/v4/web-server/users.ino

@@ -0,0 +1,216 @@
+/*
+  User.ino
+
+  This is a new module handling user systems on ESP8266
+
+*/
+
+//Check if a user login is valid by username and password
+bool UserCheckAuth(String username, String password) {
+  username.trim();
+  password.trim();
+  //Load user info from db
+  if (!DBKeyExists("user", username)) {
+    return false;
+  }
+
+  String userHashedPassword = DBRead("user", username);  //User hashed password from kvdb
+  String enteredHashedPassword = sha1(password);         //Entered hashed password
+  return userHashedPassword.equals(enteredHashedPassword);
+}
+
+//Get the username from the request, return empty string if unable to resolve
+String GetUsernameFromRequest(AsyncWebServerRequest *r) {
+  if (r->hasHeader("Cookie")) {
+    //User cookie from browser
+    String authCookie = GetCookieValueByKey(r, "web-auth");
+    if (authCookie == "") {
+      return "";
+    }
+
+    //Check if this is admin login
+    if (authCookie.equals(authSession)) {
+      return adminUsername;
+    }
+
+    //Check if user login
+    if (DBKeyExists("sess", authCookie)) {
+      //Return the username of this session
+      String username = DBRead("sess", authCookie);
+      return username;
+    }else{
+      Serial.println("session cookie not found: " + authCookie);
+    }
+
+    //Not found
+    return "";
+  }
+
+  //This user have no cookie in header
+  return "";
+}
+
+//Create new user, creator must be admin
+void HandleNewUser(AsyncWebServerRequest *r) {
+  if (!IsAdmin(r)) {
+    SendErrorResp(r, "this function require admin permission");
+    return;
+  }
+  String username = GetPara(r, "username");
+  String password = GetPara(r, "password");
+  username.trim();
+  password.trim();
+
+  //Check if the inputs are valid
+  if (username == "" || password == "") {
+    SendErrorResp(r, "username or password is an empty string");
+    return;
+  } else if (password.length() < 8) {
+    SendErrorResp(r, "password must contain at least 8 characters");
+    return;
+  }
+
+  //Check if the user already exists
+  if (DBKeyExists("user", username)) {
+    SendErrorResp(r, "user with name: " + username + " already exists");
+    return;
+  }
+
+  //OK create the user
+  bool succ = DBWrite("user", username, sha1(password));
+  if (!succ) {
+    SendErrorResp(r, "write new user to database failed");
+    return;
+  }
+  r->send(200, "application/json", "\"OK\"");
+}
+
+//Remove the given username from the system
+void HandleRemoveUser(AsyncWebServerRequest *r) {
+  if (!IsAdmin(r)) {
+    SendErrorResp(r, "this function require admin permission");
+    return;
+  }
+
+  String username = GetPara(r, "username");
+  username.trim();
+
+  //Check if the user exists
+  if (!DBKeyExists("user", username)) {
+    SendErrorResp(r, "user with name: " + username + " not exists");
+    return;
+  }
+
+  //Okey, remove the user
+  bool succ = DBRemove("user", username);
+  if (!succ) {
+    SendErrorResp(r, "remove user from system failed");
+    return;
+  }
+  r->send(200, "application/json", "\"OK\"");
+}
+
+//Admin or the user themselve change password for the account
+void HandleUserChangePassword(AsyncWebServerRequest *r) {
+  //Get requesting username
+  if (!IsUserAuthed(r)) {
+    SendErrorResp(r, "user not logged in");
+    return;
+  }
+
+  String currentUser = GetUsernameFromRequest(r);
+  if (currentUser == "") {
+    SendErrorResp(r, "unable to load user from system");
+    return;
+  }
+
+  //Check if the user can change password
+  //note that admin password cannot be changed on-the-fly
+  //admin password can only be changed in SD card config file
+  String modifyingUsername = GetPara(r, "username");
+  String newPassword = GetPara(r, "newpw");
+  modifyingUsername.trim();
+  newPassword.trim();
+
+  if (modifyingUsername == adminUsername) {
+    SendErrorResp(r, "admin username can only be changed in the config file");
+    return;
+  }
+  if (currentUser == adminUsername || modifyingUsername == currentUser) {
+    //Allow modify
+    if (newPassword.length() < 8) {
+      SendErrorResp(r, "password must contain at least 8 characters");
+      return;
+    }
+
+    //Write to database
+    bool succ = DBWrite("user", modifyingUsername, sha1(newPassword));
+    if (!succ) {
+      SendErrorResp(r, "write new user to database failed");
+      return;
+    }
+    SendOK(r);
+
+  } else {
+    SendErrorResp(r, "permission denied");
+    return;
+  }
+
+  SendOK(r);
+}
+
+//Get the current username
+void HandleGetUserinfo(AsyncWebServerRequest *r){
+  if (!HandleAuth(r)) {
+    return;
+  }
+
+  String isAdmin = "false";
+  if (IsAdmin(r)){
+    isAdmin = "true";
+  }
+
+  String username = GetUsernameFromRequest(r);
+  r->send(200, "application/json", "{\"username\":\"" + username + "\", \"admin\":" + isAdmin + "}");
+}
+
+//List all users registered in this WebStick
+void HandleUserList(AsyncWebServerRequest *r) {
+  if (!HandleAuth(r)) {
+    return;
+  }
+
+  //Build the json with brute force
+  String jsonString = "[";
+  //As the DB do not support list, it directly access the root of the folder where the kvdb stores the entries
+  File root = SD.open(DB_root + "user/");
+  bool firstObject = true;
+  if (root) {
+    while (true) {
+      File entry = root.openNextFile();
+      if (!entry) {
+        // No more files
+        break;
+      } else {
+        //There are more lines. Add a , to the end of the previous json object
+        if (!firstObject) {
+          jsonString = jsonString + ",";
+        } else {
+          firstObject = false;
+        }
+
+        //Filter out all the directory if any
+        if (entry.isDirectory()) {
+          continue;
+        }
+
+        //Append to the JSON line
+        jsonString = jsonString + "{\"Username\":\"" + entry.name() + "\"}";
+      }
+    }
+  }
+  jsonString += "]";
+
+  r->send(200, "application/json", jsonString);
+  
+}

+ 260 - 0
firmware/v4/web-server/utils.ino

@@ -0,0 +1,260 @@
+/*
+
+    Utilities
+
+*/
+
+//Pretty print for easy debug
+void prettyPrintRequest(AsyncWebServerRequest * request) {
+  if (request->method() == HTTP_GET)
+    Serial.printf("GET");
+  else if (request->method() == HTTP_POST)
+    Serial.printf("POST");
+  else if (request->method() == HTTP_DELETE)
+    Serial.printf("DELETE");
+  else if (request->method() == HTTP_PUT)
+    Serial.printf("PUT");
+  else if (request->method() == HTTP_PATCH)
+    Serial.printf("PATCH");
+  else if (request->method() == HTTP_HEAD)
+    Serial.printf("HEAD");
+  else if (request->method() == HTTP_OPTIONS)
+    Serial.printf("OPTIONS");
+  else
+    Serial.printf("UNKNOWN");
+  Serial.printf(" http://%s%s\n", request->host().c_str(), request->url().c_str());
+
+  if (request->contentLength()) {
+    Serial.printf("_CONTENT_TYPE: %s\n", request->contentType().c_str());
+    Serial.printf("_CONTENT_LENGTH: %u\n", request->contentLength());
+  }
+
+  int headers = request->headers();
+  int i;
+  for (i = 0; i < headers; i++) {
+    AsyncWebHeader* h = request->getHeader(i);
+    Serial.printf("_HEADER[%s]: %s\n", h->name().c_str(), h->value().c_str());
+  }
+
+  int params = request->params();
+  for (i = 0; i < params; i++) {
+    AsyncWebParameter* p = request->getParam(i);
+    if (p->isFile()) {
+      Serial.printf("_FILE[%s]: %s, size: %u\n", p->name().c_str(), p->value().c_str(), p->size());
+    } else if (p->isPost()) {
+      Serial.printf("_POST[%s]: %s\n", p->name().c_str(), p->value().c_str());
+    } else {
+      Serial.printf("_GET[%s]: %s\n", p->name().c_str(), p->value().c_str());
+    }
+  }
+}
+
+//Check if a path is dir
+bool IsDir(const String& path) {
+  File file = SD.open(path);
+  if (file && !file.isDirectory()) {
+    file.close();
+    return false;
+  }
+  file.close();
+  return true;
+}
+
+//Generate a random 32 bit hex
+String GeneratedRandomHex() {
+  String hexString = "";
+
+  for (int i = 0; i < 8; i++) {
+    byte randomValue = random(256);  // Generate a random byte (0 to 255)
+    hexString += String(randomValue, HEX);  // Convert the random byte to its hexadecimal representation
+  }
+
+  return hexString;
+}
+
+//Get the current unix timestamp
+unsigned long getTime() {
+  unsigned long now = timeClient.getEpochTime();
+  return now;
+}
+
+//Convert Unix timestamp to UTC string
+String getUTCTimeString(time_t unixTimestamp) {
+  char utcString[30];
+  const char* weekdayNames[] = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"};
+  const char* monthNames[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"};
+
+  tm* timeInfo = gmtime(&unixTimestamp);
+  sprintf(utcString, "%s, %02d %s %04d %02d:%02d:%02d GMT",
+          weekdayNames[timeInfo->tm_wday], timeInfo->tm_mday, monthNames[timeInfo->tm_mon],
+          timeInfo->tm_year + 1900, timeInfo->tm_hour, timeInfo->tm_min, timeInfo->tm_sec);
+
+  return String(utcString);
+}
+
+//Get a certain cookie value by its key
+String GetCookieValueByKey(AsyncWebServerRequest *request, const String& key) {
+  //Check if the Cookie header exists
+  if (!request->hasHeader("Cookie")) {
+    return "";
+  }
+
+  //Load the cookie value as string from header
+  String targetValue = "";
+  AsyncWebHeader* h = request->getHeader("Cookie");
+  String cookieHeader = String(h->value().c_str());
+
+  // Find the start and end positions of target cookie key
+  int startIndex = cookieHeader.indexOf(key + "=");
+  if (startIndex != -1) {
+    startIndex += 9;  // Length of "web-auth="
+
+    int endIndex = cookieHeader.indexOf(';', startIndex);
+    if (endIndex == -1) {
+      endIndex = cookieHeader.length();
+    }
+
+    // Extract the value of the "web-auth" cookie
+    targetValue = cookieHeader.substring(startIndex, endIndex);
+  }
+
+  return targetValue;
+}
+
+//Get the filename from filepath
+String basename(const String& filePath) {
+  int lastSlashIndex = filePath.lastIndexOf('/');
+
+  // If no slash is found, return the original path
+  if (lastSlashIndex == -1) {
+    return filePath;
+  }
+
+  // Return the substring after the last slash
+  return filePath.substring(lastSlashIndex + 1);
+}
+
+
+bool recursiveDirRemove(const String& path) {
+  Serial.println(path);
+  File directory = SD.open(path);
+  if (!directory) {
+    Serial.println("Error opening directory!");
+    return false;
+  }
+
+  // Delete all the files in the directory
+  directory.rewindDirectory();
+  while (true) {
+    File entry = directory.openNextFile();
+    if (!entry) {
+      // No more files
+      break;
+    }
+
+    String filename = String(entry.name());
+    if (entry.isDirectory()) {
+      // Recursively delete the subdirectory
+      recursiveDirRemove(path + filename);
+    } else {
+      // Delete the file
+      entry.close();
+      Serial.println("Removing " + path + filename);
+      SD.remove(path + filename);
+    }
+  }
+
+  // Close the directory
+  directory.close();
+
+  // Delete the directory itself
+  if (!SD.rmdir(path)) {
+    Serial.println("Error deleting directory!");
+    return false;
+  }
+
+  return true;
+}
+
+/*
+
+   uint32_t totalSize = 0;
+   uint16_t fileCount = 0;
+   uint16_t folderCount = 0;
+
+   // Call the recursive function to analyze the directory and its contents
+   analyzeDirectory(directoryPath, totalSize, fileCount, folderCount);
+*/
+void analyzeDirectory(const String& path, uint32_t& totalSize, uint16_t& fileCount, uint16_t& folderCount) {
+  File directory = SD.open(path);
+  if (!directory) {
+    Serial.println("Error opening directory!");
+    return;
+  }
+
+  // Analyze all files and subdirectories in the directory
+  directory.rewindDirectory();
+  while (true) {
+    File entry = directory.openNextFile();
+    if (!entry) {
+      // No more files
+      break;
+    }
+
+    if (entry.isDirectory()) {
+      // Recursively analyze the subdirectory
+      folderCount++;
+      analyzeDirectory(entry.name(), totalSize, fileCount, folderCount);
+    } else {
+      // Update the counters and add file size to total size
+      fileCount++;
+      totalSize += entry.size();
+    }
+
+    entry.close();
+  }
+
+  // Close the directory
+  directory.close();
+}
+
+void scanSDCardForKeyword(const String& directoryPath, const String& keyword, int *matchCounter, AsyncResponseStream *response) {
+  File directory = SD.open(directoryPath);
+  if (!directory) {
+    Serial.println("Error opening directory " + directoryPath);
+    return;
+  }
+
+  // Scan all files and subdirectories in the directory
+  directory.rewindDirectory();
+  while (true) {
+    File entry = directory.openNextFile();
+    if (!entry) {
+      // No more files
+      break;
+    }
+
+    if (entry.isDirectory()) {
+      // Recursively scan the subdirectory
+      scanSDCardForKeyword((directoryPath + entry.name() + "/"), keyword, matchCounter, response);
+    } else {
+      // Check if the filename contains the keyword
+      String filename = basename(entry.name());
+      if (filename.indexOf(keyword) != -1) {
+        if ((*matchCounter) > 0){
+          //Append a comma before appending next matching file
+          response->print(",");
+        }
+        
+        //When writing response, trim off the /www root folder name from directoryPath
+        response->print("\"" + directoryPath.substring(4) + entry.name() + "\"");
+        (*matchCounter)++;
+      }
+    }
+
+    entry.close();
+  }
+
+  // Close the directory
+  directory.close();
+}

+ 138 - 0
firmware/v4/web-server/web-server.ino

@@ -0,0 +1,138 @@
+/*
+
+    Web Server Stick v3
+    Author: Toby Chui
+
+    This firmware load and serve web content
+    from microSD card.
+
+    The following firmware config are recommended
+    Board: Wemos D1 Mini
+    CPU clockspeed: 160Mhz
+    IwIP Varient: v2 Higher Bandwidth
+
+    Require external library:
+    - ESPAsyncTCP (https://github.com/me-no-dev/ESPAsyncTCP)
+    - ESPAsyncWebServer (https://github.com/me-no-dev/ESPAsyncWebServer)
+    - ArduinoJson (https://github.com/bblanchon/ArduinoJson)
+    - ESPping (https://github.com/dvarrel/ESPping)
+    - Wake On LAN (https://github.com/a7md0/WakeOnLan)
+*/
+
+//WiFi library
+#include <ESP8266WiFi.h>
+
+//SD cards library
+#include <SPI.h>
+#include <SD.h>
+#include <FS.h>
+
+//Web server library
+#include <ESPAsyncTCP.h>
+#include <ESPAsyncWebServer.h>
+#include <ArduinoJson.h>
+
+//Discovery related library
+#include <ESP8266mDNS.h>
+#include <ESPping.h>
+#include <NTPClient.h>
+#include <WiFiUdp.h>
+#include <WakeOnLan.h>
+
+
+/* Hardware Configurations */
+#define CS_PIN D0
+
+/* SD Card SPI Speed Definations */
+#define SDSPI_HALF_SPEED 4000000
+#define SDSPI_DEFAULT_SPEED 32000000
+#define SDSPI_HIGH_SPEED 64000000
+
+/* Software Global Variables */
+AsyncWebServer server(80);
+String adminUsername = "";
+String adminPassword = "";
+String mdnsName = "webstick";
+String authSession = ""; //Session key for admin
+int SDCardInitSpeed = SDSPI_HIGH_SPEED;
+
+/* Time Keeping */
+WiFiUDP ntpUDP;
+NTPClient timeClient(ntpUDP, "pool.ntp.org");
+
+/* Wake On Lan */
+WakeOnLan WOL(ntpUDP);
+
+/* Debug variables */
+
+/* Function definations */
+String loadWiFiInfoFromSD();
+
+
+void setup() {
+  // Setup Debug Serial Port
+  Serial.begin(115200);
+
+  //Try Initialize SD card (blocking)
+  while (!SD.begin(CS_PIN, SDCardInitSpeed)) {
+    if (SDCardInitSpeed == SDSPI_HIGH_SPEED) {
+      //Fallback to default speed
+      SDCardInitSpeed = SDSPI_DEFAULT_SPEED;
+      Serial.println("SD card initialization failed. Falling back to default speed");
+    } else if (SDCardInitSpeed == SDSPI_DEFAULT_SPEED) {
+      //Fallback to half speed (legacy mode)
+      SDCardInitSpeed = SDSPI_HALF_SPEED;
+      Serial.println("SD card initialization failed. Falling back to legacy SPI_HALF_SPEED");
+    }else{
+      Serial.println("SD card initialization failed. Retrying in 3 seconds...");
+    }
+    delay(3000);
+  }
+  Serial.println("SD card initialized");
+  Serial.println("\n\nStorage Info:");
+  Serial.println("----------------------");
+  getSDCardTotalSpace();
+  getSDCardUsedSpace();
+  Serial.println("----------------------");
+  Serial.println();
+
+  //Connect to wifi based on settings (cfg/wifi.txt)
+  initWiFiConn();
+
+  //Load admin credentials from SD card (cfg/admin.txt)
+  initAdminCredentials();
+
+  //Start mDNS service
+  initmDNSName();
+  if (!MDNS.begin(mdnsName)) {
+    Serial.println("mDNS Error. Skipping.");
+  } else {
+    Serial.println("mDNS started. Connect to your webstick using http://" + mdnsName + ".local");
+    MDNS.addService("http", "tcp", 80);
+  }
+
+  //Start NTP time client
+  timeClient.begin();
+  Serial.print("Requesting time from NTP (unix timestamp): ");
+  timeClient.update();
+  Serial.println(getTime());
+
+  //Wake on Lan Settings
+  WOL.setRepeat(3, 100);
+  WOL.calculateBroadcastAddress(WiFi.localIP(), WiFi.subnetMask());
+
+  //Initialize database
+  DBInit();
+
+  //Resume login session if any
+  initLoginSessionKey();
+
+  // Start listening to HTTP Requests
+  initWebServer();
+}
+
+
+void loop() {
+  MDNS.update();
+  timeClient.update();
+}

BIN
firmware/v4/web-server/web-server.ino.d1_mini.bin


BIN
firmware/v4/web-server/web-server.ino.d1_mini.zip


BIN
pcb/v4/BOM_Website-Stick_2025-02-28.csv


BIN
pcb/v4/Gerber_Website-Stick_webstick-v4_2025-02-28.zip


Plik diff jest za duży
+ 22 - 0
pcb/v4/PCB_webstick-v4_2025-02-28.json


BIN
pcb/v4/PickAndPlace_webstick-v4_2025-02-28.csv


+ 60 - 100
sd_card/www/about.html

@@ -1,103 +1,63 @@
-<!DOCTYPE html>
-<html>
-<head>
-	
-  <!-- HTML Meta Tags -->
-  <title>About WebStick</title>
-  <meta name="description" content="A tiny web server powered by ESP8266, designed by tobychui">
-  <meta name="viewport" content="width=device-width, initial-scale=1" >
-  
-  <!-- Facebook Meta Tags -->
-  <meta property="og:url" content="https://imuslab.com">
-  <meta property="og:type" content="website">
-  <meta property="og:title" content="Welcome to WebStick!">
-  <meta property="og:description" content="A tiny web server powered by ESP8266, designed by tobychui">
-  <meta property="og:image" content=" ">
 
-  <!-- Twitter Meta Tags -->
-  <meta name="twitter:card" content="summary_large_image">
-  <meta property="twitter:domain" content="imuslab.com">
-  <meta property="twitter:url" content="https://imuslab.com">
-  <meta name="twitter:title" content="Welcome to WebStick!">
-  <meta name="twitter:description" content="A tiny web server powered by ESP8266, designed by tobychui">
-  <meta name="twitter:image" content=" ">
+<div class="ts-container is-very-narrow">
+  <div class="ts-image is-rounded">
+      <img src="img/wallpaper.jpg" width="100%">
+  </div>
+  <div class="ts-header has-top-spaced-large">What is a WebStick?</div>
+  <span class="ts-text">WebStick is a USB stick form factor web server powered by a ESP12E/F module. 
+      The web content is serve over the SD card connected to the MCU via SPI link, 
+      providing basic HTTP web server function with less than 2W of power usage.</span>
+  <div class="ts-divider has-top-spaced-large has-bottom-spaced-large"></div>
 
-  <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
-  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.4.1/semantic.min.css" />
-  <script src="https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.4.1/semantic.min.js"></script>
+  <div class="ts-header">Why WebStick?</div>
 
-  <style>
-	.imgwrapper{
-        max-height: 200px;
-        overflow: hidden;
-    }
-  </style>
-</head>
-<body>
-	<div class="navibar"></div>
-    <div class="imgwrapper">
-        <img src="img/wallpaper.jpg" class="ui fluid image">
-    </div>
-    <div class="ui container">
-        <br>
-        <h3>What is a WebStick?</h3>
-        <p>WebStick is a USB stick form factor web server powered by a ESP12E/F module. 
-            The web content is serve over the SD card connected to the MCU via SPI link, 
-            providing basic HTTP web server function with less than 2W of power usage.</p>
-        <h3>Why WebStick?</h3>
-        <p>If you are just hosting a web server for personal use with a few occasional guests, 
-            you do not need a Raspberry Pi that draws 15W 24/7 just for this purpose. <br>
-            Instead, a WebStick with ESP8266 is just enough to do the tasks. 
-            With the help of CDN and Caching, even a 2W MCU running over WiFi 
-            can provide relatively acceptable performance for basic HTTP web services.</p>
-        <h3>Specification</h3>
-        <table class="ui very basic celled collapsing table">
-            <thead>
-              <tr><th>Item</th>
-              <th>Properties</th>
-            </tr></thead>
-            <tbody>
-              <tr>
-                <td data-label="">Power Requirement</td>
-                <td data-label="">5V 1A</td>
-              </tr>
-              <tr>
-                <td data-label="">Actual Power Draw</td>
-                <td data-label="">around 2 - 3W</td>
-              </tr>
-              <tr>
-                <td data-label="">Wireless Network Speed</td>
-                <td data-label="">2 - 4 Mbps</td>
-              </tr>
-              <tr>
-                <td data-label="">Max SD card size supported</td>
-                <td data-label="">4GB</td>
-              </tr>
-              <tr>
-                <td data-label="">SD card format</td>
-                <td data-label="">FAT32</td>
-              </tr>
-              <tr>
-                <td data-label="">Arduino IDE Programmable</td>
-                <td data-label=""><i class="ui green check icon"></i></td>
-              </tr>
-              <tr>
-                <td data-label="">Run PHP / nodejs</td>
-                <td data-label=""><i class="ui red times icon"></i></td>
-              </tr>
-              <tr>
-                <td data-label="">Recommended Number of Users</td>
-                <td data-label="">less than 20 viewers per day</td>
-              </tr>
-            </tbody>
-          </table>
-        <a class="ui basic button" href="index.html">Back</a>
-		<br><br>
-		<p>This site is hosted on a WebStick designed by <a href="https://imuslab.com">imuslab</a></p>
-        <br><br>
-	</div>
-    <script>
-        $(".navibar").load("navi.html");
-    </script>
-</body>
-</html>
+  <span class="ts-text">If you are just hosting a web server for personal use with a few occasional guests, 
+      you do not need a Raspberry Pi that draws 15W 24/7 just for this purpose.</span>
+
+  <span class="ts-text">Instead, a WebStick with ESP8266 is just enough to do the tasks. 
+      With the help of CDN and Caching, even a 2W MCU running over WiFi 
+      can provide relatively acceptable performance for basic HTTP web services.</span>
+  <div class="ts-divider has-top-spaced-large has-bottom-spaced-large"></div>
+      
+  <div class="ts-header">Specification</div>
+  <table class="ts-table is-single-line has-top-spaced-small">
+      <thead>
+        <tr><th>Item</th>
+        <th>Properties</th>
+      </tr></thead>
+      <tbody>
+        <tr>
+          <td data-label="">Power Requirement</td>
+          <td data-label="">5V 1A</td>
+        </tr>
+        <tr>
+          <td data-label="">Actual Power Draw</td>
+          <td data-label="">around 2 - 3W</td>
+        </tr>
+        <tr>
+          <td data-label="">Wireless Network Speed</td>
+          <td data-label="">2 - 4 Mbps</td>
+        </tr>
+        <tr>
+          <td data-label="">Max SD card size</td>
+          <td data-label="">4GB</td>
+        </tr>
+        <tr>
+          <td data-label="">SD card format</td>
+          <td data-label="">FAT32</td>
+        </tr>
+        <tr>
+          <td data-label="">Arduino IDE Support</td>
+          <td data-label=""><span class="ts-icon is-check-icon" style="color:var(--ts-positive-400);"></span></td>
+        </tr>
+        <tr>
+          <td data-label="">Run PHP / nodejs</td>
+          <td data-label=""><span class="ts-icon is-xmark-icon" style="color:var(--ts-negative-400);"></span></td>
+        </tr>
+        <tr>
+          <td data-label="">Max No. of Users</td>
+          <td data-label="">~20 viewers per day</td>
+        </tr>
+      </tbody>
+    </table>
+</div>

+ 88 - 0
sd_card/www/admin/blog.html

@@ -0,0 +1,88 @@
+<!DOCTYPE html>
+<html>
+<head>
+    <meta charset="utf-8">
+    <title>PostEngine Pro</title>
+    <meta name="viewport" content="width=device-width, initial-scale=1" >
+    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
+    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.4.1/semantic.min.css" />
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.4.1/semantic.min.js"></script>
+    <style>
+        body{
+            background-color: rgb(243, 243, 243);
+            border-top: 1px solid #70aeff;
+        }
+
+        .ui.vertical.menu{
+            width: calc(15em - 3px) !important;
+        }
+
+        .postengine_context{
+            padding-left: calc(15em + 1px) !important;
+        }
+    </style>
+</head>
+<body>
+    <div class="ui container">
+
+    </div>
+    <div id="postengine_side_menu" class="ui left fixed inverted vertical menu">
+        <div class="item" style="pointer-events: none;">
+          <img class="ui fluid image" src="./img/post-engine.svg">
+        </div>
+        <div class="item">
+          <div class="header">Posts</div>
+          <div class="menu">
+            <a class="item active" xtab="postengine_all_post">All Posts</a>
+            <a class="item" xtab="postengine_new_post">Add New</a>
+            <a class="item" xtab="postengine_list_catergories">Categories</a>
+          </div>
+        </div>
+        <div class="item">
+          <div class="header">Media</div>
+          <div class="menu">
+            <a class="item" xtab="postengine_media_library">Library</a>
+            <a class="item" xtab="postengine_media_add_new">Add New</a>
+          </div>
+        </div>
+        <div class="item">
+          <div class="header">Pages</div>
+          <div class="menu">
+            <a class="item" xtab="postengine_pages_all">All Pages</a>
+            <a class="item" xtab="postengine_pages_add_new">Add New</a>
+          </div>
+        </div>
+        <div class="item">
+          <div class="header">Settings</div>
+          <div class="menu">
+            <a class="item" xtab="postengine_settings_general">General</a>
+            <a class="item" xtab="postengine_settings_permalinks">Permalinks</a>
+          </div>
+        </div>
+    </div>
+    <!-- Function Tabs -->
+    <div id="postengine_all_post" class="postengine_context"></div>
+    <div id="postengine_new_post" class="postengine_context" style="display:none;"></div>
+    <div id="postengine_list_catergories" class="postengine_context" style="display:none;"></div>
+    <div id="postengine_media_library" class="postengine_context" style="display:none;"></div>
+    <div id="postengine_media_add_new" class="postengine_context" style="display:none;"></div>
+    <div id="postengine_pages_all" class="postengine_context" style="display:none;"></div>
+    <div id="postengine_pages_add_new" class="postengine_context" style="display:none;"></div>
+    <div id="postengine_settings_general" class="postengine_context" style="display:none;"></div>
+    <div id="postengine_settings_permalinks" class="postengine_context" style="display:none;"></div>
+
+    <script>
+        // Add event listener to menu items with target attribute
+        $("#postengine_side_menu .item[xtab]").on("click", function() {
+            var targetId = $(this).attr("xtab");
+            $(".postengine_context").hide();
+            $("#postengine_side_menu .item.active").removeClass("active");
+            $(this).addClass("active");
+            $("#" + targetId).show();
+        });
+
+        //Load all the elements
+        $("#postengine_all_post").load("posteng/all.html");
+    </script>
+</body>
+</html>

+ 4 - 0
sd_card/www/admin/fs.html

@@ -27,6 +27,10 @@
                 user-select: none;
                 pointer-events: none;
             }
+
+            #propertiesView{
+                overflow-x: hidden;
+            }
         </style>
     </head>
     <body class="whiteTheme">

+ 82 - 0
sd_card/www/admin/img/post-engine.svg

@@ -0,0 +1,82 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="圖層_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 width="256px" height="64px" viewBox="0 0 256 64" enable-background="new 0 0 256 64" xml:space="preserve">
+<path fill="#00A0E9" d="M5.334,15c0-5.523,4.477-10,10-10l0,0c5.523,0,10,4.477,10,10v34c0,5.522-4.477,10-10,10l0,0
+	c-5.523,0-10-4.478-10-10V15z"/>
+<path fill="#FFFFFF" d="M48.959,49.25c0,5.385-4.365,9.75-9.75,9.75l0,0c-5.385,0-9.75-4.365-9.75-9.75v-7.5
+	c0-5.385,4.365-9.75,9.75-9.75l0,0c5.385,0,9.75,4.365,9.75,9.75V49.25z"/>
+<g>
+	<path fill="#FFFFFF" d="M62.181,8.275h4.143l1.069,7.74c0.228,1.775,0.455,3.551,0.683,5.372h0.091
+		c0.319-1.821,0.66-3.619,1.002-5.372l1.73-7.74h3.438l1.752,7.74c0.341,1.73,0.638,3.551,0.979,5.372h0.114
+		c0.205-1.821,0.432-3.619,0.637-5.372l1.116-7.74h3.847l-2.937,16.959h-5.122l-1.525-7.376c-0.273-1.297-0.501-2.663-0.66-3.938
+		h-0.091c-0.228,1.274-0.432,2.641-0.683,3.938l-1.479,7.376h-5.031L62.181,8.275z"/>
+	<path fill="#FFFFFF" d="M84.037,18.746c0-4.188,3.028-6.783,6.146-6.783c3.733,0,5.554,2.709,5.554,6.237
+		c0,0.729-0.091,1.434-0.182,1.753h-7.604c0.365,1.775,1.594,2.504,3.233,2.504c0.933,0,1.798-0.273,2.731-0.819l1.343,2.436
+		c-1.343,0.956-3.118,1.48-4.621,1.48C86.905,25.553,84.037,23.048,84.037,18.746z M92.3,17.29c0-1.275-0.568-2.231-2.048-2.231
+		c-1.116,0-2.094,0.706-2.368,2.231H92.3z"/>
+	<path fill="#FFFFFF" d="M101.931,23.845h-0.114l-0.319,1.389h-3.164V7.046h4.052v4.371l-0.114,1.935
+		c0.956-0.865,2.163-1.388,3.346-1.388c3.096,0,5.076,2.595,5.076,6.556c0,4.484-2.663,7.034-5.44,7.034
+		C104.116,25.553,102.932,24.96,101.931,23.845z M106.529,18.609c0-2.276-0.661-3.346-2.095-3.346c-0.751,0-1.366,0.342-2.048,1.093
+		v5.121c0.614,0.569,1.297,0.751,1.912,0.751C105.528,22.229,106.529,21.205,106.529,18.609z"/>
+	<path fill="#FFFFFF" d="M112.563,23.117l2.322-2.8c1.184,1.024,2.754,1.73,4.098,1.73c1.502,0,2.208-0.569,2.208-1.48
+		c0-0.979-0.933-1.297-2.39-1.912l-2.163-0.91c-1.775-0.706-3.437-2.185-3.437-4.644c0-2.846,2.549-5.122,6.146-5.122
+		c1.958,0,4.029,0.751,5.509,2.231l-2.026,2.549c-1.115-0.842-2.163-1.297-3.483-1.297c-1.251,0-2.003,0.501-2.003,1.389
+		c0,0.956,1.047,1.32,2.549,1.912l2.117,0.842c2.095,0.842,3.347,2.253,3.347,4.644c0,2.822-2.368,5.304-6.465,5.304
+		C116.684,25.553,114.316,24.733,112.563,23.117z"/>
+	<path fill="#FFFFFF" d="M127.634,20.431v-4.985h-1.753v-3.004l2.003-0.16l0.455-3.437h3.347v3.437h3.05v3.164h-3.05v4.94
+		c0,1.434,0.683,2.003,1.639,2.003c0.41,0,0.865-0.137,1.184-0.25l0.638,2.937c-0.683,0.205-1.616,0.478-2.937,0.478
+		C128.955,25.553,127.634,23.481,127.634,20.431z"/>
+	<path fill="#FFFFFF" d="M137.015,8.389c0-1.251,0.888-2.071,2.254-2.071c1.343,0,2.253,0.82,2.253,2.071
+		c0,1.275-0.91,2.095-2.253,2.095C137.902,10.483,137.015,9.664,137.015,8.389z M137.242,12.281h4.052v12.953h-4.052V12.281z"/>
+	<path fill="#FFFFFF" d="M143.821,18.746c0-4.28,3.187-6.783,6.808-6.783c1.57,0,2.799,0.546,3.777,1.388l-1.889,2.572
+		c-0.593-0.478-1.093-0.683-1.639-0.683c-1.8,0-2.913,1.366-2.913,3.505c0,2.163,1.182,3.528,2.752,3.528
+		c0.821,0,1.594-0.387,2.254-0.911l1.572,2.664c-1.275,1.115-2.914,1.525-4.28,1.525C146.622,25.553,143.821,23.048,143.821,18.746z
+		"/>
+	<path fill="#FFFFFF" d="M156.776,7.046h3.983v10.106h0.112l3.824-4.871h4.439l-4.507,5.395l4.825,7.558h-4.393l-2.799-4.917
+		l-1.503,1.707v3.21h-3.983V7.046z"/>
+</g>
+<g>
+	<path fill="#FFFFFF" d="M63.797,35.285h5.963c3.711,0,6.784,1.342,6.784,5.531c0,4.03-3.096,5.804-6.692,5.804h-1.98v5.624h-4.074
+		V35.285z M69.647,43.412c1.98,0,2.937-0.935,2.937-2.596c0-1.706-1.093-2.299-3.051-2.299h-1.662v4.895H69.647z"/>
+	<path fill="#FFFFFF" d="M77.844,45.757c0-4.28,3.051-6.785,6.351-6.785c3.278,0,6.329,2.505,6.329,6.785
+		c0,4.302-3.05,6.806-6.329,6.806C80.895,52.563,77.844,50.059,77.844,45.757z M86.381,45.757c0-2.142-0.683-3.506-2.186-3.506
+		c-1.525,0-2.208,1.364-2.208,3.506c0,2.162,0.683,3.526,2.208,3.526C85.698,49.283,86.381,47.919,86.381,45.757z"/>
+	<path fill="#FFFFFF" d="M91.823,50.741l1.798-2.527c1.184,0.887,2.254,1.367,3.278,1.367c1.07,0,1.525-0.389,1.525-1.025
+		c0-0.774-1.252-1.138-2.573-1.662c-1.547-0.615-3.3-1.661-3.3-3.823c0-2.437,1.98-4.099,5.008-4.099
+		c2.026,0,3.528,0.819,4.621,1.662l-1.798,2.413c-0.934-0.66-1.844-1.094-2.686-1.094c-0.934,0-1.366,0.321-1.366,0.935
+		c0,0.774,1.116,1.069,2.458,1.549c1.617,0.613,3.415,1.547,3.415,3.893c0,2.389-1.866,4.233-5.395,4.233
+		C95.124,52.563,93.143,51.835,91.823,50.741z"/>
+	<path fill="#FFFFFF" d="M104.55,47.439v-4.985h-1.753v-3.005l2.003-0.159l0.455-3.436h3.346v3.436h3.051v3.164h-3.051v4.94
+		c0,1.435,0.684,2.004,1.64,2.004c0.409,0,0.865-0.136,1.184-0.251l0.637,2.938c-0.683,0.203-1.616,0.478-2.937,0.478
+		C105.871,52.563,104.55,50.491,104.55,47.439z"/>
+	<path fill="#FFFFFF" d="M114.476,35.285h10.744v3.392h-6.67v3.141h5.668v3.415h-5.668v3.597h6.92v3.415h-10.995V35.285z"/>
+	<path fill="#FFFFFF" d="M128.5,39.29h3.301l0.296,1.617h0.068c1.069-1.048,2.391-1.936,4.144-1.936
+		c2.822,0,4.028,2.027,4.028,5.259v8.014h-4.052v-7.513c0-1.797-0.478-2.298-1.503-2.298c-0.91,0-1.457,0.409-2.23,1.16v8.65H128.5
+		V39.29z"/>
+	<path fill="#FFFFFF" d="M142.705,54.428c0-1.069,0.615-1.934,1.821-2.549v-0.114c-0.683-0.454-1.184-1.138-1.184-2.23
+		c0-0.91,0.593-1.821,1.457-2.413V47.03c-0.934-0.637-1.774-1.843-1.774-3.368c0-3.141,2.617-4.69,5.416-4.69
+		c0.752,0,1.457,0.115,2.049,0.318h4.758v2.938h-1.98c0.183,0.365,0.342,0.957,0.342,1.57c0,3.028-2.3,4.349-5.168,4.349
+		c-0.432,0-0.956-0.068-1.524-0.228c-0.272,0.271-0.409,0.454-0.409,0.91c0,0.593,0.522,0.864,1.911,0.864h2.05
+		c3.186,0,5.007,0.98,5.007,3.323c0,2.733-2.845,4.666-7.353,4.666C145.142,57.683,142.705,56.752,142.705,54.428z M151.584,53.723
+		c0-0.774-0.684-0.934-1.891-0.934h-1.229c-0.934,0-1.435-0.044-1.844-0.159c-0.41,0.365-0.593,0.729-0.593,1.161
+		c0,0.91,1.094,1.367,2.732,1.367C150.423,55.158,151.584,54.542,151.584,53.723z M150.104,43.662c0-1.299-0.706-1.98-1.662-1.98
+		c-0.933,0-1.638,0.682-1.638,1.98c0,1.364,0.705,2.048,1.638,2.048C149.397,45.71,150.104,45.026,150.104,43.662z"/>
+	<path fill="#FFFFFF" d="M157.276,35.398c0-1.252,0.889-2.071,2.254-2.071c1.343,0,2.254,0.819,2.254,2.071
+		c0,1.275-0.911,2.095-2.254,2.095C158.165,37.493,157.276,36.674,157.276,35.398z M157.505,39.29h4.051v12.954h-4.051V39.29z"/>
+	<path fill="#FFFFFF" d="M164.813,39.29h3.3l0.296,1.617h0.069c1.069-1.048,2.39-1.936,4.143-1.936c2.822,0,4.028,2.027,4.028,5.259
+		v8.014h-4.051v-7.513c0-1.797-0.479-2.298-1.503-2.298c-0.91,0-1.456,0.409-2.231,1.16v8.65h-4.051V39.29z"/>
+	<path fill="#FFFFFF" d="M179.063,45.757c0-4.189,3.027-6.785,6.146-6.785c3.734,0,5.556,2.71,5.556,6.237
+		c0,0.728-0.092,1.435-0.183,1.753h-7.603c0.363,1.776,1.594,2.504,3.231,2.504c0.934,0,1.799-0.273,2.732-0.819l1.344,2.437
+		c-1.344,0.955-3.12,1.479-4.621,1.479C181.932,52.563,179.063,50.059,179.063,45.757z M187.327,44.299
+		c0-1.275-0.569-2.23-2.049-2.23c-1.115,0-2.095,0.704-2.367,2.23H187.327z"/>
+	<path fill="#FFFFFF" d="M198.894,35.285h5.964c3.711,0,6.784,1.342,6.784,5.531c0,4.03-3.097,5.804-6.693,5.804h-1.98v5.624h-4.074
+		V35.285z M204.743,43.412c1.98,0,2.937-0.935,2.937-2.596c0-1.706-1.093-2.299-3.05-2.299h-1.662v4.895H204.743z"/>
+	<path fill="#FFFFFF" d="M214.217,39.29h3.3l0.296,2.254h0.069c0.956-1.729,2.39-2.572,3.687-2.572c0.774,0,1.207,0.115,1.571,0.274
+		l-0.66,3.482c-0.478-0.115-0.865-0.204-1.434-0.204c-0.956,0-2.118,0.613-2.778,2.322v7.397h-4.051V39.29z"/>
+	<path fill="#FFFFFF" d="M223.778,45.757c0-4.28,3.051-6.785,6.352-6.785c3.277,0,6.328,2.505,6.328,6.785
+		c0,4.302-3.051,6.806-6.328,6.806C226.829,52.563,223.778,50.059,223.778,45.757z M232.314,45.757c0-2.142-0.684-3.506-2.185-3.506
+		c-1.526,0-2.208,1.364-2.208,3.506c0,2.162,0.682,3.526,2.208,3.526C231.631,49.283,232.314,47.919,232.314,45.757z"/>
+</g>
+</svg>

+ 17 - 1
sd_card/www/admin/index.html

@@ -4,7 +4,7 @@
     <meta charset="utf-8">
     <title>Admin Panel</title>
     <meta name="viewport" content="width=device-width, initial-scale=1" >
-
+    <link rel="icon" type="image/png" href="/favicon.png">
     <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
     <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.4.1/semantic.min.css" />
     <script src="https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.4.1/semantic.min.js"></script>
@@ -34,6 +34,7 @@
             <img class="ui tiny image" src="img/icon.svg">
         </a>
         <a class="active yellow selectable icon item" onclick="switchFrame(event, this);" xframe="fs"><i class="folder icon"></i></a>
+        <a class="teal selectable icon item" onclick="switchFrame(event, this);" xframe="blog"><i class="ui edit icon"></i></a>
         <a class="violet selectable icon item" onclick="switchFrame(event, this);" xframe="search"><i class="ui search icon"></i></a>
         <a class="green selectable icon item" onclick="switchFrame(event, this);" xframe="shares"><i class="ui share alternate icon"></i></a>
         <a class="blue selectable icon item" onclick="switchFrame(event, this);" xframe="users"><i class="ui user icon"></i></a>
@@ -46,6 +47,9 @@
         <div id="fs" class="frameWrapper">
             <iframe src="fs.html"></iframe>
         </div>
+        <div id="blog" class="frameWrapper">
+            <iframe src="blog.html"></iframe>
+        </div>
         <div id="search" class="frameWrapper" style="display:none;">
             <iframe src="search.html"></iframe>
         </div>
@@ -76,6 +80,7 @@
             let targetFrameID = $(object).attr("xframe");
             $(".frameWrapper").hide();
             $("#" + targetFrameID).show();
+            window.location.hash = targetFrameID;
        }
 
        //Check login status
@@ -87,6 +92,17 @@
             });
        }
        initLoginCheck();
+
+       // Restore previously selected frame on page load
+       if (window.location.hash.length > 1) {
+            let targetFrameID = window.location.hash.substring(1);
+            if ($("#" + targetFrameID).length) {
+                $(".frameWrapper").hide();
+                $("#" + targetFrameID).show();
+                $(".mainmenu .item.active").removeClass("active");
+                $(".mainmenu .item[xframe='" + targetFrameID + "']").addClass("active");
+            }
+        }
     </script>
 </body>
 </html>

+ 58 - 0
sd_card/www/admin/posteng/all.html

@@ -0,0 +1,58 @@
+<br>
+<div class="ui container">
+    <h2 class="ui header">All Blog Posts</h2>
+    <table class="ui celled table">
+        <thead>
+            <tr>
+                <th>Post Name</th>
+                <th>Edit</th>
+                <th>Delete</th>
+            </tr>
+        </thead>
+        <tbody id="postTableBody">
+            <!-- Rows will be dynamically loaded here -->
+        </tbody>
+    </table>
+</div>
+
+<script>
+    $(document).ready(function() {
+        // Dummy function to fetch posts from an API
+        function fetchPosts() {
+            // Simulated API response
+            const posts = [
+                { id: 1, name: "First Blog Post" },
+                { id: 2, name: "Second Blog Post" },
+                { id: 3, name: "Third Blog Post" }
+            ];
+
+            // Clear the table body
+            $('#postTableBody').empty();
+
+            // Populate the table with posts
+            posts.forEach(post => {
+                $('#postTableBody').append(`
+                    <tr>
+                        <td>${post.name}</td>
+                        <td><button class="ui blue button edit-button" data-id="${post.id}">Edit</button></td>
+                        <td><button class="ui red button delete-button" data-id="${post.id}">Delete</button></td>
+                    </tr>
+                `);
+            });
+        }
+
+        // Call the fetchPosts function to load data
+        fetchPosts();
+
+        // Event listeners for Edit and Delete buttons
+        $(document).on('click', '.edit-button', function() {
+            const postId = $(this).data('id');
+            alert(`Edit post with ID: ${postId}`);
+        });
+
+        $(document).on('click', '.delete-button', function() {
+            const postId = $(this).data('id');
+            alert(`Delete post with ID: ${postId}`);
+        });
+    });
+</script>

BIN
sd_card/www/down/Rise Above.mp3


BIN
sd_card/www/down/minecraft.webm


BIN
sd_card/www/down/music.mp3


BIN
sd_card/www/down/video.webm


BIN
sd_card/www/favicon.ico


BIN
sd_card/www/favicon.png


BIN
sd_card/www/favicon.psd


BIN
sd_card/www/img/logo.png


BIN
sd_card/www/img/logo.psd


BIN
sd_card/www/img/wallpaper.jpg


+ 76 - 121
sd_card/www/index.html

@@ -1,134 +1,89 @@
 <!DOCTYPE html>
 <html>
 <head>
-	
-  <!-- HTML Meta Tags -->
-  <title>Welcome to WebStick!</title>
-  <meta name="description" content="A tiny web server powered by ESP8266, designed by tobychui">
-  <meta name="viewport" content="width=device-width, initial-scale=1" >
-  
-  <!-- Facebook Meta Tags -->
-  <meta property="og:url" content="https://imuslab.com">
-  <meta property="og:type" content="website">
-  <meta property="og:title" content="Welcome to WebStick!">
-  <meta property="og:description" content="A tiny web server powered by ESP8266, designed by tobychui">
-  <meta property="og:image" content=" ">
-
-  <!-- Twitter Meta Tags -->
-  <meta name="twitter:card" content="summary_large_image">
-  <meta property="twitter:domain" content="imuslab.com">
-  <meta property="twitter:url" content="https://imuslab.com">
-  <meta name="twitter:title" content="Welcome to WebStick!">
-  <meta name="twitter:description" content="A tiny web server powered by ESP8266, designed by tobychui">
-  <meta name="twitter:image" content=" ">
-
-  <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
-  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.4.1/semantic.min.css" />
-  <script src="https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.4.1/semantic.min.js"></script>
-
-  <style>
-	.character.ring{
-		width: 320px;
-		height: 320px;
-		margin-top: 20px;
-		border: 5px solid #f9c729 !important;
-		background-color: white;
-		border-radius: 50%;
-		display: inline-block;
-	}
-	
-	.character.infill{
-		width: 260px;
-		height: 260px;
-		margin-top: 25px;
-		background-color: #ffcb2e;
-		border-radius: 50%;
-		display: inline-block;
-	}
+	<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+	<link rel="icon" type="image/png" href="/favicon.png">
+	<!-- HTML Meta Tags -->
+	<title>Homepage | WebStick</title>
+	<meta name="description" content="A tiny web server powered by ESP8266, designed by tobychui">
+	<meta name="viewport" content="width=device-width, initial-scale=1" >
+		
+	<!-- Facebook Meta Tags -->
+	<meta property="og:url" content="https://imuslab.com">
+	<meta property="og:type" content="website">
+	<meta property="og:title" content="Welcome to WebStick!">
+	<meta property="og:description" content="A tiny web server powered by ESP8266, designed by tobychui">
+	<meta property="og:image" content=" ">
 
-	.charactercard{
-		width: 375px !important;
-		border: 0px solid transparent;
-		-webkit-box-shadow: none !important;
-		box-shadow: -3px 12px 19px 5px rgba(204,204,204,0.59) !important;
-	}
-
-	.dark.divider{
-		height: 3px;
-		background: #020202;
-		width: 60px;
-		margin-bottom: 1.4em;
-		margin-top: 1.6em;
-	}
-
-	.breadcrumb .section{
-		font-weight: 300;
-		font-size: 1.2em;
-	}
-
-	.blacklink{
-		color: #020202;
-		transition: margin-top ease-in-out 0.1s, color ease-in-out 0.1s !important;
-	}
-
-	.blacklink:hover{
-		color: #020202;
-		opacity: 0.7;
-		margin-top: -1.2em !important;
-	}
-	
-	.linkbuttons{
-		padding: 2em;
-	}
+	<!-- Twitter Meta Tags -->
+	<meta name="twitter:card" content="summary_large_image">
+	<meta property="twitter:domain" content="imuslab.com">
+	<meta property="twitter:url" content="https://imuslab.com">
+	<meta name="twitter:title" content="Welcome to WebStick!">
+	<meta name="twitter:description" content="A tiny web server powered by ESP8266, designed by tobychui">
+	<meta name="twitter:image" content=" ">
 
-	.linkbuttons .ui.white.button{
-		background: white !important;
-		padding: 1.2em;
-		margin-top: 0.8em;
-	}
+  	<!-- CDN -->
+	<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
+	<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tocas-ui/5.0.2/tocas.min.css">
+	<script src="https://cdnjs.cloudflare.com/ajax/libs/tocas-ui/5.0.2/tocas.min.js"></script>
+	<link rel="preconnect" href="https://fonts.googleapis.com">
+	<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+	<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;500;700&display=swap" rel="stylesheet">
 
-	.linkbuttons .ui.white.button:hover{
-		opacity: 0.8;
-	}
-  </style>
+	<style>
+		#banner{
+			background: linear-gradient(48deg, rgba(108,172,255,1) 8%, rgba(141,235,255,1) 65%)
+		}
+	</style>
 </head>
 <body>
-	<div class="ui container" align="center">
-	<br>
-		<div class="ui fluid card charactercard">
-			<div class="image" style="background-color: #ffe38d;" align="center">
-				<div class="character ring">
-					<div class="character infill">
-						<img src="img/selfie.jpg" style="border-radius: 50%; width: 100%; user-select: none; pointer-events: none;">
-					</div>    
-				</div>    
-				<h1 style="font-weight: bolder;">Hello visitor!</h1>
-				<div class="dark divider"></div>
-				<div class="ui breadcrumb">
-					<div class="section">Software Engineer</div>
-					<div class="divider"> / </div>
-					<div class="section">Electronics Maker</div>
-				</div>
-				<div class="linkbuttons">
-					<a class="ui fluid white button" href="about.html">About WebStick</a>
-					<a class="ui fluid white button" href="blog/index.html">My Blog</a>
-					<a class="ui fluid white button" href="down/"><i class="ui download icon"></i> Downloads</a>
-					<a class="ui fluid white button" href="tool/qr.html"><i class="ui qrcode icon"></i> QR Code Generator</a>
-					<div class="ui divider"></div>
-					<a class="ui fluid white button" href="admin/"><i class="ui sign in icon"></i> Admin Panel</a>
+	<div class="ts-content">
+		<div class="ts-tab is-segmented">
+			<a class="item is-active" data-tab="home">Home</a>
+			<a class="item" data-tab="about">About</a>
+			<a class="item" data-tab="posts">Posts</a>
+			<a class="item" data-tab="qr">QR</a>
+		</div>
+		<div class="has-top-spaced-small"></div>
+		<div class="ts-content " id="home">
+			<div id="banner" class="ts-content is-rounded is-padded has-top-spaced-large" style=" color: var(--ts-gray-50)">
+				<div style="max-width: 300px">
+					<div id="pageTitle" class="ts-header is-huge is-heavy">WebStick</div>
+					<p id="pageDesc">A personal web server hosted on an ESP8266 using a micro SD card</p>
+					<a href="/admin" class="ts-button is-outlined" style="color: var(--ts-gray-50)">Login</a>
 				</div>
+			</div>
+
+			<!-- Contact Information, change these to yours -->
+			<div class="ts-content is-center-aligned has-top-spaced-large">
+				<a href="https://www.youtube.com/channel/UCzbcGOZHO2BH-ANX7W0MGIg"><span class="ts-icon is-huge is-youtube-icon"></span></a>
+				<a class="has-start-spaced-large" href="https://blog.imuslab.com"><span class="ts-icon is-huge is-newspaper-icon"></span></a>
+				<a class="has-start-spaced-large" href="mailto:[email protected]"><span class="ts-icon is-huge is-envelope-icon"></span></a>
+				<a class="has-start-spaced-large" href="https://github.com/tobychui/webstick"><span class="ts-icon is-huge is-code-icon"></span></a>
+			</div>
+		</div>
+		<div class="ts-content" id="about">
+			<!-- Dynmaically loaded by ajax from about.html -->
+		</div>
+		<div class="ts-content" id="posts">
+			<!-- Dynmaically loaded by ajax from post.html -->
+		</div>
+		<div class="ts-content" id="qr">
+			<!-- An iframe will be append here for the QR code generator -->
+		</div>
+		
+		<div class="ts-divider"></div>
+		<div class="ts-content">
+			<p class="ts-text">This site is hosted on a WebStick designed by <a href="https://imuslab.com" target="_blank">tobychui</a></p>
 		</div>
-		<div class="content" align="center">
-			<a class="blacklink" href="https://www.youtube.com/channel/UCzbcGOZHO2BH-ANX7W0MGIg"><i class="big youtube icon"></i></a>
-			<a class="blacklink" href="https://blog.imuslab.com"><i class="big wordpress icon"></i></a>
-			<a class="blacklink" href="mailto:[email protected]"><i class="big envelope icon"></i></a>
-			<a class="blacklink" href="https://github.com/tobychui/"><i class="big github icon"></i></a>
-			<a class="blacklink" href="https://www.facebook.com/ImusLaboratory"><i class="big facebook icon"></i></a>
-	  </div>
-	</div>
-		<br>
-		<p>This site is hosted on a WebStick designed by <a href="https://imuslab.com">imuslab</a></p>
-		<br>
 	</div>
+	
+	<script>
+		/* Ajax load of the pages */
+		$("#about").load("about.html");
+		$("#posts").load("posts.html");
+		$("#qr").html('<iframe src="tool/qr.html" style="border: none; width: 100%; height: calc(100vh - 200px);"></iframe>');
+	</script>
 </body>
 </html>

+ 74 - 54
sd_card/www/login.html

@@ -1,67 +1,87 @@
 <!DOCTYPE html>
 <html>
-<head>
-  <title>Login</title>
-  <meta name="viewport" content="width=device-width, initial-scale=1" >
+  <head>
+    <title>Login</title>
+    <meta name="viewport" content="width=device-width, initial-scale=1" >
+    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
+    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tocas-ui/5.0.2/tocas.min.css">
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/tocas-ui/5.0.2/tocas.min.js"></script>
+    <link rel="preconnect" href="https://fonts.googleapis.com">
+    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+    <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;500;700&display=swap" rel="stylesheet">
+    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+  </head>
+  <body>
+    <div class="ts-app-center">
+      <div class="content">
+          <a href="index.html">
+            <div class="ts-image is-centered">
+              <img src="img/logo.png" width="150">
+            </div>
+          </a>
+          <div class="has-top-spaced-large"></div>
+          <div class="ts-box has-top-spaced-large" style="width: 260px">
+              <div class="ts-content">
+                  <div class="ts-wrap is-vertical">
+                      <div class="ts-text is-label">Username</div>
+                      <div class="ts-input is-start-icon is-underlined">
+                          <span class="ts-icon is-user-icon"></span>
+                          <input type="text" name="username" placeholder="Username" required>
+                      </div>
+                      <div class="ts-text is-label">Password</div>
+                      <div class="ts-input is-start-icon is-underlined">
+                          <span class="ts-icon is-lock-icon"></span>
+                          <input type="password" name="password" placeholder="Password" required>
+                      </div>
+                      <button id="loginBtn" class="ts-button is-fluid" onclick="login(event);">Login</button>
+                  </div>
 
-  <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
-  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.4.1/semantic.min.css" />
-  <script src="https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.4.1/semantic.min.js"></script>
-
-  <style>
-    .imgwrapper{
-        max-height: 200px;
-        overflow: hidden;
-    }
-  </style>
-</head>
-<body>
-	<div class="navibar"></div>
-    <div class="imgwrapper">
-        <img src="img/wallpaper.jpg" class="ui fluid image">
-    </div>
-    <div class="ui container">
-        <br><br>
-        <div class="ui very padded text container segment">
-            <h2 class="ui header">Admin Panel</h2>
-            <form id="loginForm" class="ui form">
-              <div class="field">
-                <label>Username</label>
-                <input type="text" name="username" placeholder="Username">
-              </div>
-              <div class="field">
-                <label>Password</label>
-                <input type="password" name="password" placeholder="Password">
+                  <a class="ts-text is-icon-link has-top-spaced-large" href="index.html">
+                    <span class="ts-icon is-arrow-left-icon"></span>
+                    Back
+                </a>
               </div>
-              <button id="loginButton" class="ui basic button" type="submit"><i class="ui blue sign-in icon"></i> Login</button>
-            </form>
           </div>
-	</div>
-    <script>
-        $(".navibar").load("navi.html");
+      </div>
+    </div>
 
+    <script>
 
-        $('#loginForm').submit(function(e) {
-            e.preventDefault(); // Prevent form submission
+      $(document).ready(function() {
+        $('input[name="username"]').on('keypress', function(e) {
+          if (e.which === 13) { // Enter key
+            e.preventDefault();
+            $('input[name="password"]').focus();
+          }
+        });
 
-            // Get the input values
-            var username = $('input[name="username"]').val();
-            var password = $('input[name="password"]').val();
+        $('input[name="password"]').on('keypress', function(e) {
+          if (e.which === 13) { // Enter key
+            e.preventDefault();
+            login(e);
+          }
+        });
+      });
+      
+      function login(e){
+        e.preventDefault(); // Prevent form submission
+        // Get the input values
+        var username = $('input[name="username"]').val();
+        var password = $('input[name="password"]').val();
 
-            $.ajax({
-                url: "/api/auth/login?username=" + username + "&password=" + password,
-                method: "POST",
-                success: function(data){
-                    if (data.error != undefined){
-                        alert(data.error);
-                    }else{
-                        //Logged in
-                        window.location.href = "/admin/";
-                    }
+        $.ajax({
+            url: "/api/auth/login?username=" + username + "&password=" + password,
+            method: "POST",
+            success: function(data){
+                if (data.error != undefined){
+                  $('input[name="username"], input[name="password"]').parent().addClass('is-negative');
+                }else{
+                    //Logged in
+                    window.location.href = "/admin/";
                 }
-            })
+            }
         });
-
+      }
     </script>
-</body>
+  </body>
 </html>

+ 0 - 7
sd_card/www/navi.html

@@ -1,7 +0,0 @@
-<div class="ui container">
-    <br>
-    <a href="index.html" style="user-select: none; height: 45px;">
-        <img class="ui small image" src="img/logo.png">
-    </a>
-    <br>
-</div>

+ 301 - 0
sd_card/www/posts.html

@@ -0,0 +1,301 @@
+<div class="ts-container is-very-narrow">
+    <p>Work in progress</p>
+</div>
+<script>
+     let loggedIn = false;
+
+    //Check the user has logged in
+    //Post editing function still require session check
+    //this just here to hide the edit buttons
+    $.get("/api/auth/chk", function(data){
+        if (data == false){
+            //User cannot use admin function. Hide the buttons.
+            $(".adminOnly").remove();
+            loggedIn = false;
+        }else{
+            loggedIn = true;
+        }
+        loadValue("blog-posts", function(){
+            initPosts();
+        });
+    });
+
+    //Initialize blog info
+    function initBlogInfo(){
+        loadValue("blog-title", function(title){
+            if (title.error != undefined || title == ""){
+                title = "WebStick";
+            }
+            document.title = decodeURIComponent(title);
+            $("#pageTitle").text(decodeURIComponent(title));
+        });
+
+        loadValue("blog-subtitle", function(title){
+            if (title.error != undefined || title == ""){
+                title = "A personal web server hosted on an ESP8266 using a micro SD card";
+            }
+            $("#pageDesc").text(decodeURIComponent(title));
+        });
+    }
+
+    $(document).ready(function(){
+        initBlogInfo();
+    });
+   
+
+
+    //Edit blog title and subtitles
+    function editBlogSubtitle(){
+        let newtitle = prompt("New Blog Subtitle", "");
+        if (newtitle != null) {
+            setValue("blog-subtitle", encodeURIComponent(newtitle), function(){
+                initBlogInfo();
+            })
+        } 
+    }
+
+    function editBlogTitle(){
+        let newtitle = prompt("New Blog Title", "");
+        if (newtitle != null) {
+            setValue("blog-title", encodeURIComponent(newtitle), function(){
+                initBlogInfo();
+            })
+        } 
+    }   
+
+    //Storage and loader utils
+    function setValue(key, value, callback){
+        $.get("/api/pref/set?key=" + key + "&value=" + value, function(data){
+            callback(data);
+        });
+    }
+
+    function loadValue(key, callback){
+        $.get("/api/pref/get?key=" + key, function(data){
+            callback(data);
+        });
+    }
+
+/*
+    New Post
+
+    New post is created via creating a markdown file in the server
+    side and open it with the markdown editor
+*/
+
+$("#createNewPostBtn").on("click", function(){
+    $("#newPostModal").toggle("fast");
+});
+
+function createNewPost(){
+    let filename = $("#newPostTitle").val().trim();
+    if (filename == ""){
+        alert("Post title cannot be empty.");
+        return;
+    }
+
+    if (filename.indexOf("/") >= 0){
+        //Contains /. Reject
+        alert("File name cannot contain path seperator");
+        return;
+    }
+
+    $("#confirmNewPostBtn").addClass("loading").addClass("disabled");
+    //Create the markdown file at the /blog/posts folder
+    const blob = new Blob(["# Hello World\n"], { type: 'text/plain' });
+    let storeFilename = parseInt(Date.now()/1000) + "_" + filename+'.md';
+    const file = new File([blob], storeFilename);
+    handleFile(file, "/blog/posts", function(){
+        //Update the post index
+        updatePostIndex();
+
+        $("#confirmNewPostBtn").removeClass("loading").removeClass("disabled");
+
+        //Open the markdown file in new tab
+        let hash = encodeURIComponent(JSON.stringify({
+            "filename": storeFilename,
+            "filepath": "/blog/posts/" + storeFilename
+        }))
+        window.open("/admin/mde/index.html#" + hash);
+
+        $("#newPostModal").hide();
+    });
+
+    
+}
+
+function handleFile(file, dir=currentPath, callback=undefined) {
+    // Perform actions with the selected file
+    var formdata = new FormData();
+    formdata.append("file1", file);
+    var ajax = new XMLHttpRequest();
+    ajax.addEventListener("load", function(event){
+        let responseText = event.target.responseText;
+        try{
+            responseText = JSON.parse(responseText);
+            if (responseText.error != undefined){
+                alert(responseText.error);
+            }
+        }catch(ex){
+
+        }
+        if (callback != undefined){
+            callback();
+        }
+    }, false); // doesnt appear to ever get called even upon success
+    ajax.addEventListener("error", errorHandler, false);
+    //ajax.addEventListener("abort", abortHandler, false);
+    ajax.open("POST", "/upload?dir=" + dir);
+    ajax.send(formdata);
+}
+
+function errorHandler(event) {
+    aelrt("New Post creation failed");
+    $("#pasteButton").removeClass("disabled");
+}
+
+/*
+    Post Edit functions
+*/
+
+function editPost(btn){
+    let postFilename = $(btn).attr("filename");
+    let hash = encodeURIComponent(JSON.stringify({
+        "filename": postFilename,
+        "filepath": "/blog/posts/" + postFilename
+    }))
+    window.open("/admin/mde/index.html#" + hash);
+}
+
+function deletePost(btn){
+    let postFilename = $(btn).attr("filename");
+    let postTitle = $(btn).attr("ptitle");
+    if (confirm("Confirm remove post titled: " + postTitle + "?")){
+        $.ajax({
+            url: "/api/fs/del?target=/blog/posts/" + postFilename,
+            method: "POST",
+            success: function(data){
+                if (data.error != undefined){
+                    alert("Post delete failed. See console for more info.");
+                    console.log(data.error);
+                }else{
+                   //Deleted
+                   initPosts();
+                }
+            }
+        });
+    }
+}
+
+/*
+    Rendering for Posts
+*/
+//Load a markdown file from URL and render it to target element
+function loadMarkdownToHTML(markdownURL, targetElement){
+    fetch(markdownURL).then( r => r.text() ).then( text =>{
+        var converter = new showdown.Converter();
+        let targetHTML = converter.makeHtml(text);
+        console.log(targetHTML);
+        $(targetElement).html(targetHTML);
+    });
+}
+
+function initPosts(){
+    $("#posttable").html("<div class='ui basic segment'><p><i class='ui loading spinner icon'></i> Loading Blog Posts</p></div>");
+    loadValue("blog-posts", function(data){
+        $("#posttable").html("");
+        try{
+            let postList = JSON.parse(decodeURIComponent(atob(data)));
+
+            //From latest to oldest
+            postList.reverse();
+            console.log("Post listed loaded: ", postList);
+            if (postList.length == 0){
+                $("#nopost").show();
+            }else{
+                $("#nopost").hide();
+                postList.forEach(postFilename => {
+                    renderPost(postFilename);
+                })
+            }
+        }catch(ex){
+            $("#nopost").show();
+        }
+        
+    })
+}
+
+function forceUpdatePostIndex(){
+    updatePostIndex(function(){
+        window.location.reload();
+    });
+}
+
+function updatePostIndex(callback=undefined){
+    let postList = [];
+    $.ajax({
+        url: "/api/fs/list?dir=/blog/posts",
+        success: function(data){
+            data.forEach(file => {
+                let filename = file.Filename;
+                let ext = filename.split(".").pop();
+                if (ext == "md" && file.IsDir == false){
+                    //Markdown file. Render it
+                    postList.push(filename);
+                }
+            });
+
+            setValue("blog-posts", btoa(encodeURIComponent(JSON.stringify(postList))), function(data){
+                console.log(data);
+                if (callback != undefined){
+                    callback();
+                }
+            });
+        }
+    });
+
+   
+}
+
+//Render post
+function renderPost(filename){
+    //Remove the timestamp
+    let postTitle = filename.split("_");
+    let timeStamp = postTitle.shift();
+    postTitle = postTitle.join("_");
+
+    //Pop the file extension
+    postTitle = postTitle.split(".");
+    postTitle.pop();
+    postTitle = postTitle.join(".");
+
+    var postTime = new Date(parseInt(timeStamp) * 1000).toLocaleDateString("en-US")
+    let postEditFeature = `<div class="adminOnly" style="position: absolute; top: 3em; right: 0.4em;">
+                <a class="ui basic mini icon button" onclick="editPost(this);" filename="${filename}" title="Edit Post"><i class="edit icon"></i></a>
+                <button class="ui basic mini icon button" onclick="deletePost(this);" ptitle="${postTitle}" filename="${filename}" title="Remove Post"><i class="red trash icon"></i></button>
+            </div>`;
+
+    if (!loggedIn){
+        postEditFeature = "";
+    }
+    //Create a wrapper element
+    $("#posttable").append(`
+        <div class="ui basic segment postObject" id="${timeStamp}">
+            <div class="ui divider"></div>
+            <h4 class="ui header">
+                <i class="blue paperclip icon"></i>
+                <div class="content">
+                   ${postTitle}
+                </div>
+            </h4>
+            ${postEditFeature}
+            <div class="postContent">
+
+            </div>
+            <small><i class="calendar alternate outline icon"></i> ${postTime}</small>
+        </div>
+    `);
+    let targetElement =  $("#" + timeStamp).find(".postContent");
+    loadMarkdownToHTML("/blog/posts/" + filename,targetElement);
+}
+</script>

+ 24 - 49
sd_card/www/tool/qr.html

@@ -5,39 +5,16 @@
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
     <title>QR Code Generator</title>
 	<link rel="icon" type="image/png" href="/favicon.png" />
+	<!-- QR Code -->
     <script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
 	<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js" integrity="sha512-v2CJ7UaYy4JwqLDIrZUI/4hqeoQieOmAZNXBeQyjo21dadnwR+8ZaIJVT8EE2iyI61OV8e6M8PP2/4hpQINQ/g==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
-	<script src="https://cdnjs.cloudflare.com/ajax/libs/fomantic-ui/2.9.3/semantic.min.js" integrity="sha512-gnoBksrDbaMnlE0rhhkcx3iwzvgBGz6mOEj4/Y5ZY09n55dYddx6+WYc72A55qEesV8VX2iMomteIwobeGK1BQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
-	<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/fomantic-ui/2.9.3/semantic.min.css" integrity="sha512-3quBdRGJyLy79hzhDDcBzANW+mVqPctrGCfIPosHQtMKb3rKsCxfyslzwlz2wj1dT8A7UX+sEvDjaUv+WExQrA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
-	 <link rel="preconnect" href="https://fonts.googleapis.com">
+	<!-- css -->
+	<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tocas-ui/5.0.2/tocas.min.css">
+	<script src="https://cdnjs.cloudflare.com/ajax/libs/tocas-ui/5.0.2/tocas.min.js"></script>
+	<link rel="preconnect" href="https://fonts.googleapis.com">
 	<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
-	<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@100;200&display=swap" rel="stylesheet">
-
+	<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;500;700&display=swap" rel="stylesheet">
     <style>
-		body {
-            
-        }
-
-		.main{
-			display: flex;
-            align-items: center;
-            justify-content: center;
-            height: calc(100vh - 60px);
-            margin: 0;
-		}
-		
-        #qr-code {
-            max-width: 300px;
-			height: 300px;
-        }
-
-        #generate-btn {
-            cursor: pointer;
-        }
-		
-		.ui.header,button,input{
-		    font-family: 'Noto Sans TC', sans-serif !important;
-		}
 		
 		@media screen and (min-width: 768px) {
 			#title{
@@ -54,31 +31,29 @@
 				padding-left: 5em;
 			}
 		}
+
+		#qr-code {
+			display: flex;
+			justify-content: center;
+			align-items: center;
+			margin: 0 auto;
+			height: 300px; /* Match the QR code height */
+			width: 300px; /* Match the QR code width */
+		}
     </style>
 </head>
 <body>
-	<div class="main">
-		<div class="ui stackable grid">
-		<div id="title" class="eight wide column" align="center">
-			<h2 class="ui header">
-			QR Code Generator
-			<div class="sub header">No ads and it just works</div>
-			</h2>
-			<br>
-			<a href="../">Back to homepage</a>
-		</div>
-		<div id="qrgrid" class="eight wide column"  align="center">
-		<div style="width: 300px">
-			<div class="ui fluid input" style="margin-bottom: 1em;">
-			<input type="text" id="text-input" placeholder="Enter text for QR code" onkeypress="handleKeyPress(event)">
-			</div>
-			<button class="ui fluid basic button" id="generate-btn" onclick="generateQRCode()">Generate QR Code</button>
-			<div class="ui divider"></div>
-			<div id="qr-code"></div>
+	<div class="ts-app-center">
+		<div class="ts-content">
+			<div >
+				<div class="ts-input is-circular" style="margin-bottom: 1em;">
+					<input type="text" id="text-input" placeholder="Enter text for QR code" onkeypress="handleKeyPress(event)">
+				</div>
+				<button class="ts-button is-fluid" id="generate-btn" onclick="generateQRCode()">Generate QR Code</button>
+				<div class="ts-divider has-top-spaced-large has-bottom-spaced-large"></div>
+				<div id="qr-code"></div>
 			</div>
 		</div>
-		</div>
-		
 	</div>
     <script>
 		function initQrCode(){

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików