diff --git a/web-app/index.html b/web-app/index.html index eab50af..c51f7a6 100644 --- a/web-app/index.html +++ b/web-app/index.html @@ -933,7 +933,7 @@

Legal

} })(); - +
diff --git a/web-app/js/main.js b/web-app/js/main.js index af817af..aecfee1 100644 --- a/web-app/js/main.js +++ b/web-app/js/main.js @@ -1,255 +1,262 @@ -/* - main.js - Orchestration entry point for the Premium Python Projects Gallery -*/ - -import { initTheme } from "./modules/theme.js"; -import { initModal, openProjectSafe } from "./modules/modal.js"; -import { initSearch } from "./modules/search.js"; -import { initSidebar } from "./modules/sidebar.js"; -import { - prefersReducedMotion, - updateProjectVisibility, - debounce, -} from "./modules/utils.js"; - -let currentCategory = "all"; -let currentSearchQuery = ""; -let playgroundActive = false; - -document.addEventListener("DOMContentLoaded", function () { - const html = document.documentElement; - const backToTopButton = document.getElementById("backToTop"); - const cursorGlow = document.getElementById("cursorGlow"); - const exploreBtn = document.getElementById("exploreBtn"); - const randomProjectBtn = document.getElementById("randomProjectBtn"); - const randomProjectBtnSidebar = document.getElementById( - "randomProjectBtnSidebar" - ); - const projectsGrid = document.querySelector(".projects-grid"); - const projectsTemplate = document.getElementById("projectsTemplate"); - const projectsSection = document.getElementById("projectsSection"); - const playgroundSection = document.getElementById("playgroundSection"); - const stickyFilterBar = document.getElementById("stickyFilterBar"); - const navbar = document.getElementById("mainNavbar"); - const soundToggle = document.getElementById("soundToggle"); - const heroSoundToggle = document.getElementById("heroSoundToggle"); - - // Initialize core modules - initTheme(); - initModal(); - - // Helper functions for sections - function showProjectsSection() { - playgroundActive = false; - if (playgroundSection) playgroundSection.style.display = "none"; - if (projectsSection) projectsSection.style.display = ""; - if ( - window.playgroundAPI && - typeof window.playgroundAPI.deactivate === "function" - ) { - window.playgroundAPI.deactivate(); - } +/* ═══════════════════════════════════════════════════════════════ + main.js — App wiring for Premium Python Projects Gallery + ═══════════════════════════════════════════════════════════════ */ + +function prefersReducedMotion() { + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} + +function safeRun(fn) { + try { + fn(); + } catch (e) { + console.error(e); } - - function showPlaygroundSection() { - playgroundActive = true; - if (projectsSection) projectsSection.style.display = "none"; - if (playgroundSection) { - playgroundSection.style.display = ""; - if ( - window.playgroundAPI && - typeof window.playgroundAPI.activate === "function" - ) { - window.playgroundAPI.activate(); - } - } - } - - // Init Sidebar and obtain references to update tabs - const sidebarInterface = initSidebar( - (category) => { - currentCategory = category; - updateProjectVisibility(currentCategory, currentSearchQuery); - }, - showPlaygroundSection, - showProjectsSection - ); - - // Init Search module - initSearch((query) => { - currentSearchQuery = query; - if (query && currentCategory !== "all") { - currentCategory = "all"; - if (sidebarInterface) { - sidebarInterface.syncSidebarTabs("all"); - sidebarInterface.syncStickyTabs("all"); - } - } - updateProjectVisibility(currentCategory, currentSearchQuery); +} + +function debounce(fn, ms) { + var timer; + return function () { + var args = arguments; + var ctx = this; + clearTimeout(timer); + timer = setTimeout(function () { + fn.apply(ctx, args); + }, ms); + }; +} + +function syncThemeColor(theme) { + var meta = document.getElementById("themeColorMeta"); + if (meta) + meta.setAttribute("content", theme === "light" ? "#f4f6f9" : "#0c0f1a"); +} + +function escapeHtml(str) { + var d = document.createElement("div"); + d.textContent = str; + return d.innerHTML; +} + +function setMainInert(isInert) { + var main = document.getElementById("main-content"); + if (!main) return; + if (isInert) main.setAttribute("inert", ""); + else main.removeAttribute("inert"); +} + +var modal = null; +var modalBody = null; +var modalClose = null; +var modalTitle = null; +var removeTrap = null; +var lastFocusedElement = null; +var currentCategory = "all"; +var currentSearchQuery = ""; +var playgroundActive = false; +var selectedSuggestionIndex = -1; +var projectCards = []; +var recentSearches = JSON.parse(localStorage.getItem("recentSearches") || "[]"); + +// ============================================ +// INFO MODAL FUNCTIONS +// ============================================ + +function showInfoModal(title, steps) { + var overlay = document.getElementById("infoModalOverlay"); + var titleEl = document.getElementById("infoModalTitle"); + var listEl = document.getElementById("infoModalList"); + + if (!overlay || !titleEl || !listEl) return; + + titleEl.textContent = title; + listEl.innerHTML = ""; // Safely clear the existing list + steps.forEach(function (step) { + const li = document.createElement("li"); + li.textContent = step; // textContent automatically escapes malicious scripts! + listEl.appendChild(li); }); - // Handle project modal close notification to re-filter (Fix: #601) - document.addEventListener("projectModalClosed", () => { - updateProjectVisibility(currentCategory, currentSearchQuery); - }); + overlay.classList.add("active"); - // Populate projects from Template if container is empty - if ( - projectsGrid && - projectsGrid.children.length === 0 && - projectsTemplate && - projectsTemplate.content - ) { - Array.from( - projectsTemplate.content.querySelectorAll(".project-card") - ).forEach((card) => { - projectsGrid.appendChild(card.cloneNode(true)); - }); + function closeModal() { + overlay.classList.remove("active"); + closeBtn.removeEventListener("click", closeModal); + gotItBtn.removeEventListener("click", closeModal); + overlay.removeEventListener("click", overlayClick); } - // Reference and sort project cards - const projectCards = projectsGrid - ? Array.from(projectsGrid.querySelectorAll(".project-card")) - : Array.from(document.querySelectorAll(".project-card")); + function overlayClick(e) { + if (e.target === overlay) closeModal(); + } - projectCards.sort((a, b) => { - const titleA = a.querySelector("h3")?.textContent || ""; - const titleB = b.querySelector("h3")?.textContent || ""; - return titleA.localeCompare(titleB, undefined, { - sensitivity: "base", - numeric: true, - }); - }); + var closeBtn = document.getElementById("infoModalClose"); + var gotItBtn = document.getElementById("infoModalGotIt"); - if (projectsGrid) { - projectCards.forEach((card) => projectsGrid.appendChild(card)); - } + closeBtn.addEventListener("click", closeModal); + gotItBtn.addEventListener("click", closeModal); + overlay.addEventListener("click", overlayClick); +} - // Wire up project cards (Favorites, Sharing, Play trigger) - projectCards.forEach((card) => { - const name = card.getAttribute("data-project"); - const cardActions = card.querySelector(".card-actions"); +var currentProjectName = ""; - // Favorites Button - const favBtn = document.createElement("button"); - favBtn.className = "btn-favorite"; - favBtn.setAttribute("aria-label", "Toggle favorite"); - favBtn.innerHTML = ''; +function setupModalInfoButton(projectName) { + currentProjectName = projectName; + var infoBtn = document.getElementById("modalInfoBtn"); + if (!infoBtn) return; - const favorites = JSON.parse(localStorage.getItem("favorites") || "[]"); - if (favorites.includes(name)) { - favBtn.classList.add("active"); - favBtn.innerHTML = ''; + // Remove old listener by cloning + var newBtn = infoBtn.cloneNode(true); + infoBtn.parentNode.replaceChild(newBtn, infoBtn); + + newBtn.addEventListener("click", function () { + if (typeof getProjectInstructions === "function") { + var info = getProjectInstructions(currentProjectName); + showInfoModal(info.title, info.steps); } + }); +} - favBtn.addEventListener("click", (e) => { - e.stopPropagation(); - const favs = JSON.parse(localStorage.getItem("favorites") || "[]"); - const idx = favs.indexOf(name); - if (idx === -1) { - favs.push(name); - favBtn.classList.add("active"); - favBtn.innerHTML = ''; - } else { - favs.splice(idx, 1); - favBtn.classList.remove("active"); - favBtn.innerHTML = ''; - if (currentCategory === "favorites") { - card.style.display = "none"; - } - } - localStorage.setItem("favorites", JSON.stringify(favs)); - }); +/* ── DOMContentLoaded ──────────────────────────────────────── */ +document.addEventListener("DOMContentLoaded", function () { + function repairLegacyHomeLayoutNow() { + var legacyHost = document.querySelector(".hero-code-snippets") + ? document.querySelector(".hero-code-snippets").closest(".hero-section") + : null; - if (cardActions) cardActions.appendChild(favBtn); + if (!legacyHost) { + return; + } - // Share Button - const shareBtn = document.createElement("button"); - shareBtn.className = "btn-share"; - shareBtn.setAttribute("aria-label", `Share ${name}`); - shareBtn.innerHTML = - ''; - shareBtn.title = "Copy shareable link"; + var allMains = Array.from(document.querySelectorAll("main#main-content")); + var visibleMain = allMains[1] || allMains[0] || null; + var timelineSectionNode = document.getElementById("timelineSection"); + var projectsSectionNode = document.getElementById("projectsSection"); + var playgroundSectionNode = document.getElementById("playgroundSection"); + var footerNode = document.querySelector("footer.footer"); + var backToTopNode = document.getElementById("backToTop"); + var infoModalNode = document.getElementById("infoModalOverlay"); + var sidebarDockNode = document.getElementById("mainSidebar"); + var mobileToggleNode = document.getElementById("mobileSidebarToggle"); + var heroControlsNode = document.querySelector(".hero-controls"); + var fragment = document.createDocumentFragment(); + + if (mobileToggleNode) fragment.appendChild(mobileToggleNode); + if (sidebarDockNode) fragment.appendChild(sidebarDockNode); + if (visibleMain) fragment.appendChild(visibleMain); + if (footerNode) fragment.appendChild(footerNode); + if (backToTopNode) fragment.appendChild(backToTopNode); + if (infoModalNode) fragment.appendChild(infoModalNode); + if (heroControlsNode) heroControlsNode.remove(); + + document.body.appendChild(fragment); + + legacyHost.classList.add("legacy-home-hero"); + } - shareBtn.addEventListener("click", (e) => { - e.stopPropagation(); - const url = - window.location.origin + - window.location.pathname + - "?project=" + - encodeURIComponent(name); - navigator.clipboard - .writeText(url) - .then(() => showToast("Link copied!")) - .catch(() => showToast(`Copy this: ${url}`)); - }); - - if (cardActions) cardActions.appendChild(shareBtn); - - // Play Button - const playBtns = card.querySelectorAll(".btn-play"); - playBtns.forEach((play) => { - play.setAttribute("aria-label", `Open ${name}`); - play.addEventListener("click", (e) => { - e.stopPropagation(); - openProjectSafe(name, play); - }); + repairLegacyHomeLayoutNow(); + window.addEventListener("load", repairLegacyHomeLayoutNow, { once: true }); + + var html = document.documentElement; + var themeToggle = document.querySelector(".sidebar-dock #themeToggle"); + var soundToggle = document.querySelector(".sidebar-dock #soundToggle"); + var backToTopButton = document.getElementById("backToTop"); + var searchInput = document.querySelector(".sidebar-dock #searchInput"); + var navSearchInput = document.getElementById("navSearchInput"); + var searchDropdown = document.getElementById("searchDropdown"); + var searchLoader = document.getElementById("searchLoader"); + var recentSearchesList = document.getElementById("recentSearchesList"); + var recentSearchesSection = document.getElementById("recentSearchesSection"); + var resultsList = document.getElementById("resultsList"); + var resultsSection = document.getElementById("resultsSection"); + var tipsSection = document.getElementById("tipsSection"); + var noResultsMessage = document.getElementById("noResultsMessage"); + var projectsSection = document.getElementById("projectsSection"); + var playgroundSection = document.getElementById("playgroundSection"); + var stickyFilterBar = document.getElementById("stickyFilterBar"); + var stickyTabs = document.querySelectorAll(".sticky-tab"); + var heroSection = document.querySelector(".hero-section"); + var cursorGlow = document.getElementById("cursorGlow"); + var heroProjectCounts = document.querySelectorAll("#heroProjectCount"); + var heroGameCounts = document.querySelectorAll("#heroGameCount"); + var heroMathCounts = document.querySelectorAll("#heroMathCount"); + var heroUtilityCounts = document.querySelectorAll("#heroUtilityCount"); + modal = document.getElementById("projectModal"); + modalBody = document.getElementById("modalBody"); + modalClose = document.getElementById("modalClose"); + modalTitle = document.getElementById("modalDialogTitle"); + var exploreBtn = document.getElementById("exploreBtn"); + var randomProjectBtn = document.getElementById("randomProjectBtn"); + var randomProjectBtnSidebar = document.querySelector( + ".projects-section #randomProjectBtnSidebar" + ); + var emptyState = document.getElementById("emptyState"); + var emptyStateHint = document.getElementById("emptyStateHint"); + var projectCountBadge = document.getElementById("projectCountBadge"); + var mobileMenuToggle = document.getElementById("mobileMenuToggle"); + var navControls = document.getElementById("navControls"); + var navbar = document.getElementById("mainNavbar"); + + function syncSearchInputs(value, sourceInput) { + [searchInput, navSearchInput].forEach(function (input) { + if (input && input !== sourceInput && input.value !== value) { + input.value = value; + } }); + } - // Card Click - card.addEventListener("click", (e) => { - if ( - e.target.closest(".btn-play") || - e.target.closest(".btn-favorite") || - e.target.closest(".btn-share") - ) { - return; - } - openProjectSafe(name, card); + function syncCountNodes(nodes, value) { + Array.from(nodes || []).forEach(function (node) { + node.textContent = value; }); + } - // Mouse Border Glow - if (!prefersReducedMotion()) { - card.addEventListener("mousemove", (e) => { - const rect = card.getBoundingClientRect(); - const x = ((e.clientX - rect.left) / rect.width) * 100; - const y = ((e.clientY - rect.top) / rect.height) * 100; - card.style.setProperty("--mouse-x", `${x}%`); - card.style.setProperty("--mouse-y", `${y}%`); - }); - } - }); + /* ── Theme Toggle ─────────────────────────────────────────── */ + function updateThemeToggleAria(isLight) { + if (!themeToggle) return; + themeToggle.setAttribute( + "aria-label", + isLight ? "Switch to dark mode" : "Switch to light mode" + ); + } - // Sound logic (safe wrapper) - function syncHeroControlsIcons() { - if (heroSoundToggle && soundToggle) { - const realSoundIcon = soundToggle.querySelector("i"); - const heroSoundIcon = heroSoundToggle.querySelector("i"); - if (realSoundIcon && heroSoundIcon) { - heroSoundIcon.className = realSoundIcon.className; - } - } + if (themeToggle) { + var savedTheme = localStorage.getItem("theme") || "dark"; + html.setAttribute("data-theme", savedTheme); + syncThemeColor(savedTheme); + // Prefer showing a sun icon when the site is dark (site's main theme). + // Show sun for dark theme, moon for light theme so reload displays sun by default. + themeToggle.innerHTML = + savedTheme === "dark" + ? '' + : ''; + updateThemeToggleAria(savedTheme === "light"); + + themeToggle.addEventListener("click", function () { + var current = html.getAttribute("data-theme"); + var next = current === "light" ? "dark" : "light"; + html.setAttribute("data-theme", next); + localStorage.setItem("theme", next); + syncThemeColor(next); + // After toggling, show sun when the new theme is dark, moon when it's light. + themeToggle.innerHTML = + next === "dark" + ? '' + : ''; + updateThemeToggleAria(next === "light"); + }); } - if (soundToggle) { - const updateSoundIcon = () => { - if (window.audioController) { - const isMuted = window.audioController.isMuted; - soundToggle.innerHTML = isMuted - ? '' - : ''; - soundToggle.setAttribute( - "aria-label", - isMuted ? "Unmute sound" : "Mute sound" - ); - } - }; + /* ── Sound Toggle ─────────────────────────────────────────── */ + if (soundToggle && window.audioController) { + function updateSoundIcon() { + soundToggle.innerHTML = window.audioController.isMuted + ? '' + : ''; + } updateSoundIcon(); - soundToggle.addEventListener("click", () => { - if ( - window.audioController && - typeof window.audioController.toggleMute === "function" - ) { + soundToggle.addEventListener("click", function () { + if (typeof window.audioController.toggleMute === "function") { window.audioController.toggleMute(); updateSoundIcon(); if ( @@ -258,45 +265,108 @@ document.addEventListener("DOMContentLoaded", function () { ) { window.audioController.play("click"); } - syncHeroControlsIcons(); } }); + } else if (soundToggle) { + soundToggle.addEventListener("click", function () { + var icon = soundToggle.querySelector("i"); + if (icon) + icon.className = + icon.className === "fas fa-volume-up" + ? "fas fa-volume-mute" + : "fas fa-volume-up"; + }); + } + + /* ── Hero Controls Mirror Toggles ─────────────────────────── */ + var heroSoundToggle = document.getElementById("heroSoundToggle"); + var heroThemeToggle = document.getElementById("heroThemeToggle"); + + function syncHeroControlsIcons() { + if (heroSoundToggle && soundToggle) { + var realSoundIcon = soundToggle.querySelector("i"); + var heroSoundIcon = heroSoundToggle.querySelector("i"); + if (realSoundIcon && heroSoundIcon) { + heroSoundIcon.className = realSoundIcon.className; + } + } + if (heroThemeToggle && themeToggle) { + var realThemeIcon = themeToggle.querySelector("i"); + var heroThemeIcon = heroThemeToggle.querySelector("i"); + if (realThemeIcon && heroThemeIcon) { + heroThemeIcon.className = realThemeIcon.className; + } + } } if (heroSoundToggle && soundToggle) { - heroSoundToggle.addEventListener("click", () => { + heroSoundToggle.addEventListener("click", function () { soundToggle.click(); setTimeout(syncHeroControlsIcons, 50); }); } - // Toast - function showToast(message) { - const existing = document.getElementById("shareToast"); - if (existing) existing.remove(); - const toast = document.createElement("div"); - toast.id = "shareToast"; - toast.className = "share-toast"; - toast.textContent = message; - document.body.appendChild(toast); - requestAnimationFrame(() => { - toast.classList.add("share-toast--visible"); + if (heroThemeToggle && themeToggle) { + heroThemeToggle.addEventListener("click", function () { + themeToggle.click(); + setTimeout(syncHeroControlsIcons, 50); + }); + } + + // Initial sync on load + setTimeout(syncHeroControlsIcons, 100); + + /* ── Mobile Sidebar Toggle ──────────────────────────────── */ + var mobileSidebarToggle = document.getElementById("mobileSidebarToggle"); + var mainSidebar = document.getElementById("mainSidebar"); + if (mobileSidebarToggle && mainSidebar) { + mobileSidebarToggle.addEventListener("click", function () { + var active = mainSidebar.classList.toggle("open"); + mobileSidebarToggle.setAttribute("aria-expanded", active); + var icon = mobileSidebarToggle.querySelector("i"); + if (icon) icon.className = active ? "fas fa-times" : "fas fa-bars"; + }); + + document.addEventListener("click", function (e) { + if ( + mainSidebar && + mobileSidebarToggle && + !mainSidebar.contains(e.target) && + e.target !== mobileSidebarToggle && + mainSidebar.classList.contains("open") + ) { + mainSidebar.classList.remove("open"); + mobileSidebarToggle.setAttribute("aria-expanded", "false"); + var icon = mobileSidebarToggle.querySelector("i"); + if (icon) icon.className = "fas fa-bars"; + } + }); + } + + /* ── Desktop Sidebar Toggle ──────────────────────────────── */ + var desktopSidebarToggle = document.getElementById("sidebarCollapseBtn"); + if (desktopSidebarToggle && mainSidebar) { + desktopSidebarToggle.addEventListener("click", function () { + var collapsed = mainSidebar.classList.toggle("collapsed"); + document.body.classList.toggle("sidebar-collapsed", collapsed); + + var icon = desktopSidebarToggle.querySelector("i"); + if (icon) { + icon.className = collapsed + ? "fas fa-chevron-right" + : "fas fa-chevron-left"; + } }); - setTimeout(() => { - toast.classList.remove("share-toast--visible"); - setTimeout(() => toast.remove(), 300); - }, 2500); } - // Back to Top Button if (backToTopButton) { - const toggleBackToTop = () => { + var toggleBackToTop = function () { backToTopButton.classList.toggle("visible", window.scrollY > 300); }; window.addEventListener("scroll", toggleBackToTop, { passive: true }); toggleBackToTop(); - backToTopButton.addEventListener("click", () => { + backToTopButton.addEventListener("click", function () { window.scrollTo({ top: 0, behavior: prefersReducedMotion() ? "auto" : "smooth", @@ -304,62 +374,261 @@ document.addEventListener("DOMContentLoaded", function () { }); } - // Cursor Glow + /* ── Cursor Glow ──────────────────────────────────────────── */ if ( cursorGlow && !prefersReducedMotion() && html.getAttribute("data-theme") !== "light" ) { - let glowTimeout; - document.addEventListener("pointermove", (e) => { - cursorGlow.style.left = `${e.clientX}px`; - cursorGlow.style.top = `${e.clientY}px`; + var glowTimeout; + document.addEventListener("pointermove", function (e) { + cursorGlow.style.left = e.clientX + "px"; + cursorGlow.style.top = e.clientY + "px"; cursorGlow.style.opacity = "0.5"; clearTimeout(glowTimeout); - glowTimeout = setTimeout(() => { + glowTimeout = setTimeout(function () { cursorGlow.style.opacity = "0"; }, 3000); }); - document.addEventListener("pointerleave", () => { + document.addEventListener("pointerleave", function () { cursorGlow.style.opacity = "0"; }); } - // Explore CTA Button - if (exploreBtn && projectsSection) { - exploreBtn.addEventListener("click", () => { - projectsSection.scrollIntoView({ behavior: "smooth", block: "start" }); + /* ── Gather Project Cards ─────────────────────────────────── */ + var projectsGrid = document.querySelector(".projects-grid"); + var projectsTemplate = document.getElementById("projectsTemplate"); + if ( + projectsGrid && + projectsGrid.children.length === 0 && + projectsTemplate && + projectsTemplate.content + ) { + Array.from( + projectsTemplate.content.querySelectorAll(".project-card") + ).forEach(function (card) { + projectsGrid.appendChild(card.cloneNode(true)); }); } - // Random Project Selector - function openRandomProject(trigger) { - const visible = projectCards.filter((c) => c.style.display !== "none"); - const pool = visible.length ? visible : projectCards; - const pick = pool[Math.floor(Math.random() * pool.length)]; - const name = pick.getAttribute("data-project"); - if (name) { - openProjectSafe(name, trigger); + projectCards = projectsGrid + ? Array.from(projectsGrid.querySelectorAll(".project-card")) + : Array.from(document.querySelectorAll(".project-card")); + + projectCards.sort(function (a, b) { + var titleA = (a.querySelector("h3") || {}).textContent || ""; + var titleB = (b.querySelector("h3") || {}).textContent || ""; + return titleA.localeCompare(titleB, undefined, { + sensitivity: "base", + numeric: true, + }); + }); + + if (projectsGrid) { + projectCards.forEach(function (card) { + projectsGrid.appendChild(card); + }); + } + + /* ── Hero Stats ───────────────────────────────────────────── */ + var totalCount = projectCards.length; + var gameCount = projectCards.filter(function (c) { + return c.getAttribute("data-category") === "games"; + }).length; + var mathCount = projectCards.filter(function (c) { + return c.getAttribute("data-category") === "math"; + }).length; + var utilityCount = projectCards.filter(function (c) { + return c.getAttribute("data-category") === "utilities"; + }).length; + + syncCountNodes(heroProjectCounts, String(totalCount)); + syncCountNodes(heroGameCounts, String(gameCount)); + syncCountNodes(heroMathCounts, String(mathCount)); + syncCountNodes(heroUtilityCounts, String(utilityCount)); + if (projectCountBadge) + projectCountBadge.textContent = String(totalCount) + " projects"; + + /* ── Explore Button ───────────────────────────────────────── */ + if (exploreBtn) { + exploreBtn.addEventListener("click", function () { + if (projectsSection) + projectsSection.scrollIntoView({ behavior: "smooth", block: "start" }); + }); + } + + /* ── Category Filtering ───────────────────────────────────── */ + var sidebarTabs = document.querySelectorAll(".sidebar-dock .sidebar-tab"); + var sidebarBadge = null; + + function applyCategoryFilter(category) { + if (category === "playground") return; + currentCategory = category; + syncSidebarTabs(category); + syncStickyTabs(category); + var visibleCount = 0; + var favorites = JSON.parse(localStorage.getItem("favorites") || "[]"); + projectCards.forEach(function (card) { + var cardCat = card.getAttribute("data-category"); + var projectName = card.getAttribute("data-project"); + var isFav = favorites.includes(projectName); + if ( + category === "all" || + (category === "favorites" && isFav) || + (category !== "favorites" && cardCat === category) + ) { + card.style.display = ""; + visibleCount++; + } else { + card.style.display = "none"; + } + }); + if (emptyState) { + emptyState.style.display = visibleCount === 0 ? "block" : "none"; + } + if (projectCountBadge) { + projectCountBadge.textContent = String(visibleCount) + " projects"; } } - if (randomProjectBtn) { - randomProjectBtn.addEventListener("click", () => - openRandomProject(randomProjectBtn) - ); + function syncSidebarTabs(category) { + sidebarTabs.forEach(function (st) { + var selected = st.getAttribute("data-category") === category; + st.classList.toggle("active", selected); + st.setAttribute("aria-selected", selected ? "true" : "false"); + st.setAttribute("tabindex", selected ? "0" : "-1"); + }); } - if (randomProjectBtnSidebar) { - randomProjectBtnSidebar.addEventListener("click", () => - openRandomProject(randomProjectBtnSidebar) - ); + + function syncStickyTabs(category) { + stickyTabs.forEach(function (st) { + var selected = st.getAttribute("data-sticky-category") === category; + st.classList.toggle("active", selected); + st.setAttribute("aria-selected", selected ? "true" : "false"); + st.setAttribute("tabindex", selected ? "0" : "-1"); + }); + } + + /* ── Playground Section Toggle ────────────────────────────── */ + function showProjectsSection() { + playgroundActive = false; + if (playgroundSection) playgroundSection.style.display = "none"; + if (projectsSection) projectsSection.style.display = ""; + if ( + window.playgroundAPI && + typeof window.playgroundAPI.deactivate === "function" + ) { + window.playgroundAPI.deactivate(); + } } - // Sticky Filter Bar Intersection Observer - if (stickyFilterBar && document.querySelector(".hero-section")) { - const heroSection = document.querySelector(".hero-section"); - const heroObserver = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { + function showPlaygroundSection() { + playgroundActive = true; + syncStickyTabs("playground"); + if (projectsSection) projectsSection.style.display = "none"; + if (playgroundSection) { + playgroundSection.style.display = ""; + if ( + window.playgroundAPI && + typeof window.playgroundAPI.activate === "function" + ) { + window.playgroundAPI.activate(); + } + } + } + + /* ── Sidebar Tabs ─────────────────────────────────────────── */ + sidebarTabs.forEach(function (st) { + st.addEventListener("click", function () { + var category = st.getAttribute("data-category"); + + var pageCategory = document.body.getAttribute("data-page"); + if (pageCategory) { + // We are on a subpage (games, math, or utilities) + if (category === pageCategory) { + var grid = document.getElementById("projectsGrid"); + if (grid) grid.scrollIntoView({ behavior: "smooth", block: "start" }); + return; + } + } + }); + st.addEventListener("click", function () { + var category = st.getAttribute("data-category"); + + var pageCategory = document.body.getAttribute("data-page"); + if (pageCategory && category !== pageCategory) { + var pageMap = { + all: "index.html", + games: "games.html", + math: "math.html", + utilities: "utilities.html", + favorites: "index.html?category=favorites", + playground: "index.html?category=playground", + }; + window.location.href = pageMap[category] || "index.html"; + return; + } + + // If we're already on the matching subpage, just scroll to the grid + if (pageCategory && category === pageCategory) { + var grid = document.getElementById("projectsGrid"); + if (grid) grid.scrollIntoView({ behavior: "smooth", block: "start" }); + return; + } + + syncSidebarTabs(category); + syncStickyTabs(category); + + if (category === "playground") { + showPlaygroundSection(); + if (playgroundSection) + playgroundSection.scrollIntoView({ + behavior: "smooth", + block: "start", + }); + } else { + showProjectsSection(); + applyCategoryFilter(category); + if (projectsSection) + projectsSection.scrollIntoView({ + behavior: "smooth", + block: "start", + }); + } + }); + }); + + /* ── Sticky Tabs ──────────────────────────────────────────── */ + stickyTabs.forEach(function (st) { + st.addEventListener("click", function () { + var category = st.getAttribute("data-sticky-category"); + syncStickyTabs(category); + syncSidebarTabs(category); + + if (category === "playground") { + showPlaygroundSection(); + if (playgroundSection) + playgroundSection.scrollIntoView({ + behavior: "smooth", + block: "start", + }); + } else { + showProjectsSection(); + applyCategoryFilter(category); + if (projectsSection) + projectsSection.scrollIntoView({ + behavior: "smooth", + block: "start", + }); + } + }); + }); + + /* ── Sticky Filter Bar Visibility ─────────────────────────── */ + if (stickyFilterBar && heroSection) { + var heroObserver = new IntersectionObserver( + function (entries) { + entries.forEach(function (entry) { stickyFilterBar.classList.toggle("visible", !entry.isIntersecting); }); }, @@ -369,247 +638,969 @@ document.addEventListener("DOMContentLoaded", function () { window.addEventListener( "scroll", - () => { - const navH = navbar ? navbar.getBoundingClientRect().height : 72; - stickyFilterBar.style.top = `${navH + 16}px`; + function () { + var navH = navbar ? navbar.getBoundingClientRect().height : 72; + stickyFilterBar.style.top = navH + 16 + "px"; }, { passive: true } ); } - // Serpentine SVG winding Timeline Path - const timelineItems = document.querySelectorAll( - ".timeline-item[data-reveal]" - ); - const timelineSection = document.getElementById("timelineSection"); - if (timelineItems.length && !prefersReducedMotion()) { - const timelineObserver = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - entry.target.classList.add("visible"); - } + /* ── Random Project ───────────────────────────────────────── */ + function openRandomProject(trigger) { + var visible = projectCards.filter(function (c) { + return c.style.display !== "none"; + }); + var pool = visible.length ? visible : projectCards; + var pick = pool[Math.floor(Math.random() * pool.length)]; + var name = pick.getAttribute("data-project"); + if (name && typeof openProjectSafe === "function") { + openProjectSafe(name, trigger); + } + } + + if (randomProjectBtn) { + randomProjectBtn.addEventListener("click", function () { + openRandomProject(randomProjectBtn); + }); + } + if (randomProjectBtnSidebar) { + randomProjectBtnSidebar.addEventListener("click", function () { + openRandomProject(randomProjectBtnSidebar); + }); + } + + /* ── Init sidebar ─────────────────────────────────────────── */ + var pageCategory = document.body.getAttribute("data-page"); + if (sidebarTabs.length) { + if (pageCategory) { + syncSidebarTabs(pageCategory); + } else { + syncSidebarTabs("all"); + } + } + if (stickyTabs.length) syncStickyTabs("all"); + + /* ── Sidebar Active Scroll Observer ───────────────────────── */ + if (!pageCategory && projectsSection) { + // On homepage, observe projectsSection to toggle sidebar-active class + var sidebarActiveObserver = new IntersectionObserver( + function (entries) { + entries.forEach(function (entry) { + var isVisible = + entry.isIntersecting || entry.boundingClientRect.top < 200; + document.body.classList.toggle("sidebar-active", isVisible); }); }, - { threshold: 0.25, rootMargin: "0px 0px -50px 0px" } + { threshold: 0.05 } ); + sidebarActiveObserver.observe(projectsSection); + } else if (pageCategory) { + // On subpages, always ensure sidebar is active + document.body.classList.add("sidebar-active"); + } - timelineItems.forEach((item) => timelineObserver.observe(item)); - - const svgNamespace = "http://www.w3.org/2000/svg"; - function getElementOffset(el, parent) { - let top = 0; - let left = 0; - let curr = el; - while (curr && curr !== parent) { - top += curr.offsetTop || 0; - left += curr.offsetLeft || 0; - curr = curr.offsetParent; + /* ═══════════════════════════════════════════════════════════════ + SEARCH + ═══════════════════════════════════════════════════════════════ */ + function getMatchingProjects(query) { + if (!query) return []; + var matches = []; + projectCards.forEach(function (card) { + var category = card.getAttribute("data-category"); + var title = (card.querySelector("h3") || {}).textContent || ""; + var desc = (card.querySelector("p") || {}).textContent || ""; + var tags = (card.getAttribute("data-tags") || "").toLowerCase(); + var q = query.toLowerCase(); + + var catMatch = currentCategory === "all" || category === currentCategory; + var searchMatch = + title.toLowerCase().includes(q) || + desc.toLowerCase().includes(q) || + tags.includes(q); + + if (catMatch && searchMatch) { + matches.push({ + card: card, + title: title, + tags: tags, + category: category, + }); } - return { top, left }; - } - - function rebuildTimelineSvg() { - const container = document.querySelector(".timeline-container"); - if (!container) return; - const dots = document.querySelectorAll(".timeline-dot"); - if (dots.length < 2) return; - - const containerWidth = container.offsetWidth; - const containerHeight = container.offsetHeight; - - let svg = document.getElementById("timelineSvg"); - if (!svg) { - svg = document.createElementNS(svgNamespace, "svg"); - svg.id = "timelineSvg"; - svg.setAttribute("class", "timeline-svg"); - - const defs = document.createElementNS(svgNamespace, "defs"); - const grad = document.createElementNS(svgNamespace, "linearGradient"); - grad.id = "timelineGrad"; - grad.setAttribute("x1", "0%"); - grad.setAttribute("y1", "0%"); - grad.setAttribute("x2", "0%"); - grad.setAttribute("y2", "100%"); - - const stop1 = document.createElementNS(svgNamespace, "stop"); - stop1.setAttribute("offset", "0%"); - stop1.setAttribute("stop-color", "var(--accent)"); - - const stop2 = document.createElementNS(svgNamespace, "stop"); - stop2.setAttribute("offset", "100%"); - stop2.setAttribute("stop-color", "#06b6d4"); - - grad.appendChild(stop1); - grad.appendChild(stop2); - defs.appendChild(grad); - - const mask = document.createElementNS(svgNamespace, "mask"); - mask.id = "timelineMask"; - - const maskPath = document.createElementNS(svgNamespace, "path"); - maskPath.id = "timelineMaskPath"; - maskPath.setAttribute("fill", "none"); - maskPath.setAttribute("stroke", "#ffffff"); - maskPath.setAttribute("stroke-width", "24"); - maskPath.setAttribute("stroke-linecap", "round"); - - mask.appendChild(maskPath); - defs.appendChild(mask); - svg.appendChild(defs); - - const track = document.createElementNS(svgNamespace, "path"); - track.id = "timelineSvgTrack"; - track.setAttribute("class", "timeline-svg-track"); - track.setAttribute("fill", "none"); - - const fill = document.createElementNS(svgNamespace, "path"); - fill.id = "timelineSvgFill"; - fill.setAttribute("class", "timeline-svg-fill"); - fill.setAttribute("fill", "none"); - fill.setAttribute("stroke", "var(--accent)"); - fill.setAttribute("mask", "url(#timelineMask)"); - - svg.appendChild(track); - svg.appendChild(fill); - - const grid = document.querySelector(".timeline-grid"); - container.insertBefore(svg, grid); + }); + return matches; + } + + function highlightText(container, text, query) { + var safe = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + var parts = text.split(new RegExp("(" + safe + ")", "gi")); + parts.forEach(function (part) { + if (part.toLowerCase() === query.toLowerCase()) { + var mark = document.createElement("mark"); + mark.style.background = "var(--accent-soft)"; + mark.style.color = "var(--accent)"; + mark.style.fontWeight = "600"; + mark.style.borderRadius = "2px"; + mark.style.padding = "0 2px"; + mark.textContent = part; + container.appendChild(mark); + } else if (part) { + container.appendChild(document.createTextNode(part)); } + }); + } + + function closeDropdown() { + if (searchDropdown) searchDropdown.classList.remove("active"); + } + + function renderRecentSearches() { + if (noResultsMessage) noResultsMessage.style.display = "none"; + if (!recentSearchesSection) return; + + if (recentSearches.length === 0) { + recentSearchesSection.style.display = "none"; + if (tipsSection) tipsSection.style.display = "block"; + if (resultsSection) resultsSection.style.display = "none"; + return; + } + + if (recentSearchesList) { + recentSearchesList.innerHTML = ""; + recentSearches.slice(0, 5).forEach(function (search) { + var item = document.createElement("div"); + item.className = "dropdown-recent-item"; + var text = document.createElement("div"); + text.className = "dropdown-recent-text"; + + var clock = document.createElement("i"); + clock.className = "fas fa-history"; + clock.style.opacity = "0.5"; + clock.style.fontSize = "0.8rem"; + + var label = document.createElement("span"); + label.textContent = search; + + text.append(clock, label); + + var removeBtn = document.createElement("button"); + removeBtn.className = "dropdown-recent-remove"; + removeBtn.setAttribute("aria-label", "Remove search"); + removeBtn.innerHTML = ''; + + item.append(text, removeBtn); + + label.addEventListener("click", function () { + syncSearchInputs(search, searchInput); + currentSearchQuery = search; + performSearch(); + closeDropdown(); + }); + + removeBtn.addEventListener("click", function (e) { + e.stopPropagation(); + recentSearches = recentSearches.filter(function (s) { + return s !== search; + }); + localStorage.setItem( + "recentSearches", + JSON.stringify(recentSearches) + ); + renderRecentSearches(); + }); - const coords = []; - dots.forEach((dot) => { - const offset = getElementOffset(dot, container); - const x = offset.left + dot.offsetWidth / 2; - const y = offset.top + dot.offsetHeight / 2; - coords.push({ x, y }); + recentSearchesList.appendChild(item); }); + } - let d = ""; - const startX = containerWidth / 2; - d += `M ${startX} 0 L ${coords[0].x} ${coords[0].y}`; + recentSearchesSection.style.display = "block"; + if (resultsSection) resultsSection.style.display = "none"; + if (tipsSection) tipsSection.style.display = "block"; + } - const W = Math.min(180, containerWidth * 0.16); + function renderSuggestions(query) { + if (searchLoader) searchLoader.style.display = "none"; + if (!query) { + renderRecentSearches(); + return; + } - for (let i = 0; i < coords.length - 1; i++) { - const pStart = coords[i]; - const pEnd = coords[i + 1]; - const H = pEnd.y - pStart.y; - const dy = H * 0.35; - const dx = i % 2 === 0 ? W : -W; + var matches = getMatchingProjects(query); - const cp1x = pStart.x + dx; - const cp1y = pStart.y + dy; - const cp2x = pEnd.x + dx; - const cp2y = pEnd.y - dy; + if (matches.length === 0) { + if (resultsSection) resultsSection.style.display = "none"; + if (recentSearchesSection) recentSearchesSection.style.display = "none"; + if (tipsSection) tipsSection.style.display = "block"; + if (noResultsMessage) noResultsMessage.style.display = "block"; + return; + } - d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${pEnd.x} ${pEnd.y}`; - } + if (noResultsMessage) noResultsMessage.style.display = "none"; + + if (resultsList) { + resultsList.innerHTML = ""; + matches.slice(0, 8).forEach(function (project, index) { + var item = document.createElement("div"); + item.className = + "dropdown-item" + + (index === selectedSuggestionIndex ? " selected" : ""); + + var iconBox = document.createElement("div"); + iconBox.className = "dropdown-item-icon"; + var banner = project.card.querySelector(".card-banner"); + if (banner) { + var img = document.createElement("img"); + img.src = banner.src; + img.alt = ""; + iconBox.appendChild(img); + } + + var titleBox = document.createElement("div"); + titleBox.className = "dropdown-item-text"; + highlightText(titleBox, project.title, query); - d += ` L ${coords[coords.length - 1].x} ${containerHeight}`; + var tag = document.createElement("span"); + tag.className = "dropdown-item-tag"; + tag.textContent = project.category; - const trackPath = document.getElementById("timelineSvgTrack"); - const fillPath = document.getElementById("timelineSvgFill"); - const maskPath = document.getElementById("timelineMaskPath"); - if (trackPath && fillPath && maskPath) { - trackPath.setAttribute("d", d); - fillPath.setAttribute("d", d); - maskPath.setAttribute("d", d); + item.append(iconBox, titleBox, tag); + item.addEventListener("click", function () { + selectSuggestion(project.title); + }); + item.addEventListener("mouseenter", function () { + selectedSuggestionIndex = index; + updateSuggestionHighlight(); + }); + resultsList.appendChild(item); + }); + } + + if (resultsSection) resultsSection.style.display = "block"; + if (recentSearchesSection) recentSearchesSection.style.display = "none"; + if (tipsSection) tipsSection.style.display = "none"; + selectedSuggestionIndex = -1; + } - const totalLength = maskPath.getTotalLength(); - maskPath.style.strokeDasharray = totalLength; - maskPath.dataset.totalLength = totalLength; + function updateSuggestionHighlight() { + if (!resultsList) return; + var items = resultsList.querySelectorAll(".dropdown-item"); + items.forEach(function (item, i) { + item.classList.toggle("selected", i === selectedSuggestionIndex); + }); + } + + function selectSuggestion(title) { + if (!searchInput) return; + searchInput.value = title; + currentSearchQuery = title.toLowerCase(); + performSearch(); + closeDropdown(); + if (projectsSection) { + projectsSection.scrollIntoView({ + behavior: prefersReducedMotion() ? "auto" : "smooth", + block: "start", + }); + } + } + + function performSearch() { + var query = currentSearchQuery; + if (!query) { + applyCategoryFilter(currentCategory); + if (emptyStateHint) + emptyStateHint.textContent = + "Try adjusting your search or category filter."; + return; + } + + if (currentCategory !== "all") { + currentCategory = "all"; + syncSidebarTabs("all"); + syncStickyTabs("all"); + } - updateTimelineFill(); + recentSearches = recentSearches.filter(function (s) { + return s !== query; + }); + recentSearches.unshift(query); + recentSearches = recentSearches.slice(0, 10); + localStorage.setItem("recentSearches", JSON.stringify(recentSearches)); + + var visibleCount = 0; + var favorites = JSON.parse(localStorage.getItem("favorites") || "[]"); + projectCards.forEach(function (card) { + var category = card.getAttribute("data-category"); + var title = (card.querySelector("h3") || {}).textContent || ""; + var desc = (card.querySelector("p") || {}).textContent || ""; + var tags = (card.getAttribute("data-tags") || "").toLowerCase(); + var projectName = card.getAttribute("data-project"); + var isFav = favorites.includes(projectName); + + var catMatch = + currentCategory === "all" || + (currentCategory === "favorites" && isFav) || + (currentCategory !== "favorites" && category === currentCategory); + var searchMatch = + title.toLowerCase().includes(query) || + desc.toLowerCase().includes(query) || + tags.includes(query); + + if (catMatch && searchMatch) { + card.style.display = ""; + visibleCount++; + } else { + card.style.display = "none"; + } + }); + + if (emptyState) { + emptyState.style.display = visibleCount === 0 ? "block" : "none"; + if (visibleCount === 0 && emptyStateHint) { + emptyStateHint.textContent = + 'No projects match "' + query + '". Try a different keyword.'; } } + if (projectCountBadge) + projectCountBadge.textContent = String(visibleCount) + " projects"; + } + + var searchInputs = [searchInput, navSearchInput].filter(Boolean); + if (searchInputs.length) { + var debouncedSearch = debounce(function (query) { + renderSuggestions(query); + }, 200); + + searchInputs.forEach(function (input) { + input.addEventListener("input", function (e) { + var rawValue = e.target.value; + var query = rawValue.trim().toLowerCase(); + syncSearchInputs(rawValue, e.target); + currentSearchQuery = query; + if (searchLoader) searchLoader.style.display = query ? "block" : "none"; + debouncedSearch(query); + performSearch(); + }); + + input.addEventListener("focus", function () { + if (input === searchInput && searchDropdown) + searchDropdown.classList.add("active"); + if (input === searchInput && !currentSearchQuery) + renderRecentSearches(); + }); + + input.addEventListener("keydown", function (e) { + if (e.key === "Escape") { + closeDropdown(); + input.blur(); + } + }); + }); + } + + document.addEventListener("click", function (e) { + if ( + searchDropdown && + searchInput && + !searchDropdown.contains(e.target) && + e.target !== searchInput && + e.target !== navSearchInput + ) { + closeDropdown(); + } + }); - function updateTimelineFill() { - if (!timelineSection) return; - const container = document.querySelector(".timeline-container"); - if (!container) return; + document.addEventListener("keydown", function (e) { + if ((e.ctrlKey || e.metaKey) && e.key === "k") { + e.preventDefault(); + if (navSearchInput) navSearchInput.focus(); + else if (searchInput) searchInput.focus(); + } + }); - const containerRect = container.getBoundingClientRect(); - const viewportCenterY = window.innerHeight / 2; - const relativeY = viewportCenterY - containerRect.top; - const offset = Math.max(0, Math.min(1, relativeY / containerRect.height)); + renderRecentSearches(); +}); +/* ═══════════════════════════════════════════════════════════════ + MODAL + ═══════════════════════════════════════════════════════════════ */ +function getFocusableElements(root) { + var sel = + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + return Array.from(root.querySelectorAll(sel)).filter(function (el) { + return ( + !el.closest('[aria-hidden="true"]') && + !el.classList.contains("visually-hidden") + ); + }); +} + +function trapFocus(modalEl) { + var handler = function (e) { + if (e.key !== "Tab" || !modalEl.classList.contains("active")) return; + var focusables = getFocusableElements(modalEl); + if (!focusables.length) return; + var first = focusables[0]; + var last = focusables[focusables.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus({ preventScroll: true }); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus({ preventScroll: true }); + } + }; + document.addEventListener("keydown", handler, true); + return function () { + document.removeEventListener("keydown", handler, true); + }; +} + +function openProjectSafe(name, trigger) { + if (!modal || !modalBody) return; + lastFocusedElement = trigger || document.activeElement; + modal.classList.add("active"); + modal.setAttribute("aria-hidden", "false"); + var scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; + document.body.style.paddingRight = scrollbarWidth + "px"; + document.body.style.overflow = "hidden"; + window.setMainInert(true); + + safeRun(function () { + if (typeof getProjectHTML === "function") { + const rawHTML = + getProjectHTML(name) || + '
Project content unavailable.
'; + + // Sanitize the HTML before it touches the DOM + if (window.DOMPurify) { + modalBody.innerHTML = DOMPurify.sanitize(rawHTML); + } else { + modalBody.textContent = "Security Error: Sanitizer failed to load."; + } + } else { + modalBody.innerHTML = + '
Project content unavailable.
'; + } + if (typeof initializeProject === "function") initializeProject(name); + setupModalInfoButton(name); + + // Inject info button next to the title (works for all projects) + var projectContent = modalBody.querySelector(".project-content"); + if (projectContent) { + // Try to find the title element (could be h2, or other heading) + var firstHeading = projectContent.querySelector( + "h2, h3, .resume-analyzer-copy h2, .pet-title" + ); - const maskPath = document.getElementById("timelineMaskPath"); - if (maskPath && maskPath.dataset.totalLength) { - const totalLength = parseFloat(maskPath.dataset.totalLength); - const dashoffset = totalLength - offset * totalLength; - maskPath.style.strokeDashoffset = Math.max( - 0, - Math.min(totalLength, dashoffset) + if (!firstHeading) { + // If no heading found, look for any element with a title-like class + firstHeading = projectContent.querySelector( + '[class*="title"], [class*="header"] h2' ); } - let activeIdx = -1; - const dots = document.querySelectorAll(".timeline-dot"); + if (firstHeading && !projectContent.querySelector(".inline-info-btn")) { + // Create info button + var infoBtn = document.createElement("button"); + infoBtn.className = "inline-info-btn"; + infoBtn.innerHTML = "ⓘ"; + infoBtn.setAttribute("aria-label", "How to use this project"); + + // Style the button + infoBtn.style.marginLeft = "12px"; + infoBtn.style.background = "none"; + infoBtn.style.border = "none"; + infoBtn.style.fontSize = "1.3rem"; + infoBtn.style.cursor = "pointer"; + infoBtn.style.color = "var(--accent)"; + infoBtn.style.verticalAlign = "middle"; + + infoBtn.addEventListener("click", function (e) { + e.stopPropagation(); + if (typeof getProjectInstructions === "function") { + var info = getProjectInstructions(name); + showInfoModal(info.title, info.steps); + } + }); - dots.forEach((dot, i) => { - const dotRect = dot.getBoundingClientRect(); - const dotCenterY = dotRect.top + dotRect.height / 2; - if (dotCenterY <= viewportCenterY) { - activeIdx = i; + // Make heading display inline if it's a block element + if (firstHeading.style.display !== "inline-block") { + firstHeading.style.display = "inline-block"; } - }); + firstHeading.appendChild(infoBtn); + } + } + }); + + removeTrap = trapFocus(modal); + var focusables = getFocusableElements(modalBody); + var firstFocusable = focusables[0] || modalClose; + if (firstFocusable && typeof firstFocusable.focus === "function") { + firstFocusable.focus({ preventScroll: true }); + } +} + +function closeProjectSafe() { + if (!modal || !modal.classList.contains("active")) return; + modal.classList.remove("active"); + modal.setAttribute("aria-hidden", "true"); + document.body.style.paddingRight = ""; + document.body.style.overflow = ""; + window.setMainInert(false); + if (removeTrap) { + removeTrap(); + removeTrap = null; + } + if (modalBody) { + modalBody.innerHTML = ""; + } + if (lastFocusedElement && typeof lastFocusedElement.focus === "function") { + lastFocusedElement.focus({ preventScroll: true }); + } + lastFocusedElement = null; +} + +if (modalClose) modalClose.addEventListener("click", closeProjectSafe); +if (modal) { + modal.addEventListener("click", function (e) { + if (e.target === modal) closeProjectSafe(); + }); +} +document.addEventListener("keydown", function (e) { + if (e.key === "Escape") closeProjectSafe(); +}); + +/* ── Expose for inline use ────────────────────────────────── */ +window.openProjectSafe = openProjectSafe; +window.closeProjectSafe = closeProjectSafe; + +/* ═══════════════════════════════════════════════════════════════ + WIRE PROJECT CARDS + ═══════════════════════════════════════════════════════════════ */ +projectCards.forEach(function (card) { + var name = card.getAttribute("data-project"); + + /* ── Favorite Button ──────────────────────────────────── */ + var favBtn = document.createElement("button"); + favBtn.className = "btn-favorite"; + favBtn.setAttribute("aria-label", "Toggle favorite"); + favBtn.innerHTML = ''; + + var favorites = JSON.parse(localStorage.getItem("favorites") || "[]"); + if (favorites.includes(name)) { + favBtn.classList.add("active"); + favBtn.innerHTML = ''; + } - timelineItems.forEach((item, i) => { - item.classList.toggle("active", i === activeIdx); + favBtn.addEventListener("click", function (e) { + e.stopPropagation(); + var favs = JSON.parse(localStorage.getItem("favorites") || "[]"); + var idx = favs.indexOf(name); + if (idx === -1) { + favs.push(name); + favBtn.classList.add("active"); + favBtn.innerHTML = ''; + } else { + favs.splice(idx, 1); + favBtn.classList.remove("active"); + favBtn.innerHTML = ''; + if (currentCategory === "favorites") { + card.style.display = "none"; + } + } + localStorage.setItem("favorites", JSON.stringify(favs)); + }); + + var cardActions = card.querySelector(".card-actions"); + if (cardActions) cardActions.appendChild(favBtn); + + /* ── Share Button ─────────────────────────────────────── */ + var shareBtn = document.createElement("button"); + shareBtn.className = "btn-share"; + shareBtn.setAttribute("aria-label", "Share " + name); + shareBtn.innerHTML = + ''; + shareBtn.title = "Copy shareable link"; + + shareBtn.addEventListener("click", function (e) { + e.stopPropagation(); + var url = + window.location.origin + + window.location.pathname + + "?project=" + + encodeURIComponent(name); + navigator.clipboard + .writeText(url) + .then(function () { + showToast("Link copied!"); + }) + .catch(function () { + showToast("Copy this: " + url); }); + }); + + if (cardActions) cardActions.appendChild(shareBtn); + + /* ── Play Button ──────────────────────────────────────── */ + var playBtns = card.querySelectorAll(".btn-play"); + playBtns.forEach(function (play) { + play.setAttribute("aria-label", "Open " + name); + play.addEventListener("click", function (e) { + e.stopPropagation(); + openProjectSafe(name, play); + }); + }); + + /* ── Card Click ───────────────────────────────────────── */ + card.addEventListener("click", function (e) { + if ( + e.target.closest(".btn-play") || + e.target.closest(".btn-favorite") || + e.target.closest(".btn-share") + ) + return; + openProjectSafe(name, card); + }); + + /* ── Card Mouse Tracking for Border Glow ──────────────── */ + if (!prefersReducedMotion()) { + card.addEventListener("mousemove", function (e) { + var rect = card.getBoundingClientRect(); + var x = ((e.clientX - rect.left) / rect.width) * 100; + var y = ((e.clientY - rect.top) / rect.height) * 100; + card.style.setProperty("--mouse-x", x + "%"); + card.style.setProperty("--mouse-y", y + "%"); + }); + } +}); + +/* ── Toast ─────────────────────────────────────────────────── */ +function showToast(message) { + var existing = document.getElementById("shareToast"); + if (existing) existing.remove(); + var toast = document.createElement("div"); + toast.id = "shareToast"; + toast.className = "share-toast"; + toast.textContent = message; + document.body.appendChild(toast); + requestAnimationFrame(function () { + toast.classList.add("share-toast--visible"); + }); + setTimeout(function () { + toast.classList.remove("share-toast--visible"); + setTimeout(function () { + toast.remove(); + }, 300); + }, 2500); +} + +/* ── URL params auto-open ──────────────────────────────────── */ +(function () { + var params = new URLSearchParams(window.location.search); + var projectParam = params.get("project"); + if (projectParam) { + var match = projectCards.find(function (c) { + return c.getAttribute("data-project") === projectParam; + }); + if (match) { + setTimeout(function () { + openProjectSafe(projectParam, match); + match.scrollIntoView({ behavior: "smooth", block: "center" }); + }, 300); } + } + var catParam = params.get("category"); + var valid = ["all", "games", "math", "utilities", "playground", "favorites"]; + if (catParam && valid.includes(catParam)) { + var tab = document.querySelector('[data-category="' + catParam + '"]'); + if (tab) + setTimeout(function () { + tab.click(); + }, 100); + } +})(); + +/* ═══════════════════════════════════════════════════════════════ + TIMELINE SCROLL REVEAL + ═══════════════════════════════════════════════════════════════ */ +var timelineItems = document.querySelectorAll(".timeline-item[data-reveal]"); +var timelineFill = document.getElementById("timelineFill"); +var timelineSection = document.getElementById("timelineSection"); + +if (timelineItems.length && !prefersReducedMotion()) { + var timelineObserver = new IntersectionObserver( + function (entries) { + entries.forEach(function (entry) { + if (entry.isIntersecting) { + entry.target.classList.add("visible"); + } + }); + }, + { threshold: 0.25, rootMargin: "0px 0px -50px 0px" } + ); - rebuildTimelineSvg(); - window.addEventListener("resize", debounce(rebuildTimelineSvg, 150)); - window.addEventListener("scroll", updateTimelineFill, { passive: true }); - } else if (timelineItems.length) { - timelineItems.forEach((item) => item.classList.add("visible")); + timelineItems.forEach(function (item) { + timelineObserver.observe(item); + }); + + /* ── Serpentine SVG Winding Timeline Path ─────────────────── */ + var svgNamespace = "http://www.w3.org/2000/svg"; + + function getElementOffset(el, parent) { + var top = 0; + var left = 0; + var curr = el; + while (curr && curr !== parent) { + top += curr.offsetTop || 0; + left += curr.offsetLeft || 0; + curr = curr.offsetParent; + } + return { top: top, left: left }; } - // Scroll reveal general elements - const revealItems = document.querySelectorAll(".reveal-on-scroll"); - if (revealItems.length && !prefersReducedMotion()) { - const revealObserver = new IntersectionObserver( - (entries, obs) => { - entries.forEach((entry) => { - if (!entry.isIntersecting) return; - entry.target.classList.add("is-visible"); - obs.unobserve(entry.target); - }); - }, - { threshold: 0.1, rootMargin: "0px 0px -50px 0px" } - ); - revealItems.forEach((item) => revealObserver.observe(item)); - } else { - revealItems.forEach((item) => item.classList.add("is-visible")); + function rebuildTimelineSvg() { + var container = document.querySelector(".timeline-container"); + if (!container) return; + var dots = document.querySelectorAll(".timeline-dot"); + if (dots.length < 2) return; + + var containerWidth = container.offsetWidth; + var containerHeight = container.offsetHeight; + + var svg = document.getElementById("timelineSvg"); + if (!svg) { + svg = document.createElementNS(svgNamespace, "svg"); + svg.id = "timelineSvg"; + svg.setAttribute("class", "timeline-svg"); + + var defs = document.createElementNS(svgNamespace, "defs"); + var grad = document.createElementNS(svgNamespace, "linearGradient"); + grad.id = "timelineGrad"; + grad.setAttribute("x1", "0%"); + grad.setAttribute("y1", "0%"); + grad.setAttribute("x2", "0%"); + grad.setAttribute("y2", "100%"); + + var stop1 = document.createElementNS(svgNamespace, "stop"); + stop1.setAttribute("offset", "0%"); + stop1.setAttribute("stop-color", "var(--accent)"); + + var stop2 = document.createElementNS(svgNamespace, "stop"); + stop2.setAttribute("offset", "100%"); + stop2.setAttribute("stop-color", "#06b6d4"); + + grad.appendChild(stop1); + grad.appendChild(stop2); + defs.appendChild(grad); + + // Define a dynamic layout mask path for progress crawling + var mask = document.createElementNS(svgNamespace, "mask"); + mask.id = "timelineMask"; + + var maskPath = document.createElementNS(svgNamespace, "path"); + maskPath.id = "timelineMaskPath"; + maskPath.setAttribute("fill", "none"); + maskPath.setAttribute("stroke", "#ffffff"); + maskPath.setAttribute("stroke-width", "24"); // Wide enough to fully cover glowing dots + maskPath.setAttribute("stroke-linecap", "round"); + + mask.appendChild(maskPath); + defs.appendChild(mask); + svg.appendChild(defs); + + var track = document.createElementNS(svgNamespace, "path"); + track.id = "timelineSvgTrack"; + track.setAttribute("class", "timeline-svg-track"); + track.setAttribute("fill", "none"); + + var fill = document.createElementNS(svgNamespace, "path"); + fill.id = "timelineSvgFill"; + fill.setAttribute("class", "timeline-svg-fill"); + fill.setAttribute("fill", "none"); + fill.setAttribute("stroke", "var(--accent)"); + fill.setAttribute("mask", "url(#timelineMask)"); + + svg.appendChild(track); + svg.appendChild(fill); + + var grid = document.querySelector(".timeline-grid"); + container.insertBefore(svg, grid); + } + + // Determine layout stable coordinate points for all timeline dots + var coords = []; + dots.forEach(function (dot) { + var offset = getElementOffset(dot, container); + var x = offset.left + dot.offsetWidth / 2; + var y = offset.top + dot.offsetHeight / 2; + coords.push({ x: x, y: y }); + }); + + // Create the winding path + var d = ""; + var startX = containerWidth / 2; + d += "M " + startX + " 0"; + d += " L " + coords[0].x + " " + coords[0].y; + + // Calculate a sweep width that is perfectly responsive + // e.g. 16% of container width, capped at 180px for desktop beauty + var W = Math.min(180, containerWidth * 0.16); + + for (var i = 0; i < coords.length - 1; i++) { + var pStart = coords[i]; + var pEnd = coords[i + 1]; + var H = pEnd.y - pStart.y; + var dy = H * 0.35; // Symmetrical control point height + + // Even segments (0, 2, 4...) snake to the right, odd segments to the left + var dx = i % 2 === 0 ? W : -W; + + var cp1x = pStart.x + dx; + var cp1y = pStart.y + dy; + var cp2x = pEnd.x + dx; + var cp2y = pEnd.y - dy; + + d += + " C " + + cp1x + + " " + + cp1y + + ", " + + cp2x + + " " + + cp2y + + ", " + + pEnd.x + + " " + + pEnd.y; + } + + // Straight exit to the bottom + d += " L " + coords[coords.length - 1].x + " " + containerHeight; + + var trackPath = document.getElementById("timelineSvgTrack"); + var fillPath = document.getElementById("timelineSvgFill"); + var maskPath = document.getElementById("timelineMaskPath"); + if (trackPath && fillPath && maskPath) { + trackPath.setAttribute("d", d); + fillPath.setAttribute("d", d); + maskPath.setAttribute("d", d); + + var totalLength = maskPath.getTotalLength(); + maskPath.style.strokeDasharray = totalLength; + maskPath.dataset.totalLength = totalLength; + + // Trigger scroll progress sync immediately + updateTimelineFill(); + } } - // Footer category quick-links - document.querySelectorAll(".footer-cat-link").forEach((a) => { - a.addEventListener("click", (e) => { - e.preventDefault(); - const cat = a.getAttribute("data-cat"); - const tab = document.querySelector( - `.sidebar-tab[data-category="${cat}"]` + /* ── Timeline Fill Progress ───────────────────────────── */ + function updateTimelineFill() { + if (!timelineSection) return; + var container = document.querySelector(".timeline-container"); + if (!container) return; + + var containerRect = container.getBoundingClientRect(); + var viewportCenterY = window.innerHeight / 2; + + // Calculate relative vertical scroll position of the viewport center relative to the container + var relativeY = viewportCenterY - containerRect.top; + var offset = Math.max(0, Math.min(1, relativeY / containerRect.height)); + + /* ── Dynamic SVG path mask scroll synchronization ──────── */ + var maskPath = document.getElementById("timelineMaskPath"); + if (maskPath && maskPath.dataset.totalLength) { + var totalLength = parseFloat(maskPath.dataset.totalLength); + var dashoffset = totalLength - offset * totalLength; + maskPath.style.strokeDashoffset = Math.max( + 0, + Math.min(totalLength, dashoffset) ); - if (tab) tab.click(); + } + + /* ── Activate item based on viewport center crossing timeline dots ── */ + var activeIdx = -1; + var dots = document.querySelectorAll(".timeline-dot"); + + dots.forEach(function (dot, i) { + var dotRect = dot.getBoundingClientRect(); + var dotCenterY = dotRect.top + dotRect.height / 2; + + // A dot is crossed/passed if its vertical center in the viewport is <= the viewport center + if (dotCenterY <= viewportCenterY) { + activeIdx = i; + } + }); + + timelineItems.forEach(function (item, i) { + item.classList.toggle("active", i === activeIdx); }); + } + + // Initialize SVG path layout recalculations on page render & resize + rebuildTimelineSvg(); + window.addEventListener("resize", debounce(rebuildTimelineSvg, 150)); + window.addEventListener("scroll", updateTimelineFill, { passive: true }); +} else if (timelineItems.length) { + timelineItems.forEach(function (item) { + item.classList.add("visible"); + }); +} + +/* ── Reveal on Scroll (general) ────────────────────────────── */ +var revealItems = document.querySelectorAll(".reveal-on-scroll"); +if (revealItems.length && !prefersReducedMotion()) { + var revealObserver = new IntersectionObserver( + function (entries, obs) { + entries.forEach(function (entry) { + if (!entry.isIntersecting) return; + entry.target.classList.add("is-visible"); + obs.unobserve(entry.target); + }); + }, + { threshold: 0.1, rootMargin: "0px 0px -50px 0px" } + ); + revealItems.forEach(function (item) { + revealObserver.observe(item); }); +} else { + revealItems.forEach(function (item) { + item.classList.add("is-visible"); + }); +} - // Scroll progress bar - const progressBar = document.getElementById("scrollProgressBar"); - if (progressBar) { - let ticking = false; - const updateScrollProgress = () => { - const scrollTop = window.scrollY || document.documentElement.scrollTop; - const docHeight = - document.documentElement.scrollHeight - - document.documentElement.clientHeight; - const progress = docHeight ? (scrollTop / docHeight) * 100 : 0; - progressBar.style.width = `${progress}%`; - ticking = false; - }; +document.querySelectorAll(".footer-cat-link").forEach(function (link) { + link.addEventListener("click", function (e) { + e.preventDefault(); + + var cat = link.getAttribute("data-cat"); + var tab = document.querySelector( + '.sidebar-tab[data-category="' + cat + '"]' + ); + if (tab) tab.click(); + }); +}); + +/* ── Scroll Progress Bar ───────────────────────────── */ + +var progressBar = document.getElementById("scrollProgressBar"); + +if (progressBar) { + let ticking = false; + + function updateScrollProgress() { + var scrollTop = window.scrollY || document.documentElement.scrollTop; + + var docHeight = + document.documentElement.scrollHeight - + document.documentElement.clientHeight; + + var progress = docHeight ? (scrollTop / docHeight) * 100 : 0; + + progressBar.style.width = progress + "%"; + + ticking = false; + } window.addEventListener("scroll", () => { if (!ticking) { diff --git a/web-app/js/projects/armstrong.js b/web-app/js/projects/armstrong.js index 101b1f4..15b985a 100644 --- a/web-app/js/projects/armstrong.js +++ b/web-app/js/projects/armstrong.js @@ -1,5 +1,5 @@ function getArmstrongHTML() { - return ` + return `

💎 Armstrong Number Checker

Check if a number equals the sum of its digits raised to the power of the number of digits

@@ -161,55 +161,63 @@ function getArmstrongHTML() { } function initArmstrong() { - const numberInput = document.getElementById('armstrongNumber'); - const checkBtn = document.getElementById('checkArmstrong'); - const resultDiv = document.getElementById('armstrongResult'); - const exampleBtns = document.querySelectorAll('.example-btn'); + const numberInput = document.getElementById("armstrongNumber"); + const checkBtn = document.getElementById("checkArmstrong"); + const resultDiv = document.getElementById("armstrongResult"); + const exampleBtns = document.querySelectorAll(".example-btn"); - function showError(msg) { - resultDiv.textContent = ` -

- ⚠️ ${msg} -

- `; - } + function showError(msg) { + // 1. Safely clear the div + resultDiv.innerHTML = ""; + + // 2. Create the paragraph element in memory + const errorText = document.createElement("p"); + errorText.style.color = "var(--danger-color)"; + errorText.style.fontWeight = "600"; - function checkArmstrong() { - const raw = numberInput.value.trim(); + // 3. Inject the message using textContent (this neutralizes any scripts!) + errorText.textContent = `⚠️ ${msg}`; - // ❌ Empty input check - if (raw === '') { - showError("Please enter a number!"); - return; - } + // 4. Append the safe element to the DOM + resultDiv.appendChild(errorText); + } + + function checkArmstrong() { + const raw = numberInput.value.trim(); + + // ❌ Empty input check + if (raw === "") { + showError("Please enter a number!"); + return; + } - const num = Number(raw); + const num = Number(raw); - // ❌ Invalid number check (NaN, decimals, negatives) - if (!Number.isFinite(num) || num < 0 || !Number.isInteger(num)) { - showError("Please enter a valid positive integer!"); - return; - } + // ❌ Invalid number check (NaN, decimals, negatives) + if (!Number.isFinite(num) || num < 0 || !Number.isInteger(num)) { + showError("Please enter a valid positive integer!"); + return; + } - const numStr = String(num); - const digits = numStr.split('').map(Number); - const power = digits.length; + const numStr = String(num); + const digits = numStr.split("").map(Number); + const power = digits.length; - let sum = 0; - const calculations = []; + let sum = 0; + const calculations = []; - digits.forEach(d => { - const p = Math.pow(d, power); - sum += p; - calculations.push({ digit: d, power: p }); - }); + digits.forEach((d) => { + const p = Math.pow(d, power); + sum += p; + calculations.push({ digit: d, power: p }); + }); - const isArmstrong = sum === num; + const isArmstrong = sum === num; - resultDiv.textContent = ` + resultDiv.innerHTML = `
-
- ${isArmstrong ? '✅ Armstrong Number!' : '❌ Not an Armstrong Number'} +
+ ${isArmstrong ? "✅ Armstrong Number!" : "❌ Not an Armstrong Number"}
@@ -219,20 +227,25 @@ function initArmstrong() {
- ${calculations.map(c => ` + ${calculations + .map( + (c) => `
${c.digit}
${c.digit}^${power} = ${c.power}
- `).join('')} + ` + ) + .join("")}
- Sum: ${calculations.map(c => c.power).join(' + ')} = ${sum} + Sum: ${calculations.map((c) => c.power).join(" + ")} = ${sum}
- ${isArmstrong + ${ + isArmstrong ? `✓ ${sum} = ${num}` : `✗ ${sum} ≠ ${num}` } @@ -240,23 +253,23 @@ function initArmstrong() {
`; - } + } - // Click event - checkBtn.addEventListener('click', checkArmstrong); + // Click event + checkBtn.addEventListener("click", checkArmstrong); - // Enter key support - numberInput.addEventListener('keypress', (e) => { - if (e.key === 'Enter') { - checkArmstrong(); - } - }); + // Enter key support + numberInput.addEventListener("keypress", (e) => { + if (e.key === "Enter") { + checkArmstrong(); + } + }); - // Example buttons - exampleBtns.forEach(btn => { - btn.addEventListener('click', () => { - numberInput.value = btn.dataset.num; - checkArmstrong(); - }); + // Example buttons + exampleBtns.forEach((btn) => { + btn.addEventListener("click", () => { + numberInput.value = btn.dataset.num; + checkArmstrong(); }); -} \ No newline at end of file + }); +}