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];2223242526async function timeline() {27 const svg = document.querySelector("svg.timeline");28 if (!svg) return;29 const response = await fetch(svg.dataset.src);30 if (!response.ok) return;31 const repo = svg.dataset.src.split("/")[1];32 const css = getComputedStyle(document.documentElement);33 const saves = (await response.json())34 .map(({ id, t, message, meta }) => {35 const [, color, alpha] = KINDS.find(([kind]) => kind.test(message)) ?? [36 null,37 "text-secondary",38 0.8,39 ];40 const fill = css.getPropertyValue(`--${color}`).trim();41 return { href: `/${repo}/saves/${id}`, id, message, meta, fill, alpha, t };42 })43 .sort((a, b) => a.t - b.t);44 if (saves.length === 0) return;45 const canvas = document.createElement("canvas");46 canvas.className = "timeline-canvas";47 canvas.setAttribute("role", "img");48 canvas.setAttribute("aria-label", svg.getAttribute("aria-label"));49 svg.replaceWith(canvas);50 const ctx = canvas.getContext("2d");51 const tip = document.createElement("div");52 tip.className = "timeline-tip";53 tip.hidden = true;54 document.body.append(tip);55 const reduce = matchMedia("(prefers-reduced-motion: reduce)");56 let [width, height, strip, radius, offset] = [0, 0, 0, 0, 0];57 58 59 60 const spacing = (seconds) => radius + 8 + Math.min(96, 12 * Math.log2(1 + seconds / 3600));61 const resize = () => {62 width = canvas.parentElement.getBoundingClientRect().width;63 64 height = 64;65 canvas.width = Math.round(width * devicePixelRatio);66 canvas.height = Math.round(height * devicePixelRatio);67 canvas.style.width = `${width}px`;68 canvas.style.height = `${height}px`;69 ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);70 radius = 5;71 let x = 0;72 saves.forEach((save, i) => {73 if (i > 0) x += spacing(save.t - saves[i - 1].t);74 save.x = x;75 });76 77 strip = Math.max(x + spacing(Number.POSITIVE_INFINITY), width);78 offset = x - width * 0.82;79 };80 81 const onScreen = (x, margin) => {82 const shift = ((offset % strip) + strip) % strip;83 const copies = [];84 for (let sx = x - shift - strip; sx < width + margin; sx += strip) {85 if (sx > -margin) copies.push(sx);86 }87 return copies;88 };89 const hit = (event) => {90 const box = canvas.getBoundingClientRect();91 const [mx, my] = [event.clientX - box.left, event.clientY - box.top];92 if (Math.abs(my - height / 2) > radius + 7) return null;93 return (94 saves.find((save) => onScreen(save.x, 0).some((x) => Math.abs(mx - x) < radius + 7)) ?? null95 );96 };97 let [hovered, drag, dragFrom, downX, lastX, velocity, resumeAt, lastFrame] = [98 null,99 null,100 0,101 0,102 0,103 0,104 0,105 0,106 ];107 const hover = (save, event) => {108 if (save !== hovered) {109 hovered = save;110 tip.hidden = save === null;111 if (save)112 tip.replaceChildren(113 line("tip-id", save.id),114 line("tip-msg", save.message),115 line("tip-meta", save.meta),116 );117 }118 if (save) {119 const left = event.clientX + 14;120 tip.style.left = `${left + 340 > innerWidth ? Math.max(8, event.clientX - 354) : left}px`;121 tip.style.top = `${event.clientY - 10}px`;122 }123 canvas.style.cursor = save ? "pointer" : drag === null ? "grab" : "grabbing";124 };125 const draw = (time) => {126 const dt = lastFrame ? Math.min((time - lastFrame) / 1000, 0.1) : 0;127 lastFrame = time;128 if (drag === null && hovered === null) {129 if (Math.abs(velocity) > 0.5) {130 offset -= velocity;131 velocity *= 0.92;132 } else if (!reduce.matches && time >= resumeAt) offset += 20 * dt;133 }134 const y = height / 2;135 ctx.clearRect(0, 0, width, height);136 ctx.lineWidth = 1;137 ctx.globalAlpha = 0.7;138 ctx.strokeStyle = css.getPropertyValue("--text-muted");139 ctx.setLineDash([4, 4]);140 ctx.beginPath();141 ctx.moveTo(0, y);142 ctx.lineTo(width, y);143 ctx.stroke();144 ctx.setLineDash([]);145 ctx.font = '10px "Martian Mono", monospace';146 ctx.textAlign = "center";147 for (const save of saves) {148 for (const x of onScreen(save.x, 10)) {149 ctx.globalAlpha = hovered === null ? save.alpha : hovered === save ? 1 : save.alpha * 0.6;150 ctx.fillStyle = save.fill;151 ctx.beginPath();152 ctx.arc(x, y, radius, 0, Math.PI * 2);153 ctx.fill();154 if (hovered === save) {155 ctx.fillStyle = css.getPropertyValue("--accent");156 ctx.fillText(save.id.slice(0, 6), x, y - radius - 6);157 }158 }159 }160 ctx.globalAlpha = 1;161 if (visible) requestAnimationFrame(draw);162 else drawing = false;163 };164 canvas.addEventListener("pointerdown", (event) => {165 [drag, dragFrom, downX, lastX, velocity] = [166 event.clientX,167 offset,168 event.clientX,169 event.clientX,170 0,171 ];172 canvas.setPointerCapture(event.pointerId);173 });174 canvas.addEventListener("pointermove", (event) => {175 if (drag !== null) {176 offset = dragFrom - (event.clientX - drag);177 velocity = velocity * 0.6 + (event.clientX - lastX) * 0.4;178 lastX = event.clientX;179 }180 if (event.pointerType === "mouse") hover(hit(event), event);181 });182 const release = () => {183 drag = null;184 resumeAt = performance.now() + 900;185 };186 canvas.addEventListener("pointerup", (event) => {187 release();188 const save = Math.abs(event.clientX - downX) > 4 ? null : hit(event);189 if (save) location.href = save.href;190 });191 canvas.addEventListener("pointercancel", release);192 canvas.addEventListener("pointerleave", (event) => {193 if (drag === null) hover(null, event);194 });195 addEventListener("resize", resize);196 resize();197 198 let [visible, drawing] = [true, false];199 new IntersectionObserver(([entry]) => {200 visible = entry.isIntersecting;201 if (visible && !drawing) {202 drawing = true;203 lastFrame = 0;204 requestAnimationFrame(draw);205 }206 }).observe(canvas);207}208209function line(className, text) {210 const div = document.createElement("div");211 div.className = className;212 div.textContent = text ?? "";213 return div;214}215216function copyButtons(root) {217 for (const button of root.querySelectorAll("button[data-copy]")) {218 const label = button.textContent;219 button.hidden = false;220 button.addEventListener("click", async () => {221 await navigator.clipboard.writeText(button.dataset.copy);222 button.textContent = "copied";223 button.classList.add("copied");224 setTimeout(() => {225 button.textContent = label;226 button.classList.remove("copied");227 }, 1400);228 });229 }230}231232233234235async function fetchMain(url, init) {236 try {237 const response = await fetch(url, init);238 const page = new DOMParser().parseFromString(await response.text(), "text/html");239 const main = page.querySelector("main");240 for (const link of main?.querySelectorAll("a[href]:not([href^='#'])") ?? []) {241 link.href = new URL(link.getAttribute("href"), response.url || url).href;242 }243 return main;244 } catch {245 return null;246 }247}248249250function modified(event) {251 return event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;252}253254255256257258function panels() {259 const host = document.querySelector(".repo-panel");260 if (!host) return;261 let open = null;262 let shown = "";263 const load = async (url, init) => {264 shown = url;265 const main = await fetchMain(url, init);266 if (shown !== url) return;267 if (!main) {268 location.href = url;269 return;270 }271 if (new URL(url).pathname === new URL(open.href).pathname) {272 main.querySelector(".breadcrumb")?.remove();273 main.querySelector(".file-page-title")?.remove();274 }275 host.replaceChildren(...main.childNodes);276 copyButtons(host);277 host.hidden = false;278 };279 const inside = (url) => {280 const base = new URL(open.href).pathname;281 const { pathname } = new URL(url);282 return (pathname === base || pathname.startsWith(`${base}/`)) && !/\.[^/]*$/.test(pathname);283 };284 for (const tab of document.querySelectorAll(".repo-tab[data-panel]")) {285 tab.addEventListener("click", (event) => {286 if (modified(event)) return;287 event.preventDefault();288 if (open === tab && shown !== tab.href) {289 load(tab.href);290 return;291 }292 open?.classList.remove("active");293 if (open === tab) {294 open = null;295 shown = "";296 host.hidden = true;297 return;298 }299 open = tab;300 tab.classList.add("active");301 load(tab.href);302 });303 }304 host.addEventListener("click", (event) => {305 const link = event.target.closest("a[href]");306 if (!link || !open || modified(event) || link.hasAttribute("download")) return;307 if (!inside(link.href)) return;308 event.preventDefault();309 load(link.href);310 });311 host.addEventListener("submit", (event) => {312 const form = event.target;313 if (!open || !inside(form.action)) return;314 event.preventDefault();315 const method = form.method.toUpperCase();316 const data = new FormData(form);317 if (method === "GET") load(`${form.action}?${new URLSearchParams(data)}`);318 else load(form.action, { method, body: data });319 });320}321322323324325326function issueReplies() {327 let picked = "";328 329 document.addEventListener(330 "pointerdown",331 (event) => {332 const post = event.target.closest?.(".issue-post-actions")?.closest(".issue-post");333 const selection = getSelection();334 picked = post?.contains(selection.anchorNode) ? selection.toString().trim() : "";335 },336 true,337 );338 document.addEventListener(339 "click",340 (event) => {341 const link = event.target.closest(".issue-post-actions a, .issue-composer-to .issues-cancel");342 const form = document.getElementById("reply");343 if (!link || !form || modified(event)) return;344 if (link.dataset.action === "quote" && !picked) return;345 event.preventDefault();346 event.stopPropagation();347 const post = link.closest(".issue-post");348 const to = form.querySelector(".issue-composer-to");349 form.elements.reply_to.value = post?.dataset.entry ?? "";350 to.hidden = !post?.dataset.entry;351 if (post) {352 const name = to.querySelector(".issue-composer-name");353 name.textContent = post.dataset.author;354 name.href = `#${post.id}`;355 }356 const body = form.elements.body;357 if (link.dataset.action === "quote") {358 const quote = `${picked.replace(/^/gm, "> ")}\n\n`;359 body.value = body.value.trim() ? `${body.value.trimEnd()}\n\n${quote}` : quote;360 picked = "";361 }362 body.focus();363 body.scrollIntoView({ block: "center" });364 },365 true,366 );367}368369370371function issueAttachments() {372 const fileInput = (target) =>373 target.closest?.("form")?.querySelector("input[type=file][name=files]") ?? null;374 const add = (input, files) => {375 const all = new DataTransfer();376 for (const file of [...input.files, ...files]) all.items.add(file);377 input.files = all.files;378 input.dispatchEvent(new Event("change", { bubbles: true }));379 };380 document.addEventListener("paste", (event) => {381 const input = fileInput(event.target);382 const files = [...(event.clipboardData?.files ?? [])];383 if (!input || files.length === 0) return;384 event.preventDefault();385 add(input, files);386 });387 document.addEventListener("dragover", (event) => {388 if (fileInput(event.target) && event.dataTransfer?.types.includes("Files")) {389 event.preventDefault();390 }391 });392 document.addEventListener("drop", (event) => {393 const input = fileInput(event.target);394 if (!input || !event.dataTransfer?.files.length) return;395 event.preventDefault();396 add(input, event.dataTransfer.files);397 });398 document.addEventListener("change", (event) => {399 const input = event.target;400 const list = input.closest?.("form")?.querySelector(".issue-attached");401 if (input.name !== "files" || !list) return;402 for (const image of list.querySelectorAll("img")) URL.revokeObjectURL(image.src);403 list.replaceChildren(404 ...[...input.files].map((file) => {405 const item = element("li", "", file.name);406 if (file.type.startsWith("image/")) {407 const image = element("img", "");408 image.src = URL.createObjectURL(file);409 image.alt = "";410 item.prepend(image);411 }412 return item;413 }),414 );415 });416}417418419420function saveLists() {421 for (const list of document.querySelectorAll(".save-list")) {422 const observer = new IntersectionObserver(423 async ([entry]) => {424 const older = list.querySelector(".pager-older");425 if (!entry?.isIntersecting || !older) return;426 observer.disconnect();427 const main = await fetchMain(older.href);428 const next = main?.querySelector(`.save-list[data-list="${list.dataset.list}"]`);429 if (!next) return;430 next.querySelector(".pager a:not(.pager-older)")?.remove();431 list.querySelector(".pager").replaceWith(...next.childNodes);432 watch();433 },434 { root: list },435 );436 const watch = () => {437 const older = list.querySelector(".pager-older");438 if (older) observer.observe(older);439 };440 watch();441 }442}443444445function lineRanges() {446 const code = document.querySelector(".code");447 if (!code) return;448 let anchor = null;449 const mark = () => {450 for (const marked of code.querySelectorAll(".line.hl")) marked.classList.remove("hl");451 const match = /^#L(\d+)(?:-L(\d+))?$/.exec(location.hash);452 if (!match) return;453 const from = Number(match[1]);454 const to = Number(match[2] ?? match[1]);455 anchor = from;456 for (let n = Math.min(from, to); n <= Math.max(from, to); n++) {457 document.getElementById(`L${n}`)?.classList.add("hl");458 }459 document.getElementById(`L${Math.min(from, to)}`)?.scrollIntoView({ block: "center" });460 };461 code.addEventListener("click", (event) => {462 const link = event.target.closest(".ln");463 if (!link || !event.shiftKey || anchor === null) return;464 event.preventDefault();465 const n = Number(link.textContent);466 history.replaceState(null, "", `#L${Math.min(anchor, n)}-L${Math.max(anchor, n)}`);467 mark();468 });469 window.addEventListener("hashchange", mark);470 mark();471}472473474475function atlas() {476 const root = document.querySelector(".atlas");477 const filter = document.querySelector(".atlas-filter");478 if (!root || !filter) return;479 const panel = root.querySelector(".atlas-panel");480 const folders = [...root.querySelectorAll(".atlas-folder")];481 const files = [...root.querySelectorAll(".atlas-file")];482 483 root.style.height = `${root.offsetHeight}px`;484 root.style.overflowY = "hidden";485 const key = `kepr:atlas:${root.dataset.repo}`;486 const opened = new Set(remembered(key));487 488 const unfold = new URLSearchParams(location.search).get("open");489 const onPath = (path) => unfold !== null && `${unfold}/`.startsWith(`${path}/`);490 for (const folder of folders) {491 folder.open = opened.has(folder.dataset.path) || onPath(folder.dataset.path);492 folder.addEventListener("toggle", () => {493 if (filter.value.trim()) return;494 if (folder.open) opened.add(folder.dataset.path);495 else opened.delete(folder.dataset.path);496 remember(key, [...opened]);497 });498 }499 const panels = new Map();500 const panelFor = (path) => {501 if (!panels.has(path)) {502 const query = new URLSearchParams({ path });503 if (root.dataset.at) query.set("at", root.dataset.at);504 const text = fetch(`/${root.dataset.repo}/atlas/panel?${query}`)505 .then((response) => (response.ok ? response.text() : Promise.reject()))506 .catch(() => {507 panels.delete(path);508 return "";509 });510 panels.set(path, text);511 }512 return panels.get(path).then((text) => {513 const template = document.createElement("template");514 template.innerHTML = text;515 return template.content;516 });517 };518 let selected = null;519 const show = async (row) => {520 selected?.classList.remove("selected");521 selected = row;522 row.classList.add("selected");523 const content = await panelFor(row.dataset.path ?? `${row.parentElement.dataset.path}/`);524 if (selected === row) panel.replaceChildren(content);525 };526 for (const row of root.querySelectorAll(".atlas-row")) {527 row.addEventListener("pointerenter", (event) => {528 if (event.pointerType === "mouse") show(row);529 });530 row.addEventListener("focus", () => show(row));531 }532 533 const jump = (path) => {534 const row = path.endsWith("/")535 ? folders.find((folder) => `${folder.dataset.path}/` === path)?.firstElementChild536 : files.find((file) => file.dataset.path === path);537 if (!row) return false;538 for (const folder of folders) folder.open ||= path.startsWith(`${folder.dataset.path}/`);539 show(row);540 row.scrollIntoView({ block: "nearest" });541 return true;542 };543 panel.addEventListener("click", (event) => {544 const link = event.target.closest("a.atlas-jump");545 if (link && !modified(event) && jump(link.dataset.path)) event.preventDefault();546 });547 const viewer = fileViewer(root, jump, panelFor);548 for (const file of files) {549 file.addEventListener("click", (event) => {550 if (modified(event)) return;551 event.preventDefault();552 viewer(file);553 });554 }555 const target = folders.find((folder) => folder.dataset.path === unfold)?.firstElementChild;556 if (target) {557 show(target);558 target.scrollIntoView({ block: "center" });559 }560 filter.addEventListener("input", () => {561 const query = filter.value.trim().toLowerCase();562 for (const file of files) file.hidden = query !== "" && !file.dataset.name.includes(query);563 for (const folder of folders) {564 folder.hidden = query !== "" && !folder.querySelector(".atlas-file:not([hidden])");565 folder.open = query !== "" || opened.has(folder.dataset.path);566 }567 });568 569 const fold = document.querySelector(".atlas-fold-btn");570 const label = () => {571 fold.textContent = folders.every((folder) => folder.open) ? "collapse all" : "expand all";572 };573 fold.addEventListener("click", () => {574 const open = !folders.every((folder) => folder.open);575 for (const folder of folders) folder.open = open;576 label();577 });578 for (const folder of folders) folder.addEventListener("toggle", label);579 label();580 fold.hidden = folders.length === 0;581 582 const search = document.querySelector(".atlas-search-btn");583 search.addEventListener("click", () => {584 search.hidden = true;585 filter.hidden = false;586 filter.focus();587 });588 filter.addEventListener("blur", () => {589 if (filter.value.trim() !== "") return;590 filter.hidden = true;591 search.hidden = false;592 });593 search.hidden = false;594 panel.hidden = false;595}596597598599function atlasPicker() {600 const picker = document.querySelector(".atlas-picker");601 if (!picker) return;602 const filter = picker.querySelector(".atlas-picker-filter");603 const list = picker.querySelector(".atlas-picker-list");604 let rows = [];605 const narrow = () => {606 const query = filter.value.trim().toLowerCase();607 for (const row of rows) {608 row.hidden = query !== "" && !row.textContent.toLowerCase().includes(query);609 }610 };611 filter.addEventListener("input", narrow);612 let loading = null;613 picker.addEventListener("toggle", () => {614 if (!picker.open) return;615 filter.focus();616 if (loading) return;617 loading = fetch(list.dataset.src)618 .then((response) => (response.ok ? response.text() : Promise.reject()))619 .then((text) => {620 const template = document.createElement("template");621 template.innerHTML = text;622 list.replaceChildren(template.content);623 rows = [...list.children];624 narrow();625 })626 .catch(() => {627 loading = null;628 });629 });630 document.addEventListener("click", (event) => {631 if (!picker.contains(event.target)) picker.open = false;632 });633 picker.addEventListener("keydown", (event) => {634 if (event.key === "Escape") picker.open = false;635 });636 filter.hidden = false;637}638639640641642function fileViewer(root, jump, panelFor) {643 let [row, size, wrap] = [null, 12, false];644 const overlay = element("div", "fv-overlay");645 overlay.tabIndex = -1;646 overlay.setAttribute("role", "dialog");647 overlay.setAttribute("aria-modal", "true");648 const close = () => {649 overlay.remove();650 row?.focus();651 row = null;652 };653 const step = (by) => {654 const rows = [...root.querySelectorAll(".atlas-file")].filter((file) => !file.hidden);655 const next = rows[rows.indexOf(row) + by];656 if (next) open(next);657 };658 const restyle = () => {659 overlay.style.setProperty("--fv-size", `${size}px`);660 overlay.classList.toggle("fv-wrap", wrap);661 };662 const open = async (file, url = file.href) => {663 row = file;664 const rows = [...root.querySelectorAll(".atlas-file")].filter((each) => !each.hidden);665 const index = rows.indexOf(file);666 const path = file.dataset.path;667 const name = path.split("/").pop();668 const raw = new URL(file.href);669 raw.pathname = raw.pathname.replace(/^(\/[^/]+)\/files\//, "$1/raw/");670 const button = (text, action, className = "fv-ctrl-btn") => {671 const b = element("button", className, text);672 b.type = "button";673 b.addEventListener("click", action);674 return b;675 };676 const link = (text, download) => {677 const a = element("a", "fv-ctrl-btn", text);678 a.href = raw.href;679 if (download) a.download = name;680 return a;681 };682 const source = element("div", "fv-source", "loading…");683 const panel = element("aside", "fv-detail");684 panelFor(path).then((content) => {685 if (row === file) panel.replaceChildren(content);686 });687 const header = element("div", "fv-header");688 const title = element("div", "fv-title");689 title.append(element("span", "fv-name", name), element("span", "fv-path mono", path));690 const controls = element("div", "fv-controls mono");691 controls.append(692 button("A−", () => {693 size = Math.max(9, size - 1);694 restyle();695 }),696 button("A+", () => {697 size = Math.min(20, size + 1);698 restyle();699 }),700 button("⇌ wrap", (event) => {701 wrap = !wrap;702 event.currentTarget.classList.toggle("active", wrap);703 restyle();704 }),705 link("raw", false),706 link("download", true),707 );708 controls.children[2].classList.toggle("active", wrap);709 const nav = element("div", "fv-nav mono");710 const back = button("←", () => step(-1), "fv-ctrl-btn");711 const forward = button("→", () => step(1), "fv-ctrl-btn");712 back.disabled = index <= 0;713 forward.disabled = index >= rows.length - 1;714 nav.append(back, element("span", "fv-pos", `${index + 1}/${rows.length}`), forward);715 header.append(button("×", close, "fv-close"), title, controls, nav);716 const body = element("div", "fv-body");717 body.append(source, panel);718 overlay.replaceChildren(header, body);719 restyle();720 if (!overlay.isConnected) document.body.append(overlay);721 overlay.focus();722 const main = await fetchMain(url);723 if (row !== file) return;724 if (!main) {725 location.href = url;726 return;727 }728 for (const heading of main.querySelectorAll(".breadcrumb, .file-page-title")) heading.remove();729 main.querySelector(".file-meta:not(:last-child)")?.remove();730 source.replaceChildren(...main.childNodes);731 };732 overlay.addEventListener("keydown", (event) => {733 if (event.key === "Escape") close();734 else if (event.key === "ArrowLeft" || event.key === "ArrowRight") {735 event.preventDefault();736 step(event.key === "ArrowLeft" ? -1 : 1);737 }738 });739 740 overlay.addEventListener("click", (event) => {741 const link = event.target.closest(".fv-detail a.atlas-jump");742 if (!link || modified(event)) return;743 const path = link.dataset.path;744 const file = [...root.querySelectorAll(".atlas-file")].find((f) => f.dataset.path === path);745 event.preventDefault();746 if (file) open(file);747 else {748 close();749 jump(path);750 }751 });752 753 overlay.addEventListener("click", (event) => {754 const link = event.target.closest(".fv-source a[href]");755 if (!link || !row || modified(event)) return;756 if (new URL(link.href).pathname !== new URL(row.href).pathname) return;757 event.preventDefault();758 open(row, link.href);759 });760 return open;761}762763764765function navMenu() {766 const nav = document.querySelector("body > nav");767 const links = nav?.querySelector(".nav-links");768 if (!links) return;769 links.id = "nav-links";770 const button = element("button", "nav-toggle");771 button.type = "button";772 button.setAttribute("aria-label", "menu");773 button.setAttribute("aria-controls", "nav-links");774 button.setAttribute("aria-expanded", "false");775 button.append(element("span", ""), element("span", ""), element("span", ""));776 const open = () => nav.classList.contains("nav--open");777 const set = (opened) => {778 nav.classList.toggle("nav--open", opened);779 button.setAttribute("aria-expanded", String(opened));780 };781 button.addEventListener("click", () => set(!open()));782 document.addEventListener("click", (event) => {783 if (open() && !nav.contains(event.target)) set(false);784 });785 document.addEventListener("keydown", (event) => {786 if (event.key !== "Escape" || !open()) return;787 set(false);788 button.focus();789 });790 nav.classList.add("nav--menu");791 792 links.before(button);793}794function element(tag, className, text) {795 const node = document.createElement(tag);796 node.className = className;797 if (text !== undefined) node.textContent = text;798 return node;799}800801802803804805async function repoEditor() {806 const head = document.querySelector(".repo-head");807 if (!head || !/(^|;\s*)kepr_editor=[0-9a-f]{64}/.test(document.cookie)) return;808 const base = `/admin/repos/${location.pathname.split("/")[1]}`;809 const response = await fetch(`${base}/text`).catch(() => null);810 if (!response?.ok) return;811 const texts = await response.json();812 const details = document.querySelector(".repo-details");813 const places = [814 ["description", head.querySelector(".repo-desc"), head.querySelector(".repo-desc")],815 ["details", details, details.querySelector(".repo-details-body")],816 ];817 for (const [field, section, body] of places) {818 section.hidden = false;819 if (field === "description") {820 section.tabIndex = 0;821 section.setAttribute("role", "button");822 section.setAttribute("aria-label", "edit repository description");823 section.classList.add("repo-desc-editable");824 if (!section.textContent.trim()) section.textContent = "add a description";825 const activate = (event) => {826 if (event.target.closest(".editor") || body.querySelector(".editor")) return;827 event.preventDefault();828 editText(base, texts.form, field, body, texts[field]);829 };830 section.addEventListener("click", activate);831 section.addEventListener("keydown", (event) => {832 if (event.target === section && (event.key === "Enter" || event.key === " "))833 activate(event);834 });835 continue;836 }837 const button = element("button", "edit-btn mono", "edit");838 button.type = "button";839 (section.querySelector(".repo-details-head") ?? section).append(button);840 button.addEventListener("click", (event) => {841 event.preventDefault();842 if (!body.querySelector(".editor")) editText(base, texts.form, field, body, texts[field]);843 });844 }845}846847const MEDIA_MARKDOWN = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g;848const MEDIA_TYPES = "image/png,image/jpeg,image/gif,image/webp,video/mp4,video/webm";849850function editText(base, form, field, body, text) {851 const kept = [...body.childNodes];852 if (field === "description") {853 body.removeAttribute("role");854 body.removeAttribute("tabindex");855 body.removeAttribute("aria-label");856 }857 const input = element("textarea", "editor-input mono");858 input.value = text;859 input.rows = field === "details" ? 16 : 3;860 const preview = element("div", `editor-preview${field === "details" ? " markdown" : ""}`);861 const media = element("div", "editor-media mono");862 const status = element("span", "editor-status");863 const post = (path, fields) => {864 const data = new FormData();865 data.append("form", form);866 for (const [name, value] of Object.entries(fields)) data.append(name, value);867 return fetch(`${base}/${path}`, { method: "POST", body: data });868 };869 let timer = null;870 const changed = () => {871 inspect();872 clearTimeout(timer);873 timer = setTimeout(async () => {874 const answer = await post("preview", { field, value: input.value }).catch(() => null);875 if (!answer?.ok) return;876 877 preview.innerHTML = input.value.trim() ? await answer.text() : "";878 preview.dataset.empty = input.value.trim() ? "" : "the README's words show here";879 }, 250);880 };881 const wrap = (before, after = before, fallback = "text") => {882 const [from, to] = [input.selectionStart, input.selectionEnd];883 const chosen = input.value.slice(from, to) || fallback;884 input.setRangeText(before + chosen + after, from, to);885 input.setSelectionRange(from + before.length, from + before.length + chosen.length);886 input.focus();887 changed();888 };889 const prefix = (mark) => {890 const start = input.value.lastIndexOf("\n", input.selectionStart - 1) + 1;891 input.setRangeText(mark, start, start, "end");892 input.focus();893 changed();894 };895 const upload = async (file) => {896 status.textContent = `uploading ${file.name}…`;897 const answer = await post("media", { file }).catch(() => null);898 if (!answer?.ok) {899 status.textContent = answer ? await answer.text() : "upload failed";900 return;901 }902 const { url } = await answer.json();903 const alt = file.name.replace(/\.[^.]+$/, "").replace(/[[\]]/g, "");904 input.setRangeText(905 `\n\n`,906 input.selectionStart,907 input.selectionEnd,908 "end",909 );910 status.textContent = "";911 input.focus();912 changed();913 };914 const picker = element("input", "");915 picker.type = "file";916 picker.accept = MEDIA_TYPES;917 picker.hidden = true;918 picker.addEventListener("change", () => {919 for (const file of picker.files) upload(file);920 picker.value = "";921 });922 923 const underCaret = () =>924 [...input.value.matchAll(MEDIA_MARKDOWN)].find(925 (m) => input.selectionStart >= m.index && input.selectionStart <= m.index + m[0].length,926 );927 function inspect() {928 const found = field === "details" ? underCaret() : undefined;929 media.hidden = !found;930 if (!found) return;931 const words = (found[3] ?? "").split(/\s+/).filter(Boolean);932 const width = words.find((word) => word.startsWith("width="))?.slice(6) ?? "100";933 const align = words.find((word) => /^(left|center|right)$/.test(word)) ?? "center";934 const loop = words.includes("loop");935 const video = /\.(mp4|webm)$/i.test(found[2]);936 const set = (next) => {937 const options = [`width=${next.width}`, next.align, next.loop && "loop"].filter(Boolean);938 const markdown = `![${found[1]}](${found[2]} "${options.join(" ")}")`;939 const caret = input.selectionStart;940 input.setRangeText(markdown, found.index, found.index + found[0].length);941 input.setSelectionRange(caret, caret);942 changed();943 };944 const choice = (label, active, action) => {945 const b = element("button", `fv-ctrl-btn${active ? " active" : ""}`, label);946 b.type = "button";947 b.addEventListener("click", action);948 return b;949 };950 media.replaceChildren(951 element("span", "editor-label", video ? "video" : "picture"),952 ...["25", "33", "50", "66", "75", "100"].map((w) =>953 choice(`${w}%`, w === width, () => set({ width: w, align, loop })),954 ),955 ...["left", "center", "right"].map((a) =>956 choice(a, a === align, () => set({ width, align: a, loop })),957 ),958 ...(video ? [choice("loop like a GIF", loop, () => set({ width, align, loop: !loop }))] : []),959 );960 }961 const save = async (value) => {962 status.textContent = "saving…";963 const answer = await post("text", { field, value }).catch(() => null);964 if (answer?.ok) location.reload();965 else status.textContent = answer ? await answer.text() : "not saved: offline?";966 };967 const cancel = () => {968 body.replaceChildren(...kept);969 if (field === "description") {970 body.tabIndex = 0;971 body.setAttribute("role", "button");972 body.setAttribute("aria-label", "edit repository description");973 body.focus();974 }975 };976 const tool = (label, title, action) => {977 const b = element("button", "fv-ctrl-btn", label);978 b.type = "button";979 b.title = title;980 b.addEventListener("click", action);981 return b;982 };983 const toolbar = element("div", "editor-toolbar mono");984 toolbar.append(985 tool("B", "bold (⌘B)", () => wrap("**")),986 tool("I", "italic (⌘I)", () => wrap("_")),987 tool("link", "link (⌘K)", () => wrap("[", "](https://)")),988 tool("code", "code", () => wrap("`")),989 );990 if (field === "details") {991 toolbar.append(992 tool("heading", "heading", () => prefix("## ")),993 tool("list", "list", () => prefix("- ")),994 tool("quote", "quote", () => prefix("> ")),995 tool("picture or video", "add a picture, GIF or video (or drop or paste one)", () =>996 picker.click(),997 ),998 picker,999 );1000 input.addEventListener("dragover", (event) => event.preventDefault());1001 input.addEventListener("drop", (event) => {1002 if (event.dataTransfer.files.length === 0) return;1003 event.preventDefault();1004 for (const file of event.dataTransfer.files) upload(file);1005 });1006 input.addEventListener("paste", (event) => {1007 const files = [...event.clipboardData.files];1008 if (files.length === 0) return;1009 event.preventDefault();1010 for (const file of files) upload(file);1011 });1012 }1013 input.addEventListener("input", changed);1014 for (const name of ["click", "keyup"]) input.addEventListener(name, inspect);1015 input.addEventListener("keydown", (event) => {1016 const command = event.metaKey || event.ctrlKey;1017 if (event.key === "Escape") cancel();1018 else if (command && event.key === "Enter") save(input.value);1019 else if (command && event.key === "b") wrap("**");1020 else if (command && event.key === "i") wrap("_");1021 else if (command && event.key === "k") wrap("[", "](https://)");1022 else return;1023 event.preventDefault();1024 });1025 const actions = element("div", "editor-actions mono");1026 actions.append(1027 tool("save", "save (⌘↵)", () => save(input.value)),1028 tool("cancel", "cancel (esc)", cancel),1029 tool("use the README", "clear your words and show the README's again", () => save("")),1030 status,1031 );1032 const panes = element("div", "editor-panes");1033 panes.append(input, preview);1034 const editor = element("div", `editor editor-${field}`);1035 editor.append(toolbar, panes, media, actions);1036 body.replaceChildren(editor);1037 input.focus();1038 changed();1039}1040104110421043function remembered(key) {1044 try {1045 const value = JSON.parse(localStorage.getItem(key) ?? "[]");1046 return Array.isArray(value) && value.every((path) => typeof path === "string") ? value : [];1047 } catch {1048 return [];1049 }1050}10511052function remember(key, value) {1053 try {1054 localStorage.setItem(key, JSON.stringify(value));1055 } catch {}1056}10571058function hexStream() {1059 const backdrop = document.querySelector(".hex-backdrop");1060 if (!backdrop) return;1061 let visible = true;1062 const update = () => backdrop.classList.toggle("paused", !visible || document.hidden);1063 new IntersectionObserver(([entry]) => {1064 visible = entry.isIntersecting;1065 update();1066 }).observe(backdrop);1067 document.addEventListener("visibilitychange", update);1068 update();1069}1070107110721073function tickerPace() {1074 for (const track of document.querySelectorAll(".ticker-track")) {1075 const speed = Number(getComputedStyle(track).getPropertyValue("--speed"));1076 if (speed > 0) track.style.animationDuration = `${track.scrollWidth / 2 / speed}s`;1077 }1078}107910801081document.fonts.ready.then(tickerPace);1082timeline()1083 .catch((error) => console.error("kepr: the timeline could not load", error))1084 .finally(() => document.documentElement.classList.remove("timeline-pending"));1085copyButtons(document);1086panels();1087issueReplies();1088issueAttachments();1089saveLists();1090atlasPicker();1091repoEditor();1092navMenu();1093lineRanges();1094atlas();1095hexStream();