feat: add guestbook with localStorage persistence
Some checks failed
CI / validate (pull_request) Failing after 22s
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:
68
app.js
68
app.js
@@ -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, '&').replace(/</g, '<') : 'Anonymous';
|
||||
const safeMsg = e.msg.replace(/&/g, '&').replace(/</g, '<');
|
||||
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';
|
||||
|
||||
|
||||
20
index.html
20
index.html
@@ -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>✦ Guestbook</span>
|
||||
<button id="guestbook-close" aria-label="Close guestbook">✕</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…" 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
133
style.css
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user