1/* ========================================================================== 2 Site interactions. 3 Loaded after components.js so the injected header already exists. 4 ========================================================================== */ 5 6function setupMobileNav() { 7 const toggle = document.querySelector(".nav-toggle"); 8 const nav = document.getElementById("primary-nav"); 9 10 if (!toggle || !nav) return; 11 12 toggle.addEventListener("click", () => { 13 const isOpen = nav.classList.toggle("is-open"); 14 toggle.setAttribute("aria-expanded", String(isOpen)); 15 }); 16 17 /* Collapse the menu after following a link on mobile. */ 18 nav.addEventListener("click", (event) => { 19 if (event.target.matches(".nav__link")) { 20 nav.classList.remove("is-open"); 21 toggle.setAttribute("aria-expanded", "false"); 22 } 23 }); 24} 25 26function setupChatbot() { 27 const toggle = document.getElementById("chatbot-toggle"); 28 const close = document.getElementById("chat-close"); 29 const chatWindow = document.getElementById("chat-window"); 30 31 if (!toggle || !chatWindow) return; 32 33 const isOpen = () => chatWindow.classList.contains("is-active"); 34 35 const toggleChat = () => { 36 const isActive = chatWindow.classList.toggle("is-active"); 37 toggle.setAttribute("aria-expanded", String(isActive)); 38 chatWindow.setAttribute("aria-hidden", String(!isActive)); 39 /* Collapse the pill into the small circle while the chat is open; restore 40 the full pill once it's closed. */ 41 toggle.classList.toggle("is-collapsed", isActive); 42 }; 43 44 toggle.addEventListener("click", toggleChat); 45 close?.addEventListener("click", toggleChat); 46 47 // Close on Escape key 48 document.addEventListener("keydown", (e) => { 49 if (e.key === "Escape" && isOpen()) toggleChat(); 50 }); 51 52 setupSpeechToText(chatWindow); 53 setupChatMessaging(chatWindow); 54} 55 56/* Replace with the real n8n webhook URL once it's set up. */ 57const N8N_WEBHOOK_URL = "https://n8n-production-3a960.up.railway.app/webhook/ff78618a-90f7-474a-a205-2c718800f7dd"; 58 59/* sessionStorage keys: continuity lasts for the browsing session (across page 60 navigations in the same tab) and clears when the tab is closed. */ 61const SESSION_ID_KEY = "michael-blake-session"; 62const TRANSCRIPT_KEY = "michael-blake-transcript"; 63const UPDATED_AT_KEY = "michael-blake-updated"; 64 65/* Reset the conversation after this much idle time so old chats don't reappear. */ 66const IDLE_TTL_MS = 30 * 60 * 1000; 67 68/* If the last activity is older than the TTL, wipe the session id and transcript 69 so both the UI and n8n's memory (keyed by session id) start fresh. */ 70function expireIfStale() { 71 const last = Number(sessionStorage.getItem(UPDATED_AT_KEY) || 0); 72 if (last && Date.now() - last > IDLE_TTL_MS) { 73 sessionStorage.removeItem(SESSION_ID_KEY); 74 sessionStorage.removeItem(TRANSCRIPT_KEY); 75 sessionStorage.removeItem(UPDATED_AT_KEY); 76 } 77} 78 79/* Stable per-visitor id so n8n's memory can tie messages to one conversation. */ 80function getSessionId() { 81 let id = sessionStorage.getItem(SESSION_ID_KEY); 82 if (!id) { 83 id = crypto.randomUUID 84 ? crypto.randomUUID() 85 : `${Date.now()}-${Math.random().toString(16).slice(2)}`; 86 sessionStorage.setItem(SESSION_ID_KEY, id); 87 } 88 return id; 89} 90 91function loadTranscript() { 92 try { 93 return JSON.parse(sessionStorage.getItem(TRANSCRIPT_KEY)) ?? []; 94 } catch { 95 return []; 96 } 97} 98 99function saveTranscript(history) { 100 sessionStorage.setItem(TRANSCRIPT_KEY, JSON.stringify(history)); 101 sessionStorage.setItem(UPDATED_AT_KEY, String(Date.now())); 102} 103 104/* Typewriter reveal pacing: ~65 chars/sec, but never longer than 105 REVEAL_MAX_MS in total so long replies speed up instead of dragging. */ 106const REVEAL_TICK_MS = 30; 107const REVEAL_MIN_CHARS_PER_TICK = 2; 108const REVEAL_MAX_MS = 6000; 109 110/* Gradually reveals an already-rendered bot bubble, character by character. 111 The bubble's HTML is final before this runs; only text nodes are blanked 112 and refilled, so the markdown structure never reflows or flickers. 113 Returns a finish() function that completes the reveal instantly, or null 114 if no animation was started (reduced motion / empty reply). */ 115function revealBotReply(bubble, body) { 116 if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return null; 117 118 /* Text nodes in document order, each with its full text snapshotted and 119 the bubble's top-level block it belongs to (hidden until reached). */ 120 const parts = []; 121 (function collect(node) { 122 for (const child of node.childNodes) { 123 if (child.nodeType === 3) { 124 let block = child; 125 while (block.parentNode !== bubble) block = block.parentNode; 126 parts.push({ node: child, fullText: child.nodeValue, block }); 127 } else { 128 collect(child); 129 } 130 } 131 })(bubble); 132 133 const totalChars = parts.reduce((sum, p) => sum + p.fullText.length, 0); 134 if (totalChars === 0) return null; 135
136 parts.forEach((p) => { 137 p.node.nodeValue = ""; 138 if (p.block !== p.node) p.block.hidden = true; 139 }); 140 bubble.classList.add("chat-msg--revealing"); 141 142 const charsPerTick = Math.max( 143 REVEAL_MIN_CHARS_PER_TICK, 144 Math.ceil((totalChars * REVEAL_TICK_MS) / REVEAL_MAX_MS) 145 ); 146 147 let partIndex = 0; 148 let charIndex = 0; 149 let done = false; 150 151 /* Keep the newest text in view, but don't yank the user down if they 152 have scrolled up to read something earlier. */ 153 const scrollIfPinned = () => { 154 if (body.scrollHeight - body.scrollTop - body.clientHeight < 48) { 155 body.scrollTop = body.scrollHeight; 156 } 157 }; 158 159 const finish = () => { 160 if (done) return; 161 done = true; 162 clearInterval(timer); 163 parts.forEach((p) => { 164 p.node.nodeValue = p.fullText; 165 if (p.block !== p.node) p.block.hidden = false; 166 }); 167 bubble.classList.remove("chat-msg--revealing"); 168 body.scrollTop = body.scrollHeight; 169 }; 170 171 const timer = setInterval(() => { 172 let budget = charsPerTick; 173 while (budget > 0 && partIndex < parts.length) { 174 const part = parts[partIndex]; 175 if (charIndex === 0 && part.block !== part.node) part.block.hidden = false; 176 charIndex = Math.min(charIndex + budget, part.fullText.length); 177 budget -= charIndex - part.node.nodeValue.length; 178 part.node.nodeValue = part.fullText.slice(0, charIndex); 179 if (charIndex >= part.fullText.length) { 180 partIndex += 1; 181 charIndex = 0; 182 } 183 } 184 scrollIfPinned(); 185 if (partIndex >= parts.length) finish(); 186 }, REVEAL_TICK_MS); 187 188 return finish; 189} 190 191/* Sends typed/dictated messages to the n8n webhook and shows the reply. */ 192function setupChatMessaging(chatWindow) { 193 const body = chatWindow.querySelector(".chat-window__body"); 194 const input = chatWindow.querySelector(".chat-window__footer input"); 195 const sendBtn = chatWindow.querySelector(".chat-window__send"); 196 197 if (!body || !input || !sendBtn) return; 198 199 expireIfStale(); 200 const sessionId = getSessionId(); 201 const history = loadTranscript(); 202 203 /* finish() of the reply currently being revealed, if any. */ 204 let finishActiveReveal = null; 205 206 /* Fill a bubble: user text stays literal (safe), while bot replies render 207 Markdown, sanitised with DOMPurify to strip unsafe HTML before it hits 208 the DOM. Falls back to plain text if the libraries fail to load. */ 209 const setBubbleContent = (bubble, text, sender) => { 210 if (sender !== "user" && window.marked && window.DOMPurify) { 211 bubble.classList.add("chat-msg--rich"); 212 bubble.innerHTML = window.DOMPurify.sanitize( 213 window.marked.parse(text, { breaks: true }) 214 ); 215 } else { 216 bubble.classList.remove("chat-msg--rich"); 217 bubble.textContent = text; 218 } 219 }; 220 221 /* Build a message bubble, add it to the chat, and keep the view scrolled down. */ 222 const addMessage = (text, sender) => { 223 const bubble = document.createElement("div"); 224 bubble.className = sender === "user" ? "chat-msg chat-msg--user" : "chat-msg"; 225 setBubbleContent(bubble, text, sender); 226 body.appendChild(bubble); 227 body.scrollTop = body.scrollHeight; 228 return bubble; 229 }; 230 231 /* Persist a finalised message so it reappears after navigating pages. */ 232 const record = (text, sender) => { 233 history.push({ text, sender }); 234 saveTranscript(history); 235 }; 236 237 // Replay this session's earlier messages below the static welcome line.
238 history.forEach((entry) => addMessage(entry.text, entry.sender)); 239 240 const handleSend = async () => { 241 const message = input.value.trim(); 242 if (message === "") return; 243 244 // A new message completes any still-animating reply instantly. 245 if (finishActiveReveal) finishActiveReveal(); 246 finishActiveReveal = null; 247 248 addMessage(message, "user"); 249 record(message, "user"); 250 input.value = ""; 251 input.focus(); 252 253 // Temporary placeholder shown until the webhook responds. 254 const pending = addMessage("Michael Blake is typingâ¦", "bot"); 255 pending.classList.add("chat-msg--pending"); 256 257 try { 258 const response = await fetch(N8N_WEBHOOK_URL, { 259 method: "POST", 260 headers: { "Content-Type": "application/json" }, 261 body: JSON.stringify({ message, sessionId }), 262 }); 263 264 if (!response.ok) throw new Error(`Request failed: ${response.status}`); 265 266 const data = await response.json(); 267 const reply = data.reply ?? "Sorry, I didn't get a response."; 268 pending.classList.remove("chat-msg--pending"); 269 setBubbleContent(pending, reply, "bot"); 270 record(reply, "bot"); 271 finishActiveReveal = revealBotReply(pending, body); 272 } catch (error) { 273 pending.classList.remove("chat-msg--pending"); 274 pending.textContent = "Sorry, something went wrong. Please try again."; 275 } finally { 276 body.scrollTop = body.scrollHeight; 277 } 278 }; 279 280 sendBtn.addEventListener("click", handleSend); 281 282 input.addEventListener("keydown", (e) => { 283 if (e.key === "Enter") { 284 e.preventDefault(); 285 handleSend(); 286 } 287 }); 288} 289 290/* Speech-to-text: dictate into the chat input using the Web Speech API. */ 291function setupSpeechToText(chatWindow) { 292 const mic = document.getElementById("chat-mic"); 293 const input = chatWindow.querySelector(".chat-window__footer input"); 294 295 if (!mic || !input) return; 296 297 const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; 298 299 // Hide the button gracefully if the browser doesn't support speech recognition. 300 if (!SpeechRecognition) { 301 mic.style.display = "none"; 302 return; 303 } 304 305 const recognition = new SpeechRecognition(); 306 recognition.lang = "en-US"; 307 /* Keep listening through short pauses so speech isn't cut off mid-sentence, 308 and stream interim results so we can preview text live. */ 309 recognition.continuous = true; 310 recognition.interimResults = true; 311 recognition.maxAlternatives = 1; 312 313 let listening = false; 314 /* Text already in the box before dictation started; dictation appends to it. */ 315 let baseText = ""; 316 /* Finalised chunks are banked here so they're never lost, even as newer 317 interim results come and go. */ 318 let finalTranscript = ""; 319 320 mic.addEventListener("click", () => { 321 if (listening) { 322 /* Stop now; the `end` handler commits whatever was captured. */ 323 recognition.stop(); 324 return; 325 } 326 try { 327 recognition.start(); 328 } catch { 329 /* start() throws if called while already starting â safe to ignore. */ 330 } 331 }); 332 333 recognition.addEventListener("start", () => { 334 listening = true; 335 baseText = input.value.trim(); 336 finalTranscript = ""; 337 mic.classList.add("is-listening"); 338 mic.setAttribute("aria-label", "Stop listening"); 339 }); 340 341 const reset = () => { 342 listening = false; 343 mic.classList.remove("is-listening"); 344 mic.setAttribute("aria-label", "Speak your message"); 345 }; 346 347 /* Combine the base text with the finalised + interim speech and show it, 348 keeping the input scrolled to the end so the newest words stay visible. */ 349 const render = (interim = "") => { 350 const spoken = (finalTranscript + interim).trim(); 351 input.value = baseText && spoken ? `${baseText} ${spoken}` : baseText || spoken; 352 input.scrollLeft = input.scrollWidth; 353 }; 354 355 recognition.addEventListener("end", () => { 356 render(); 357 input.focus(); 358 reset(); 359 }); 360 361 recognition.addEventListener("error", (event) => { 362 reset(); 363 if (event.error === "not-allowed") { 364 alert("Microphone permission was denied. Please allow microphone access in your browser settings."); 365 } else if (event.error !== "no-speech") { 366 alert("Speech recognition failed. Please try typing instead."); 367 } 368 }); 369 370 recognition.addEventListener("result", (event) => { 371 let interim = ""; 372 /* Bank any newly finalised chunks; collect the rest as live interim text. */ 373 for (let i = event.resultIndex; i < event.results.length; i++) { 374 const chunk = event.results[i][0].transcript; 375 if (event.results[i].isFinal) { 376 finalTranscript += chunk + " "; 377 } else { 378 interim += chunk; 379 } 380 } 381 render(interim); 382 }); 383} 384 385setupMobileNav(); 386setupChatbot();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.