216 lines
12 KiB
HTML
216 lines
12 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>__WIKI_NAME__</title>
|
|
<style>
|
|
:root { color-scheme: dark; --bg: #0e0f12; --panel: #16181d; --card: #1c1f25; --line: #2a2d34; --line2: #3a3e47; --ink: #f4f1ea; --soft: #d9d4ca; --mute: #9d988e; --accent: #c9b28a; }
|
|
* { box-sizing: border-box; }
|
|
html, body { height: 100%; }
|
|
body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.5 system-ui, sans-serif; display: flex; flex-direction: column; }
|
|
a { color: var(--ink); }
|
|
header { display: flex; align-items: center; gap: 12px; padding: 14px 20px; border-bottom: 1px solid var(--line); }
|
|
.brand { display: flex; align-items: center; gap: 10px; font: 500 22px/1 Georgia, serif; letter-spacing: 0.01em; color: var(--ink); text-decoration: none; }
|
|
.brand .mark { width: 22px; height: 22px; border-radius: 6px; background: linear-gradient(135deg, #d8c39b, #8f7a55); }
|
|
.brand small { font: 13px/1 system-ui, sans-serif; color: var(--mute); margin-left: 4px; }
|
|
header nav { margin-left: auto; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
|
a.nav, button { border: 1px solid var(--line2); background: transparent; color: var(--ink); border-radius: 999px; padding: 7px 12px; font: inherit; font-size: 14px; cursor: pointer; text-decoration: none; }
|
|
#who { color: var(--mute); font-size: 13px; }
|
|
.shell { flex: 1; display: grid; grid-template-columns: 240px minmax(0, 1fr); min-height: 0; }
|
|
aside { border-right: 1px solid var(--line); padding: 18px 12px; overflow-y: auto; background: var(--panel); }
|
|
aside h2 { margin: 4px 8px 10px; font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--mute); font-weight: 600; }
|
|
.space { display: block; padding: 9px 10px; border-radius: 10px; text-decoration: none; color: var(--soft); margin-bottom: 2px; }
|
|
.space:hover { background: var(--card); color: var(--ink); }
|
|
.space.on { background: var(--card); color: var(--ink); box-shadow: inset 2px 0 0 var(--accent); }
|
|
.space small { display: block; color: var(--mute); font-size: 12px; margin-top: 2px; }
|
|
.side-note { margin: 22px 8px 0; color: #6d6962; font-size: 12px; }
|
|
.side-note code { color: var(--mute); }
|
|
main { overflow-y: auto; padding: 28px 32px 60px; }
|
|
.wrap { max-width: 760px; margin: 0 auto; }
|
|
.crumbs { font-size: 13px; color: var(--mute); margin: 0 0 14px; }
|
|
.crumbs a { color: var(--mute); text-decoration: none; }
|
|
.crumbs a:hover { color: var(--ink); }
|
|
h1 { margin: 0 0 10px; font: 500 38px/1.15 Georgia, serif; }
|
|
.lede { color: var(--soft); font: 17px/1.55 Georgia, serif; margin: 0 0 6px; }
|
|
.about p { color: var(--soft); font: 16px/1.6 Georgia, serif; margin: 0 0 10px; }
|
|
.meta { color: var(--mute); font-size: 13px; }
|
|
.meta a { color: var(--mute); }
|
|
.bar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 16px 0 26px; }
|
|
.bar .meta { flex-basis: 100%; }
|
|
.foot.bar .meta { flex-basis: auto; margin-right: auto; }
|
|
h3.sec { margin: 26px 0 10px; font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--mute); font-weight: 600; }
|
|
.post { display: block; padding: 16px 18px; border: 1px solid var(--line); border-radius: 14px; background: var(--panel); text-decoration: none; color: var(--ink); margin-bottom: 10px; }
|
|
.post:hover { border-color: var(--line2); background: var(--card); }
|
|
.post h4 { margin: 0 0 6px; font: 500 21px/1.25 Georgia, serif; }
|
|
.post p { margin: 6px 0 0; color: var(--soft); }
|
|
.empty { color: var(--mute); padding: 18px; border: 1px dashed var(--line2); border-radius: 14px; }
|
|
article.read { font: 18px/1.65 Georgia, serif; color: var(--soft); }
|
|
article.read h2, article.read h3, article.read h4 { color: var(--ink); font-weight: 500; margin: 1.4em 0 0.5em; }
|
|
article.read p { margin: 0 0 1em; }
|
|
article.read a { color: var(--accent); }
|
|
article.read code { font: 0.85em ui-monospace, monospace; background: var(--panel); padding: 1px 5px; border-radius: 5px; }
|
|
article.read pre { background: var(--panel); border: 1px solid var(--line); padding: 12px 14px; border-radius: 10px; overflow-x: auto; }
|
|
article.read pre code { background: none; padding: 0; }
|
|
article.read blockquote { margin: 0 0 1em; padding: 2px 16px; border-left: 3px solid var(--line2); color: var(--mute); }
|
|
article.read hr { border: 0; border-top: 1px solid var(--line); margin: 2em 0; }
|
|
.foot { margin-top: 34px; padding-top: 14px; border-top: 1px solid var(--line); }
|
|
#status { color: var(--mute); }
|
|
@media (max-width: 760px) {
|
|
.shell { grid-template-columns: 1fr; }
|
|
aside { border-right: 0; border-bottom: 1px solid var(--line); padding: 10px 12px; display: flex; gap: 6px; overflow-x: auto; }
|
|
aside h2, .side-note { display: none; }
|
|
.space { white-space: nowrap; }
|
|
.space small { display: none; }
|
|
main { padding: 20px 16px 48px; }
|
|
h1 { font-size: 30px; }
|
|
.brand small, #who { display: none; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<a class="brand" id="home" href="/quill"><span class="mark" aria-hidden="true"></span>__WIKI_NAME__<small>wiki</small></a>
|
|
<nav>
|
|
__NAV_LINKS__
|
|
<span id="who"></span>
|
|
</nav>
|
|
</header>
|
|
<div class="shell">
|
|
<aside>
|
|
<h2>Spaces</h2>
|
|
<div id="spaces"></div>
|
|
<p class="side-note">Every space is a Git repository. To write, push to it: branch, pull request, merge. __WIKI_NAME__ shows the merged result.</p>
|
|
</aside>
|
|
<main><div class="wrap" id="view"><p id="status">Loading…</p></div></main>
|
|
</div>
|
|
<script>
|
|
"use strict";
|
|
// Routes: <base>, <base>/<space>, <base>/<space>/<post-slug>. <base> is /quill or /wiki.
|
|
const BASE = location.pathname.startsWith("/wiki") ? "/wiki" : "/quill";
|
|
const WIKI = document.title;
|
|
const view = document.getElementById("view");
|
|
const spacesEl = document.getElementById("spaces");
|
|
document.getElementById("home").href = BASE;
|
|
let spaces = [];
|
|
const cache = {};
|
|
|
|
function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
|
|
function link(href, text, cls) { const a = el("a", cls, text); a.href = href; return a; }
|
|
function external(href, text, cls) { const a = link(href, text, cls); a.target = "_blank"; a.rel = "noopener"; return a; }
|
|
|
|
async function api(url) {
|
|
const res = await fetch(url, { credentials: "same-origin" });
|
|
if (res.status === 401) { location.href = "/view?next=" + encodeURIComponent(location.pathname); throw new Error("signed_out"); }
|
|
let data = {};
|
|
try { data = await res.json(); } catch (e) {}
|
|
if (!res.ok) { const err = new Error(data.error || "failed"); err.status = res.status; throw err; }
|
|
return data;
|
|
}
|
|
|
|
function when(iso) {
|
|
if (!iso) return "";
|
|
const d = new Date(iso);
|
|
if (isNaN(d)) return iso;
|
|
return d.toLocaleString(undefined, { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" });
|
|
}
|
|
|
|
function byline(p) { return [p.date, p.author ? "by " + p.author : ""].filter(Boolean).join(" \u00b7 "); }
|
|
|
|
function route() {
|
|
const rest = location.pathname.slice(BASE.length).replace(/^\/+|\/+$/g, "");
|
|
const parts = rest ? rest.split("/").map(decodeURIComponent) : [];
|
|
return { space: parts[0] || "", post: parts[1] || "" };
|
|
}
|
|
|
|
function go(href) { history.pushState(null, "", href); render(); main.scrollTop = 0; }
|
|
const main = document.querySelector("main");
|
|
document.addEventListener("click", (e) => {
|
|
const a = e.target.closest("a");
|
|
if (!a || a.target || e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
|
|
const url = new URL(a.href, location.href);
|
|
if (url.origin === location.origin && (url.pathname === BASE || url.pathname.startsWith(BASE + "/"))) { e.preventDefault(); go(url.pathname); }
|
|
});
|
|
window.addEventListener("popstate", render);
|
|
|
|
function drawSidebar(active) {
|
|
spacesEl.replaceChildren(...spaces.map((s) => {
|
|
const a = link(BASE + "/" + s.key, s.name, "space" + (s.key === active ? " on" : ""));
|
|
a.append(el("small", null, s.repo));
|
|
return a;
|
|
}));
|
|
}
|
|
|
|
function fail(err, what) {
|
|
const box = el("div", "empty");
|
|
box.textContent = err.status === 404 ? what + " not found." : "Could not load " + what.toLowerCase() + " from Git right now.";
|
|
box.append(" ", link(BASE, "Back to " + WIKI));
|
|
view.replaceChildren(box);
|
|
}
|
|
|
|
async function loadSpace(key) {
|
|
if (!cache[key] || Date.now() - cache[key].at > 15000) cache[key] = { at: Date.now(), data: await api("/v1/quill/spaces/" + encodeURIComponent(key)) };
|
|
return cache[key].data;
|
|
}
|
|
|
|
async function showSpace(key) {
|
|
let data;
|
|
try { data = await loadSpace(key); } catch (e) { if (e.message !== "signed_out") fail(e, "Space"); return; }
|
|
const s = data.space;
|
|
document.title = s.name + " \u00b7 " + WIKI;
|
|
const out = [];
|
|
const crumbs = el("p", "crumbs"); crumbs.append(link(BASE, WIKI), " / ", s.name); out.push(crumbs);
|
|
out.push(el("h1", null, s.name));
|
|
if (s.description) out.push(el("p", "lede", s.description));
|
|
const bar = el("div", "bar");
|
|
const lc = data.last_change || {};
|
|
const m = el("span", "meta", lc.at ? "Last change " + when(lc.at) + (lc.author ? " by " + lc.author : "") + (lc.message ? " \u2014 " + lc.message : "") : "");
|
|
bar.append(m, link(s.git_url, "Source in Git", "nav"));
|
|
if (s.public_url) bar.append(external(s.public_url, "Published site", "nav"));
|
|
out.push(bar);
|
|
if (data.about_html) { const about = el("div", "about"); about.innerHTML = data.about_html; out.push(about); }
|
|
out.push(el("h3", "sec", (data.posts.length || "No") + (data.posts.length === 1 ? " article" : " articles")));
|
|
if (!data.posts.length) out.push(el("p", "empty", "Nothing here yet. Push a Markdown file to " + s.repo + " and merge it."));
|
|
data.posts.forEach((p) => {
|
|
const a = link(BASE + "/" + s.key + "/" + encodeURIComponent(p.slug), null, "post");
|
|
a.append(el("h4", null, p.title));
|
|
const b = byline(p); if (b) a.append(el("span", "meta", b));
|
|
if (p.summary) a.append(el("p", null, p.summary));
|
|
out.push(a);
|
|
});
|
|
const foot = el("p", "meta foot");
|
|
foot.append("Write here: branch from ", el("code", null, data.branch), " in ", link(s.git_url, s.repo), ", add a Markdown file (or an ", el("code", null, "<article>"), " in index.html), open a pull request, merge.");
|
|
out.push(foot);
|
|
view.replaceChildren(...out);
|
|
}
|
|
|
|
async function showPost(key, slug) {
|
|
let data;
|
|
try { data = await api("/v1/quill/spaces/" + encodeURIComponent(key) + "/posts/" + encodeURIComponent(slug)); } catch (e) { if (e.message !== "signed_out") fail(e, "Article"); return; }
|
|
const s = data.space, p = data.post;
|
|
document.title = p.title + " \u00b7 " + s.name + " \u00b7 " + WIKI;
|
|
const crumbs = el("p", "crumbs"); crumbs.append(link(BASE, WIKI), " / ", link(BASE + "/" + s.key, s.name), " / ", p.title);
|
|
const h = el("h1", null, p.title);
|
|
const meta = el("p", "meta", byline(p));
|
|
const body = el("article", "read"); body.innerHTML = p.html; // sanitized server-side (whitelist)
|
|
const foot = el("div", "bar foot");
|
|
foot.append(el("span", "meta", "From " + s.repo + " \u00b7 " + p.source), link(p.git_url, "View source", "nav"), link(p.history_url, "History", "nav"), link(BASE + "/" + s.key, "All articles", "nav"));
|
|
view.replaceChildren(crumbs, h, meta, body, foot);
|
|
}
|
|
|
|
async function render() {
|
|
const r = route();
|
|
if (!spaces.length) {
|
|
try { spaces = (await api("/v1/quill/spaces")).spaces || []; } catch (e) { if (e.message !== "signed_out") fail(e, "Spaces"); return; }
|
|
}
|
|
const key = r.space || (spaces[0] && spaces[0].key) || "";
|
|
drawSidebar(key);
|
|
if (!key) { view.replaceChildren(el("p", "empty", "No spaces yet.")); return; }
|
|
if (r.post) await showPost(key, r.post); else await showSpace(key);
|
|
}
|
|
|
|
render();
|
|
</script>
|
|
</body>
|
|
</html>
|