PageSourceSearch

https://katelyn-lee.com/main.js

js katelyn-lee.com collected 2026-10-07 19:31:22 UTC 9,550 bytes, 375 lines download raw bytes

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.