Files
anonymous_github/public/script/components.js
T
Thomas DurieuxandClaude Opus 5.5 579ce970c7 feat(ui): redesign the viewer, landing, author and admin screens
Viewer: full-height app bar with a Help menu, search + ZIP sidebar, one
toolbar for README and code, reading mode with a scroll-tracking outline,
docked find for Markdown and code, and redaction slots (a blank stone slot
holding the term number) in prose, headings, code, the tree and the path.

Landing and FAQ: live redaction demo, serif display headings, teal accent.

Author screens: My work table with copy/open/edit actions, a usage meter,
a single-container New anonymization flow with a sticky submit bar, themed
form controls, and consistent GitHub connection cards.

Admin: underline tabs, severity-first errors chart and list, error tiles
that filter (older pages load until matches appear), queue and overview
health states, and shared toolbar heights and borders.

Fixes: guarded localStorage access, failed README images render a
placeholder, phone hover states no longer stick, and UI tests updated for
the new copy and markup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 10:52:10 +00:00

135 lines
6.0 KiB
JavaScript

import { loadLibrary, loadEditor } from "./lazy-assets.js";
import { h, ref, watch, onMounted, onBeforeUnmount, nextTick, defineAsyncComponent } from "vue";
import HtmlDoc from "./html-doc.js";
import PdfViewer from "./pdf-viewer.js";
import { render as quotaUsageTemplate } from "../partials/quotaUsage.htm";
const QuotaUsage = {
props: ["quota", "fmt"],
setup(props) {
return { formatAmount: (value, kind) => kind === "bytes"
? props.fmt.humanFileSize(value).replace(/([a-zA-Z]+)$/, " $1") : props.fmt.number(value) };
},
render: quotaUsageTemplate,
};
const editorModes = new Set(__ACE_MODES__);
const Markdown = {
props: ["content", "terms", "options"],
setup(props) {
const host = ref(null);
const render = () => {
host.value.innerHTML = renderMD(props.content || "", window.location.pathname + "/../");
};
onMounted(() => { render(); watch(() => [props.content, props.terms, props.options], render, { deep: true }); });
return () => h("markdown", { ref: host });
},
};
const GistFile = {
props: ["file", "terms", "options"],
setup(props) {
const host = ref(null);
const highlight = () => nextTick(() => host.value?.querySelectorAll("pre code").forEach(el => window.Prism?.highlightElement(el)));
onMounted(highlight);
watch(() => [props.file?.content, props.terms, props.options], highlight, { deep: true });
return () => {
const file = props.file || {};
const extension = (file.filename || "").split(".").pop().toLowerCase();
if (["md", "markdown"].includes(extension) || file.language === "Markdown") {
return h("gist-file", { ref: host }, h(Markdown, { content: file.content, terms: props.terms, options: props.options }));
}
const aliases = { js: "javascript", jsx: "javascript", ts: "javascript", typescript: "javascript", py: "python", html: "markup", xml: "markup", svg: "markup", sh: "bash" };
const language = (file.language || extension || "none").toLowerCase();
return h("gist-file", { ref: host }, h("pre", { class: "line-numbers" }, h("code", { class: "language-" + (aliases[language] || language), key: file.content }, file.content || "")));
};
},
};
const Notebook = {
props: ["file", "content"],
setup(props) {
const host = ref(null);
let generation = 0;
let request;
const render = async () => {
const current = ++generation;
request?.abort();
request = new AbortController();
try {
const json = props.content ? JSON.parse(props.content) : await fetch(props.file?.download_url || props.file, { signal: request.signal }).then(r => { if (!r.ok) throw Error("Notebook request failed"); return r.json(); });
await loadLibrary("notebook");
if (current !== generation) return;
host.value.innerHTML = DOMPurify.sanitize(nb.parse(json).render());
host.value.querySelectorAll("pre code").forEach(el => window.Prism?.highlightElement(el));
} catch (error) { if (current === generation && error.name !== "AbortError") host.value.textContent = "Unable to render the notebook."; }
};
onMounted(() => { render(); watch(() => [props.file, props.content], render); });
onBeforeUnmount(() => { generation++; request?.abort(); });
return () => h("notebook", { ref: host });
},
};
const Loc = {
props: ["stats"],
setup(props) {
return () => {
const rows = Object.entries(props.stats || {}).filter(([, stat]) => stat.code);
const total = rows.reduce((sum, [, stat]) => sum + stat.code, 0);
return h("loc", rows.map(([language, stat]) => h("div", { class: "lang", title: `${language}: ${stat.code.toLocaleString()} lines`, style: { width: stat.code * 100 / total + "%", background: langColors[language] } })));
};
},
};
export const components = { Markdown, GistFile, Notebook, Loc, HtmlDoc, QuotaUsage, Pdfviewer: defineAsyncComponent(async () => {
await loadLibrary("pdf");
pdfjsLib.GlobalWorkerOptions.workerSrc = "/script/external/pdf.worker.js";
return PdfViewer;
}) };
export const codeEditor = {
async mounted(el, { value }) {
el._editorValue = value;
try {
await loadEditor();
if (el._editorDisposed) return;
const latest = el._editorValue;
const editor = ace.edit(el);
el._editor = editor;
editor.setValue(String(latest.content ?? ""), -1);
applyEditorOptions(el, latest.options);
el._editorCleanup = latest.options?.onLoad?.(editor);
} catch (error) {
if (!el._editorDisposed) el.textContent = error.message;
}
},
updated(el, { value }) {
el._editorValue = value;
if (!el._editor) return;
if (el._editor.getValue() !== String(value.content ?? "")) el._editor.setValue(String(value.content ?? ""), -1);
applyEditorOptions(el, value.options);
},
beforeUnmount(el) { el._editorDisposed = true; el._editorCleanup?.(); el._editor?.destroy(); },
};
function applyEditorOptions(el, options = {}) {
const mode = editorModes.has(options.mode) ? options.mode : "text";
el._editor.session.setMode("ace/mode/" + mode);
if (options.theme) el._editor.setTheme("ace/theme/" + options.theme);
el._editor.setReadOnly(options.readOnly !== false);
}
export const paperScrollspy = {
mounted(el) {
if (!window.IntersectionObserver) return;
const links = [...el.querySelectorAll('a[href^="#"]')];
const visible = new Set();
const observer = new IntersectionObserver(entries => {
entries.forEach(e => e.isIntersecting ? visible.add(e.target.id) : visible.delete(e.target.id));
const active = links.find(a => visible.has(a.hash.slice(1)));
if (active) links.forEach(a => a.classList.toggle("active", a === active));
}, { rootMargin: "-20% 0px -60% 0px", threshold: 0 });
nextTick(() => {
links.forEach(a => { const target = document.getElementById(a.hash.slice(1)); if (target) observer.observe(target); });
if (links[0] && !links.some(a => a.classList.contains("active"))) links[0].classList.add("active");
});
el._observer = observer;
},
beforeUnmount(el) { el._observer?.disconnect(); },
};