add guestbook

This commit is contained in:
4DBug
2026-03-01 15:36:34 -06:00
parent fd0d3a2906
commit 1584ebaa0d
17 changed files with 587 additions and 5 deletions

126
js/guestbook-worker.js Normal file
View File

@@ -0,0 +1,126 @@
const CORS_HEADERS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
const KV_KEY = 'messages';
const MAX_MESSAGES = 500;
const MAX_NAME_LENGTH = 30;
const MAX_MESSAGE_LENGTH = 500;
function jsonResponse(data, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { 'Content-Type': 'application/json', ...CORS_HEADERS },
});
}
function generateId() {
return crypto.randomUUID();
}
function formatDate() {
return new Date().toISOString();
}
function sanitize(str) {
return str
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
async function hashIP(ip) {
const encoder = new TextEncoder();
const data = encoder.encode(ip + '_guestbook_salt');
const hash = await crypto.subtle.digest('SHA-256', data);
return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 16);
}
async function getMessages(env) {
const raw = await env.GUESTBOOK.get(KV_KEY);
return raw ? JSON.parse(raw) : [];
}
async function saveMessages(env, messages) {
await env.GUESTBOOK.put(KV_KEY, JSON.stringify(messages));
}
export default {
async fetch(request, env) {
if (request.method === 'OPTIONS') {
return new Response(null, { headers: CORS_HEADERS });
}
const url = new URL(request.url);
const path = url.pathname;
if (request.method === 'GET' && path === '/messages') {
const messages = await getMessages(env);
const sanitized = messages.map(m => ({
...m,
likes: Array.isArray(m.likes) ? m.likes.length : (typeof m.likes === 'number' ? m.likes : 0),
}));
return jsonResponse(sanitized);
}
if (request.method === 'POST' && path === '/messages') {
const body = await request.json().catch(() => null);
if (!body || !body.message || !body.message.trim()) {
return jsonResponse({ error: 'Message is required' }, 400);
}
const name = sanitize((body.name || 'Anonymous').trim().slice(0, MAX_NAME_LENGTH));
const message = sanitize(body.message.trim().slice(0, MAX_MESSAGE_LENGTH));
const messages = await getMessages(env);
const entry = {
id: generateId(),
name,
message,
date: formatDate(),
likes: [],
replies: [],
};
messages.unshift(entry);
if (messages.length > MAX_MESSAGES) messages.length = MAX_MESSAGES;
await saveMessages(env, messages);
return jsonResponse(entry, 201);
}
const likeMatch = path.match(/^\/messages\/([^/]+)\/like$/);
if (request.method === 'POST' && likeMatch) {
const messageId = likeMatch[1];
const ip = request.headers.get('CF-Connecting-IP') || 'unknown';
const ipHash = await hashIP(ip);
const messages = await getMessages(env);
const msg = messages.find(m => m.id === messageId);
if (!msg) return jsonResponse({ error: 'Message not found' }, 404);
if (!Array.isArray(msg.likes)) {
const oldCount = typeof msg.likes === 'number' ? msg.likes : 0;
msg.likes = [];
for (let i = 0; i < oldCount; i++) msg.likes.push('legacy_' + i);
}
const alreadyLiked = msg.likes.includes(ipHash);
if (!alreadyLiked) {
msg.likes.push(ipHash);
}
await saveMessages(env, messages);
return jsonResponse({ likes: msg.likes.length, liked: true });
}
return jsonResponse({ error: 'Not found' }, 404);
},
};

244
js/guestbook.js Normal file
View File

