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'), + `${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 = ` ${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); } +}