diff --git a/data/app.js b/data/app.js
new file mode 100644
index 0000000..d045619
--- /dev/null
+++ b/data/app.js
@@ -0,0 +1,204 @@
+const API = '';
+let currentPage = 'dashboard';
+
+async function api(path, opts = {}) {
+ const res = await fetch(API + path, {
+ headers: { 'Content-Type': 'application/json' },
+ ...opts
+ });
+ return res.json();
+}
+
+function toast(msg) {
+ let t = document.querySelector('.toast');
+ if (!t) { t = document.createElement('div'); t.className = 'toast'; document.body.appendChild(t); }
+ t.textContent = msg;
+ t.classList.add('show');
+ setTimeout(() => t.classList.remove('show'), 3000);
+}
+
+function navigate(page) {
+ currentPage = page;
+ document.querySelectorAll('#nav-links a').forEach(a => {
+ a.classList.toggle('active', a.dataset.page === page);
+ });
+ renderPage(page);
+}
+
+async function renderPage(page) {
+ const el = document.getElementById('content');
+ switch (page) {
+ case 'dashboard': return renderDashboard(el);
+ case 'albums': return renderAlbums(el);
+ case 'slideshow': return renderSlideshow(el);
+ case 'display': return renderDisplay(el);
+ case 'device': return renderDevice(el);
+ case 'firmware': return renderFirmware(el);
+ }
+}
+
+async function renderDashboard(el) {
+ const status = await api('/api/status');
+ el.innerHTML = `
+
+
Status
+
Battery${status.battery}%${status.charging ? ' ⚡' : ''}
+
WiFi Signal${status.wifi_rssi} dBm
+
IP Address${status.ip}
+
Uptime${Math.floor(status.uptime/60)}m
+
Free RAM${Math.floor(status.free_heap/1024)}KB
+
Free PSRAM${Math.floor(status.free_psram/1024)}KB
+
+
+
Controls
+
+
+
+
+
+
+
`;
+}
+
+async function renderAlbums(el) {
+ const [albums, settings] = await Promise.all([api('/api/albums'), api('/api/settings')]);
+ const selected = JSON.parse(settings.albums_json || '[]');
+ el.innerHTML = `
+
+
Albums
+
+
+
+
+
`;
+ document.getElementById('saveAlbums').onclick = async () => {
+ const ids = [...el.querySelectorAll('input[type=checkbox]:checked')].map(c => c.value);
+ await api('/api/albums/select', { method: 'POST', body: JSON.stringify({ album_ids: ids }) });
+ toast('Albums saved');
+ };
+}
+
+async function renderSlideshow(el) {
+ const settings = await api('/api/settings');
+ const intervals = [1, 5, 15, 30, 60];
+ const modes = ['Random', 'Chronological', 'Reverse Chrono', 'Favorites Weighted'];
+ el.innerHTML = `
+
+
Interval
+
+ ${intervals.map(i => ``).join('')}
+
+
+
+
Cycling Mode
+
+
+
+
+ `;
+ el.querySelectorAll('[data-interval]').forEach(btn => {
+ btn.onclick = () => {
+ el.querySelectorAll('[data-interval]').forEach(b => b.classList.remove('active'));
+ btn.classList.add('active');
+ };
+ });
+ document.getElementById('saveSlideshow').onclick = async () => {
+ const interval = parseInt(el.querySelector('[data-interval].active')?.dataset.interval || '5');
+ const mode = parseInt(document.getElementById('cycleMode').value);
+ await api('/api/settings', { method: 'POST', body: JSON.stringify({ interval_min: interval, cycle_mode: mode }) });
+ toast('Slideshow settings saved');
+ };
+}
+
+async function renderDisplay(el) {
+ const settings = await api('/api/settings');
+ const flags = settings.meta_flags;
+ el.innerHTML = `
+
+
Image Quality
+
+
+
+
+
+
Metadata Overlay
+
+
+
+
+
+
+ `;
+ document.getElementById('saveDisplay').onclick = async () => {
+ let flags = 0;
+ el.querySelectorAll('[data-flag]').forEach(cb => { if (cb.checked) flags |= parseInt(cb.dataset.flag); });
+ await api('/api/settings', { method: 'POST', body: JSON.stringify({
+ img_quality: parseInt(document.getElementById('imgQuality').value),
+ meta_flags: flags,
+ meta_pos: parseInt(document.getElementById('metaPos').value)
+ })});
+ toast('Display settings saved');
+ };
+}
+
+async function renderDevice(el) {
+ el.innerHTML = `
+
+
Device
+
+
+
+
+
`;
+}
+
+async function renderFirmware(el) {
+ el.innerHTML = `
+
+
Firmware Update
+
+
+
+
+
+
+
`;
+ document.getElementById('uploadFw').onclick = async () => {
+ const file = document.getElementById('fwFile').files[0];
+ if (!file) return toast('Select a file first');
+ const formData = new FormData();
+ formData.append('firmware', file);
+ document.getElementById('fwStatus').textContent = 'Uploading...';
+ const res = await fetch('/api/firmware', { method: 'POST', body: formData });
+ const data = await res.json();
+ document.getElementById('fwStatus').textContent = data.msg || (data.ok ? 'Success!' : 'Failed');
+ };
+}
+
+// Navigation
+document.querySelectorAll('#nav-links a').forEach(a => {
+ a.addEventListener('click', (e) => { e.preventDefault(); navigate(a.dataset.page); });
+});
+
+// Initial render
+navigate('dashboard');
diff --git a/data/index.html b/data/index.html
new file mode 100644
index 0000000..0e00c61
--- /dev/null
+++ b/data/index.html
@@ -0,0 +1,24 @@
+
+
+
+
+
+ PaperColor
+
+
+
+
+
+
+
+
diff --git a/data/setup.html b/data/setup.html
new file mode 100644
index 0000000..2473104
--- /dev/null
+++ b/data/setup.html
@@ -0,0 +1,89 @@
+
+
+
+
+
+ PaperColor Setup
+
+
+
+ PaperColor Setup
+
+
+
+
+
+
diff --git a/data/style.css b/data/style.css
new file mode 100644
index 0000000..f784afb
--- /dev/null
+++ b/data/style.css
@@ -0,0 +1,29 @@
+* { box-sizing: border-box; margin: 0; padding: 0; }
+body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; background: #f8fafc; color: #1e293b; }
+nav { background: #1e293b; color: white; padding: 16px 24px; display: flex; align-items: center; gap: 24px; flex-wrap: wrap; }
+nav h1 { font-size: 18px; white-space: nowrap; }
+#nav-links { display: flex; gap: 12px; flex-wrap: wrap; }
+#nav-links a { color: #94a3b8; text-decoration: none; padding: 4px 8px; border-radius: 4px; font-size: 14px; }
+#nav-links a.active { color: white; background: #334155; }
+main { max-width: 800px; margin: 24px auto; padding: 0 16px; }
+.card { background: white; border-radius: 8px; padding: 20px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
+.card h2 { font-size: 16px; margin-bottom: 12px; color: #475569; }
+.stat { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #f1f5f9; }
+.stat:last-child { border-bottom: none; }
+.field { margin-bottom: 16px; }
+.field label { display: block; font-weight: 600; margin-bottom: 4px; font-size: 14px; color: #64748b; }
+.field input, .field select { width: 100%; padding: 8px 12px; border: 1px solid #e2e8f0; border-radius: 6px; font-size: 14px; }
+.btn { padding: 8px 16px; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; }
+.btn-primary { background: #2563eb; color: white; }
+.btn-primary:hover { background: #1d4ed8; }
+.btn-danger { background: #dc2626; color: white; }
+.btn-danger:hover { background: #b91c1c; }
+.btn-group { display: flex; gap: 8px; margin-top: 12px; }
+.checkbox-list { list-style: none; }
+.checkbox-list li { padding: 8px 0; border-bottom: 1px solid #f1f5f9; display: flex; align-items: center; gap: 8px; }
+.checkbox-list input[type="checkbox"] { width: 18px; height: 18px; }
+.preset-btns { display: flex; gap: 8px; flex-wrap: wrap; }
+.preset-btns button { padding: 6px 12px; border: 1px solid #e2e8f0; border-radius: 4px; background: white; cursor: pointer; }
+.preset-btns button.active { background: #2563eb; color: white; border-color: #2563eb; }
+.toast { position: fixed; bottom: 20px; right: 20px; background: #065f46; color: white; padding: 12px 20px; border-radius: 6px; display: none; }
+.toast.show { display: block; }
diff --git a/src/web_server.cpp b/src/web_server.cpp
new file mode 100644
index 0000000..c24f51f
--- /dev/null
+++ b/src/web_server.cpp
@@ -0,0 +1,284 @@
+#include "web_server.h"
+#include
+#include
+#include
+#include
+#include "immich_client.h"
+#include "power_manager.h"
+#include "wifi_manager.h"
+#include "config.h"
+
+void AppWebServer::begin(SettingsManager& settings, ImmichClient& immich,
+ PowerManager& power, WiFiManager& wifi) {
+ _settings = &settings;
+ _immich = &immich;
+ _power = &power;
+ _wifi = &wifi;
+
+ if (!LittleFS.begin(true)) {
+ Serial.println("[web] LittleFS mount failed");
+ }
+
+ if (_wifi->isAPMode()) {
+ setupCaptivePortal();
+ } else {
+ setupAPIRoutes();
+ setupStaticFiles();
+ setupOTA();
+ }
+
+ _server.begin();
+ Serial.printf("[web] Server started (mode: %s)\n",
+ _wifi->isAPMode() ? "AP/captive" : "LAN");
+}
+
+void AppWebServer::setActionCallback(std::function cb) {
+ _actionCb = cb;
+}
+
+bool AppWebServer::isOTAInProgress() {
+ return _otaInProgress;
+}
+
+void AppWebServer::setupCaptivePortal() {
+ // Serve setup page for all requests (captive portal behavior)
+ _server.on("/", HTTP_GET, [](AsyncWebServerRequest* request) {
+ request->send(LittleFS, "/setup.html", "text/html");
+ });
+
+ // Handle WiFi scan
+ _server.on("/api/wifi/scan", HTTP_GET, [](AsyncWebServerRequest* request) {
+ int n = WiFi.scanNetworks();
+ JsonDocument doc;
+ JsonArray arr = doc.to();
+ for (int i = 0; i < n; i++) {
+ JsonObject net = arr.add();
+ net["ssid"] = WiFi.SSID(i);
+ net["rssi"] = WiFi.RSSI(i);
+ net["secure"] = WiFi.encryptionType(i) != WIFI_AUTH_OPEN;
+ }
+ String response;
+ serializeJson(doc, response);
+ request->send(200, "application/json", response);
+ });
+
+ // Handle setup submission
+ _server.on("/api/setup", HTTP_POST, [](AsyncWebServerRequest* request) {
+ request->send(200);
+ }, nullptr, [this](AsyncWebServerRequest* request, uint8_t* data, size_t len,
+ size_t index, size_t total) {
+ if (index + len == total) {
+ handlePostSetup(request, data, len);
+ }
+ });
+
+ // Captive portal redirect for all other paths
+ _server.onNotFound([](AsyncWebServerRequest* request) {
+ request->redirect("/");
+ });
+}
+
+void AppWebServer::setupAPIRoutes() {
+ _server.on("/api/status", HTTP_GET,
+ [this](AsyncWebServerRequest* req) { handleGetStatus(req); });
+
+ _server.on("/api/albums", HTTP_GET,
+ [this](AsyncWebServerRequest* req) { handleGetAlbums(req); });
+
+ _server.on("/api/albums/select", HTTP_POST,
+ [](AsyncWebServerRequest* req) { req->send(200); },
+ nullptr,
+ [this](AsyncWebServerRequest* req, uint8_t* data, size_t len,
+ size_t index, size_t total) {
+ if (index + len == total) handlePostAlbumsSelect(req, data, len);
+ });
+
+ _server.on("/api/settings", HTTP_GET,
+ [this](AsyncWebServerRequest* req) { handleGetSettings(req); });
+
+ _server.on("/api/settings", HTTP_POST,
+ [](AsyncWebServerRequest* req) { req->send(200); },
+ nullptr,
+ [this](AsyncWebServerRequest* req, uint8_t* data, size_t len,
+ size_t index, size_t total) {
+ if (index + len == total) handlePostSettings(req, data, len);
+ });
+
+ // Action endpoints
+ _server.on("/api/action/next", HTTP_POST,
+ [this](AsyncWebServerRequest* req) {
+ if (_actionCb) _actionCb("next");
+ req->send(200, "application/json", "{\"ok\":true}");
+ });
+
+ _server.on("/api/action/random", HTTP_POST,
+ [this](AsyncWebServerRequest* req) {
+ if (_actionCb) _actionCb("random");
+ req->send(200, "application/json", "{\"ok\":true}");
+ });
+
+ _server.on("/api/action/pause", HTTP_POST,
+ [this](AsyncWebServerRequest* req) {
+ if (_actionCb) _actionCb("pause");
+ req->send(200, "application/json", "{\"ok\":true}");
+ });
+
+ _server.on("/api/action/play", HTTP_POST,
+ [this](AsyncWebServerRequest* req) {
+ if (_actionCb) _actionCb("play");
+ req->send(200, "application/json", "{\"ok\":true}");
+ });
+
+ _server.on("/api/action/sleep", HTTP_POST,
+ [this](AsyncWebServerRequest* req) {
+ req->send(200, "application/json", "{\"ok\":true}");
+ delay(100);
+ _power->enterDeepSleep();
+ });
+}
+
+void AppWebServer::setupStaticFiles() {
+ _server.serveStatic("/", LittleFS, "/").setDefaultFile("index.html");
+}
+
+void AppWebServer::setupOTA() {
+ _server.on("/api/firmware", HTTP_POST,
+ [this](AsyncWebServerRequest* request) {
+ _otaInProgress = false;
+ bool success = !Update.hasError();
+ request->send(200, "application/json",
+ success ? "{\"ok\":true,\"msg\":\"Rebooting...\"}"
+ : "{\"ok\":false,\"msg\":\"Update failed\"}");
+ if (success) {
+ delay(500);
+ ESP.restart();
+ }
+ },
+ [this](AsyncWebServerRequest* request, const String& filename,
+ size_t index, uint8_t* data, size_t len, bool final) {
+ if (index == 0) {
+ _otaInProgress = true;
+ Serial.printf("[web] OTA start: %s\n", filename.c_str());
+ if (!Update.begin(UPDATE_SIZE_UNKNOWN, U_FLASH)) {
+ Serial.println("[web] OTA begin failed");
+ }
+ }
+ if (Update.isRunning()) {
+ Update.write(data, len);
+ }
+ if (final) {
+ if (Update.end(true)) {
+ Serial.printf("[web] OTA complete: %u bytes\n", index + len);
+ } else {
+ Serial.println("[web] OTA finalize failed");
+ Update.printError(Serial);
+ }
+ }
+ });
+}
+
+void AppWebServer::handleGetStatus(AsyncWebServerRequest* request) {
+ JsonDocument doc;
+ doc["battery"] = _power->getBatteryPercent();
+ doc["charging"] = _power->isCharging();
+ doc["wifi_rssi"] = _wifi->getRSSI();
+ doc["ip"] = _wifi->getIP();
+ doc["uptime"] = millis() / 1000;
+ doc["free_heap"] = ESP.getFreeHeap();
+ doc["free_psram"] = ESP.getFreePsram();
+
+ String response;
+ serializeJson(doc, response);
+ request->send(200, "application/json", response);
+}
+
+void AppWebServer::handleGetAlbums(AsyncWebServerRequest* request) {
+ auto albums = _immich->fetchAlbums();
+ JsonDocument doc;
+ JsonArray arr = doc.to();
+ for (auto& album : albums) {
+ JsonObject obj = arr.add();
+ obj["id"] = album.id;
+ obj["title"] = album.title;
+ obj["assetCount"] = album.assetCount;
+ }
+ String response;
+ serializeJson(doc, response);
+ request->send(200, "application/json", response);
+}
+
+void AppWebServer::handlePostAlbumsSelect(AsyncWebServerRequest* request,
+ uint8_t* data, size_t len) {
+ String body = String((char*)data).substring(0, len);
+ JsonDocument doc;
+ if (deserializeJson(doc, body)) {
+ request->send(400, "application/json", "{\"error\":\"Invalid JSON\"}");
+ return;
+ }
+ String albumsJson;
+ serializeJson(doc["album_ids"], albumsJson);
+ _settings->saveField("albums_json", albumsJson.c_str());
+ request->send(200, "application/json", "{\"ok\":true}");
+}
+
+void AppWebServer::handleGetSettings(AsyncWebServerRequest* request) {
+ Settings s = _settings->get();
+ JsonDocument doc;
+ doc["interval_min"] = s.interval_min;
+ doc["cycle_mode"] = static_cast(s.cycle_mode);
+ doc["img_quality"] = static_cast(s.img_quality);
+ doc["meta_flags"] = s.meta_flags;
+ doc["meta_pos"] = static_cast(s.meta_pos);
+ doc["led_brightness"] = s.led_brightness;
+ doc["immich_url"] = s.immich_url;
+ doc["albums_json"] = s.albums_json;
+
+ String response;
+ serializeJson(doc, response);
+ request->send(200, "application/json", response);
+}
+
+void AppWebServer::handlePostSettings(AsyncWebServerRequest* request,
+ uint8_t* data, size_t len) {
+ String body = String((char*)data).substring(0, len);
+ JsonDocument doc;
+ if (deserializeJson(doc, body)) {
+ request->send(400, "application/json", "{\"error\":\"Invalid JSON\"}");
+ return;
+ }
+
+ Settings s = _settings->get();
+
+ if (!doc["interval_min"].isNull()) s.interval_min = doc["interval_min"];
+ if (!doc["cycle_mode"].isNull()) s.cycle_mode = static_cast((uint8_t)doc["cycle_mode"]);
+ if (!doc["img_quality"].isNull()) s.img_quality = static_cast((uint8_t)doc["img_quality"]);
+ if (!doc["meta_flags"].isNull()) s.meta_flags = doc["meta_flags"];
+ if (!doc["meta_pos"].isNull()) s.meta_pos = static_cast((uint8_t)doc["meta_pos"]);
+ if (!doc["led_brightness"].isNull()) s.led_brightness = doc["led_brightness"];
+ if (!doc["immich_url"].isNull()) s.immich_url = doc["immich_url"].as();
+ if (!doc["immich_key"].isNull()) s.immich_key = doc["immich_key"].as();
+
+ _settings->save(s);
+ request->send(200, "application/json", "{\"ok\":true}");
+}
+
+void AppWebServer::handlePostSetup(AsyncWebServerRequest* request,
+ uint8_t* data, size_t len) {
+ String body = String((char*)data).substring(0, len);
+ JsonDocument doc;
+ if (deserializeJson(doc, body)) {
+ request->send(400, "application/json", "{\"error\":\"Invalid JSON\"}");
+ return;
+ }
+
+ Settings s = _settings->get();
+ s.wifi_ssid = doc["wifi_ssid"].as();
+ s.wifi_pass = doc["wifi_pass"].as();
+ s.immich_url = doc["immich_url"] | DEFAULT_IMMICH_URL;
+ s.immich_key = doc["immich_key"].as();
+ _settings->save(s);
+
+ request->send(200, "application/json", "{\"ok\":true,\"msg\":\"Rebooting...\"}");
+ delay(1000);
+ ESP.restart();
+}
diff --git a/src/web_server.h b/src/web_server.h
new file mode 100644
index 0000000..bd479e6
--- /dev/null
+++ b/src/web_server.h
@@ -0,0 +1,40 @@
+#pragma once
+
+#include
+#include
+#include
+#include "settings.h"
+
+class ImmichClient;
+class PowerManager;
+class WiFiManager;
+
+class AppWebServer {
+public:
+ void begin(SettingsManager& settings, ImmichClient& immich,
+ PowerManager& power, WiFiManager& wifi);
+ void setActionCallback(std::function cb);
+ bool isOTAInProgress();
+
+private:
+ AsyncWebServer _server{80};
+ SettingsManager* _settings = nullptr;
+ ImmichClient* _immich = nullptr;
+ PowerManager* _power = nullptr;
+ WiFiManager* _wifi = nullptr;
+ std::function _actionCb;
+ bool _otaInProgress = false;
+
+ void setupCaptivePortal();
+ void setupAPIRoutes();
+ void setupStaticFiles();
+ void setupOTA();
+
+ void handleGetStatus(AsyncWebServerRequest* request);
+ void handleGetAlbums(AsyncWebServerRequest* request);
+ void handlePostAlbumsSelect(AsyncWebServerRequest* request, uint8_t* data, size_t len);
+ void handleGetSettings(AsyncWebServerRequest* request);
+ void handlePostSettings(AsyncWebServerRequest* request, uint8_t* data, size_t len);
+ void handlePostAction(AsyncWebServerRequest* request);
+ void handlePostSetup(AsyncWebServerRequest* request, uint8_t* data, size_t len);
+};