@@ -0,0 +1,244 @@
const GUESTBOOK_API = 'https://guestbook.bug-dev-mail.workers.dev';
const EMOTES = {
':happy:': 'images/emotes/joy.gif',
':love:': 'images/emotes/love.gif',
':laugh:': 'images/emotes/laugh.gif',
':shock:': 'images/emotes/astonished.gif',
':hello:': 'images/emotes/wave.gif',
':kiss:': 'images/emotes/kiss.gif',
':clap:': 'images/emotes/applause.gif',
':dance:': 'images/emotes/caramelldansen.gif',
':yippee:': 'images/emotes/yippee.gif',
};
function replaceLinks(text) {
function cleanUrl(url) {
return url.replace(/&amp;/g, '&');
}
var result = text.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
function(_, label, url) {
return '<a href="' + cleanUrl(url) + '" target="_blank" rel="noopener noreferrer">' + label + '</a>';
});
result = result.replace(/(^|[^"'])(https?:\/\/[^\s<]+)/g,
function(_, pre, url) {
return pre + '<a href="' + cleanUrl(url) + '" target="_blank" rel="noopener noreferrer">' + url + '</a>';
});
return result;
}
function replaceEmotes(text) {
let result = replaceLinks(text);
for (const [code, src] of Object.entries(EMOTES)) {
const escaped = code.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
result = result.replace(new RegExp(escaped, 'g'),
`<img src="${src}" class="gb-emote" title="${code}" alt="${code}">`);
}
return result;
}
function timeAgo(dateStr) {
if (!dateStr.includes('T') && !dateStr.includes('-')) {
return dateStr;
}
const d = new Date(dateStr);
const now = new Date();
const diffMs = now - d;
const diffMin = Math.floor(diffMs / 60000);
const diffHr = Math.floor(diffMs / 3600000);
const diffDay = Math.floor(diffMs / 86400000);
if (diffMin < 1) return 'just now';
if (diffMin < 60) return diffMin + ' min ago';
if (diffHr < 24) return diffHr + ' hr ago';
if (diffDay === 1) return '1 day ago';
return diffDay + ' days ago';
}
function createMessageBubble(msg) {
const entry = document.createElement('div');
entry.style.setProperty('--col', '#90c9ff');
const nameDiv = document.createElement('div');
nameDiv.textContent = msg.name || 'Anonymous';
const dateDiv = document.createElement('div');
dateDiv.className = 'gb-date';
dateDiv.textContent = timeAgo(msg.date);
const msgDiv = document.createElement('div');
msgDiv.className = 'gb-msg-content';
msgDiv.innerHTML = replaceEmotes(msg.message);
entry.appendChild(nameDiv);
entry.appendChild(dateDiv);
entry.appendChild(msgDiv);
const actions = document.createElement('div');
actions.className = 'gb-actions';
const likeBtn = document.createElement('button');
likeBtn.className = 'gb-like-btn';
const likeCount = typeof msg.likes === 'number' ? msg.likes : (Array.isArray(msg.likes) ? msg.likes.length : 0);
likeBtn.innerHTML = `<span class="gb-like-icon">&hearts;</span> ${likeCount}`;
likeBtn.onclick = () => likeMessage(msg.id, likeBtn);
actions.appendChild(likeBtn);
entry.appendChild(actions);
return entry;
}
async function loadMessages() {
const picto = document.querySelector('#pictochat-content .picto');
if (!picto) return;
picto.innerHTML = '';
try {
const res = await fetch(GUESTBOOK_API + '/messages');
const messages = await res.json();
messages.forEach(msg => {
picto.appendChild(createMessageBubble(msg));
});
} catch (e) {
const err = document.createElement('div');
err.style.setProperty('--col', '#90c9ff');
const nameDiv = document.createElement('div');
nameDiv.textContent = 'system';
const msgDiv = document.createElement('div');
msgDiv.textContent = 'No one has commented yet. Be the first!';
err.appendChild(nameDiv);
err.appendChild(document.createTextNode(''));
err.appendChild(document.createElement('br'));
err.appendChild(msgDiv);
picto.appendChild(err);
}
}
async function sendMessage() {
const nameInput = document.getElementById('gb-name');
const msgInput = document.getElementById('gb-message');
const sendBtn = document.getElementById('gb-send');
const message = msgInput.value.trim();
if (!message) return;
sendBtn.classList.add('gb-disabled');
try {
await fetch(GUESTBOOK_API + '/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: nameInput.value.trim() || 'Anonymous',
message: message,
}),
});
msgInput.value = '';
await loadMessages();
setTimeout(replaceTextWithEmotes, 500);
} catch (e) {
}
sendBtn.classList.remove('gb-disabled');
}
async function likeMessage(id, btn) {
try {
const res = await fetch(GUESTBOOK_API + `/messages/${id}/like`, { method: 'POST' });
const data = await res.json();
btn.innerHTML = `<span class="gb-like-icon">&hearts;</span> ${data.likes}`;
btn.classList.add('gb-liked');
} catch (e) {
}
}
function toggleEmoteSelector() {
const els = document.getElementsByClassName('gb-toggle-div');
for (let i = 0; i < els.length; i++) {
if (els[i].style.display === 'none' || els[i].style.display === '') {
els[i].style.display = 'block';
} else {
els[i].style.display = 'none';
}
}
}
function addText(text) {
const textarea = document.getElementById('gb-message');
textarea.value += text;
textarea.focus();
}
function replaceTextWithEmotes() {
const picto = document.querySelector('#pictochat-content .picto');
if (!picto) return;
function replaceTextNodes(element) {
element.childNodes.forEach(function (node) {
if (node.nodeType === Node.TEXT_NODE) {
let replacedText = node.textContent;
let changed = false;
for (const [key, src] of Object.entries(EMOTES)) {
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const imgTag = "<img src='" + src + "' title='" + key + "' class='gb-emote' />";
const newText = replacedText.replace(new RegExp(escapedKey, 'g'), imgTag);
if (newText !== replacedText) {
replacedText = newText;
changed = true;
}
}
if (changed) {
const newElement = document.createElement('span');
newElement.innerHTML = replacedText;
node.parentNode.replaceChild(newElement, node);
}
} else {
replaceTextNodes(node);
}
});
}
replaceTextNodes(picto);
}
(function () {
const picto = document.querySelector('#pictochat-content .picto');
if (!picto) return;
const sendBtn = document.getElementById('gb-send');
if (sendBtn) sendBtn.addEventListener('click', sendMessage);
const msgInput = document.getElementById('gb-message');
if (msgInput) {
msgInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
}
const emoteBtn = document.getElementById('gb-emote-toggle');
if (emoteBtn) emoteBtn.addEventListener('click', toggleEmoteSelector);
document.addEventListener('click', function (e) {
const selector = document.getElementById('gb-emote-selector');
const toggle = document.getElementById('gb-emote-toggle');
if (selector && selector.style.display === 'block' &&
!selector.contains(e.target) && e.target !== toggle) {
selector.style.display = 'none';
}
});
const emoteItems = document.querySelectorAll('#gb-emote-selector .gb-emote-item');
emoteItems.forEach(function (img) {
img.addEventListener('click', function () {
addText(img.title + ' ');
});
});
loadMessages().then(function () {
replaceTextWithEmotes();
});
})();