diff --git a/images/emotebutton.gif b/images/emotebutton.gif
new file mode 100644
index 0000000..c0a967f
Binary files /dev/null and b/images/emotebutton.gif differ
diff --git a/images/emotes/applause.gif b/images/emotes/applause.gif
new file mode 100644
index 0000000..cd68c19
Binary files /dev/null and b/images/emotes/applause.gif differ
diff --git a/images/emotes/astonished.gif b/images/emotes/astonished.gif
new file mode 100644
index 0000000..ea79708
Binary files /dev/null and b/images/emotes/astonished.gif differ
diff --git a/images/emotes/caramelldansen.gif b/images/emotes/caramelldansen.gif
new file mode 100644
index 0000000..a1b221c
Binary files /dev/null and b/images/emotes/caramelldansen.gif differ
diff --git a/images/emotes/joy.gif b/images/emotes/joy.gif
new file mode 100644
index 0000000..8abeb88
Binary files /dev/null and b/images/emotes/joy.gif differ
diff --git a/images/emotes/kiss.gif b/images/emotes/kiss.gif
new file mode 100644
index 0000000..91f188b
Binary files /dev/null and b/images/emotes/kiss.gif differ
diff --git a/images/emotes/laugh.gif b/images/emotes/laugh.gif
new file mode 100644
index 0000000..4da7930
Binary files /dev/null and b/images/emotes/laugh.gif differ
diff --git a/images/emotes/love.gif b/images/emotes/love.gif
new file mode 100644
index 0000000..b46c317
Binary files /dev/null and b/images/emotes/love.gif differ
diff --git a/images/emotes/wave.gif b/images/emotes/wave.gif
new file mode 100644
index 0000000..77e310b
Binary files /dev/null and b/images/emotes/wave.gif differ
diff --git a/images/emotes/yippee.gif b/images/emotes/yippee.gif
new file mode 100644
index 0000000..c7fac01
Binary files /dev/null and b/images/emotes/yippee.gif differ
diff --git a/images/pitcochatmenu.png b/images/pitcochatmenu.png
index 51c9255..ef678eb 100644
Binary files a/images/pitcochatmenu.png and b/images/pitcochatmenu.png differ
diff --git a/images/sendbutton.png b/images/sendbutton.png
new file mode 100644
index 0000000..d59c7cf
Binary files /dev/null and b/images/sendbutton.png differ
diff --git a/images/sendbuttonhover.png b/images/sendbuttonhover.png
new file mode 100644
index 0000000..247834f
Binary files /dev/null and b/images/sendbuttonhover.png differ
diff --git a/index.html b/index.html
index 0ba2448..3af149e 100644
--- a/index.html
+++ b/index.html
@@ -8,7 +8,7 @@
-
+
@@ -213,15 +213,31 @@
-
UPDATES
+
GUESTBOOK
diff --git a/js/guestbook-worker.js b/js/guestbook-worker.js
new file mode 100644
index 0000000..ebc62fa
--- /dev/null
+++ b/js/guestbook-worker.js
@@ -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, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+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);
+ },
+};
diff --git a/js/guestbook.js b/js/guestbook.js
new file mode 100644
index 0000000..a788374
--- /dev/null
+++ b/js/guestbook.js
@@ -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(/&/g, '&');
+ }
+ var result = text.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
+ function(_, label, url) {
+ return '' + label + '';
+ });
+ result = result.replace(/(^|[^"'])(https?:\/\/[^\s<]+)/g,
+ function(_, pre, url) {
+ return pre + '' + url + '';
+ });
+ 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'),
+ `
`);
+ }
+ 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 = `♥ ${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 = `♥ ${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 = "
";
+ 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();
+ });
+})();
diff --git a/style.css b/style.css
index edfef5d..cc425e4 100644
--- a/style.css
+++ b/style.css
@@ -847,10 +847,10 @@ a:hover {
border-radius: 18px;
box-shadow: inset 0px 0px 5px 0px rgb(202, 202, 202), 0px -4px 5px -3px #bbbbbb, 0px 5px 1px -3px #ffffff;
z-index: 1;
- overflow: hidden;
}
#pictochat-content {
+ position: relative;
display: flex;
flex-direction: column;
align-items: center;
@@ -936,8 +936,9 @@ a:hover {
color: var(--col);
}
-#pictochat-content .picto > div > div:last-of-type {
+#pictochat-content .picto > div > .gb-msg-content {
padding: 3px;
+ padding-top: 20px;
text-indent: 0px;
line-height: 18px;
}
@@ -975,7 +976,7 @@ a:hover {
calc(100% - 4px) calc(100% - 0px), calc(100% - 4px) calc(100% - 2px), calc(100% - 2px) calc(100% - 2px), calc(100% - 2px) calc(100% - 4px), calc(100% - 0px) calc(100% - 4px));
}
-#pictochat-content .picto > div img:first-of-type {
+#pictochat-content .picto > div img:first-of-type:not(.gb-emote) {
position: absolute;
top: 0;
left: auto;
@@ -1301,3 +1302,198 @@ a:hover {
letter-spacing: 4px;
text-shadow: 0 0 3px #93bdec66;
}
+
+
+#gb-name {
+ position: absolute;
+ top: 153px;
+ left: 24px;
+ height: 17px;
+ width: 125px;
+ font-size: 11px;
+ font-family: 'DSWare';
+ color: #30baf3;
+ font-weight: 500;
+ border: rgba(255, 0, 0, 0) solid 1px;
+ background-color: rgba(255, 255, 255, 0);
+ outline: none;
+ z-index: 10;
+}
+
+#gb-name::placeholder {
+ font-size: 10px;
+ font-weight: normal;
+ font-family: 'DSWare';
+ color: #30baf3;
+}
+
+#gb-name:focus {
+ outline: none;
+ border: 1px solid rgba(163, 197, 11, 0);
+}
+
+#gb-message {
+ position: absolute;
+ top: 167px;
+ left: 20px;
+ width: 200px;
+ height: 64px;
+ resize: none;
+ padding-top: 1px;
+ line-height: 1.6;
+ font-size: 10px;
+ font-family: 'DSWare';
+ color: #494949;
+ border: rgba(0, 255, 21, 0) solid 1px;
+ background-color: rgba(255, 255, 255, 0);
+ outline: none;
+ z-index: 10;
+}
+
+#gb-message:focus {
+ outline: none;
+ border: 1px solid rgba(163, 197, 11, 0);
+}
+
+#gb-message::placeholder {
+ font-size: 10px;
+ font-family: 'DSWare';
+ color: #b0bcc8;
+}
+
+#gb-send {
+ position: absolute !important;
+ top: 244px;
+ left: 217.5px;
+ width: 32px !important;
+ height: 32px !important;
+ margin: 0px;
+ padding: 0px;
+ cursor: pointer;
+ z-index: 100;
+ image-rendering: pixelated;
+ background: none;
+ border: none;
+ animation: gb-sendPulse 2s infinite ease;
+}
+
+#gb-send:hover {
+ content: url('images/sendbuttonhover.png');
+}
+
+#gb-send.gb-disabled {
+ opacity: 0.4;
+ cursor: default;
+ animation: none;
+ pointer-events: none;
+}
+
+@keyframes gb-sendPulse {
+ 0%, 100% { opacity: 1; }
+ 30% { opacity: 0.3; }
+}
+
+#gb-emote-toggle {
+ top: 275px;
+ left: 217.5px;
+ cursor: pointer;
+}
+
+#gb-emote-selector {
+ display: none;
+ position: relative;
+ padding: 8px 6px;
+ z-index: 100;
+ top: -52px;
+ left: 15px;
+ width: 205px;
+ box-sizing: border-box;
+ image-rendering: pixelated;
+ animation: gbFadeIn 0.3s;
+ background: linear-gradient(90deg, #fafbfb 0%, #f7f7f7 50%, #fafbfb 100%);
+ border: 1.5px solid #c9c9c9;
+ border-radius: 18px;
+ box-shadow: inset 0px 0px 5px 0px rgb(202, 202, 202), 0px 0px 5px 0px #dadada;
+}
+
+#gb-emote-selector.gb-visible {
+ display: block;
+}
+
+.gb-emote-item {
+ width: 22px;
+ height: 22px;
+ cursor: pointer;
+ image-rendering: pixelated;
+ border-radius: 3px;
+ padding: 2px;
+ transition: background 0.1s;
+}
+
+.gb-emote-item:hover {
+ background: #e0f0ff;
+}
+
+.gb-date {
+ position: absolute !important;
+ top: 2px !important;
+ right: 10px !important;
+ left: auto !important;
+ width: auto !important;
+ height: auto !important;
+ text-indent: 0 !important;
+ font-size: 9px;
+ color: #494949;
+ font-family: 'DSWare';
+ z-index: 1;
+ background: none !important;
+}
+
+.gb-date::before,
+.gb-date::after {
+ display: none !important;
+}
+
+.gb-emote {
+ display: inline;
+ height: 16px;
+ width: 16px;
+ vertical-align: middle;
+ image-rendering: pixelated;
+}
+
+.gb-actions {
+ display: flex;
+ gap: 6px;
+ margin-top: 3px;
+ padding-top: 0;
+ padding-left: 2px;
+ text-indent: 0;
+}
+
+.gb-like-btn {
+ background: none;
+ border: none;
+ font-family: 'DSWare';
+ font-size: 9px;
+ color: #30baf3;
+ cursor: pointer;
+ padding: 0 2px;
+}
+
+.gb-like-btn:hover {
+ color: #0080d0;
+}
+
+.gb-liked {
+ color: #ff6b8a !important;
+}
+
+.gb-like-icon {
+ font-size: 10px;
+}
+
+@keyframes gbFadeIn {
+ from { opacity: 0; transform: translateY(4px); }
+ to { opacity: 1; transform: translateY(0); }
+}