|
|
|
@@ -2,16 +2,16 @@
|
|
|
|
#include <WiFi.h>
|
|
|
|
#include <WiFi.h>
|
|
|
|
#include <WebServer.h>
|
|
|
|
#include <WebServer.h>
|
|
|
|
#include "SD_Card.h"
|
|
|
|
#include "SD_Card.h"
|
|
|
|
|
|
|
|
#include "Display_PNG.h"
|
|
|
|
#include "Audio_Player.h"
|
|
|
|
#include "Audio_Player.h"
|
|
|
|
|
|
|
|
|
|
|
|
// ── Inter-task flags (set by web handler, read by main loop) ──────────────────
|
|
|
|
WebServer server(80);
|
|
|
|
|
|
|
|
File uploadFile;
|
|
|
|
|
|
|
|
bool uploadSuccess = false;
|
|
|
|
|
|
|
|
String uploadedFilename = "";
|
|
|
|
volatile int g_webDisplayRequest = -1;
|
|
|
|
volatile int g_webDisplayRequest = -1;
|
|
|
|
volatile bool g_webDisplayDirty = false;
|
|
|
|
volatile bool g_webDisplayDirty = false;
|
|
|
|
|
|
|
|
static String buildPage(const String& status, const String& file);
|
|
|
|
static WebServer server(80);
|
|
|
|
|
|
|
|
static File uploadFile;
|
|
|
|
|
|
|
|
static bool uploadOK = false;
|
|
|
|
|
|
|
|
static String uploadSavedAs = "";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
static bool allowedPhoto(const String& name) {
|
|
|
|
static bool allowedPhoto(const String& name) {
|
|
|
|
@@ -23,7 +23,6 @@ static bool allowedAudio(const String& name) {
|
|
|
|
return l.endsWith(".mp3") || l.endsWith(".wav");
|
|
|
|
return l.endsWith(".mp3") || l.endsWith(".wav");
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Build a compact JSON representation of all memories
|
|
|
|
|
|
|
|
static String memoriesJson() {
|
|
|
|
static String memoriesJson() {
|
|
|
|
String j = "[";
|
|
|
|
String j = "[";
|
|
|
|
for (int i = 0; i < g_memoryCount; i++) {
|
|
|
|
for (int i = 0; i < g_memoryCount; i++) {
|
|
|
|
@@ -39,114 +38,267 @@ static String memoriesJson() {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Single-page app HTML ──────────────────────────────────────────────────────
|
|
|
|
// ── Single-page app HTML ──────────────────────────────────────────────────────
|
|
|
|
// Served from PROGMEM to save RAM
|
|
|
|
|
|
|
|
static const char APP_HTML[] PROGMEM = R"HTML(
|
|
|
|
static const char APP_HTML[] PROGMEM = R"HTML(
|
|
|
|
<!DOCTYPE html>
|
|
|
|
<!DOCTYPE html>
|
|
|
|
<html lang="en">
|
|
|
|
<html lang="en">
|
|
|
|
<head>
|
|
|
|
<head>
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
|
|
|
|
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
|
|
|
|
<title>Memory Frame</title>
|
|
|
|
<title>Sunflower Keepsake Frame</title>
|
|
|
|
<style>
|
|
|
|
<style>
|
|
|
|
:root{--bg:#0d0d14;--card:#1a1a2e;--card2:#16213e;--accent:#e91e8c;--accent2:#9c27b0;--text:#f0f0f8;--muted:#7070a0;--radius:16px;--shadow:0 8px 32px rgba(0,0,0,.5)}
|
|
|
|
:root {
|
|
|
|
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
|
|
|
|
--bg: #0d0a05;
|
|
|
|
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;overscroll-behavior:none}
|
|
|
|
--card: #1f160b;
|
|
|
|
|
|
|
|
--card2: #2b1f11;
|
|
|
|
|
|
|
|
--accent: #ffb300; /* Sunflower Gold */
|
|
|
|
|
|
|
|
--accent-orange: #f57c00; /* Warm Amber */
|
|
|
|
|
|
|
|
--accent-light: #ffe082; /* Soft Petal Yellow */
|
|
|
|
|
|
|
|
--text: #fcfaf2;
|
|
|
|
|
|
|
|
--muted: #a3907c;
|
|
|
|
|
|
|
|
--radius: 16px;
|
|
|
|
|
|
|
|
--shadow: 0 8px 32px rgba(0,0,0,.7);
|
|
|
|
|
|
|
|
--border: 1.5px solid rgba(255, 179, 0, 0.15);
|
|
|
|
|
|
|
|
--border-hover: 1.5px solid rgba(255, 179, 0, 0.45);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
|
|
|
|
|
|
|
|
body {
|
|
|
|
|
|
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
|
|
|
|
|
|
background: var(--bg);
|
|
|
|
|
|
|
|
background-image: radial-gradient(circle at 50% 30%, #1e1307 0%, var(--bg) 70%);
|
|
|
|
|
|
|
|
color: var(--text);
|
|
|
|
|
|
|
|
min-height: 100vh;
|
|
|
|
|
|
|
|
overscroll-behavior: none;
|
|
|
|
|
|
|
|
}
|
|
|
|
/* Nav */
|
|
|
|
/* Nav */
|
|
|
|
nav{position:fixed;bottom:0;left:0;right:0;background:rgba(13,13,20,.95);backdrop-filter:blur(20px);display:flex;border-top:1px solid rgba(255,255,255,.08);z-index:100;padding-bottom:env(safe-area-inset-bottom)}
|
|
|
|
nav {
|
|
|
|
nav button{flex:1;padding:14px 0 10px;background:none;border:none;color:var(--muted);font-size:10px;display:flex;flex-direction:column;align-items:center;gap:3px;transition:.2s;cursor:pointer}
|
|
|
|
position: fixed;
|
|
|
|
nav button.active{color:var(--accent)}
|
|
|
|
bottom: 0;
|
|
|
|
nav button svg{width:22px;height:22px;fill:currentColor}
|
|
|
|
left: 0;
|
|
|
|
|
|
|
|
right: 0;
|
|
|
|
|
|
|
|
background: rgba(13,10,5,.95);
|
|
|
|
|
|
|
|
backdrop-filter: blur(20px);
|
|
|
|
|
|
|
|
-webkit-backdrop-filter: blur(20px);
|
|
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
|
|
border-top: 1px solid rgba(255, 179, 0, 0.12);
|
|
|
|
|
|
|
|
z-index: 100;
|
|
|
|
|
|
|
|
padding-bottom: env(safe-area-inset-bottom);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
nav button {
|
|
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
|
|
padding: 14px 0 10px;
|
|
|
|
|
|
|
|
background: none;
|
|
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
|
|
font-size: 10px;
|
|
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
|
|
gap: 3px;
|
|
|
|
|
|
|
|
transition: .2s;
|
|
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
nav button.active { color: var(--accent); }
|
|
|
|
|
|
|
|
nav button svg { width: 22px; height: 22px; fill: currentColor; }
|
|
|
|
|
|
|
|
|
|
|
|
/* Pages */
|
|
|
|
/* Pages */
|
|
|
|
.page{display:none;padding:16px 16px 90px;min-height:100vh}
|
|
|
|
.page { display: none; padding: 16px 16px 90px; min-height: 100vh; }
|
|
|
|
.page.active{display:block}
|
|
|
|
.page.active { display: block; }
|
|
|
|
h1{font-size:24px;font-weight:700;margin-bottom:4px;background:linear-gradient(135deg,var(--accent),var(--accent2));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
|
|
|
h1 {
|
|
|
|
.sub{color:var(--muted);font-size:13px;margin-bottom:24px}
|
|
|
|
font-size: 24px;
|
|
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
|
|
margin-bottom: 4px;
|
|
|
|
|
|
|
|
background: linear-gradient(135deg, var(--accent), var(--accent-orange));
|
|
|
|
|
|
|
|
-webkit-background-clip: text;
|
|
|
|
|
|
|
|
-webkit-text-fill-color: transparent;
|
|
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
|
|
gap: 6px;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
.sub { color: var(--muted); font-size: 13px; margin-bottom: 24px; }
|
|
|
|
|
|
|
|
|
|
|
|
/* Gallery */
|
|
|
|
/* Gallery */
|
|
|
|
.gallery{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
|
|
|
.gallery { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
|
|
|
.mem-card{background:var(--card);border-radius:var(--radius);overflow:hidden;cursor:pointer;box-shadow:var(--shadow);transition:.2s;position:relative}
|
|
|
|
.mem-card {
|
|
|
|
.mem-card:active{transform:scale(.97)}
|
|
|
|
background: var(--card);
|
|
|
|
.mem-thumb{width:100%;aspect-ratio:172/320;object-fit:cover;display:block;background:#222}
|
|
|
|
border: var(--border);
|
|
|
|
.mem-thumb.no-img{display:flex;align-items:center;justify-content:center;font-size:40px}
|
|
|
|
border-radius: var(--radius);
|
|
|
|
.mem-info{padding:10px 12px 12px}
|
|
|
|
overflow: hidden;
|
|
|
|
.mem-name{font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
|
|
cursor: pointer;
|
|
|
|
.mem-del{position:absolute;top:8px;right:8px;background:rgba(0,0,0,.6);border:none;color:#fff;border-radius:50%;width:28px;height:28px;font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;line-height:1}
|
|
|
|
box-shadow: var(--shadow);
|
|
|
|
.play-btn{width:100%;margin-top:8px;padding:8px;background:linear-gradient(135deg,var(--accent),var(--accent2));border:none;border-radius:8px;color:#fff;font-size:12px;font-weight:600;cursor:pointer;transition:.2s}
|
|
|
|
transition: .2s;
|
|
|
|
.play-btn:active{opacity:.8}
|
|
|
|
position: relative;
|
|
|
|
.empty{text-align:center;padding:60px 20px;color:var(--muted)}
|
|
|
|
}
|
|
|
|
.empty .icon{font-size:56px;margin-bottom:16px}
|
|
|
|
.mem-card:active { transform: scale(.97); }
|
|
|
|
.fab{position:fixed;bottom:80px;right:20px;width:56px;height:56px;border-radius:50%;background:linear-gradient(135deg,var(--accent),var(--accent2));border:none;color:#fff;font-size:28px;cursor:pointer;box-shadow:0 4px 20px rgba(233,30,140,.4);display:flex;align-items:center;justify-content:center;z-index:90}
|
|
|
|
.mem-card:hover { border-color: rgba(255, 179, 0, 0.35); }
|
|
|
|
/* Add Memory wizard */
|
|
|
|
.mem-thumb { width: 100%; aspect-ratio: 172/320; object-fit: cover; display: block; background: #1a150e; }
|
|
|
|
.wizard-step{display:none}.wizard-step.active{display:block}
|
|
|
|
.mem-thumb.no-img { display: flex; align-items: center; justify-content: center; font-size: 40px; }
|
|
|
|
.step-label{font-size:12px;color:var(--muted);margin-bottom:6px;font-weight:600;text-transform:uppercase;letter-spacing:.5px}
|
|
|
|
.mem-info { padding: 10px 12px 12px; }
|
|
|
|
.upload-box{border:2px dashed rgba(233,30,140,.4);border-radius:var(--radius);padding:32px;text-align:center;cursor:pointer;margin-bottom:16px;transition:.2s;background:rgba(233,30,140,.04)}
|
|
|
|
.mem-name { font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #fff; }
|
|
|
|
.upload-box:hover,.upload-box.hover{border-color:var(--accent);background:rgba(233,30,140,.08)}
|
|
|
|
.mem-del {
|
|
|
|
.upload-box .icon{font-size:40px;margin-bottom:8px}
|
|
|
|
position: absolute;
|
|
|
|
.upload-box p{color:var(--muted);font-size:13px}
|
|
|
|
top: 8px;
|
|
|
|
.upload-box strong{color:var(--accent)}
|
|
|
|
right: 8px;
|
|
|
|
.preview-img{width:100%;max-height:200px;object-fit:contain;border-radius:12px;margin-bottom:12px;display:none}
|
|
|
|
background: rgba(0,0,0,.6);
|
|
|
|
.preview-audio{width:100%;margin-bottom:12px;display:none}
|
|
|
|
border: 1px solid rgba(255,179,0,0.3);
|
|
|
|
input[type=file]{display:none}
|
|
|
|
color: #fff;
|
|
|
|
.text-input{width:100%;background:var(--card2);border:1.5px solid rgba(255,255,255,.1);border-radius:12px;padding:14px 16px;color:var(--text);font-size:16px;outline:none;transition:.2s;margin-bottom:16px}
|
|
|
|
border-radius: 50%;
|
|
|
|
.text-input:focus{border-color:var(--accent)}
|
|
|
|
width: 28px;
|
|
|
|
.btn{width:100%;padding:16px;border:none;border-radius:12px;font-size:16px;font-weight:700;cursor:pointer;transition:.2s;margin-bottom:8px}
|
|
|
|
height: 28px;
|
|
|
|
.btn-primary{background:linear-gradient(135deg,var(--accent),var(--accent2));color:#fff}
|
|
|
|
font-size: 14px;
|
|
|
|
.btn-primary:active{opacity:.85}
|
|
|
|
cursor: pointer;
|
|
|
|
.btn-secondary{background:var(--card2);color:var(--text)}
|
|
|
|
display: flex;
|
|
|
|
.progress-wrap{margin-bottom:16px;display:none}
|
|
|
|
align-items: center;
|
|
|
|
.progress-bar{height:4px;background:rgba(255,255,255,.1);border-radius:99px;overflow:hidden}
|
|
|
|
justify-content: center;
|
|
|
|
.progress-fill{height:100%;width:0%;background:linear-gradient(90deg,var(--accent),var(--accent2));border-radius:99px;transition:width .3s}
|
|
|
|
line-height: 1;
|
|
|
|
.progress-txt{font-size:12px;color:var(--muted);text-align:center;margin-top:6px}
|
|
|
|
}
|
|
|
|
.toast{border-radius:12px;padding:14px 16px;margin-bottom:16px;font-size:14px;font-weight:500;display:none}
|
|
|
|
.play-btn {
|
|
|
|
.toast.ok{background:rgba(16,185,129,.15);border:1px solid rgba(16,185,129,.3);color:#34d399}
|
|
|
|
width: 100%;
|
|
|
|
.toast.err{background:rgba(239,68,68,.15);border:1px solid rgba(239,68,68,.3);color:#f87171}
|
|
|
|
margin-top: 8px;
|
|
|
|
|
|
|
|
padding: 8px;
|
|
|
|
|
|
|
|
background: linear-gradient(135deg, var(--accent), var(--accent-orange));
|
|
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
|
|
color: #000;
|
|
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
|
|
transition: .2s;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
.play-btn:active { opacity: .8; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.empty { text-align: center; padding: 60px 20px; color: var(--muted); }
|
|
|
|
|
|
|
|
.empty .icon { font-size: 56px; margin-bottom: 16px; animation: pulse 3s infinite ease-in-out; }
|
|
|
|
|
|
|
|
.fab {
|
|
|
|
|
|
|
|
position: fixed;
|
|
|
|
|
|
|
|
bottom: 80px;
|
|
|
|
|
|
|
|
right: 20px;
|
|
|
|
|
|
|
|
width: 56px;
|
|
|
|
|
|
|
|
height: 56px;
|
|
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
|
|
background: linear-gradient(135deg, var(--accent), var(--accent-orange));
|
|
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
|
|
color: #000;
|
|
|
|
|
|
|
|
font-size: 28px;
|
|
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
|
|
box-shadow: 0 4px 20px rgba(255,179,0,.4);
|
|
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
|
|
z-index: 90;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
@keyframes pulse {
|
|
|
|
|
|
|
|
0%, 100% { transform: scale(1); }
|
|
|
|
|
|
|
|
50% { transform: scale(1.08); }
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* Wizard */
|
|
|
|
|
|
|
|
.wizard-step { display: none; }
|
|
|
|
|
|
|
|
.wizard-step.active { display: block; }
|
|
|
|
|
|
|
|
.step-label { font-size: 12px; color: var(--accent); margin-bottom: 6px; font-weight: 700; text-transform: uppercase; letter-spacing: .5px; }
|
|
|
|
|
|
|
|
.upload-box {
|
|
|
|
|
|
|
|
border: 2px dashed rgba(255, 179, 0, 0.3);
|
|
|
|
|
|
|
|
border-radius: var(--radius);
|
|
|
|
|
|
|
|
padding: 32px;
|
|
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
|
|
margin-bottom: 16px;
|
|
|
|
|
|
|
|
transition: .2s;
|
|
|
|
|
|
|
|
background: rgba(255, 179, 0, 0.03);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
.upload-box:hover, .upload-box.hover {
|
|
|
|
|
|
|
|
border-color: var(--accent);
|
|
|
|
|
|
|
|
background: rgba(255, 179, 0, 0.08);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
.upload-box .icon { font-size: 40px; margin-bottom: 8px; }
|
|
|
|
|
|
|
|
.upload-box p { color: var(--muted); font-size: 13px; }
|
|
|
|
|
|
|
|
.upload-box strong { color: var(--accent); }
|
|
|
|
|
|
|
|
.preview-img { width: 100%; max-height: 200px; object-fit: contain; border-radius: 12px; border: var(--border); margin-bottom: 12px; display: none; }
|
|
|
|
|
|
|
|
.preview-audio { width: 100%; margin-bottom: 12px; display: none; accent-color: var(--accent); }
|
|
|
|
|
|
|
|
input[type=file] { display: none; }
|
|
|
|
|
|
|
|
.text-input {
|
|
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
|
|
background: var(--card2);
|
|
|
|
|
|
|
|
border: var(--border);
|
|
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
|
|
padding: 14px 16px;
|
|
|
|
|
|
|
|
color: var(--text);
|
|
|
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
|
|
|
outline: none;
|
|
|
|
|
|
|
|
transition: .2s;
|
|
|
|
|
|
|
|
margin-bottom: 16px;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
.text-input:focus { border-color: var(--accent); box-shadow: 0 0 10px rgba(255,179,0,0.2); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.btn { width: 100%; padding: 16px; border: none; border-radius: 12px; font-size: 16px; font-weight: 700; cursor: pointer; transition: .2s; margin-bottom: 8px; }
|
|
|
|
|
|
|
|
.btn-primary { background: linear-gradient(135deg, var(--accent), var(--accent-orange)); color: #000; }
|
|
|
|
|
|
|
|
.btn-primary:active { opacity: .85; }
|
|
|
|
|
|
|
|
.btn-secondary { background: var(--card2); border: var(--border); color: var(--text); }
|
|
|
|
|
|
|
|
.btn-secondary:hover { border-color: var(--accent); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.progress-wrap { margin-bottom: 16px; display: none; }
|
|
|
|
|
|
|
|
.progress-bar { height: 4px; background: rgba(255,255,255,.1); border-radius: 99px; overflow: hidden; }
|
|
|
|
|
|
|
|
.progress-fill { height: 100%; width: 0%; background: linear-gradient(90deg, var(--accent), var(--accent-orange)); border-radius: 99px; transition: width .3s; }
|
|
|
|
|
|
|
|
.progress-txt { font-size: 12px; color: var(--muted); text-align: center; margin-top: 6px; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.toast { border-radius: 12px; padding: 14px 16px; margin-bottom: 16px; font-size: 14px; font-weight: 500; display: none; }
|
|
|
|
|
|
|
|
.toast.ok { background: rgba(16,185,129,.15); border: 1px solid rgba(16,185,129,.3); color: #34d399; }
|
|
|
|
|
|
|
|
.toast.err { background: rgba(239,68,68,.15); border: 1px solid rgba(239,68,68,.3); color: #f87171; }
|
|
|
|
|
|
|
|
|
|
|
|
/* Settings */
|
|
|
|
/* Settings */
|
|
|
|
.setting-row{background:var(--card);border-radius:var(--radius);padding:18px;margin-bottom:12px}
|
|
|
|
.setting-row { background: var(--card); border: var(--border); border-radius: var(--radius); padding: 18px; margin-bottom: 12px; }
|
|
|
|
.setting-label{font-size:13px;font-weight:600;margin-bottom:4px}
|
|
|
|
.setting-label { font-size: 13px; font-weight: 600; margin-bottom: 4px; color: var(--accent); }
|
|
|
|
.setting-sub{font-size:12px;color:var(--muted);margin-bottom:12px}
|
|
|
|
.setting-sub { font-size: 12px; color: var(--muted); margin-bottom: 12px; }
|
|
|
|
input[type=range]{width:100%;accent-color:var(--accent)}
|
|
|
|
input[type=range] { width: 100%; }
|
|
|
|
.vol-display{text-align:right;font-size:13px;color:var(--accent);font-weight:700;margin-top:4px}
|
|
|
|
.vol-display { text-align: right; font-size: 13px; color: var(--accent); font-weight: 700; margin-top: 4px; }
|
|
|
|
.now-playing{background:linear-gradient(135deg,rgba(233,30,140,.15),rgba(156,39,176,.15));border:1px solid rgba(233,30,140,.3);border-radius:var(--radius);padding:18px;margin-bottom:12px}
|
|
|
|
.now-playing {
|
|
|
|
.np-label{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px}
|
|
|
|
background: linear-gradient(135deg, rgba(255,179,0,.15), rgba(245,124,0,.15));
|
|
|
|
.np-name{font-size:18px;font-weight:700}
|
|
|
|
border: 1px solid rgba(255,179,0,.3);
|
|
|
|
|
|
|
|
border-radius: var(--radius);
|
|
|
|
|
|
|
|
padding: 18px;
|
|
|
|
|
|
|
|
margin-bottom: 12px;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
.np-label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; margin-bottom: 6px; }
|
|
|
|
|
|
|
|
.np-name { font-size: 18px; font-weight: 700; color: #fff; }
|
|
|
|
</style>
|
|
|
|
</style>
|
|
|
|
</head>
|
|
|
|
</head>
|
|
|
|
<body>
|
|
|
|
<body>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Gallery Page -->
|
|
|
|
<!-- Gallery Page -->
|
|
|
|
<div id="page-gallery" class="page active">
|
|
|
|
<div id="page-gallery" class="page active">
|
|
|
|
<h1>Memory Frame</h1>
|
|
|
|
<h1>🌻 Keepsake Frame</h1>
|
|
|
|
<p class="sub" id="sub-count">Loading memories...</p>
|
|
|
|
<p class="sub" id="sub-count">Loading memories...</p>
|
|
|
|
<div class="gallery" id="gallery"></div>
|
|
|
|
<div class="gallery" id="gallery"></div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Add Memory Page -->
|
|
|
|
<!-- Add Memory Page -->
|
|
|
|
<div id="page-add" class="page">
|
|
|
|
<div id="page-add" class="page">
|
|
|
|
<h1>Add Memory</h1>
|
|
|
|
<h1>🌻 Add Keepsake</h1>
|
|
|
|
<p class="sub">Upload a photo and a soundbite together</p>
|
|
|
|
<p class="sub">Create a beautiful photo-sound memorial</p>
|
|
|
|
<div id="toast" class="toast"></div>
|
|
|
|
<div id="toast" class="toast"></div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Step 1: Photo -->
|
|
|
|
<!-- Step 1: Photo -->
|
|
|
|
<div id="step1" class="wizard-step active">
|
|
|
|
<div id="step1" class="wizard-step active">
|
|
|
|
<div class="step-label">Step 1 of 3 — Choose a Photo</div>
|
|
|
|
<div class="step-label">Step 1 of 3 — Choose a Photo</div>
|
|
|
|
<div class="upload-box" id="photoBox" onclick="document.getElementById('photoFile').click()">
|
|
|
|
<div class="upload-box" id="photoBox" onclick="document.getElementById('photoFile').click()">
|
|
|
|
<div class="icon">📷</div>
|
|
|
|
<div class="icon">📸</div>
|
|
|
|
<p>Tap to choose a photo<br><strong>from your camera roll</strong></p>
|
|
|
|
<p>Tap to select any photo<br><strong>from your library or camera</strong></p>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<input type="file" id="photoFile" accept="image/*" onchange="onPhotoSelected(this)">
|
|
|
|
<input type="file" id="photoFile" accept="image/*" onchange="onPhotoSelected(this)">
|
|
|
|
<img id="previewImg" class="preview-img" alt="preview">
|
|
|
|
<img id="previewImg" class="preview-img" alt="preview">
|
|
|
|
<div id="progress1" class="progress-wrap">
|
|
|
|
<div id="progress1" class="progress-wrap">
|
|
|
|
<div class="progress-bar"><div class="progress-fill" id="fill1"></div></div>
|
|
|
|
<div class="progress-bar"><div class="progress-fill" id="fill1"></div></div>
|
|
|
|
<div class="progress-txt" id="txt1">Uploading photo...</div>
|
|
|
|
<div class="progress-txt" id="txt1">Preparing photo...</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<button class="btn btn-primary" id="nextStep1" onclick="uploadPhoto()" disabled>Next →</button>
|
|
|
|
<button class="btn btn-primary" id="nextStep1" onclick="uploadPhoto()" disabled>Next Step →</button>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Step 2: Audio -->
|
|
|
|
<!-- Step 2: Audio (Optional Choice) -->
|
|
|
|
<div id="step2" class="wizard-step">
|
|
|
|
<div id="step2" class="wizard-step">
|
|
|
|
<div class="step-label">Step 2 of 3 — Choose a Soundbite</div>
|
|
|
|
<div class="step-label">Step 2 of 3 — Choose a Soundbite (Optional)</div>
|
|
|
|
<div class="upload-box" id="audioBox" onclick="document.getElementById('audioFile').click()">
|
|
|
|
<div class="upload-box" id="audioBox" onclick="document.getElementById('audioFile').click()">
|
|
|
|
<div class="icon">🎤</div>
|
|
|
|
<div class="icon">🎙️</div>
|
|
|
|
<p>Tap to choose a sound clip<br><strong>.mp3 or .wav file</strong></p>
|
|
|
|
<p>Tap to attach a voice note or song<br><strong>.mp3 or .wav file</strong></p>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<input type="file" id="audioFile" accept=".mp3,.wav" onchange="onAudioSelected(this)">
|
|
|
|
<input type="file" id="audioFile" accept=".mp3,.wav" onchange="onAudioSelected(this)">
|
|
|
|
<audio id="previewAudio" class="preview-audio" controls></audio>
|
|
|
|
<audio id="previewAudio" class="preview-audio" controls></audio>
|
|
|
|
@@ -154,15 +306,16 @@ input[type=range]{width:100%;accent-color:var(--accent)}
|
|
|
|
<div class="progress-bar"><div class="progress-fill" id="fill2"></div></div>
|
|
|
|
<div class="progress-bar"><div class="progress-fill" id="fill2"></div></div>
|
|
|
|
<div class="progress-txt" id="txt2">Uploading audio...</div>
|
|
|
|
<div class="progress-txt" id="txt2">Uploading audio...</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<button class="btn btn-primary" id="nextStep2" onclick="uploadAudio()" disabled>Next →</button>
|
|
|
|
<button class="btn btn-primary" id="nextStep2" onclick="uploadAudio()" disabled>Upload Soundbite →</button>
|
|
|
|
|
|
|
|
<button class="btn btn-secondary" onclick="skipAudio()" style="color:var(--accent);">No Soundbite (Skip) →</button>
|
|
|
|
<button class="btn btn-secondary" onclick="goStep(1)">← Back</button>
|
|
|
|
<button class="btn btn-secondary" onclick="goStep(1)">← Back</button>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Step 3: Name -->
|
|
|
|
<!-- Step 3: Name -->
|
|
|
|
<div id="step3" class="wizard-step">
|
|
|
|
<div id="step3" class="wizard-step">
|
|
|
|
<div class="step-label">Step 3 of 3 — Name This Memory</div>
|
|
|
|
<div class="step-label">Step 3 of 3 — Name This Memory</div>
|
|
|
|
<input class="text-input" id="memName" type="text" placeholder="e.g. Mom in the Garden" maxlength="60">
|
|
|
|
<input class="text-input" id="memName" type="text" placeholder="e.g. Mom Laughing in the Garden" maxlength="60">
|
|
|
|
<button class="btn btn-primary" onclick="saveMemory()">❤ Save Memory</button>
|
|
|
|
<button class="btn btn-primary" onclick="saveMemory()">🌻 Save Memory Keepsake</button>
|
|
|
|
<button class="btn btn-secondary" onclick="goStep(2)">← Back</button>
|
|
|
|
<button class="btn btn-secondary" onclick="goStep(2)">← Back</button>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
@@ -170,11 +323,11 @@ input[type=range]{width:100%;accent-color:var(--accent)}
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Settings Page -->
|
|
|
|
<!-- Settings Page -->
|
|
|
|
<div id="page-settings" class="page">
|
|
|
|
<div id="page-settings" class="page">
|
|
|
|
<h1>Settings</h1>
|
|
|
|
<h1>🌻 Settings</h1>
|
|
|
|
<p class="sub">Configure your frame</p>
|
|
|
|
<p class="sub">Configure frame preferences</p>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="now-playing" id="nowPlaying">
|
|
|
|
<div class="now-playing" id="nowPlaying">
|
|
|
|
<div class="np-label">Now showing</div>
|
|
|
|
<div class="np-label">Active Memory</div>
|
|
|
|
<div class="np-name" id="npName">-</div>
|
|
|
|
<div class="np-name" id="npName">-</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
@@ -185,6 +338,19 @@ input[type=range]{width:100%;accent-color:var(--accent)}
|
|
|
|
<div class="vol-display" id="volDisplay">12 / 21</div>
|
|
|
|
<div class="vol-display" id="volDisplay">12 / 21</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<div class="setting-row">
|
|
|
|
|
|
|
|
<div class="setting-label">🌻 Tribute Time Alarm</div>
|
|
|
|
|
|
|
|
<div class="setting-sub">Select a daily time to play through all memories automatically</div>
|
|
|
|
|
|
|
|
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:12px;">
|
|
|
|
|
|
|
|
<span style="font-size:14px; color:var(--muted);">Enable Daily Tribute</span>
|
|
|
|
|
|
|
|
<input type="checkbox" id="alarmEnabled" style="width:20px; height:20px; accent-color:var(--accent);">
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<div style="display:flex; align-items:center; justify-content:space-between;">
|
|
|
|
|
|
|
|
<span style="font-size:14px; color:var(--muted);">Scheduled Time</span>
|
|
|
|
|
|
|
|
<input type="time" id="alarmTime" style="background:var(--card2); border:var(--border); color:#fff; border-radius:8px; padding:6px 12px; font-family:inherit; outline:none;">
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<button class="btn btn-secondary" onclick="saveSettings()" style="margin-top:8px">Save Settings</button>
|
|
|
|
<button class="btn btn-secondary" onclick="saveSettings()" style="margin-top:8px">Save Settings</button>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
@@ -245,10 +411,10 @@ function loadGallery() {
|
|
|
|
function renderGallery() {
|
|
|
|
function renderGallery() {
|
|
|
|
var g = document.getElementById('gallery');
|
|
|
|
var g = document.getElementById('gallery');
|
|
|
|
var sub = document.getElementById('sub-count');
|
|
|
|
var sub = document.getElementById('sub-count');
|
|
|
|
sub.textContent = memories.length + ' memor' + (memories.length===1?'y':'ies') + ' saved';
|
|
|
|
sub.textContent = memories.length + ' keepsake' + (memories.length===1?'':'s') + ' saved';
|
|
|
|
if(memories.length===0){
|
|
|
|
if(memories.length===0){
|
|
|
|
g.innerHTML = '';
|
|
|
|
g.innerHTML = '';
|
|
|
|
g.insertAdjacentHTML('beforeend','<div class="empty" style="grid-column:1/-1"><div class="icon">💗</div><p>No memories yet.<br>Tap + to add the first one.</p></div>');
|
|
|
|
g.insertAdjacentHTML('beforeend','<div class="empty" style="grid-column:1/-1"><div class="icon">🌻</div><p>No memories yet.<br>Tap + to add a sunflower keepsake.</p></div>');
|
|
|
|
return;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
g.innerHTML = '';
|
|
|
|
g.innerHTML = '';
|
|
|
|
@@ -256,13 +422,13 @@ function renderGallery() {
|
|
|
|
var imgSrc = m.photo ? '/photo/'+encodeURIComponent(m.photo) : '';
|
|
|
|
var imgSrc = m.photo ? '/photo/'+encodeURIComponent(m.photo) : '';
|
|
|
|
var imgHtml = imgSrc
|
|
|
|
var imgHtml = imgSrc
|
|
|
|
? '<img class="mem-thumb" src="'+imgSrc+'" loading="lazy" onerror="this.style.display=\'none\'">'
|
|
|
|
? '<img class="mem-thumb" src="'+imgSrc+'" loading="lazy" onerror="this.style.display=\'none\'">'
|
|
|
|
: '<div class="mem-thumb no-img">🥻</div>';
|
|
|
|
: '<div class="mem-thumb no-img">🌻</div>';
|
|
|
|
var html = '<div class="mem-card">'
|
|
|
|
var html = '<div class="mem-card">'
|
|
|
|
+ imgHtml
|
|
|
|
+ imgHtml
|
|
|
|
+ '<button class="mem-del" onclick="deleteMemory('+m.id+',event)" title="Delete">✕</button>'
|
|
|
|
+ '<button class="mem-del" onclick="deleteMemory('+m.id+',event)" title="Delete">✕</button>'
|
|
|
|
+ '<div class="mem-info">'
|
|
|
|
+ '<div class="mem-info">'
|
|
|
|
+ '<div class="mem-name">'+escHtml(m.name)+'</div>'
|
|
|
|
+ '<div class="mem-name">'+escHtml(m.name)+'</div>'
|
|
|
|
+ '<button class="play-btn" onclick="playMemory('+m.id+')">▶ Play on Frame</button>'
|
|
|
|
+ '<button class="play-btn" onclick="playMemory('+m.id+')">▶ Show Keepsake</button>'
|
|
|
|
+ '</div></div>';
|
|
|
|
+ '</div></div>';
|
|
|
|
g.insertAdjacentHTML('beforeend', html);
|
|
|
|
g.insertAdjacentHTML('beforeend', html);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
@@ -271,13 +437,13 @@ function renderGallery() {
|
|
|
|
function playMemory(id) {
|
|
|
|
function playMemory(id) {
|
|
|
|
fetch('/api/display?id='+id, {method:'POST'})
|
|
|
|
fetch('/api/display?id='+id, {method:'POST'})
|
|
|
|
.then(function(r){return r.json()})
|
|
|
|
.then(function(r){return r.json()})
|
|
|
|
.then(function(d){ if(d.ok) showToastGlobal('Showing on frame!', true); })
|
|
|
|
.then(function(d){ if(d.ok) showToastGlobal('Showing keepsake!', true); })
|
|
|
|
.catch(function(){});
|
|
|
|
.catch(function(){});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function deleteMemory(id, e) {
|
|
|
|
function deleteMemory(id, e) {
|
|
|
|
e.stopPropagation();
|
|
|
|
e.stopPropagation();
|
|
|
|
if(!confirm('Delete this memory?')) return;
|
|
|
|
if(!confirm('Delete this keepsake?')) return;
|
|
|
|
fetch('/api/memory?id='+id, {method:'DELETE'})
|
|
|
|
fetch('/api/memory?id='+id, {method:'DELETE'})
|
|
|
|
.then(function(){loadGallery()}).catch(function(){});
|
|
|
|
.then(function(){loadGallery()}).catch(function(){});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
@@ -461,6 +627,11 @@ function uploadAudio() {
|
|
|
|
);
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function skipAudio() {
|
|
|
|
|
|
|
|
uploadedAudio = '';
|
|
|
|
|
|
|
|
goStep(3);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function saveMemory() {
|
|
|
|
function saveMemory() {
|
|
|
|
var name = document.getElementById('memName').value.trim();
|
|
|
|
var name = document.getElementById('memName').value.trim();
|
|
|
|
if(!name){ showToast('Please give this memory a name.', false); return; }
|
|
|
|
if(!name){ showToast('Please give this memory a name.', false); return; }
|
|
|
|
@@ -470,7 +641,7 @@ function saveMemory() {
|
|
|
|
.then(function(r){return r.json()})
|
|
|
|
.then(function(r){return r.json()})
|
|
|
|
.then(function(d){
|
|
|
|
.then(function(d){
|
|
|
|
if(d.ok){
|
|
|
|
if(d.ok){
|
|
|
|
showToast('Memory saved! \u2764\uFE0F', true);
|
|
|
|
showToast('Keepsake saved! 🌻💛', true);
|
|
|
|
setTimeout(function(){ goPage('gallery'); }, 1500);
|
|
|
|
setTimeout(function(){ goPage('gallery'); }, 1500);
|
|
|
|
} else showToast('Save failed: '+d.error, false);
|
|
|
|
} else showToast('Save failed: '+d.error, false);
|
|
|
|
}).catch(function(){ showToast('Save failed.', false); });
|
|
|
|
}).catch(function(){ showToast('Save failed.', false); });
|
|
|
|
@@ -481,6 +652,14 @@ function loadSettings() {
|
|
|
|
fetch('/api/settings').then(function(r){return r.json()}).then(function(d){
|
|
|
|
fetch('/api/settings').then(function(r){return r.json()}).then(function(d){
|
|
|
|
document.getElementById('volSlider').value = d.volume||12;
|
|
|
|
document.getElementById('volSlider').value = d.volume||12;
|
|
|
|
document.getElementById('volDisplay').textContent = (d.volume||12)+' / 21';
|
|
|
|
document.getElementById('volDisplay').textContent = (d.volume||12)+' / 21';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('alarmEnabled').checked = d.alarmEnabled||false;
|
|
|
|
|
|
|
|
if (d.alarmHour !== undefined && d.alarmMinute !== undefined) {
|
|
|
|
|
|
|
|
var h = String(d.alarmHour).padStart(2, '0');
|
|
|
|
|
|
|
|
var m = String(d.alarmMinute).padStart(2, '0');
|
|
|
|
|
|
|
|
document.getElementById('alarmTime').value = h + ':' + m;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var nm = document.getElementById('npName');
|
|
|
|
var nm = document.getElementById('npName');
|
|
|
|
if(d.currentName) nm.textContent = d.currentName;
|
|
|
|
if(d.currentName) nm.textContent = d.currentName;
|
|
|
|
else nm.textContent = memories.length ? memories[d.currentIndex||0].name||'-' : '-';
|
|
|
|
else nm.textContent = memories.length ? memories[d.currentIndex||0].name||'-' : '-';
|
|
|
|
@@ -493,7 +672,24 @@ function onVolChange(v) {
|
|
|
|
|
|
|
|
|
|
|
|
function saveSettings() {
|
|
|
|
function saveSettings() {
|
|
|
|
var v = parseInt(document.getElementById('volSlider').value)||12;
|
|
|
|
var v = parseInt(document.getElementById('volSlider').value)||12;
|
|
|
|
fetch('/api/settings', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({volume:v})})
|
|
|
|
var ae = document.getElementById('alarmEnabled').checked;
|
|
|
|
|
|
|
|
var at = document.getElementById('alarmTime').value || "19:00";
|
|
|
|
|
|
|
|
var parts = at.split(':');
|
|
|
|
|
|
|
|
var ah = parseInt(parts[0])||19;
|
|
|
|
|
|
|
|
var am = parseInt(parts[1])||0;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
var payload = {
|
|
|
|
|
|
|
|
volume: v,
|
|
|
|
|
|
|
|
alarmEnabled: ae,
|
|
|
|
|
|
|
|
alarmHour: ah,
|
|
|
|
|
|
|
|
alarmMinute: am
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
fetch('/api/settings', {
|
|
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
|
|
body: JSON.stringify(payload)
|
|
|
|
|
|
|
|
})
|
|
|
|
.then(function(r){return r.json()})
|
|
|
|
.then(function(r){return r.json()})
|
|
|
|
.then(function(d){ if(d.ok) showToastGlobal('Settings saved!', true); })
|
|
|
|
.then(function(d){ if(d.ok) showToastGlobal('Settings saved!', true); })
|
|
|
|
.catch(function(){});
|
|
|
|
.catch(function(){});
|
|
|
|
@@ -515,8 +711,8 @@ function showToastGlobal(msg, ok) {
|
|
|
|
var t = document.createElement('div');
|
|
|
|
var t = document.createElement('div');
|
|
|
|
t.id='gtToast';
|
|
|
|
t.id='gtToast';
|
|
|
|
t.style.cssText='position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:999;padding:12px 20px;border-radius:12px;font-size:14px;font-weight:600;box-shadow:0 4px 20px rgba(0,0,0,.4)';
|
|
|
|
t.style.cssText='position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:999;padding:12px 20px;border-radius:12px;font-size:14px;font-weight:600;box-shadow:0 4px 20px rgba(0,0,0,.4)';
|
|
|
|
t.style.background = ok ? 'rgba(16,185,129,.9)' : 'rgba(239,68,68,.9)';
|
|
|
|
t.style.background = ok ? 'rgba(255,179,0,.9)' : 'rgba(239,68,68,.9)';
|
|
|
|
t.style.color = '#fff';
|
|
|
|
t.style.color = ok ? '#000' : '#fff';
|
|
|
|
t.textContent = msg;
|
|
|
|
t.textContent = msg;
|
|
|
|
document.body.appendChild(t);
|
|
|
|
document.body.appendChild(t);
|
|
|
|
clearTimeout(globalToastTimer);
|
|
|
|
clearTimeout(globalToastTimer);
|
|
|
|
@@ -527,7 +723,17 @@ function escHtml(s) {
|
|
|
|
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
|
|
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
|
|
|
function syncTime() {
|
|
|
|
|
|
|
|
var epoch = Math.floor(Date.now() / 1000);
|
|
|
|
|
|
|
|
var offset = new Date().getTimezoneOffset() * 60; // offset in seconds
|
|
|
|
|
|
|
|
fetch('/api/time', {
|
|
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
|
|
body: JSON.stringify({ epoch: epoch, offset: offset })
|
|
|
|
|
|
|
|
}).catch(function(){});
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
syncTime();
|
|
|
|
loadGallery();
|
|
|
|
loadGallery();
|
|
|
|
loadSettings();
|
|
|
|
loadSettings();
|
|
|
|
</script>
|
|
|
|
</script>
|
|
|
|
@@ -535,23 +741,22 @@ loadSettings();
|
|
|
|
</html>
|
|
|
|
</html>
|
|
|
|
)HTML";
|
|
|
|
)HTML";
|
|
|
|
|
|
|
|
|
|
|
|
// ── Upload handlers ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
static String pendingUploadPath = "";
|
|
|
|
static String pendingUploadPath = "";
|
|
|
|
|
|
|
|
|
|
|
|
static void handleUploadFile(const String& subdir, bool (*validator)(const String&)) {
|
|
|
|
static void handleUploadFile(const String& subdir, bool (*validator)(const String&)) {
|
|
|
|
HTTPUpload& up = server.upload();
|
|
|
|
HTTPUpload& up = server.upload();
|
|
|
|
if (up.status == UPLOAD_FILE_START) {
|
|
|
|
if (up.status == UPLOAD_FILE_START) {
|
|
|
|
String fname = up.filename;
|
|
|
|
String fname = up.filename;
|
|
|
|
uploadOK = false;
|
|
|
|
uploadSuccess = false;
|
|
|
|
uploadSavedAs = "";
|
|
|
|
uploadedFilename = "";
|
|
|
|
if (!validator(fname)) {
|
|
|
|
if (!validator(fname)) {
|
|
|
|
printf("Upload: rejected %s\r\n", fname.c_str());
|
|
|
|
printf("Upload: rejected %s\r\n", fname.c_str());
|
|
|
|
return;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
pendingUploadPath = "/" + subdir + "/" + fname;
|
|
|
|
pendingUploadPath = "/" + subdir + "/" + fname;
|
|
|
|
uploadFile = SD_MMC.open(pendingUploadPath, "w");
|
|
|
|
uploadFile = SD_MMC.open(pendingUploadPath, "w");
|
|
|
|
uploadOK = (bool)uploadFile;
|
|
|
|
uploadSuccess = (bool)uploadFile;
|
|
|
|
if (uploadOK) uploadSavedAs = fname;
|
|
|
|
if (uploadSuccess) uploadedFilename = fname;
|
|
|
|
printf("Upload: writing to %s\r\n", pendingUploadPath.c_str());
|
|
|
|
printf("Upload: writing to %s\r\n", pendingUploadPath.c_str());
|
|
|
|
}
|
|
|
|
}
|
|
|
|
else if (up.status == UPLOAD_FILE_WRITE) {
|
|
|
|
else if (up.status == UPLOAD_FILE_WRITE) {
|
|
|
|
@@ -559,72 +764,116 @@ static void handleUploadFile(const String& subdir, bool (*validator)(const Strin
|
|
|
|
}
|
|
|
|
}
|
|
|
|
else if (up.status == UPLOAD_FILE_END) {
|
|
|
|
else if (up.status == UPLOAD_FILE_END) {
|
|
|
|
if (uploadFile) { uploadFile.close(); printf("Upload: done (%u bytes)\r\n", up.totalSize); }
|
|
|
|
if (uploadFile) { uploadFile.close(); printf("Upload: done (%u bytes)\r\n", up.totalSize); }
|
|
|
|
else { uploadOK = false; }
|
|
|
|
else { uploadSuccess = false; }
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Route setup ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
void initWebServer() {
|
|
|
|
void initWebServer() {
|
|
|
|
WiFi.softAP("love", "love1234");
|
|
|
|
WiFi.softAP("love", "love1234");
|
|
|
|
printf("WiFi AP: SSID='love' PW='love1234' IP=%s\r\n", WiFi.softAPIP().toString().c_str());
|
|
|
|
IPAddress IP = WiFi.softAPIP();
|
|
|
|
|
|
|
|
printf("WiFi AP Started. SSID: 'love' Password: 'love1234'\r\n");
|
|
|
|
// Main SPA
|
|
|
|
printf("Web Server IP: %s\r\n", IP.toString().c_str());
|
|
|
|
|
|
|
|
|
|
|
|
server.on("/", HTTP_GET, []() {
|
|
|
|
server.on("/", HTTP_GET, []() {
|
|
|
|
server.send_P(200, "text/html", APP_HTML);
|
|
|
|
String status = server.hasArg("status") ? server.arg("status") : "";
|
|
|
|
|
|
|
|
String file = server.hasArg("file") ? server.arg("file") : "";
|
|
|
|
|
|
|
|
server.send(200, "text/html", buildPage(status, file));
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// GET /api/memories → JSON array
|
|
|
|
|
|
|
|
server.on("/api/memories", HTTP_GET, []() {
|
|
|
|
server.on("/api/memories", HTTP_GET, []() {
|
|
|
|
server.send(200, "application/json", memoriesJson());
|
|
|
|
server.send(200, "application/json", memoriesJson());
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// GET /api/settings → {volume, currentIndex, currentName}
|
|
|
|
|
|
|
|
server.on("/api/settings", HTTP_GET, []() {
|
|
|
|
server.on("/api/settings", HTTP_GET, []() {
|
|
|
|
String name = "";
|
|
|
|
String name = "";
|
|
|
|
if (g_currentIndex >= 0 && g_currentIndex < g_memoryCount)
|
|
|
|
if (g_currentIndex >= 0 && g_currentIndex < g_memoryCount)
|
|
|
|
name = String(g_memories[g_currentIndex].name);
|
|
|
|
name = String(g_memories[g_currentIndex].name);
|
|
|
|
String j = "{\"volume\":"; j += g_volume;
|
|
|
|
String j = "{\"volume\":"; j += g_volume;
|
|
|
|
j += ",\"currentIndex\":"; j += g_currentIndex;
|
|
|
|
j += ",\"currentIndex\":"; j += g_currentIndex;
|
|
|
|
j += ",\"currentName\":\""; j += name; j += "\"}";
|
|
|
|
j += ",\"currentName\":\""; j += name;
|
|
|
|
|
|
|
|
j += "\",\"alarmEnabled\":"; j += g_tributeAlarmEnabled ? "true" : "false";
|
|
|
|
|
|
|
|
j += ",\"alarmHour\":"; j += g_tributeAlarmHour;
|
|
|
|
|
|
|
|
j += ",\"alarmMinute\":"; j += g_tributeAlarmMinute;
|
|
|
|
|
|
|
|
j += ",\"timeSynced\":"; j += g_timeSynchronized ? "true" : "false";
|
|
|
|
|
|
|
|
j += "}";
|
|
|
|
server.send(200, "application/json", j);
|
|
|
|
server.send(200, "application/json", j);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// POST /api/settings body: {volume:N}
|
|
|
|
|
|
|
|
server.on("/api/settings", HTTP_POST, []() {
|
|
|
|
server.on("/api/settings", HTTP_POST, []() {
|
|
|
|
String body = server.arg("plain");
|
|
|
|
String body = server.arg("plain");
|
|
|
|
// parse volume from simple JSON: {"volume":12}
|
|
|
|
|
|
|
|
|
|
|
|
// Volume
|
|
|
|
int vi = body.indexOf("\"volume\":");
|
|
|
|
int vi = body.indexOf("\"volume\":");
|
|
|
|
if (vi >= 0) {
|
|
|
|
if (vi >= 0) {
|
|
|
|
int v = body.substring(vi + 9).toInt();
|
|
|
|
int v = body.substring(vi + 9).toInt();
|
|
|
|
if (v < 0) v = 0; if (v > 21) v = 21;
|
|
|
|
if (v < 0) v = 0; if (v > 21) v = 21;
|
|
|
|
setPlayerVolume((uint8_t)v);
|
|
|
|
setPlayerVolume((uint8_t)v);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Alarm Enabled
|
|
|
|
|
|
|
|
int ae = body.indexOf("\"alarmEnabled\":");
|
|
|
|
|
|
|
|
if (ae >= 0) {
|
|
|
|
|
|
|
|
String sub = body.substring(ae + 15);
|
|
|
|
|
|
|
|
g_tributeAlarmEnabled = sub.startsWith("true");
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Alarm Hour
|
|
|
|
|
|
|
|
int ah = body.indexOf("\"alarmHour\":");
|
|
|
|
|
|
|
|
if (ah >= 0) {
|
|
|
|
|
|
|
|
g_tributeAlarmHour = body.substring(ah + 12).toInt();
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Alarm Minute
|
|
|
|
|
|
|
|
int am = body.indexOf("\"alarmMinute\":");
|
|
|
|
|
|
|
|
if (am >= 0) {
|
|
|
|
|
|
|
|
g_tributeAlarmMinute = body.substring(am + 14).toInt();
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Config_Save();
|
|
|
|
|
|
|
|
server.send(200, "application/json", "{\"ok\":true}");
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
server.on("/api/time", HTTP_POST, []() {
|
|
|
|
|
|
|
|
String body = server.arg("plain");
|
|
|
|
|
|
|
|
int ep = body.indexOf("\"epoch\":");
|
|
|
|
|
|
|
|
int os = body.indexOf("\"offset\":");
|
|
|
|
|
|
|
|
if (ep >= 0 && os >= 0) {
|
|
|
|
|
|
|
|
long long epoch = strtoll(body.substring(ep + 8).c_str(), NULL, 10);
|
|
|
|
|
|
|
|
int offset = body.substring(os + 9).toInt();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
#include <sys/time.h>
|
|
|
|
|
|
|
|
struct timeval tv;
|
|
|
|
|
|
|
|
tv.tv_sec = epoch;
|
|
|
|
|
|
|
|
tv.tv_usec = 0;
|
|
|
|
|
|
|
|
settimeofday(&tv, NULL);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
g_timezoneOffset = offset;
|
|
|
|
|
|
|
|
g_timeSynchronized = true;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
printf("Time Synced: UTC Epoch %lld, Local offset %d sec\r\n", epoch, offset);
|
|
|
|
Config_Save();
|
|
|
|
Config_Save();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
server.send(200, "application/json", "{\"ok\":true}");
|
|
|
|
server.send(200, "application/json", "{\"ok\":true}");
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// POST /api/upload/photo — multipart upload
|
|
|
|
|
|
|
|
server.on("/api/upload/photo", HTTP_POST,
|
|
|
|
server.on("/api/upload/photo", HTTP_POST,
|
|
|
|
[]() {
|
|
|
|
[]() {
|
|
|
|
if (uploadOK) server.send(200, "application/json", "{\"ok\":true,\"filename\":\"" + uploadSavedAs + "\"}");
|
|
|
|
if (uploadSuccess) server.send(200, "application/json", "{\"ok\":true,\"filename\":\"" + uploadedFilename + "\"}");
|
|
|
|
else server.send(400, "application/json", "{\"ok\":false,\"error\":\"upload failed\"}");
|
|
|
|
else server.send(400, "application/json", "{\"ok\":false,\"error\":\"upload failed\"}");
|
|
|
|
},
|
|
|
|
},
|
|
|
|
[]() { handleUploadFile("photos", allowedPhoto); }
|
|
|
|
[]() { handleUploadFile("photos", allowedPhoto); }
|
|
|
|
);
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// POST /api/upload/audio — multipart upload
|
|
|
|
|
|
|
|
server.on("/api/upload/audio", HTTP_POST,
|
|
|
|
server.on("/api/upload/audio", HTTP_POST,
|
|
|
|
[]() {
|
|
|
|
[]() {
|
|
|
|
if (uploadOK) server.send(200, "application/json", "{\"ok\":true,\"filename\":\"" + uploadSavedAs + "\"}");
|
|
|
|
if (uploadSuccess) server.send(200, "application/json", "{\"ok\":true,\"filename\":\"" + uploadedFilename + "\"}");
|
|
|
|
else server.send(400, "application/json", "{\"ok\":false,\"error\":\"upload failed\"}");
|
|
|
|
else server.send(400, "application/json", "{\"ok\":false,\"error\":\"upload failed\"}");
|
|
|
|
},
|
|
|
|
},
|
|
|
|
[]() { handleUploadFile("audio", allowedAudio); }
|
|
|
|
[]() { handleUploadFile("audio", allowedAudio); }
|
|
|
|
);
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// POST /api/memory body: {name, photo, audio}
|
|
|
|
|
|
|
|
server.on("/api/memory", HTTP_POST, []() {
|
|
|
|
server.on("/api/memory", HTTP_POST, []() {
|
|
|
|
String body = server.arg("plain");
|
|
|
|
String body = server.arg("plain");
|
|
|
|
// Minimal parser — extract name, photo, audio
|
|
|
|
|
|
|
|
auto extract = [&](const char* key) -> String {
|
|
|
|
auto extract = [&](const char* key) -> String {
|
|
|
|
String k = String("\"") + key + "\":\"";
|
|
|
|
String k = String("\"") + key + "\":\"";
|
|
|
|
int p = body.indexOf(k);
|
|
|
|
int p = body.indexOf(k);
|
|
|
|
@@ -646,7 +895,6 @@ void initWebServer() {
|
|
|
|
else server.send(500, "application/json", "{\"ok\":false,\"error\":\"too many memories\"}");
|
|
|
|
else server.send(500, "application/json", "{\"ok\":false,\"error\":\"too many memories\"}");
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// DELETE /api/memory?id=N
|
|
|
|
|
|
|
|
server.on("/api/memory", HTTP_DELETE, []() {
|
|
|
|
server.on("/api/memory", HTTP_DELETE, []() {
|
|
|
|
if (!server.hasArg("id")) { server.send(400, "application/json", "{\"ok\":false}"); return; }
|
|
|
|
if (!server.hasArg("id")) { server.send(400, "application/json", "{\"ok\":false}"); return; }
|
|
|
|
int id = server.arg("id").toInt();
|
|
|
|
int id = server.arg("id").toInt();
|
|
|
|
@@ -654,7 +902,6 @@ void initWebServer() {
|
|
|
|
server.send(200, "application/json", ok ? "{\"ok\":true}" : "{\"ok\":false,\"error\":\"not found\"}");
|
|
|
|
server.send(200, "application/json", ok ? "{\"ok\":true}" : "{\"ok\":false,\"error\":\"not found\"}");
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// POST /api/display?id=N — show a memory on the frame NOW
|
|
|
|
|
|
|
|
server.on("/api/display", HTTP_POST, []() {
|
|
|
|
server.on("/api/display", HTTP_POST, []() {
|
|
|
|
if (!server.hasArg("id")) { server.send(400, "application/json", "{\"ok\":false}"); return; }
|
|
|
|
if (!server.hasArg("id")) { server.send(400, "application/json", "{\"ok\":false}"); return; }
|
|
|
|
int id = server.arg("id").toInt();
|
|
|
|
int id = server.arg("id").toInt();
|
|
|
|
@@ -671,11 +918,9 @@ void initWebServer() {
|
|
|
|
server.send(404, "application/json", "{\"ok\":false,\"error\":\"memory not found\"}");
|
|
|
|
server.send(404, "application/json", "{\"ok\":false,\"error\":\"memory not found\"}");
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// GET /photo/{filename} — serve a photo thumbnail from SD
|
|
|
|
|
|
|
|
server.on("/photo", HTTP_GET, []() {
|
|
|
|
server.on("/photo", HTTP_GET, []() {
|
|
|
|
String name = server.pathArg(0);
|
|
|
|
String name = server.pathArg(0);
|
|
|
|
if (name.isEmpty() && server.hasArg("f")) name = server.arg("f");
|
|
|
|
if (name.isEmpty() && server.hasArg("f")) name = server.arg("f");
|
|
|
|
// Fallback: extract from URL
|
|
|
|
|
|
|
|
String uri = server.uri();
|
|
|
|
String uri = server.uri();
|
|
|
|
if (name.isEmpty()) {
|
|
|
|
if (name.isEmpty()) {
|
|
|
|
int slash = uri.lastIndexOf('/');
|
|
|
|
int slash = uri.lastIndexOf('/');
|
|
|
|
@@ -690,7 +935,6 @@ void initWebServer() {
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
server.onNotFound([]() {
|
|
|
|
server.onNotFound([]() {
|
|
|
|
// Also handle /photo/xxx.png via URI parsing
|
|
|
|
|
|
|
|
String uri = server.uri();
|
|
|
|
String uri = server.uri();
|
|
|
|
if (uri.startsWith("/photo/")) {
|
|
|
|
if (uri.startsWith("/photo/")) {
|
|
|
|
String name = uri.substring(7);
|
|
|
|
String name = uri.substring(7);
|
|
|
|
@@ -707,7 +951,21 @@ void initWebServer() {
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
server.begin();
|
|
|
|
server.begin();
|
|
|
|
printf("Web server started\r\n");
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static String buildPage(const String& status, const String& file) {
|
|
|
|
|
|
|
|
String toast = "";
|
|
|
|
|
|
|
|
if (status == "ok") {
|
|
|
|
|
|
|
|
String safeName = file;
|
|
|
|
|
|
|
|
safeName.replace("<", "<");
|
|
|
|
|
|
|
|
safeName.replace(">", ">");
|
|
|
|
|
|
|
|
toast = "<div class=\"toast success\">✅ <span>\"" + safeName + "\" uploaded successfully!</span></div>";
|
|
|
|
|
|
|
|
} else if (status == "fail") {
|
|
|
|
|
|
|
|
toast = "<div class=\"toast error\">❌ <span>Upload failed. Only .png, .mp3 and .wav files are accepted.</span></div>";
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
String page = String(APP_HTML);
|
|
|
|
|
|
|
|
page.replace("%TOAST%", toast);
|
|
|
|
|
|
|
|
return page;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void handleWebServer() {
|
|
|
|
void handleWebServer() {
|
|
|
|
|