mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-11-14 00:07:00 +01:00
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>
148 lines
7.2 KiB
JavaScript
148 lines
7.2 KiB
JavaScript
import { loadLibrary } from "./lazy-assets.js";
|
||
import { createApp, h, watch, provide, inject, onBeforeUnmount } from "vue";
|
||
import { createRouter, createWebHistory, RouterView } from "vue-router";
|
||
import { pageRoutes } from "./routes.js";
|
||
import { templates } from "./templates.js";
|
||
import { render as iconsTemplate } from "../partials/icons.htm";
|
||
import { initializeTemplate } from "./template-state.js";
|
||
import { createPageState, createTimers } from "./state.js";
|
||
import { createHttp, promises } from "./http.js";
|
||
import { mainController } from "./app.js";
|
||
import { createQuotaService } from "./quota.js";
|
||
import * as formatters from "./formatters.js";
|
||
import translations from "../i18n/locale-en.json";
|
||
import { components, codeEditor, paperScrollspy } from "./components.js";
|
||
import Tree from "./tree.js";
|
||
import { fieldDirective, formDirective, submitForm } from "./forms.js";
|
||
|
||
const sessionKey = Symbol("session");
|
||
export const serverPaths = /^\/(w|api|github)(\/|$)/;
|
||
function translate(key, params = {}) {
|
||
const message = key?.split(".").reduce((value, part) => value?.[part], translations);
|
||
return String(message ?? key ?? "").replace(/{{\s*([^}]+?)\s*}}/g, (_, name) => params[name] ?? "");
|
||
}
|
||
const fmt = { ...formatters, translate };
|
||
export function safeUrl(value) {
|
||
if (value == null || value === "") return value;
|
||
try {
|
||
const url = new URL(value, window.location.href);
|
||
return ["http:", "https:", "mailto:", "tel:", "blob:"].includes(url.protocol) ? value : undefined;
|
||
} catch { return undefined; }
|
||
}
|
||
|
||
export function mountApplication(target = "#app", options = {}) {
|
||
// AngularJS links stored the repository route in the fragment. Normalize it
|
||
// before web history reads the URL, replacing the entry so Back still works.
|
||
if (!options.history && window.location.pathname === "/" && /^#!\/r\/[^/?#]+/.test(window.location.hash)) {
|
||
window.history.replaceState(window.history.state, "", window.location.hash.slice(2));
|
||
}
|
||
const events = new Map();
|
||
const http = createHttp(options.fetch);
|
||
let root;
|
||
let router;
|
||
function services() {
|
||
const timers = createTimers();
|
||
const location = {
|
||
url(value) { if (value === undefined) return router.currentRoute.value.fullPath; router.push(value); return location; },
|
||
path() { return router.currentRoute.value.path; },
|
||
search() { return router.currentRoute.value.query; },
|
||
};
|
||
const params = new Proxy({}, { get(_, key) {
|
||
const value = router.currentRoute.value.params[key];
|
||
return Array.isArray(value) ? value.join("/") : value;
|
||
} });
|
||
return { http, ...timers, location, params, window, promises, html: { trustAsHtml: value => value }, translate: (key, params) => Promise.resolve(translate(key, params)), quotaService: createQuotaService(http) };
|
||
}
|
||
function pageComponent(definition) {
|
||
return {
|
||
name: definition.template,
|
||
setup() {
|
||
const state = createPageState(inject(sessionKey), events);
|
||
definition.setup(state, services());
|
||
initializeTemplate(definition.template, state);
|
||
const cache = [];
|
||
return () => templates[definition.template](state, cache);
|
||
},
|
||
};
|
||
}
|
||
router = createRouter({
|
||
history: options.history || createWebHistory(),
|
||
routes: pageRoutes.map(definition => definition.redirect ? definition : ({
|
||
path: definition.path,
|
||
component: pageComponent(definition),
|
||
meta: { title: definition.title, preserveExplorer: definition.preserveExplorer },
|
||
})),
|
||
});
|
||
const app = createApp({
|
||
setup() {
|
||
root = createPageState(null, events);
|
||
root.fmt = fmt;
|
||
root.window = window;
|
||
root.Math = Math;
|
||
root.sanitize = value => DOMPurify.sanitize(value ?? "");
|
||
root.submitForm = submitForm;
|
||
root.safeUrl = safeUrl;
|
||
const context = services();
|
||
mainController(root, context.http, context.location, context.timeout);
|
||
provide(sessionKey, root);
|
||
watch(() => root.title, title => { document.title = title || "Anonymous GitHub"; });
|
||
const headerCache = [];
|
||
const navigate = event => {
|
||
const anchor = event.target.closest?.("a[href]");
|
||
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || !anchor || anchor.hasAttribute("download") || (anchor.target && anchor.target !== "_self") || anchor.dataset.toggle) return;
|
||
const href = anchor.getAttribute("href");
|
||
if (href.startsWith("#")) return;
|
||
const url = new URL(anchor.href, window.location.href);
|
||
if (url.origin !== window.location.origin || serverPaths.test(url.pathname)) return;
|
||
event.preventDefault();
|
||
router.push(url.pathname + url.search + url.hash);
|
||
$("#navbarSupportedContent.show").collapse("hide");
|
||
};
|
||
document.addEventListener("click", navigate);
|
||
onBeforeUnmount(() => document.removeEventListener("click", navigate));
|
||
return () => [
|
||
iconsTemplate(root, []),
|
||
// Repository viewers are a full-height app shell; the explorer carries brand and links itself.
|
||
/^\/(r|repository)\//.test(root.path || "") ? null : h("header", { class: ["app-header", { "is-contained": root.path === "/" || root.path === "/faq" }] }, templates["partials/header.htm"](root, headerCache)),
|
||
h("main", { class: "app-view align-items-stretch w-100" }, h(RouterView, null, {
|
||
default: ({ Component, route }) => Component ? h(Component, { key: route.meta.preserveExplorer ? route.matched[0]?.path : route.path }) : null,
|
||
})),
|
||
h("div", { class: "position-fixed p-3", style: { zIndex: 999999999, right: 0, bottom: 0 } }, root.toasts.map((toast, i) => h("div", { class: "toast show", role: "alert", "aria-live": "assertive", "aria-atomic": "true", key: i }, [
|
||
h("div", { class: "toast-header" }, [h("strong", { class: "mr-auto" }, toast.title), h("button", { type: "button", class: "ml-2 mb-1 close", "aria-label": "Close", onClick: () => root.removeToast(toast) }, "×")]),
|
||
h("div", { class: "toast-body" }, toast.body),
|
||
]))),
|
||
];
|
||
},
|
||
});
|
||
for (const [name, component] of Object.entries(components)) app.component(name, component);
|
||
app.component("Tree", Tree);
|
||
app.component("PartialView", {
|
||
props: ["name", "state"],
|
||
setup(props) { const cache = []; return () => templates[props.name](props.state, cache); },
|
||
});
|
||
app.directive("field", fieldDirective);
|
||
app.directive("form", formDirective);
|
||
app.directive("code-editor", codeEditor);
|
||
app.directive("paper-scrollspy", paperScrollspy);
|
||
app.use(router);
|
||
router.beforeEach(async to => {
|
||
root?.emit("routeLeave");
|
||
if (/^\/(r|repository|anonymize|pull-request-anonymize|gist-anonymize|pr|gist)(\/|$)/.test(to.path)) {
|
||
await loadLibrary("markdown");
|
||
}
|
||
if (/^\/(r|repository)\//.test(to.path)) await loadLibrary("org");
|
||
});
|
||
router.afterEach(to => {
|
||
if (!root) return;
|
||
root.title = to.meta.title;
|
||
root.emit("routeChange", { title: to.meta.title });
|
||
root.emit("routeUpdate", { title: to.meta.title });
|
||
const view = document.querySelector(".app-view");
|
||
if (!to.meta.preserveExplorer && view) view.scrollTop = 0;
|
||
});
|
||
app.mount(target);
|
||
return { app, router, state: root };
|
||
}
|
||
|
||
if (document.querySelector("#app")) window.anonymousApp = mountApplication();
|