projects.js (5640B)
1 import { isEditableTarget } from "./dom.js"; 2 import { updateAppTitle } from "./header.js"; 3 4 const projectsHandlers = { 5 keydown: null, 6 }; 7 8 export function initProjects() { 9 const section = document.getElementById("projects"); 10 if (!section) return; 11 12 const items = Array.from(section.querySelectorAll(".list-item")); 13 if (items.length === 0) return; 14 15 const detailView = section.querySelector(".detail-view"); 16 17 let selectedIndex = 0; 18 19 function readmeScroller() { 20 if (!detailView) return null; 21 22 const readme = detailView.querySelector(".readme-content"); 23 if (readme && readme.scrollHeight > readme.clientHeight) { 24 return readme; 25 } 26 27 if (detailView.scrollHeight > detailView.clientHeight) { 28 return detailView; 29 } 30 31 return null; 32 } 33 34 function setSelected(selectedItem) { 35 items.forEach((item) => item.classList.toggle("selected", item === selectedItem)); 36 selectedIndex = items.indexOf(selectedItem); 37 if (selectedIndex < 0) selectedIndex = 0; 38 39 selectedItem.scrollIntoView({ behavior: "smooth", block: "nearest" }); 40 } 41 42 function updateProjectURL(projectID) { 43 if (!projectID) return; 44 const path = `/projects/${encodeURIComponent(projectID)}`; 45 if (window.location.pathname !== path) { 46 history.replaceState({ tab: "projects" }, "", path); 47 updateAppTitle(); 48 } 49 } 50 51 function loadDetailForItem(item) { 52 const url = item.getAttribute("hx-get"); 53 if (!url) return; 54 55 updateProjectURL(item.dataset.id); 56 57 if (window.htmx && typeof window.htmx.ajax === "function") { 58 window.htmx.ajax("GET", url, "#project-detail"); 59 return; 60 } 61 62 item.click(); 63 } 64 65 function selectByIndex(index, loadDetail) { 66 if (index < 0 || index >= items.length) return; 67 setSelected(items[index]); 68 if (loadDetail) loadDetailForItem(items[index]); 69 } 70 71 function copyCloneCommand() { 72 section.querySelector("[data-clone-copy]")?.click(); 73 } 74 75 items.forEach((item, index) => { 76 if (item.dataset.boundClick === "1") return; 77 item.dataset.boundClick = "1"; 78 item.addEventListener("click", () => { 79 selectedIndex = index; 80 setSelected(item); 81 updateProjectURL(item.dataset.id); 82 }); 83 }); 84 85 if (projectsHandlers.keydown) { 86 document.removeEventListener("keydown", projectsHandlers.keydown); 87 } 88 89 projectsHandlers.keydown = (e) => { 90 if (!section.isConnected) return; 91 if (isEditableTarget(e.target)) return; 92 if (window.isLightboxOpen?.()) return; 93 94 if ((e.key === "ArrowUp" || e.key === "ArrowDown") && e.shiftKey) { 95 const scroller = readmeScroller(); 96 if (scroller) { 97 e.preventDefault(); 98 scroller.scrollBy({ top: e.key === "ArrowUp" ? -80 : 80, behavior: "smooth" }); 99 return; 100 } 101 } 102 103 switch (e.key) { 104 case "ArrowUp": 105 e.preventDefault(); 106 selectByIndex(selectedIndex - 1, true); 107 break; 108 case "ArrowDown": 109 e.preventDefault(); 110 selectByIndex(selectedIndex + 1, true); 111 break; 112 case "Enter": 113 e.preventDefault(); 114 copyCloneCommand(); 115 break; 116 default: 117 break; 118 } 119 }; 120 121 document.addEventListener("keydown", projectsHandlers.keydown); 122 123 initCloneCopy(section); 124 125 const initialItem = section.querySelector(".list-item.selected") || items[0]; 126 setSelected(initialItem); 127 // /projects shows the first project, so the URL and title name it too. 128 if (window.location.pathname.replace(/\/+$/, "") === "/projects") { 129 updateProjectURL(initialItem?.dataset.id); 130 } 131 132 const detailContainer = document.getElementById("project-detail"); 133 const needsInitialFetch = detailContainer?.querySelector("[data-project-placeholder='true']"); 134 if (needsInitialFetch) { 135 setTimeout(() => loadDetailForItem(initialItem), 0); 136 } 137 } 138 139 const CLONE_PROTO_KEY = "clone-proto"; 140 141 function initCloneCopy(section) { 142 const bar = section.querySelector("[data-clone]"); 143 if (!bar || bar.dataset.bound === "true") return; 144 bar.dataset.bound = "true"; 145 146 const tabs = Array.from(bar.querySelectorAll(".clone-tab")); 147 const urlEl = bar.querySelector("[data-clone-url]"); 148 const copyButton = bar.querySelector("[data-clone-copy]"); 149 if (!tabs.length || !urlEl || !copyButton) return; 150 151 const select = (proto, persist) => { 152 const tab = tabs.find((t) => t.dataset.proto === proto) || tabs[0]; 153 tabs.forEach((t) => { 154 const on = t === tab; 155 t.classList.toggle("active", on); 156 t.setAttribute("aria-checked", on ? "true" : "false"); 157 }); 158 urlEl.textContent = tab.dataset.addr || ""; 159 if (persist) { 160 try { 161 window.localStorage.setItem(CLONE_PROTO_KEY, tab.dataset.proto || ""); 162 } catch { 163 // storage unavailable 164 } 165 } 166 }; 167 168 let saved = ""; 169 try { 170 saved = window.localStorage.getItem(CLONE_PROTO_KEY) || ""; 171 } catch { 172 // storage unavailable 173 } 174 select(saved || "https", false); 175 176 tabs.forEach((t) => t.addEventListener("click", () => select(t.dataset.proto, true))); 177 178 let resetTimer = 0; 179 copyButton.addEventListener("click", async () => { 180 const url = (urlEl.textContent || "").trim(); 181 if (!url) return; 182 183 let copied = false; 184 try { 185 await navigator.clipboard.writeText(url); 186 copied = true; 187 } catch { 188 copied = false; 189 } 190 191 copyButton.textContent = copied ? "copied" : "failed"; 192 copyButton.classList.toggle("copied", copied); 193 window.clearTimeout(resetTimer); 194 resetTimer = window.setTimeout(() => { 195 if (!copyButton.isConnected) return; 196 copyButton.textContent = "copy"; 197 copyButton.classList.remove("copied"); 198 }, 1200); 199 }); 200 }