1234567const KINDS = [8 [/\b(release|version|bump|tag)\b/i, "accent", 1],9 [/\b(fix|bug|patch|issue|crash|error|regress|revert)\b/i, "accent-dim", 0.8],10 [/\b(add|new|implement|feat|introduce|wire|support)\b/i, "accent", 1],11 [12 /\b(refactor|restructure|reorganize|extract|move|rename|split|cleanup|clean up)\b/i,13 "text",14 0.85,15 ],16 [/\b(perf|optim|speed|fast|slow|cache|memory|alloc)\b/i, "accent", 0.9],17 [/\b(test|spec|coverage|assert)\b/i, "text-secondary", 0.74],18 [/\b(doc|readme|comment|example|guide)\b/i, "text-secondary", 0.7],19 [/\b(polish|style|format|lint|whitespace|typo|wording|copy)\b/i, "text-secondary", 0.66],20 [/\b(chore|deps|update|upgrade|build|ci|config)\b/i, "text-secondary", 0.62],21];22232425function timeline() {26 const svg = document.querySelector("svg.timeline");27 if (!svg) return;28 const css = getComputedStyle(document.documentElement);29 const saves = [...svg.querySelectorAll(".dot")]30 .map((dot) => {31 const [id, message, meta] = (dot.querySelector("title")?.textContent ?? "").split("\n");32 const [, color, alpha] = KINDS.find(([kind]) => kind.test(message)) ?? [33 null,34 "text-secondary",35 0.8,36 ];37 const fill = css.getPropertyValue(`--${color}`).trim();38 return { href: dot.getAttribute("href"), id, message, meta, fill, alpha, t: +dot.dataset.t };39 })40 .sort((a, b) => a.t - b.t);41 if (saves.length === 0) return;42 const canvas = document.createElement("canvas");43 canvas.className = "timeline-canvas";44 canvas.setAttribute("role", "img");45 canvas.setAttribute("aria-label", svg.getAttribute("aria-label"));46 svg.replaceWith(canvas);47 const ctx = canvas.getContext("2d");48 const tip = document.createElement("div");49 tip.className = "timeline-tip";50 tip.hidden = true;51 document.body.append(tip);52 const reduce = matchMedia("(prefers-reduced-motion: reduce)");53 let [width, height, strip, radius, offset] = [0, 0, 0, 0, 0];54 55 56 57 const spacing = (seconds) => radius + 8 + Math.min(96, 12 * Math.log2(1 + seconds / 3600));58 const resize = () => {59 width = canvas.parentElement.getBoundingClientRect().width;60 61 height = 64;62 canvas.width = Math.round(width * devicePixelRatio);63 canvas.height = Math.round(height * devicePixelRatio);64 canvas.style.width = `${width}px`;65 canvas.style.height = `${height}px`;66 ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);67 radius = 5;68 let x = 0;69 saves.forEach((save, i) => {70 if (i > 0) x += spacing(save.t - saves[i - 1].t);71 save.x = x;72 });73 74 strip = Math.max(x + spacing(Number.POSITIVE_INFINITY), width);75 offset = x - width * 0.82;76 };77 78 const onScreen = (x, margin) => {79 const shift = ((offset % strip) + strip) % strip;80 const copies = [];81 for (let sx = x - shift - strip; sx < width + margin; sx += strip) {82 if (sx > -margin) copies.push(sx);83 }84 return copies;85 };86 const hit = (event) => {87 const box = canvas.getBoundingClientRect();88 const [mx, my] = [event.clientX - box.left, event.clientY - box.top];89 if (Math.abs(my - height / 2) > radius + 7) return null;90 return (91 saves.find((save) => onScreen(save.x, 0).some((x) => Math.abs(mx - x) < radius + 7)) ?? null92 );93 };94 let [hovered, drag, dragFrom, downX, lastX, velocity, resumeAt, lastFrame] = [95 null,96 null,97 0,98 0,99 0,100 0,101 0,102 0,103 ];104 const hover = (save, event) => {105 if (save !== hovered) {106 hovered = save;107 tip.hidden = save === null;108 if (save)109 tip.replaceChildren(110 line("tip-id", save.id),111 line("tip-msg", save.message),112 line("tip-meta", save.meta),113 );114 }115 if (save) {116 const left = event.clientX + 14;117 tip.style.left = `${left + 340 > innerWidth ? Math.max(8, event.clientX - 354) : left}px`;118 tip.style.top = `${event.clientY - 10}px`;119 }120 canvas.style.cursor = save ? "pointer" : drag === null ? "grab" : "grabbing";121 };122 const draw = (time) => {123 const dt = lastFrame ? Math.min((time - lastFrame) / 1000, 0.1) : 0;124 lastFrame = time;125 if (drag === null && hovered === null) {126 if (Math.abs(velocity) > 0.5) {127 offset -= velocity;128 velocity *= 0.92;129 } else if (!reduce.matches && time >= resumeAt) offset += 20 * dt;130 }131 const y = height / 2;132 ctx.clearRect(0, 0, width, height);133 ctx.lineWidth = 1;134 ctx.globalAlpha = 0.7;135 ctx.strokeStyle = css.getPropertyValue("--text-muted");136 ctx.setLineDash([4, 4]);137 ctx.beginPath();138 ctx.moveTo(0, y);139 ctx.lineTo(width, y);140 ctx.stroke();141 ctx.setLineDash([]);142 ctx.font = '10px "Martian Mono", monospace';143 ctx.textAlign = "center";144 for (const save of saves) {145 for (const x of onScreen(save.x, 10)) {146 ctx.globalAlpha = hovered === null ? save.alpha : hovered === save ? 1 : save.alpha * 0.6;147 ctx.fillStyle = save.fill;148 ctx.beginPath();149 ctx.arc(x, y, radius, 0, Math.PI * 2);150 ctx.fill();151 if (hovered === save) {152 ctx.fillStyle = css.getPropertyValue("--accent");153 ctx.fillText(save.id.slice(0, 6), x, y - radius - 6);154 }155 }156 }157 ctx.globalAlpha = 1;158 requestAnimationFrame(draw);159 };160 canvas.addEventListener("pointerdown", (event) => {161 [drag, dragFrom, downX, lastX, velocity] = [162 event.clientX,163 offset,164 event.clientX,165 event.clientX,166 0,167 ];168 canvas.setPointerCapture(event.pointerId);169 });170 canvas.addEventListener("pointermove", (event) => {171 if (drag !== null) {172 offset = dragFrom - (event.clientX - drag);173 velocity = velocity * 0.6 + (event.clientX - lastX) * 0.4;174 lastX = event.clientX;175 }176 if (event.pointerType === "mouse") hover(hit(event), event);177 });178 const release = () => {179 drag = null;180 resumeAt = performance.now() + 900;181 };182 canvas.addEventListener("pointerup", (event) => {183 release();184 const save = Math.abs(event.clientX - downX) > 4 ? null : hit(event);185 if (save) location.href = save.href;186 });187 canvas.addEventListener("pointercancel", release);188 canvas.addEventListener("pointerleave", (event) => {189 if (drag === null) hover(null, event);190 });191 addEventListener("resize", resize);192 resize();193 requestAnimationFrame(draw);194}195196function line(className, text) {197 const div = document.createElement("div");198 div.className = className;199 div.textContent = text ?? "";200 return div;201}202203function copyButtons(root) {204 for (const button of root.querySelectorAll("button[data-copy]")) {205 const label = button.textContent;206 button.hidden = false;207 button.addEventListener("click", async () => {208 await navigator.clipboard.writeText(button.dataset.copy);209 button.textContent = "copied";210 button.classList.add("copied");211 setTimeout(() => {212 button.textContent = label;213 button.classList.remove("copied");214 }, 1400);215 });216 }217}218219220221222async function fetchMain(url, init) {223 try {224 const response = await fetch(url, init);225 const page = new DOMParser().parseFromString(await response.text(), "text/html");226 const main = page.querySelector("main");227 for (const link of main?.querySelectorAll("a[href]:not([href^='#'])") ?? []) {228 link.href = new URL(link.getAttribute("href"), response.url || url).href;229 }230 return main;231 } catch {232 return null;233 }234}235236237function modified(event) {238 return event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;239}240241242243244245function panels() {246 const host = document.querySelector(".repo-panel");247 if (!host) return;248 let open = null;249 let shown = "";250 const load = async (url, init) => {251 shown = url;252 const main = await fetchMain(url, init);253 if (shown !== url) return;254 if (!main) {255 location.href = url;256 return;257 }258 main.querySelector(".breadcrumb")?.remove();259 main.querySelector(".file-page-title")?.remove();260 host.replaceChildren(...main.childNodes);261 copyButtons(host);262 host.hidden = false;263 };264 const inside = (url) => {265 const base = new URL(open.href).pathname;266 const { pathname } = new URL(url);267 return (pathname === base || pathname.startsWith(`${base}/`)) && !/\.[^/]*$/.test(pathname);268 };269 for (const tab of document.querySelectorAll(".repo-tab[data-panel]")) {270 tab.addEventListener("click", (event) => {271 if (modified(event)) return;272 event.preventDefault();273 if (open === tab && shown !== tab.href) {274 load(tab.href);275 return;276 }277 open?.classList.remove("active");278 if (open === tab) {279 open = null;280 shown = "";281 host.hidden = true;282 return;283 }284 open = tab;285 tab.classList.add("active");286 load(tab.href);287 });288 }289 host.addEventListener("click", (event) => {290 const link = event.target.closest("a[href]");291 if (!link || !open || modified(event) || link.hasAttribute("download")) return;292 if (!inside(link.href)) return;293 event.preventDefault();294 load(link.href);295 });296 host.addEventListener("submit", (event) => {297 const form = event.target;298 if (!open || !inside(form.action)) return;299 event.preventDefault();300 const method = form.method.toUpperCase();301 const data = new FormData(form);302 if (method === "GET") load(`${form.action}?${new URLSearchParams(data)}`);303 else load(form.action, { method, body: data });304 });305}306307308309function saveLists() {310 for (const list of document.querySelectorAll(".save-list")) {311 const observer = new IntersectionObserver(312 async ([entry]) => {313 const older = list.querySelector(".pager-older");314 if (!entry?.isIntersecting || !older) return;315 observer.disconnect();316 const main = await fetchMain(older.href);317 const next = main?.querySelector(`.save-list[data-list="${list.dataset.list}"]`);318 if (!next) return;319 next.querySelector(".pager a:not(.pager-older)")?.remove();320 list.querySelector(".pager").replaceWith(...next.childNodes);321 watch();322 },323 { root: list },324 );325 const watch = () => {326 const older = list.querySelector(".pager-older");327 if (older) observer.observe(older);328 };329 watch();330 }331}332333334function lineRanges() {335 const code = document.querySelector(".code");336 if (!code) return;337 let anchor = null;338 const mark = () => {339 for (const marked of code.querySelectorAll(".line.hl")) marked.classList.remove("hl");340 const match = /^#L(\d+)(?:-L(\d+))?$/.exec(location.hash);341 if (!match) return;342 const from = Number(match[1]);343 const to = Number(match[2] ?? match[1]);344 anchor = from;345 for (let n = Math.min(from, to); n <= Math.max(from, to); n++) {346 document.getElementById(`L${n}`)?.classList.add("hl");347 }348 document.getElementById(`L${Math.min(from, to)}`)?.scrollIntoView({ block: "center" });349 };350 code.addEventListener("click", (event) => {351 const link = event.target.closest(".ln");352 if (!link || !event.shiftKey || anchor === null) return;353 event.preventDefault();354 const n = Number(link.textContent);355 history.replaceState(null, "", `#L${Math.min(anchor, n)}-L${Math.max(anchor, n)}`);356 mark();357 });358 window.addEventListener("hashchange", mark);359 mark();360}361362363364function atlas() {365 const root = document.querySelector(".atlas");366 const filter = document.querySelector(".atlas-filter");367 if (!root || !filter) return;368 const panel = root.querySelector(".atlas-panel");369 const folders = [...root.querySelectorAll(".atlas-folder")];370 const files = [...root.querySelectorAll(".atlas-file")];371 372 root.style.height = `${root.offsetHeight}px`;373 root.style.overflowY = "hidden";374 const key = `kepr:atlas:${root.dataset.repo}`;375 const opened = new Set(remembered(key));376 377 const unfold = new URLSearchParams(location.search).get("open");378 const onPath = (path) => unfold !== null && `${unfold}/`.startsWith(`${path}/`);379 for (const folder of folders) {380 folder.open = opened.has(folder.dataset.path) || onPath(folder.dataset.path);381 folder.addEventListener("toggle", () => {382 if (filter.value.trim()) return;383 if (folder.open) opened.add(folder.dataset.path);384 else opened.delete(folder.dataset.path);385 remember(key, [...opened]);386 });387 }388 const panels = new Map();389 const panelFor = (path) => {390 if (!panels.has(path)) {391 const query = new URLSearchParams({ path });392 if (root.dataset.at) query.set("at", root.dataset.at);393 const text = fetch(`/${root.dataset.repo}/atlas/panel?${query}`)394 .then((response) => (response.ok ? response.text() : Promise.reject()))395 .catch(() => {396 panels.delete(path);397 return "";398 });399 panels.set(path, text);400 }401 return panels.get(path).then((text) => {402 const template = document.createElement("template");403 template.innerHTML = text;404 return template.content;405 });406 };407 let selected = null;408 const show = async (row) => {409 selected?.classList.remove("selected");410 selected = row;411 row.classList.add("selected");412 const content = await panelFor(row.dataset.path ?? `${row.parentElement.dataset.path}/`);413 if (selected === row) panel.replaceChildren(content);414 };415 for (const row of root.querySelectorAll(".atlas-row")) {416 row.addEventListener("pointerenter", (event) => {417 if (event.pointerType === "mouse") show(row);418 });419 row.addEventListener("focus", () => show(row));420 }421 422 const jump = (path) => {423 const row = path.endsWith("/")424 ? folders.find((folder) => `${folder.dataset.path}/` === path)?.firstElementChild425 : files.find((file) => file.dataset.path === path);426 if (!row) return false;427 for (const folder of folders) folder.open ||= path.startsWith(`${folder.dataset.path}/`);428 show(row);429 row.scrollIntoView({ block: "nearest" });430 return true;431 };432 panel.addEventListener("click", (event) => {433 const link = event.target.closest("a.atlas-jump");434 if (link && !modified(event) && jump(link.dataset.path)) event.preventDefault();435 });436 const viewer = fileViewer(root, jump, panelFor);437 for (const file of files) {438 file.addEventListener("click", (event) => {439 if (modified(event)) return;440 event.preventDefault();441 viewer(file);442 });443 }444 const target = folders.find((folder) => folder.dataset.path === unfold)?.firstElementChild;445 if (target) {446 show(target);447 target.scrollIntoView({ block: "center" });448 }449 filter.addEventListener("input", () => {450 const query = filter.value.trim().toLowerCase();451 for (const file of files) file.hidden = query !== "" && !file.dataset.name.includes(query);452 for (const folder of folders) {453 folder.hidden = query !== "" && !folder.querySelector(".atlas-file:not([hidden])");454 folder.open = query !== "" || opened.has(folder.dataset.path);455 }456 });457 458 const fold = document.querySelector(".atlas-fold-btn");459 const label = () => {460 fold.textContent = folders.every((folder) => folder.open) ? "collapse all" : "expand all";461 };462 fold.addEventListener("click", () => {463 const open = !folders.every((folder) => folder.open);464 for (const folder of folders) folder.open = open;465 label();466 });467 for (const folder of folders) folder.addEventListener("toggle", label);468 label();469 fold.hidden = folders.length === 0;470 471 const search = document.querySelector(".atlas-search-btn");472 search.addEventListener("click", () => {473 search.hidden = true;474 filter.hidden = false;475 filter.focus();476 });477 filter.addEventListener("blur", () => {478 if (filter.value.trim() !== "") return;479 filter.hidden = true;480 search.hidden = false;481 });482 search.hidden = false;483 panel.hidden = false;484}485486487488function atlasPicker() {489 const picker = document.querySelector(".atlas-picker");490 if (!picker) return;491 const filter = picker.querySelector(".atlas-picker-filter");492 const list = picker.querySelector(".atlas-picker-list");493 let rows = [];494 const narrow = () => {495 const query = filter.value.trim().toLowerCase();496 for (const row of rows) {497 row.hidden = query !== "" && !row.textContent.toLowerCase().includes(query);498 }499 };500 filter.addEventListener("input", narrow);501 let loading = null;502 picker.addEventListener("toggle", () => {503 if (!picker.open) return;504 filter.focus();505 if (loading) return;506 loading = fetch(list.dataset.src)507 .then((response) => (response.ok ? response.text() : Promise.reject()))508 .then((text) => {509 const template = document.createElement("template");510 template.innerHTML = text;511 list.replaceChildren(template.content);512 rows = [...list.children];513 narrow();514 })515 .catch(() => {516 loading = null;517 });518 });519 document.addEventListener("click", (event) => {520 if (!picker.contains(event.target)) picker.open = false;521 });522 picker.addEventListener("keydown", (event) => {523 if (event.key === "Escape") picker.open = false;524 });525 filter.hidden = false;526}527528529530531function fileViewer(root, jump, panelFor) {532 let [row, size, wrap] = [null, 12, false];533 const overlay = element("div", "fv-overlay");534 overlay.tabIndex = -1;535 overlay.setAttribute("role", "dialog");536 overlay.setAttribute("aria-modal", "true");537 const close = () => {538 overlay.remove();539 row?.focus();540 row = null;541 };542 const step = (by) => {543 const rows = [...root.querySelectorAll(".atlas-file")].filter((file) => !file.hidden);544 const next = rows[rows.indexOf(row) + by];545 if (next) open(next);546 };547 const restyle = () => {548 overlay.style.setProperty("--fv-size", `${size}px`);549 overlay.classList.toggle("fv-wrap", wrap);550 };551 const open = async (file, url = file.href) => {552 row = file;553 const rows = [...root.querySelectorAll(".atlas-file")].filter((each) => !each.hidden);554 const index = rows.indexOf(file);555 const path = file.dataset.path;556 const name = path.split("/").pop();557 const raw = new URL(file.href);558 raw.pathname = raw.pathname.replace(/^(\/[^/]+)\/files\//, "$1/raw/");559 const button = (text, action, className = "fv-ctrl-btn") => {560 const b = element("button", className, text);561 b.type = "button";562 b.addEventListener("click", action);563 return b;564 };565 const link = (text, download) => {566 const a = element("a", "fv-ctrl-btn", text);567 a.href = raw.href;568 if (download) a.download = name;569 return a;570 };571 const source = element("div", "fv-source", "loading…");572 const panel = element("aside", "fv-detail");573 panelFor(path).then((content) => {574 if (row === file) panel.replaceChildren(content);575 });576 const header = element("div", "fv-header");577 const title = element("div", "fv-title");578 title.append(element("span", "fv-name", name), element("span", "fv-path mono", path));579 const controls = element("div", "fv-controls mono");580 controls.append(581 button("A−", () => {582 size = Math.max(9, size - 1);583 restyle();584 }),585 button("A+", () => {586 size = Math.min(20, size + 1);587 restyle();588 }),589 button("⇌ wrap", (event) => {590 wrap = !wrap;591 event.currentTarget.classList.toggle("active", wrap);592 restyle();593 }),594 link("raw", false),595 link("download", true),596 );597 controls.children[2].classList.toggle("active", wrap);598 const nav = element("div", "fv-nav mono");599 const back = button("←", () => step(-1), "fv-ctrl-btn");600 const forward = button("→", () => step(1), "fv-ctrl-btn");601 back.disabled = index <= 0;602 forward.disabled = index >= rows.length - 1;603 nav.append(back, element("span", "fv-pos", `${index + 1}/${rows.length}`), forward);604 header.append(button("×", close, "fv-close"), title, controls, nav);605 const body = element("div", "fv-body");606 body.append(source, panel);607 overlay.replaceChildren(header, body);608 restyle();609 if (!overlay.isConnected) document.body.append(overlay);610 overlay.focus();611 const main = await fetchMain(url);612 if (row !== file) return;613 if (!main) {614 location.href = url;615 return;616 }617 for (const heading of main.querySelectorAll(".breadcrumb, .file-page-title")) heading.remove();618 main.querySelector(".file-meta:not(:last-child)")?.remove();619 source.replaceChildren(...main.childNodes);620 };621 overlay.addEventListener("keydown", (event) => {622 if (event.key === "Escape") close();623 else if (event.key === "ArrowLeft" || event.key === "ArrowRight") {624 event.preventDefault();625 step(event.key === "ArrowLeft" ? -1 : 1);626 }627 });628 629 overlay.addEventListener("click", (event) => {630 const link = event.target.closest(".fv-detail a.atlas-jump");631 if (!link || modified(event)) return;632 const path = link.dataset.path;633 const file = [...root.querySelectorAll(".atlas-file")].find((f) => f.dataset.path === path);634 event.preventDefault();635 if (file) open(file);636 else {637 close();638 jump(path);639 }640 });641 642 overlay.addEventListener("click", (event) => {643 const link = event.target.closest(".fv-source a[href]");644 if (!link || !row || modified(event)) return;645 if (new URL(link.href).pathname !== new URL(row.href).pathname) return;646 event.preventDefault();647 open(row, link.href);648 });649 return open;650}651652async function navbarLogin() {653 const link = document.querySelector('.nav-links a[href^="/sign-in"]');654 if (!link || !/(^|;\s*)kepr_editor=1(?:;|$)/.test(document.cookie)) return;655 try {656 const response = await fetch("/admin", { cache: "no-store" });657 if (!response.ok) return;658 const doc = new DOMParser().parseFromString(await response.text(), "text/html");659 const token = doc.querySelector('form[action="/admin/sign-out"] input[name="form"]');660 if (!token) return;661 const form = element("form", "nav-logout");662 form.method = "post";663 form.action = "/admin/sign-out";664 const button = element("button", "", "log out");665 button.type = "submit";666 form.append(token, button);667 form.addEventListener("submit", async (event) => {668 event.preventDefault();669 button.disabled = true;670 try {671 const result = await fetch(form.action, { method: "POST", body: new FormData(form) });672 if (!result.ok) throw new Error("Log out failed. Reload and try again.");673 location.href = "/";674 } catch (error) {675 button.disabled = false;676 button.textContent = "try log out again";677 console.error(error);678 }679 });680 const admin = element("a", "", "admin");681 admin.href = "/admin";682 link.replaceWith(admin, form);683 } catch (error) {684 console.error("Could not check navbar sign-in state", error);685 }686}687688689690function navMenu() {691 const nav = document.querySelector("body > nav");692 const links = nav?.querySelector(".nav-links");693 if (!links) return;694 links.id = "nav-links";695 const button = element("button", "nav-toggle");696 button.type = "button";697 button.setAttribute("aria-label", "menu");698 button.setAttribute("aria-controls", "nav-links");699 button.setAttribute("aria-expanded", "false");700 button.append(element("span", ""), element("span", ""), element("span", ""));701 const open = () => nav.classList.contains("nav--open");702 const set = (opened) => {703 nav.classList.toggle("nav--open", opened);704 button.setAttribute("aria-expanded", String(opened));705 };706 button.addEventListener("click", () => set(!open()));707 document.addEventListener("click", (event) => {708 if (open() && !nav.contains(event.target)) set(false);709 });710 document.addEventListener("keydown", (event) => {711 if (event.key !== "Escape" || !open()) return;712 set(false);713 button.focus();714 });715 nav.classList.add("nav--menu");716 717 links.before(button);718}719720function element(tag, className, text) {721 const node = document.createElement(tag);722 node.className = className;723 if (text !== undefined) node.textContent = text;724 return node;725}726727728729730731async function repoEditor() {732 const head = document.querySelector(".repo-head");733 if (!head || !/(^|;\s*)kepr_editor=1/.test(document.cookie)) return;734 const base = `/admin/repos/${location.pathname.split("/")[1]}`;735 const response = await fetch(`${base}/text`).catch(() => null);736 if (!response?.ok) return;737 const texts = await response.json();738 const details = document.querySelector(".repo-details");739 const places = [740 ["description", head.querySelector(".repo-desc"), head.querySelector(".repo-desc")],741 ["details", details, details.querySelector(".repo-details-body")],742 ];743 for (const [field, section, body] of places) {744 section.hidden = false;745 if (field === "description") {746 section.tabIndex = 0;747 section.setAttribute("role", "button");748 section.setAttribute("aria-label", "edit repository description");749 section.classList.add("repo-desc-editable");750 if (!section.textContent.trim()) section.textContent = "add a description";751 const activate = (event) => {752 if (event.target.closest(".editor") || body.querySelector(".editor")) return;753 event.preventDefault();754 editText(base, texts.form, field, body, texts[field]);755 };756 section.addEventListener("click", activate);757 section.addEventListener("keydown", (event) => {758 if (event.target === section && (event.key === "Enter" || event.key === " "))759 activate(event);760 });761 continue;762 }763 const button = element("button", "edit-btn mono", "edit");764 button.type = "button";765 (section.querySelector(".repo-details-head") ?? section).append(button);766 button.addEventListener("click", (event) => {767 event.preventDefault();768 if (!body.querySelector(".editor")) editText(base, texts.form, field, body, texts[field]);769 });770 }771}772773const MEDIA_MARKDOWN = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g;774const MEDIA_TYPES = "image/png,image/jpeg,image/gif,image/webp,video/mp4,video/webm";775776function editText(base, form, field, body, text) {777 const kept = [...body.childNodes];778 if (field === "description") {779 body.removeAttribute("role");780 body.removeAttribute("tabindex");781 body.removeAttribute("aria-label");782 }783 const input = element("textarea", "editor-input mono");784 input.value = text;785 input.rows = field === "details" ? 16 : 3;786 const preview = element("div", `editor-preview${field === "details" ? " markdown" : ""}`);787 const media = element("div", "editor-media mono");788 const status = element("span", "editor-status");789 const post = (path, fields) => {790 const data = new FormData();791 data.append("form", form);792 for (const [name, value] of Object.entries(fields)) data.append(name, value);793 return fetch(`${base}/${path}`, { method: "POST", body: data });794 };795 let timer = null;796 const changed = () => {797 inspect();798 clearTimeout(timer);799 timer = setTimeout(async () => {800 const answer = await post("preview", { field, value: input.value }).catch(() => null);801 if (!answer?.ok) return;802 803 preview.innerHTML = input.value.trim() ? await answer.text() : "";804 preview.dataset.empty = input.value.trim() ? "" : "the README's words show here";805 }, 250);806 };807 const wrap = (before, after = before, fallback = "text") => {808 const [from, to] = [input.selectionStart, input.selectionEnd];809 const chosen = input.value.slice(from, to) || fallback;810 input.setRangeText(before + chosen + after, from, to);811 input.setSelectionRange(from + before.length, from + before.length + chosen.length);812 input.focus();813 changed();814 };815 const prefix = (mark) => {816 const start = input.value.lastIndexOf("\n", input.selectionStart - 1) + 1;817 input.setRangeText(mark, start, start, "end");818 input.focus();819 changed();820 };821 const upload = async (file) => {822 status.textContent = `uploading ${file.name}…`;823 const answer = await post("media", { file }).catch(() => null);824 if (!answer?.ok) {825 status.textContent = answer ? await answer.text() : "upload failed";826 return;827 }828 const { url } = await answer.json();829 const alt = file.name.replace(/\.[^.]+$/, "").replace(/[[\]]/g, "");830 input.setRangeText(831 `\n\n`,832 input.selectionStart,833 input.selectionEnd,834 "end",835 );836 status.textContent = "";837 input.focus();838 changed();839 };840 const picker = element("input", "");841 picker.type = "file";842 picker.accept = MEDIA_TYPES;843 picker.hidden = true;844 picker.addEventListener("change", () => {845 for (const file of picker.files) upload(file);846 picker.value = "";847 });848 849 const underCaret = () =>850 [...input.value.matchAll(MEDIA_MARKDOWN)].find(851 (m) => input.selectionStart >= m.index && input.selectionStart <= m.index + m[0].length,852 );853 function inspect() {854 const found = field === "details" ? underCaret() : undefined;855 media.hidden = !found;856 if (!found) return;857 const words = (found[3] ?? "").split(/\s+/).filter(Boolean);858 const width = words.find((word) => word.startsWith("width="))?.slice(6) ?? "100";859 const align = words.find((word) => /^(left|center|right)$/.test(word)) ?? "center";860 const loop = words.includes("loop");861 const video = /\.(mp4|webm)$/i.test(found[2]);862 const set = (next) => {863 const options = [`width=${next.width}`, next.align, next.loop && "loop"].filter(Boolean);864 const markdown = `![${found[1]}](${found[2]} "${options.join(" ")}")`;865 const caret = input.selectionStart;866 input.setRangeText(markdown, found.index, found.index + found[0].length);867 input.setSelectionRange(caret, caret);868 changed();869 };870 const choice = (label, active, action) => {871 const b = element("button", `fv-ctrl-btn${active ? " active" : ""}`, label);872 b.type = "button";873 b.addEventListener("click", action);874 return b;875 };876 media.replaceChildren(877 element("span", "editor-label", video ? "video" : "picture"),878 ...["25", "33", "50", "66", "75", "100"].map((w) =>879 choice(`${w}%`, w === width, () => set({ width: w, align, loop })),880 ),881 ...["left", "center", "right"].map((a) =>882 choice(a, a === align, () => set({ width, align: a, loop })),883 ),884 ...(video ? [choice("loop like a GIF", loop, () => set({ width, align, loop: !loop }))] : []),885 );886 }887 const save = async (value) => {888 status.textContent = "saving…";889 const answer = await post("text", { field, value }).catch(() => null);890 if (answer?.ok) location.reload();891 else status.textContent = answer ? await answer.text() : "not saved: offline?";892 };893 const cancel = () => {894 body.replaceChildren(...kept);895 if (field === "description") {896 body.tabIndex = 0;897 body.setAttribute("role", "button");898 body.setAttribute("aria-label", "edit repository description");899 body.focus();900 }901 };902 const tool = (label, title, action) => {903 const b = element("button", "fv-ctrl-btn", label);904 b.type = "button";905 b.title = title;906 b.addEventListener("click", action);907 return b;908 };909 const toolbar = element("div", "editor-toolbar mono");910 toolbar.append(911 tool("B", "bold (⌘B)", () => wrap("**")),912 tool("I", "italic (⌘I)", () => wrap("_")),913 tool("link", "link (⌘K)", () => wrap("[", "](https://)")),914 tool("code", "code", () => wrap("`")),915 );916 if (field === "details") {917 toolbar.append(918 tool("heading", "heading", () => prefix("## ")),919 tool("list", "list", () => prefix("- ")),920 tool("quote", "quote", () => prefix("> ")),921 tool("picture or video", "add a picture, GIF or video (or drop or paste one)", () =>922 picker.click(),923 ),924 picker,925 );926 input.addEventListener("dragover", (event) => event.preventDefault());927 input.addEventListener("drop", (event) => {928 if (event.dataTransfer.files.length === 0) return;929 event.preventDefault();930 for (const file of event.dataTransfer.files) upload(file);931 });932 input.addEventListener("paste", (event) => {933 const files = [...event.clipboardData.files];934 if (files.length === 0) return;935 event.preventDefault();936 for (const file of files) upload(file);937 });938 }939 input.addEventListener("input", changed);940 for (const name of ["click", "keyup"]) input.addEventListener(name, inspect);941 input.addEventListener("keydown", (event) => {942 const command = event.metaKey || event.ctrlKey;943 if (event.key === "Escape") cancel();944 else if (command && event.key === "Enter") save(input.value);945 else if (command && event.key === "b") wrap("**");946 else if (command && event.key === "i") wrap("_");947 else if (command && event.key === "k") wrap("[", "](https://)");948 else return;949 event.preventDefault();950 });951 const actions = element("div", "editor-actions mono");952 actions.append(953 tool("save", "save (⌘↵)", () => save(input.value)),954 tool("cancel", "cancel (esc)", cancel),955 tool("use the README", "clear your words and show the README's again", () => save("")),956 status,957 );958 const panes = element("div", "editor-panes");959 panes.append(input, preview);960 const editor = element("div", `editor editor-${field}`);961 editor.append(toolbar, panes, media, actions);962 body.replaceChildren(editor);963 input.focus();964 changed();965}966967968969function remembered(key) {970 try {971 const value = JSON.parse(localStorage.getItem(key) ?? "[]");972 return Array.isArray(value) && value.every((path) => typeof path === "string") ? value : [];973 } catch {974 return [];975 }976}977978function remember(key, value) {979 try {980 localStorage.setItem(key, JSON.stringify(value));981 } catch {}982}983984985986function hexGlow() {987 const columns = document.querySelectorAll(".hex-col");988 if (columns.length === 0) return;989 const reduce = matchMedia("(prefers-reduced-motion: reduce)");990 let timer = null;991 const update = () => {992 clearInterval(timer);993 for (const column of columns) column.classList.remove("glow");994 if (reduce.matches) return;995 timer = setInterval(() => {996 const column = columns[Math.floor(Math.random() * columns.length)];997 if (document.querySelectorAll(".hex-col.glow").length >= 3) return;998 column.classList.add("glow");999 setTimeout(() => column.classList.remove("glow"), 3200);1000 }, 1500);1001 };1002 reduce.addEventListener("change", update);1003 update();1004}1005100610071008function tickerPace() {1009 for (const track of document.querySelectorAll(".ticker-track")) {1010 const speed = Number(getComputedStyle(track).getPropertyValue("--speed"));1011 if (speed > 0) track.style.animationDuration = `${track.scrollWidth / 2 / speed}s`;1012 }1013}101410151016document.fonts.ready.then(tickerPace);1017timeline();1018copyButtons(document);1019panels();1020saveLists();1021atlasPicker();1022repoEditor();1023navbarLogin();1024navMenu();1025lineRanges();1026atlas();1027hexGlow();