diff --git a/.gitignore b/.gitignore
index 25b36fb..bae255e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@
.DS_Store
.vscode
lib/EpdFont/fontsrc
+*.generated.h
diff --git a/docs/images/wifi/webserver_files.png b/docs/images/wifi/webserver_files.png
new file mode 100644
index 0000000..84835d1
Binary files /dev/null and b/docs/images/wifi/webserver_files.png differ
diff --git a/docs/images/wifi/webserver_homepage.png b/docs/images/wifi/webserver_homepage.png
new file mode 100644
index 0000000..368e681
Binary files /dev/null and b/docs/images/wifi/webserver_homepage.png differ
diff --git a/docs/images/wifi/webserver_upload.png b/docs/images/wifi/webserver_upload.png
new file mode 100644
index 0000000..d7295c5
Binary files /dev/null and b/docs/images/wifi/webserver_upload.png differ
diff --git a/docs/images/wifi/wifi_connected.jpeg b/docs/images/wifi/wifi_connected.jpeg
new file mode 100644
index 0000000..a9d74db
Binary files /dev/null and b/docs/images/wifi/wifi_connected.jpeg differ
diff --git a/docs/images/wifi/wifi_networks.jpeg b/docs/images/wifi/wifi_networks.jpeg
new file mode 100644
index 0000000..9c42dc8
Binary files /dev/null and b/docs/images/wifi/wifi_networks.jpeg differ
diff --git a/docs/images/wifi/wifi_password.jpeg b/docs/images/wifi/wifi_password.jpeg
new file mode 100644
index 0000000..1ca2fed
Binary files /dev/null and b/docs/images/wifi/wifi_password.jpeg differ
diff --git a/docs/webserver.md b/docs/webserver.md
new file mode 100644
index 0000000..2c96b8e
--- /dev/null
+++ b/docs/webserver.md
@@ -0,0 +1,272 @@
+# Web Server Guide
+
+This guide explains how to connect your CrossPoint Reader to WiFi and use the built-in web server to upload EPUB files from your computer or phone.
+
+## Overview
+
+CrossPoint Reader includes a built-in web server that allows you to:
+
+- Upload EPUB files wirelessly from any device on the same WiFi network
+- Browse and manage files on your device's SD card
+- Create folders to organize your ebooks
+- Delete files and folders
+
+## Prerequisites
+
+- Your CrossPoint Reader device
+- A WiFi network
+- A computer, phone, or tablet connected to the **same WiFi network**
+
+---
+
+## Step 1: Accessing the WiFi Screen
+
+1. From the main menu or file browser, navigate to the **Settings** screen
+2. Select the **WiFi** option
+3. The device will automatically start scanning for available networks
+
+---
+
+## Step 2: Connecting to WiFi
+
+### Viewing Available Networks
+
+Once the scan completes, you'll see a list of available WiFi networks with the following indicators:
+
+- **Signal strength bars** (`||||`, `|||`, `||`, `|`) - Shows connection quality
+- **`*` symbol** - Indicates the network is password-protected (encrypted)
+- **`+` symbol** - Indicates you have previously saved credentials for this network
+
+
+
+### Selecting a Network
+
+1. Use the **Left/Right** (or **Volume Up/Down**) buttons to navigate through the network list
+2. Press **Confirm** to select the highlighted network
+
+### Entering Password (for encrypted networks)
+
+If the network requires a password:
+
+1. An on-screen keyboard will appear
+2. Use the navigation buttons to select characters
+3. Press **Confirm** to enter each character
+4. When complete, select the **Done** option on the keyboard
+
+
+
+**Note:** If you've previously connected to this network, the saved password will be used automatically.
+
+### Connection Process
+
+The device will display "Connecting..." while establishing the connection. This typically takes 5-10 seconds.
+
+### Saving Credentials
+
+If this is a new network, you'll be prompted to save the password:
+
+- Select **Yes** to save credentials for automatic connection next time (NOTE: These are stored in plaintext on the device's SD card. Do not use this for sensitive networks.)
+- Select **No** to connect without saving
+
+---
+
+## Step 3: Connection Success
+
+Once connected, the screen will display:
+
+- **Network name** (SSID)
+- **IP Address** (e.g., `192.168.1.102`)
+- **Web server URL** (e.g., `http://192.168.1.102/`)
+
+
+
+**Important:** Make note of the IP address - you'll need this to access the web interface from your computer or phone.
+
+---
+
+## Step 4: Accessing the Web Interface
+
+### From a Computer
+
+1. Ensure your computer is connected to the **same WiFi network** as your CrossPoint Reader
+2. Open any web browser (Chrome is recommended)
+3. Type the IP address shown on your device into the browser's address bar
+ - Example: `http://192.168.1.102/`
+4. Press Enter
+
+### From a Phone or Tablet
+
+1. Ensure your phone/tablet is connected to the **same WiFi network** as your CrossPoint Reader
+2. Open your mobile browser (Safari, Chrome, etc.)
+3. Type the IP address into the address bar
+ - Example: `http://192.168.1.102/`
+4. Tap Go
+
+---
+
+## Step 5: Using the Web Interface
+
+### Home Page
+
+The home page displays:
+
+- Device status and version information
+- WiFi connection status
+- Current IP address
+- Available memory
+
+Navigation links:
+
+- **Home** - Returns to the status page
+- **File Manager** - Access file management features
+
+
+
+### File Manager
+
+Click **File Manager** to access file management features.
+
+#### Browsing Files
+
+- The file manager displays all files and folders on your SD card
+- **Folders** are highlighted in yellow with a 📁 icon
+- **EPUB files** are highlighted in green with a 📗 icon
+- Click on a folder name to navigate into it
+- Use the breadcrumb navigation at the top to go back to parent folders
+
+
+
+#### Uploading EPUB Files
+
+1. Click the **+ Add** button in the top-right corner
+2. Select **Upload eBook** from the dropdown menu
+3. Click **Choose File** and select an `.epub` file from your device
+4. Click **Upload**
+5. A progress bar will show the upload status
+6. The page will automatically refresh when the upload is complete
+
+**Note:** Only `.epub` files are accepted. Other file types will be rejected.
+
+
+
+#### Creating Folders
+
+1. Click the **+ Add** button in the top-right corner
+2. Select **New Folder** from the dropdown menu
+3. Enter a folder name (letters, numbers, underscores, and hyphens only)
+4. Click **Create Folder**
+
+This is useful for organizing your ebooks by genre, author, or series.
+
+#### Deleting Files and Folders
+
+1. Click the **🗑️** (trash) icon next to any file or folder
+2. Confirm the deletion in the popup dialog
+3. Click **Delete** to permanently remove the item
+
+**Warning:** Deletion is permanent and cannot be undone!
+
+**Note:** Folders must be empty before they can be deleted.
+
+---
+
+## Troubleshooting
+
+### Cannot See the Device on the Network
+
+**Problem:** Browser shows "Cannot connect" or "Site can't be reached"
+
+**Solutions:**
+
+1. Verify both devices are on the **same WiFi network**
+ - Check your computer/phone WiFi settings
+ - Confirm the CrossPoint Reader shows "Connected" status
+2. Double-check the IP address
+ - Make sure you typed it correctly
+ - Include `http://` at the beginning
+3. Try disabling VPN if you're using one
+4. Some networks have "client isolation" enabled - check with your network administrator
+
+### Connection Drops or Times Out
+
+**Problem:** WiFi connection is unstable
+
+**Solutions:**
+
+1. Move closer to the WiFi router
+2. Check signal strength on the device (should be at least `||` or better)
+3. Avoid interference from other devices
+4. Try a different WiFi network if available
+
+### Upload Fails
+
+**Problem:** File upload doesn't complete or shows an error
+
+**Solutions:**
+
+1. Ensure the file is a valid `.epub` file
+2. Check that the SD card has enough free space
+3. Try uploading a smaller file first to test
+4. Refresh the browser page and try again
+
+### Saved Password Not Working
+
+**Problem:** Device fails to connect with saved credentials
+
+**Solutions:**
+
+1. When connection fails, you'll be prompted to "Forget Network"
+2. Select **Yes** to remove the saved password
+3. Reconnect and enter the password again
+4. Choose to save the new password
+
+---
+
+## Security Notes
+
+- The web server runs on port 80 (standard HTTP)
+- **No authentication is required** - anyone on the same network can access the interface
+- The web server is only accessible while the WiFi screen shows "Connected"
+- The web server automatically stops when you exit the WiFi screen
+- For security, only use on trusted private networks
+
+---
+
+## Technical Details
+
+- **Supported WiFi:** 2.4GHz networks (802.11 b/g/n)
+- **Web Server Port:** 80 (HTTP)
+- **Maximum Upload Size:** Limited by available SD card space
+- **Supported File Format:** `.epub` only
+- **Browser Compatibility:** All modern browsers (Chrome, Firefox, Safari, Edge)
+
+---
+
+## Tips and Best Practices
+
+1. **Organize with folders** - Create folders before uploading to keep your library organized
+2. **Check signal strength** - Stronger signals (`|||` or `||||`) provide faster, more reliable uploads
+3. **Upload multiple files** - You can upload files one at a time; the page refreshes after each upload
+4. **Use descriptive names** - Name your folders clearly (e.g., "SciFi", "Mystery", "Non-Fiction")
+5. **Keep credentials saved** - Save your WiFi password for quick reconnection in the future
+6. **Exit when done** - Press **Back** to exit the WiFi screen and save battery
+
+---
+
+## Exiting WiFi Mode
+
+When you're finished uploading files:
+
+1. Press the **Back** button on your CrossPoint Reader
+2. The web server will automatically stop
+3. WiFi will disconnect to conserve battery
+4. You'll return to the previous screen
+
+Your uploaded files will be immediately available in the file browser!
+
+---
+
+## Related Documentation
+
+- [User Guide](../USER_GUIDE.md) - General device operation
+- [README](../README.md) - Project overview and features
diff --git a/platformio.ini b/platformio.ini
index eb839e2..5150c08 100644
--- a/platformio.ini
+++ b/platformio.ini
@@ -31,6 +31,9 @@ board_build.flash_mode = dio
board_build.flash_size = 16MB
board_build.partitions = partitions.csv
+extra_scripts =
+ pre:scripts/build_html.py
+
; Libraries
lib_deps =
BatteryMonitor=symlink://open-x4-sdk/libs/hardware/BatteryMonitor
diff --git a/scripts/build_html.py b/scripts/build_html.py
new file mode 100644
index 0000000..248aba8
--- /dev/null
+++ b/scripts/build_html.py
@@ -0,0 +1,51 @@
+import os
+import re
+
+SRC_DIR = "src"
+
+def minify_html(html: str) -> str:
+ # Tags where whitespace should be preserved
+ preserve_tags = ['pre', 'code', 'textarea', 'script', 'style']
+ preserve_regex = '|'.join(preserve_tags)
+
+ # Protect preserve blocks with placeholders
+ preserve_blocks = []
+ def preserve(match):
+ preserve_blocks.append(match.group(0))
+ return f"__PRESERVE_BLOCK_{len(preserve_blocks)-1}__"
+
+ html = re.sub(rf'<({preserve_regex})[\s\S]*?\1>', preserve, html, flags=re.IGNORECASE)
+
+ # Remove HTML comments
+ html = re.sub(r'', '', html, flags=re.DOTALL)
+
+ # Collapse all whitespace between tags
+ html = re.sub(r'>\s+<', '><', html)
+
+ # Collapse multiple spaces inside tags
+ html = re.sub(r'\s+', ' ', html)
+
+ # Restore preserved blocks
+ for i, block in enumerate(preserve_blocks):
+ html = html.replace(f"__PRESERVE_BLOCK_{i}__", block)
+
+ return html.strip()
+
+for root, _, files in os.walk(SRC_DIR):
+ for file in files:
+ if file.endswith(".html"):
+ html_path = os.path.join(root, file)
+ with open(html_path, "r", encoding="utf-8") as f:
+ html_content = f.read()
+
+ # minified = regex.sub("\g<1>", html_content)
+ minified = minify_html(html_content)
+ base_name = f"{os.path.splitext(file)[0]}Html"
+ header_path = os.path.join(root, f"{base_name}.generated.h")
+
+ with open(header_path, "w", encoding="utf-8") as h:
+ h.write(f"// THIS FILE IS AUTOGENERATED, DO NOT EDIT MANUALLY\n\n")
+ h.write(f"#pragma once\n")
+ h.write(f'constexpr char {base_name}[] PROGMEM = R"rawliteral({minified})rawliteral";\n')
+
+ print(f"Generated: {header_path}")
diff --git a/src/CrossPointWebServer.cpp b/src/CrossPointWebServer.cpp
new file mode 100644
index 0000000..a4e70bb
--- /dev/null
+++ b/src/CrossPointWebServer.cpp
@@ -0,0 +1,706 @@
+#include "CrossPointWebServer.h"
+
+#include
+#include
+
+#include
+
+#include "config.h"
+#include "html/FilesPageFooterHtml.generated.h"
+#include "html/FilesPageHeaderHtml.generated.h"
+#include "html/HomePageHtml.generated.h"
+
+// Global instance
+CrossPointWebServer crossPointWebServer;
+
+// Folders/files to hide from the web interface file browser
+// Note: Items starting with "." are automatically hidden
+static const char* HIDDEN_ITEMS[] = {"System Volume Information", "XTCache"};
+static const size_t HIDDEN_ITEMS_COUNT = sizeof(HIDDEN_ITEMS) / sizeof(HIDDEN_ITEMS[0]);
+
+// Helper function to escape HTML special characters to prevent XSS
+static String escapeHtml(const String& input) {
+ String output;
+ output.reserve(input.length() * 1.1); // Pre-allocate with some extra space
+
+ for (size_t i = 0; i < input.length(); i++) {
+ char c = input.charAt(i);
+ switch (c) {
+ case '&':
+ output += "&";
+ break;
+ case '<':
+ output += "<";
+ break;
+ case '>':
+ output += ">";
+ break;
+ case '"':
+ output += """;
+ break;
+ case '\'':
+ output += "'";
+ break;
+ default:
+ output += c;
+ break;
+ }
+ }
+ return output;
+}
+
+// File listing page template - now using generated headers:
+// - HomePageHtml (from html/HomePage.html)
+// - FilesPageHeaderHtml (from html/FilesPageHeader.html)
+// - FilesPageFooterHtml (from html/FilesPageFooter.html)
+CrossPointWebServer::CrossPointWebServer() {}
+
+CrossPointWebServer::~CrossPointWebServer() { stop(); }
+
+void CrossPointWebServer::begin() {
+ if (running) {
+ Serial.printf("[%lu] [WEB] Web server already running\n", millis());
+ return;
+ }
+
+ if (WiFi.status() != WL_CONNECTED) {
+ Serial.printf("[%lu] [WEB] Cannot start webserver - WiFi not connected\n", millis());
+ return;
+ }
+
+ Serial.printf("[%lu] [WEB] [MEM] Free heap before begin: %d bytes\n", millis(), ESP.getFreeHeap());
+
+ Serial.printf("[%lu] [WEB] Creating web server on port %d...\n", millis(), port);
+ server = new WebServer(port);
+ Serial.printf("[%lu] [WEB] [MEM] Free heap after WebServer allocation: %d bytes\n", millis(), ESP.getFreeHeap());
+
+ if (!server) {
+ Serial.printf("[%lu] [WEB] Failed to create WebServer!\n", millis());
+ return;
+ }
+
+ // Setup routes
+ Serial.printf("[%lu] [WEB] Setting up routes...\n", millis());
+ server->on("/", HTTP_GET, [this]() { handleRoot(); });
+ server->on("/status", HTTP_GET, [this]() { handleStatus(); });
+ server->on("/files", HTTP_GET, [this]() { handleFileList(); });
+
+ // Upload endpoint with special handling for multipart form data
+ server->on("/upload", HTTP_POST, [this]() { handleUploadPost(); }, [this]() { handleUpload(); });
+
+ // Create folder endpoint
+ server->on("/mkdir", HTTP_POST, [this]() { handleCreateFolder(); });
+
+ // Delete file/folder endpoint
+ server->on("/delete", HTTP_POST, [this]() { handleDelete(); });
+
+ server->onNotFound([this]() { handleNotFound(); });
+ Serial.printf("[%lu] [WEB] [MEM] Free heap after route setup: %d bytes\n", millis(), ESP.getFreeHeap());
+
+ server->begin();
+ running = true;
+
+ Serial.printf("[%lu] [WEB] Web server started on port %d\n", millis(), port);
+ Serial.printf("[%lu] [WEB] Access at http://%s/\n", millis(), WiFi.localIP().toString().c_str());
+ Serial.printf("[%lu] [WEB] [MEM] Free heap after server.begin(): %d bytes\n", millis(), ESP.getFreeHeap());
+}
+
+void CrossPointWebServer::stop() {
+ if (!running || !server) {
+ return;
+ }
+
+ Serial.printf("[%lu] [WEB] [MEM] Free heap before stop: %d bytes\n", millis(), ESP.getFreeHeap());
+
+ server->stop();
+ Serial.printf("[%lu] [WEB] [MEM] Free heap after server->stop(): %d bytes\n", millis(), ESP.getFreeHeap());
+
+ delete server;
+ server = nullptr;
+ running = false;
+
+ Serial.printf("[%lu] [WEB] Web server stopped\n", millis());
+ Serial.printf("[%lu] [WEB] [MEM] Free heap after delete server: %d bytes\n", millis(), ESP.getFreeHeap());
+
+ // Note: Static upload variables (uploadFileName, uploadPath, uploadError) are declared
+ // later in the file and will be cleared when they go out of scope or on next upload
+ Serial.printf("[%lu] [WEB] [MEM] Free heap final: %d bytes\n", millis(), ESP.getFreeHeap());
+}
+
+void CrossPointWebServer::handleClient() {
+ static unsigned long lastDebugPrint = 0;
+ if (running && server) {
+ // Print debug every 10 seconds to confirm handleClient is being called
+ if (millis() - lastDebugPrint > 10000) {
+ Serial.printf("[%lu] [WEB] handleClient active, server running on port %d\n", millis(), port);
+ lastDebugPrint = millis();
+ }
+ server->handleClient();
+ }
+}
+
+void CrossPointWebServer::handleRoot() {
+ String html = HomePageHtml;
+
+ // Replace placeholders with actual values
+ html.replace("%VERSION%", CROSSPOINT_VERSION);
+ html.replace("%IP_ADDRESS%", WiFi.localIP().toString());
+ html.replace("%FREE_HEAP%", String(ESP.getFreeHeap()));
+
+ server->send(200, "text/html", html);
+ Serial.printf("[%lu] [WEB] Served root page\n", millis());
+}
+
+void CrossPointWebServer::handleNotFound() {
+ String message = "404 Not Found\n\n";
+ message += "URI: " + server->uri() + "\n";
+ server->send(404, "text/plain", message);
+}
+
+void CrossPointWebServer::handleStatus() {
+ String json = "{";
+ json += "\"version\":\"" + String(CROSSPOINT_VERSION) + "\",";
+ json += "\"ip\":\"" + WiFi.localIP().toString() + "\",";
+ json += "\"rssi\":" + String(WiFi.RSSI()) + ",";
+ json += "\"freeHeap\":" + String(ESP.getFreeHeap()) + ",";
+ json += "\"uptime\":" + String(millis() / 1000);
+ json += "}";
+
+ server->send(200, "application/json", json);
+}
+
+std::vector CrossPointWebServer::scanFiles(const char* path) {
+ std::vector files;
+
+ File root = SD.open(path);
+ if (!root) {
+ Serial.printf("[%lu] [WEB] Failed to open directory: %s\n", millis(), path);
+ return files;
+ }
+
+ if (!root.isDirectory()) {
+ Serial.printf("[%lu] [WEB] Not a directory: %s\n", millis(), path);
+ root.close();
+ return files;
+ }
+
+ Serial.printf("[%lu] [WEB] Scanning files in: %s\n", millis(), path);
+
+ File file = root.openNextFile();
+ while (file) {
+ String fileName = String(file.name());
+
+ // Skip hidden items (starting with ".")
+ bool shouldHide = fileName.startsWith(".");
+
+ // Check against explicitly hidden items list
+ if (!shouldHide) {
+ for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) {
+ if (fileName.equals(HIDDEN_ITEMS[i])) {
+ shouldHide = true;
+ break;
+ }
+ }
+ }
+
+ if (!shouldHide) {
+ FileInfo info;
+ info.name = fileName;
+ info.isDirectory = file.isDirectory();
+
+ if (info.isDirectory) {
+ info.size = 0;
+ info.isEpub = false;
+ } else {
+ info.size = file.size();
+ info.isEpub = isEpubFile(info.name);
+ }
+
+ files.push_back(info);
+ }
+
+ file.close();
+ file = root.openNextFile();
+ }
+ root.close();
+
+ Serial.printf("[%lu] [WEB] Found %d items (files and folders)\n", millis(), files.size());
+ return files;
+}
+
+String CrossPointWebServer::formatFileSize(size_t bytes) {
+ if (bytes < 1024) {
+ return String(bytes) + " B";
+ } else if (bytes < 1024 * 1024) {
+ return String(bytes / 1024.0, 1) + " KB";
+ } else {
+ return String(bytes / (1024.0 * 1024.0), 1) + " MB";
+ }
+}
+
+bool CrossPointWebServer::isEpubFile(const String& filename) {
+ String lower = filename;
+ lower.toLowerCase();
+ return lower.endsWith(".epub");
+}
+
+void CrossPointWebServer::handleFileList() {
+ String html = FilesPageHeaderHtml;
+
+ // Get current path from query string (default to root)
+ String currentPath = "/";
+ if (server->hasArg("path")) {
+ currentPath = server->arg("path");
+ // Ensure path starts with /
+ if (!currentPath.startsWith("/")) {
+ currentPath = "/" + currentPath;
+ }
+ // Remove trailing slash unless it's root
+ if (currentPath.length() > 1 && currentPath.endsWith("/")) {
+ currentPath = currentPath.substring(0, currentPath.length() - 1);
+ }
+ }
+
+ // Get message from query string if present
+ if (server->hasArg("msg")) {
+ String msg = escapeHtml(server->arg("msg"));
+ String msgType = server->hasArg("type") ? escapeHtml(server->arg("type")) : "success";
+ html += "
" + msg + "
";
+ }
+
+ // Hidden input to store current path for JavaScript
+ html += "";
+
+ // Scan files in current path first (we need counts for the header)
+ std::vector files = scanFiles(currentPath.c_str());
+
+ // Count items
+ int epubCount = 0;
+ int folderCount = 0;
+ size_t totalSize = 0;
+ for (const auto& file : files) {
+ if (file.isDirectory) {
+ folderCount++;
+ } else {
+ if (file.isEpub) epubCount++;
+ totalSize += file.size;
+ }
+ }
+
+ // Page header with inline breadcrumb and action buttons
+ html += "
";
+ html += "
";
+ html += "
📁 File Manager
";
+
+ // Inline breadcrumb
+ html += "
";
+ html += "/";
+
+ if (currentPath == "/") {
+ html += "🏠";
+ } else {
+ html += "🏠";
+ String pathParts = currentPath.substring(1); // Remove leading /
+ String buildPath = "";
+ int start = 0;
+ int end = pathParts.indexOf('/');
+
+ while (start < (int)pathParts.length()) {
+ String part;
+ if (end == -1) {
+ part = pathParts.substring(start);
+ buildPath += "/" + part;
+ html += "/" + escapeHtml(part) + "";
+ break;
+ } else {
+ part = pathParts.substring(start, end);
+ buildPath += "/" + part;
+ html += "/" + escapeHtml(part) + "";
+ start = end + 1;
+ end = pathParts.indexOf('/', start);
+ }
+ }
+ }
+ html += "
";
+ html += "
";
+
+ // Action buttons
+ html += "
";
+ html += "";
+ html += "";
+ html += "
";
+
+ html += "
"; // end page-header
+
+ // Contents card with inline summary
+ html += "
";
+
+ // Contents header with inline stats
+ html += "
";
+ html += "
Contents
";
+ html += "";
+ html += String(folderCount) + " folder" + (folderCount != 1 ? "s" : "") + ", ";
+ html += String(files.size() - folderCount) + " file" + ((files.size() - folderCount) != 1 ? "s" : "") + ", ";
+ html += formatFileSize(totalSize);
+ html += "";
+ html += "
";
+
+ if (files.empty()) {
+ html += "
This folder is empty
";
+ } else {
+ html += "
";
+ html += "
Name
Type
Size
Actions
";
+
+ // Sort files: folders first, then epub files, then other files, alphabetically within each group
+ std::sort(files.begin(), files.end(), [](const FileInfo& a, const FileInfo& b) {
+ // Folders come first
+ if (a.isDirectory != b.isDirectory) return a.isDirectory > b.isDirectory;
+ // Then sort by epub status (epubs first among files)
+ if (!a.isDirectory && !b.isDirectory) {
+ if (a.isEpub != b.isEpub) return a.isEpub > b.isEpub;
+ }
+ // Then alphabetically
+ return a.name < b.name;
+ });
+
+ for (const auto& file : files) {
+ String rowClass;
+ String icon;
+ String badge;
+ String typeStr;
+ String sizeStr;
+
+ if (file.isDirectory) {
+ rowClass = "folder-row";
+ icon = "📁";
+ badge = "FOLDER";
+ typeStr = "Folder";
+ sizeStr = "-";
+
+ // Build the path to this folder
+ String folderPath = currentPath;
+ if (!folderPath.endsWith("/")) folderPath += "/";
+ folderPath += file.name;
+
+ html += "