feat: add guestbook with localStorage persistence
Some checks failed
CI / validate (pull_request) Failing after 22s

Visitors can leave a short message (up to 140 chars) via a panel
toggled from the bottom-left corner. Entries are stored in
localStorage (capped at 20), displayed newest-first, and
HTML-escaped before rendering.

Fixes #135
This commit is contained in:
Alexander Whitestone
2026-03-24 00:06:11 -04:00
parent 2ebd153493
commit fea13910a8
3 changed files with 221 additions and 0 deletions

68
app.js
View File

@@ -209,6 +209,74 @@ document.getElementById('debug-toggle').addEventListener('click', () => {
}
});
// === GUESTBOOK ===
const GUESTBOOK_KEY = 'nexus_guestbook';
const GUESTBOOK_MAX = 20;
/**
* Load guestbook entries from localStorage.
* @returns {Array<{name: string, msg: string, ts: number}>}
*/
function guestbookLoad() {
try {
return JSON.parse(localStorage.getItem(GUESTBOOK_KEY) || '[]');
} catch (_) {
return [];
}
}
/**
* Save an entry to localStorage (max GUESTBOOK_MAX entries, newest first).
* @param {string} name
* @param {string} msg
*/
function guestbookSave(name, msg) {
const entries = guestbookLoad();
entries.unshift({ name: name.trim().slice(0, 32), msg: msg.trim().slice(0, 140), ts: Date.now() });
localStorage.setItem(GUESTBOOK_KEY, JSON.stringify(entries.slice(0, GUESTBOOK_MAX)));
}
/**
* Render guestbook entries into the messages container.
*/
function guestbookRender() {
const container = document.getElementById('guestbook-messages');
const entries = guestbookLoad();
if (entries.length === 0) {
container.innerHTML = '<div class="guestbook-empty">No messages yet. Be the first!</div>';
return;
}
container.innerHTML = entries.map(e => {
const safeName = e.name ? e.name.replace(/&/g, '&amp;').replace(/</g, '&lt;') : 'Anonymous';
const safeMsg = e.msg.replace(/&/g, '&amp;').replace(/</g, '&lt;');
return `<div class="guestbook-entry"><div class="guestbook-entry-name">${safeName}</div><div class="guestbook-entry-msg">${safeMsg}</div></div>`;
}).join('');
}
const guestbookPanel = document.getElementById('guestbook-panel');
const guestbookBtn = document.getElementById('guestbook-btn');
const guestbookClose = document.getElementById('guestbook-close');
const guestbookForm = document.getElementById('guestbook-form');
guestbookBtn.addEventListener('click', () => {
guestbookRender();
guestbookPanel.classList.toggle('open');
});
guestbookClose.addEventListener('click', () => {
guestbookPanel.classList.remove('open');
});
guestbookForm.addEventListener('submit', (e) => {
e.preventDefault();
const name = /** @type {HTMLInputElement} */ (document.getElementById('guestbook-name')).value;
const msg = /** @type {HTMLTextAreaElement} */ (document.getElementById('guestbook-msg')).value.trim();
if (!msg) return;
guestbookSave(name, msg);
guestbookForm.reset();
guestbookRender();
});
// === WEBSOCKET CLIENT ===
import { wsClient } from './ws-client.js';

View File

@@ -41,6 +41,26 @@
<span class="overview-hint">[Tab] to exit</span>
</div>
<!-- Guestbook -->
<div id="guestbook-toggle" class="hud-controls" style="position: absolute; bottom: 16px; left: 16px;">
<button id="guestbook-btn" class="chat-toggle-btn" aria-label="Open guestbook" style="background-color: var(--color-secondary); color: var(--color-text); padding: 4px 10px; border-radius: 4px; font-size: 12px; cursor: pointer; border: 1px solid var(--color-primary);">
Guestbook
</button>
</div>
<div id="guestbook-panel" role="dialog" aria-modal="true" aria-label="Guestbook">
<div id="guestbook-header">
<span>&#10022; Guestbook</span>
<button id="guestbook-close" aria-label="Close guestbook">&#10005;</button>
</div>
<div id="guestbook-messages"></div>
<form id="guestbook-form" autocomplete="off">
<input id="guestbook-name" type="text" maxlength="32" placeholder="Your name (optional)" aria-label="Your name" />
<textarea id="guestbook-msg" maxlength="140" placeholder="Leave a message&#x2026;" required aria-label="Your message"></textarea>
<button type="submit">Sign</button>
</form>
</div>
<script type="module" src="app.js"></script>
</body>
</html>

133
style.css
View File

@@ -106,3 +106,136 @@ canvas {
0%, 100% { opacity: 0.7; }
50% { opacity: 1; }
}
/* === GUESTBOOK === */
#guestbook-panel {
display: none;
position: fixed;
bottom: 56px;
left: 16px;
width: 280px;
max-height: 400px;
background: rgba(0, 0, 16, 0.88);
border: 1px solid var(--color-primary);
border-radius: 6px;
z-index: 30;
font-family: var(--font-body);
font-size: 12px;
color: var(--color-text);
box-shadow: 0 0 18px rgba(68, 136, 255, 0.18);
flex-direction: column;
}
#guestbook-panel.open {
display: flex;
}
#guestbook-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid var(--color-secondary);
letter-spacing: 0.15em;
color: var(--color-primary);
font-size: 11px;
text-transform: uppercase;
}
#guestbook-close {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: 12px;
padding: 0 2px;
line-height: 1;
}
#guestbook-close:hover {
color: var(--color-text);
}
#guestbook-messages {
overflow-y: auto;
flex: 1;
padding: 8px 12px;
min-height: 60px;
max-height: 200px;
}
.guestbook-entry {
padding: 5px 0;
border-bottom: 1px solid rgba(51, 68, 136, 0.3);
}
.guestbook-entry:last-child {
border-bottom: none;
}
.guestbook-entry-name {
color: var(--color-primary);
font-size: 11px;
margin-bottom: 2px;
}
.guestbook-entry-msg {
color: var(--color-text);
font-size: 12px;
word-break: break-word;
}
.guestbook-empty {
color: var(--color-text-muted);
font-size: 11px;
text-align: center;
padding: 12px 0;
}
#guestbook-form {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 12px;
border-top: 1px solid var(--color-secondary);
}
#guestbook-name,
#guestbook-msg {
background: rgba(0, 0, 16, 0.6);
border: 1px solid var(--color-secondary);
border-radius: 3px;
color: var(--color-text);
font-family: var(--font-body);
font-size: 12px;
padding: 5px 8px;
outline: none;
resize: none;
}
#guestbook-name:focus,
#guestbook-msg:focus {
border-color: var(--color-primary);
}
#guestbook-msg {
height: 56px;
}
#guestbook-form button[type="submit"] {
background-color: var(--color-primary);
color: var(--color-bg);
border: none;
border-radius: 3px;
padding: 5px 0;
font-family: var(--font-body);
font-size: 12px;
cursor: pointer;
letter-spacing: 0.1em;
transition: background-color 0.2s;
}
#guestbook-form button[type="submit"]:hover {
background-color: var(--color-secondary);
color: var(--color-text);
}