1const sections = document.querySelectorAll("main section[id]"); 2const navLinks = document.querySelectorAll('.top-nav a[href^="#"]'); 3const content = document.querySelector(".content"); 4const projectCards = [...document.querySelectorAll(".project-card[data-project]")]; 5const projectLinks = [...document.querySelectorAll(".project-link[data-project]")]; 6const timeEl = document.getElementById("sidebar-time"); 7 8function updateSidebarTime() { 9 if (!timeEl) return; 10 11 const now = new Date(); 12 13const formatted = new Intl.DateTimeFormat("en-US", { 14 hour: "numeric", 15 minute: "2-digit", 16 second: "2-digit", 17 hour12: true 18}).format(now); 19 20 timeEl.textContent = formatted; 21} 22 23function updateActiveNav() { 24 let currentSection = ""; 25 26 sections.forEach((section) => { 27 const rect = section.getBoundingClientRect(); 28 if (rect.top <= 120 && rect.bottom >= 120) { 29 currentSection = section.id; 30 } 31 }); 32 33 navLinks.forEach((link) => { 34 link.classList.toggle( 35 "active", 36 link.getAttribute("href") === `#${currentSection}` 37 ); 38 }); 39} 40 41function setActiveProject(projectId) { 42 projectLinks.forEach((link) => { 43 link.classList.toggle("active", link.dataset.project === projectId); 44 }); 45} 46 47function updateActiveProject() { 48 if (!content || !projectCards.length || window.innerWidth <= 768) return; 49 50 const contentTop = content.getBoundingClientRect().top; 51 const triggerOffset = 140; 52 53 let activeProject = projectCards[0].dataset.project; 54 55 projectCards.forEach((card) => { 56 const topWithinContent = card.getBoundingClientRect().top - contentTop; 57 58 if (topWithinContent <= triggerOffset) { 59 activeProject = card.dataset.project; 60 } 61 }); 62 63 setActiveProject(activeProject); 64} 65 66function scrollToProjectCard(targetCard, projectId) { 67 if (!content || !targetCard) return; 68 69 const defaultOffset = 80; 70 const firstCardOffset = 160; 71 const isFirstCard = projectCards[0] === targetCard; 72 const offset = isFirstCard ? firstCardOffset : defaultOffset; 73 74 const targetTop = Math.max(0, targetCard.offsetTop - offset); 75 76 content.scrollTo({ 77 top: targetTop, 78 behavior: "smooth" 79 }); 80 81 setActiveProject(projectId); 82} 83 84function bindDesktopProjectHover() { 85 if (window.innerWidth <= 768) return; 86 87 projectLinks.forEach((link) => { 88 const targetId = link.dataset.project; 89 const targetCard = document.querySelector(`.project-card[data-project="${targetId}"]`); 90 91 if (!targetCard) return; 92 93 link.addEventListener("mouseenter", () => { 94 scrollToProjectCard(targetCard, targetId); 95 }); 96 97 link.addEventListener("focus", () => { 98 scrollToProjectCard(targetCard, targetId); 99 }); 100 }); 101} 102 103window.addEventListener("load", () => { 104 updateActiveNav(); 105 updateActiveProject(); 106 bindDesktopProjectHover(); 107 updateSidebarTime(); 108 setInterval(updateSidebarTime, 1000); 109}); 110 111if (content) { 112 content.addEventListener("scroll", () => { 113 updateActiveNav(); 114 updateActiveProject(); 115 }); 116} 117 118/* --------------------------------- 119 INTRO: HOVER INDIVIDUAL CHARACTERS 120---------------------------------- */ 121 122const intro = document.querySelector(".intro"); 123 124const introGlyphs = [ 125 "â", "â", "â", "â£", "â½", "â²", "â", "â", "â", 126 "â¡", "â³", "â", "â", "â¦", "â", "â¤", "â", "â" 127]; 128 129function prepareIntroCharacters() { 130 if (!intro || intro.dataset.charactersPrepared === "true") return; 131 132 const walker = document.createTreeWalker( 133 intro, 134 NodeFilter.SHOW_TEXT, 135 { 136 acceptNode(node) { 137 return node.nodeValue.trim() 138 ? NodeFilter.FILTER_ACCEPT 139 : NodeFilter.FILTER_REJECT; 140 } 141 } 142 ); 143 144 const textNodes = []; 145 let currentNode; 146 147 while ((currentNode = walker.nextNode())) { 148 textNodes.push(currentNode); 149 } 150 151 let glyphIndex = 0; 152 153 textNodes.forEach((textNode) => { 154 const fragment = document.createDocumentFragment(); 155 156 [...textNode.nodeValue].forEach((character) => { 157 if (/[\p{L}\p{N}]/u.test(character)) { 158 const span = document.createElement("span"); 159 160 span.className = "intro-character"; 161 span.textContent = character; 162 163 span.dataset.original = character; 164 span.dataset.glyph = 165 introGlyphs[glyphIndex % introGlyphs.length]; 166 167 glyphIndex++; 168 169 fragment.appendChild(span); 170 } else { 171 fragment.appendChild( 172 document.createTextNode(character) 173 ); 174 } 175 }); 176 177 textNode.parentNode.replaceChild(fragment, textNode); 178 }); 179 180 intro.dataset.charactersPrepared = "true"; 181} 182 183function bindIntroCharacterHover() { 184 const characters = document.querySelectorAll(".intro-character"); 185 const CHARACTER_RESTORE_DELAY = 2000; 186
187 characters.forEach((character) => { 188 let restoreTimeout; 189 190 character.addEventListener("mouseenter", () => { 191 clearTimeout(restoreTimeout); 192 193 character.textContent = character.dataset.glyph; 194 character.classList.add("is-glyph"); 195 }); 196 197 character.addEventListener("mouseleave", () => { 198 restoreTimeout = setTimeout(() => { 199 character.textContent = character.dataset.original; 200 character.classList.remove("is-glyph"); 201 }, CHARACTER_RESTORE_DELAY); 202 }); 203 }); 204} 205 206prepareIntroCharacters(); 207bindIntroCharacterHover(); 208 209function previewIntroInteraction() { 210 const characters = [...document.querySelectorAll(".intro-character")]; 211 212 if (!characters.length) return; 213 214 // how many letters animate 215 const PREVIEW_COUNT = 5; 216 217 // copy the array and shuffle it 218 const shuffled = [...characters].sort(() => Math.random() - 0.5); 219 220 // grab the first few 221 const previewCharacters = shuffled.slice(0, PREVIEW_COUNT); 222 223 setTimeout(() => { 224 previewCharacters.forEach((character, index) => { 225 setTimeout(() => { 226 character.textContent = character.dataset.glyph; 227 character.classList.add("is-glyph"); 228 229 setTimeout(() => { 230 character.textContent = character.dataset.original; 231 character.classList.remove("is-glyph"); 232 }, 1000); 233 }, index * 420); 234 }); 235 }, 1500); 236} 237 238previewIntroInteraction(); 239 240/* --------------------------------- 241 PROJECT IMAGE CAROUSELS 242---------------------------------- */ 243 244function prepareProjectCarousels() { 245 const carousels = document.querySelectorAll(".project-carousel"); 246 const carouselCursor = document.querySelector(".carousel-cursor"); 247 248 if (!carouselCursor) return; 249 250 carousels.forEach((carousel) => { 251 const image = carousel.querySelector(".carousel-media"); 252 const previousButton = carousel.querySelector(".carousel-zone-left"); 253 const nextButton = carousel.querySelector(".carousel-zone-right"); 254 255 if (!image || !previousButton || !nextButton) return; 256 257 const images = (carousel.dataset.images || "") 258 .split(",") 259 .map((path) => path.trim()) 260 .filter(Boolean); 261 262 if (images.length < 2) { 263 previousButton.hidden = true; 264 nextButton.hidden = true; 265 return; 266 } 267 268 let currentIndex = Math.max( 269 0, 270 images.indexOf(image.getAttribute("src")) 271 ); 272 273 // Preload every image so swaps happen immediately. 274 images.forEach((src) => { 275 if (src.toLowerCase().endsWith(".mp4")) { 276 const preload = document.createElement("video"); 277 preload.preload = "metadata"; 278 preload.src = src; 279 } else { 280 const preload = new Image(); 281 preload.src = src; 282 } 283}); 284 285 function showImage(index) { 286 currentIndex = (index + images.length) % images.length; 287 288 const src = images[currentIndex]; 289 const isVideo = src.toLowerCase().endsWith(".mp4"); 290 291 let media = carousel.querySelector(".carousel-media"); 292 293 if (isVideo) { 294 if (!media || media.tagName !== "VIDEO") { 295 const video = document.createElement("video"); 296 297 video.className = "carousel-image carousel-media"; 298 video.autoplay = true; 299 video.loop = true; 300 video.muted = true; 301 video.playsInline = true; 302 303 if (media) { 304 media.replaceWith(video); 305 } else { 306 image.replaceWith(video); 307 } 308 309 media = video; 310 } 311 312 media.src = src; 313 media.load(); 314 media.play().catch(() => {}); 315 } else { 316 if (!media || media.tagName !== "IMG") { 317 const newImage = document.createElement("img"); 318 319 newImage.className = "carousel-image carousel-media"; 320 newImage.alt = image.alt || "Project preview"; 321 322 if (media) { 323 media.replaceWith(newImage); 324 } else { 325 image.replaceWith(newImage); 326 } 327 328 media = newImage; 329 } 330 331 media.src = src; 332 } 333} 334 335 function moveCursor(event) { 336 carouselCursor.style.left = `${event.clientX}px`; 337 carouselCursor.style.top = `${event.clientY}px`; 338 } 339 340 function showCursor(direction) { 341 carouselCursor.textContent = 342 direction === "left" ? "â" : "â"; 343 344 carouselCursor.classList.add("is-visible"); 345 } 346 347 function hideCursor() { 348 carouselCursor.classList.remove("is-visible"); 349 } 350 351 previousButton.addEventListener("mouseenter", () => { 352 showCursor("left"); 353 }); 354 355 previousButton.addEventListener("mousemove", moveCursor); 356 previousButton.addEventListener("mouseleave", hideCursor); 357 358 previousButton.addEventListener("click", () => { 359 showImage(currentIndex - 1); 360 }); 361 362 nextButton.addEventListener("mouseenter", () => { 363 showCursor("right"); 364 }); 365 366 nextButton.addEventListener("mousemove", moveCursor); 367 nextButton.addEventListener("mouseleave", hideCursor); 368 369 nextButton.addEventListener("click", () => { 370 showImage(currentIndex + 1); 371 }); 372 }); 373} 374 375prepareProjectCarousels();
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.