mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-11-14 00:07:00 +01:00
The UI was uneven: the home page looked finished, but app pages used three different display typefaces, the anonymize flow changed frame between steps, and the dashboard, explorer and admin screens each had their own controls, status colours and edge-case handling. Make every screen follow one system: shared page header, grid, buttons, filters and status colours; a repository picker and step trail on anonymize; a "What reviewers will see" preview of the actual reviewer page; consistent file headers and viewers for every file type; truthful admin charts and tables. Fold the accumulated override blocks back into style.css (22% smaller, no visual change). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
161 lines
8.3 KiB
JavaScript
161 lines
8.3 KiB
JavaScript
import { takeReviewFragment } from "./review-consent.js";
|
||
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, highlightRedactions } 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 = {}) {
|
||
let message = key?.split(".").reduce((value, part) => value?.[part], translations);
|
||
// Unknown error codes would otherwise surface as the raw "ERRORS.code" key.
|
||
if (message == null && key?.startsWith("ERRORS.")) message = translations.ERRORS.unknown_error;
|
||
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 = {}) {
|
||
const isolatedConsent = !!document.querySelector('meta[name="review-consent-page"][content="1"]');
|
||
let reviewHandoff = isolatedConsent && window.location.pathname === "/review-link" ? takeReviewFragment(window) : null;
|
||
const takeReviewHandoff = () => { const value = reviewHandoff; reviewHandoff = null; return value; };
|
||
// 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; },
|
||
replace(value) { return router.replace(value); },
|
||
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 { takeReviewHandoff, 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 ?? "");
|
||
// Reading site_options re-renders Markdown once the configured mask loads.
|
||
root.highlightRedactions = html => (root.site_options, highlightRedactions(html));
|
||
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 (isolatedConsent || /^\/review-link\/?$/i.test(url.pathname) || 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.
|
||
isolatedConsent || /^\/(r|repository)\//.test(root.path || "") ? null : h("header", { class: ["app-header", { "is-contained": root.path === "/" || root.path === "/faq", "is-signin": root.path === "/signin" }] }, 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 => {
|
||
if ((to.path === "/review-link" && !isolatedConsent) || (isolatedConsent && to.path !== "/review-link")) {
|
||
window.location.assign(to.fullPath);
|
||
return false;
|
||
}
|
||
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();
|