upgrades
This commit is contained in:
+602
@@ -0,0 +1,602 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Wiki</title>
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/toastui-editor.min.css">
|
||||
<script src="https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js"></script>
|
||||
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body { margin: 0; font-family: system-ui, sans-serif; display: flex; overflow: hidden; }
|
||||
#sidebar { width: 260px; border-right: 1px solid #dee2e6; display: flex; flex-direction: column;
|
||||
overflow: hidden; background: #f8f9fa; flex: 0 0 auto; transition: width .15s ease; }
|
||||
#sidebar header { padding: 8px; display: flex; gap: 6px; }
|
||||
#sidebar input { flex: 1; }
|
||||
#notelist { overflow-y: auto; flex: 1; }
|
||||
#notelist .item { padding: 6px 12px; cursor: pointer; font-size: 14px; }
|
||||
#notelist .item:hover { background: #eef2ff; }
|
||||
#notelist .item.active { background: #e0e7ff; font-weight: 600; }
|
||||
#notelist .item.nopage { opacity: .6; font-style: italic; }
|
||||
#notelist .matched { font-size: 11px; color: #888; }
|
||||
/* collapsible sidebar sections */
|
||||
.side-section { display: flex; align-items: center; gap: 7px; padding: 8px 10px;
|
||||
font-size: 13px; text-transform: uppercase; letter-spacing: .03em; color: #1f2937;
|
||||
font-weight: 700; cursor: pointer; user-select: none;
|
||||
background: #e9edf3; border-top: 1px solid #d3d9e2; border-bottom: 1px solid #d3d9e2; }
|
||||
.side-section:hover { background: #dfe5ee; }
|
||||
.side-section .caret { font-size: 11px; width: 11px; color: #6b7280; }
|
||||
.side-section .count { margin-left: auto; font-weight: 700; font-size: 11px;
|
||||
color: #fff; background: #98a2b3; border-radius: 9px; padding: 0 7px; min-width: 20px; text-align: center; }
|
||||
#main { flex: 1 1 auto; display: flex; flex-direction: column; min-width: 0; }
|
||||
#main header { padding: 6px 10px; display: flex; gap: 6px; align-items: center; border-bottom: 1px solid #dee2e6; }
|
||||
#main header h2 { font-size: 16px; margin: 0; flex: 1; padding: 2px 6px; border-radius: 4px; outline: none; min-width: 0; }
|
||||
#main header h2:hover { background: #f1f3f5; }
|
||||
#main header h2[contenteditable="true"]:focus { background: #fff; box-shadow: inset 0 0 0 2px #c7d2fe; }
|
||||
#editor { flex: 1; min-height: 0; }
|
||||
/* collapsible side panels */
|
||||
#sidebar.collapsed, #panel.collapsed { width: 0 !important; border: 0 !important; padding: 0 !important; }
|
||||
#panel { width: 240px; flex: 0 0 auto; border-left: 1px solid #dee2e6; overflow-y: auto; padding: 10px;
|
||||
font-size: 13px; background: #f8f9fa; transition: width .15s ease; }
|
||||
#panel h4 { margin: 12px 0 4px; color: #555; }
|
||||
#panel a { display: block; color: #2456c4; text-decoration: none; padding: 2px 0; }
|
||||
#panel .att { display: flex; gap: 4px; align-items: center; }
|
||||
#panel .badge { font-size: 10px; padding: 0 4px; border-radius: 3px; background: #eee; color: #666; }
|
||||
a.wikilink { color: #2456c4; cursor: pointer; }
|
||||
a.wikilink.missing { color: #c0392b; border-bottom: 1px dashed #c0392b; }
|
||||
button { cursor: pointer; }
|
||||
|
||||
/* jira badges */
|
||||
.jira-badge { display: inline-flex; align-items: center; gap: 4px; vertical-align: baseline;
|
||||
padding: 0 4px; border: 1px solid #dcdce0; border-radius: 4px; background: #fafafe; }
|
||||
.jira-badge a.jira-key { font-family: ui-monospace, monospace; font-size: 0.92em; color: #2456c4; text-decoration: none; cursor: pointer; }
|
||||
.jira-badge a.jira-ext { font-size: 0.85em; color: #888; text-decoration: none; }
|
||||
.jira-badge a.jira-ext:hover { color: #2456c4; }
|
||||
.side-key { font-family: ui-monospace, monospace; font-size: 11px; color: #8a8a93; margin-right: 4px; }
|
||||
#jirahead { display: inline-flex; align-items: center; gap: 6px; margin-right: 8px; }
|
||||
.jira-pill { font-size: 10px; line-height: 1.4; padding: 0 5px; border-radius: 8px;
|
||||
text-transform: uppercase; letter-spacing: .02em; color: #fff; white-space: nowrap; }
|
||||
.jira-pill.loading, .jira-pill.unknown { background: #b8b8c0; }
|
||||
.jira-pill.todo { background: #6b7280; }
|
||||
.jira-pill.inprogress { background: #2563eb; }
|
||||
.jira-pill.done { background: #16a34a; }
|
||||
#jira-hovercard { position: absolute; z-index: 9999; max-width: 360px; display: none;
|
||||
background: #fff; border: 1px solid #d0d0d8; border-radius: 6px; box-shadow: 0 4px 16px rgba(0,0,0,.15);
|
||||
padding: 10px 12px; font-size: 12px; line-height: 1.45; color: #222; }
|
||||
#jira-hovercard .hc-title { font-weight: 600; margin-bottom: 4px; }
|
||||
#jira-hovercard .hc-meta { color: #666; margin-bottom: 6px; }
|
||||
#jira-hovercard .hc-preview { color: #444; white-space: pre-wrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="sidebar">
|
||||
<header>
|
||||
<input id="search" class="form-control form-control-sm" placeholder="Search…" />
|
||||
<button class="btn btn-sm btn-primary" onclick="newNote()" title="New page (a Jira id creates a defect page)">+</button>
|
||||
</header>
|
||||
<div id="notelist"></div>
|
||||
</div>
|
||||
|
||||
<div id="main">
|
||||
<header>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="toggleLeft()" title="Toggle sidebar">☰</button>
|
||||
<h2 id="title" contenteditable="true" spellcheck="false" title="Click to rename"></h2>
|
||||
<span id="jirahead"></span>
|
||||
<span id="status" class="text-muted small">saved</span>
|
||||
<input id="upload" type="file" style="display:none" onchange="onPick(event)" />
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="document.getElementById('upload').click()">Attach</button>
|
||||
<button id="archiveBtn" class="btn btn-sm btn-outline-secondary" onclick="toggleArchive()">Archive</button>
|
||||
<button class="btn btn-sm btn-primary" onclick="checkpoint()" title="Save a checkpoint/version">Checkpoint</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="toggleRight()" title="Toggle panel">⮞</button>
|
||||
</header>
|
||||
<div id="editor"></div>
|
||||
</div>
|
||||
|
||||
<div id="panel"></div>
|
||||
<div id="jira-hovercard"></div>
|
||||
|
||||
<script>
|
||||
// --- state ---------------------------------------------------------------
|
||||
let editor, currentSlug = null, currentNote = null, slugSet = new Set();
|
||||
let loading = false; // guards programmatic content sets
|
||||
let dirtySince = false; // unsaved edits pending autosave
|
||||
let saveTimer = null;
|
||||
const clientId = Math.random().toString(36).slice(2);
|
||||
// accordion: at most one open section. null-from-storage -> default; "" -> all closed.
|
||||
let openSection = localStorage.getItem("openSection");
|
||||
if (openSection === null) openSection = "In Progress";
|
||||
|
||||
function setStatus(text, color) {
|
||||
const s = document.getElementById("status");
|
||||
s.textContent = text; s.style.color = color || "";
|
||||
}
|
||||
|
||||
// --- panel / section collapse (persisted) --------------------------------
|
||||
function applyPanelState() {
|
||||
document.getElementById("sidebar").classList.toggle("collapsed", localStorage.getItem("leftCollapsed") === "1");
|
||||
document.getElementById("panel").classList.toggle("collapsed", localStorage.getItem("rightCollapsed") === "1");
|
||||
}
|
||||
function toggleLeft() {
|
||||
localStorage.setItem("leftCollapsed", localStorage.getItem("leftCollapsed") === "1" ? "0" : "1");
|
||||
applyPanelState();
|
||||
}
|
||||
function toggleRight() {
|
||||
localStorage.setItem("rightCollapsed", localStorage.getItem("rightCollapsed") === "1" ? "0" : "1");
|
||||
applyPanelState();
|
||||
}
|
||||
function refreshAccordion() {
|
||||
document.querySelectorAll("#notelist .side-group").forEach(g => {
|
||||
const open = g.dataset.title === openSection;
|
||||
g.querySelector(".side-items").style.display = open ? "" : "none";
|
||||
g.querySelector(".caret").textContent = open ? "▾" : "▸";
|
||||
});
|
||||
}
|
||||
|
||||
// --- title helpers -------------------------------------------------------
|
||||
function titleText() { return document.getElementById("title").textContent.trim(); }
|
||||
function setTitle(t) { document.getElementById("title").textContent = t; }
|
||||
function setArchiveButton(archived) {
|
||||
document.getElementById("archiveBtn").textContent = archived ? "Unarchive" : "Archive";
|
||||
}
|
||||
|
||||
// --- jira badges ---------------------------------------------------------
|
||||
const jiraCache = new Map(); // key -> Promise<{ok, data}>
|
||||
function fetchJira(key) {
|
||||
if (!jiraCache.has(key)) {
|
||||
jiraCache.set(key, fetch("/api/jira/" + key)
|
||||
.then(r => r.json().then(data => ({ ok: r.ok, data })))
|
||||
.catch(() => ({ ok: false, data: {} })));
|
||||
}
|
||||
return jiraCache.get(key);
|
||||
}
|
||||
|
||||
const PILL_LABEL = { todo: "TODO", inprogress: "In Progress", done: "Done" };
|
||||
// slug whose leading segment is a Jira ticket -> uppercase key, else null
|
||||
// (matches both "ms-7764" and legacy "ms-7764-bare-metal-...")
|
||||
function jiraKeyFromSlug(slug) {
|
||||
const m = /^(ms-\d+)(?:-|$)/i.exec(slug || "");
|
||||
return m ? m[1].toUpperCase() : null;
|
||||
}
|
||||
|
||||
// Inline badge: key navigates to the internal note page, ↗ opens the Jira ticket.
|
||||
function jiraBadge(key) {
|
||||
const slug = slugify(key);
|
||||
const span = document.createElement("span");
|
||||
span.className = "jira-badge";
|
||||
|
||||
const k = document.createElement("a");
|
||||
k.className = "jira-key";
|
||||
k.href = "/wiki/" + slug;
|
||||
k.textContent = key;
|
||||
k.addEventListener("click", (e) => { e.preventDefault(); openNote(slug); });
|
||||
|
||||
const pill = document.createElement("span");
|
||||
pill.className = "jira-pill loading";
|
||||
pill.textContent = "…";
|
||||
|
||||
const ext = document.createElement("a");
|
||||
ext.className = "jira-ext";
|
||||
ext.target = "_blank"; ext.rel = "noopener";
|
||||
ext.href = "https://metalsoft.atlassian.net/browse/" + key;
|
||||
ext.textContent = "↗";
|
||||
ext.title = "Open in Jira";
|
||||
|
||||
span.append(k, pill, ext);
|
||||
|
||||
fetchJira(key).then(({ ok, data }) => {
|
||||
if (ok && data.category) {
|
||||
ext.href = data.url || ext.href;
|
||||
pill.className = "jira-pill " + data.category;
|
||||
pill.textContent = PILL_LABEL[data.category] || data.status || "?";
|
||||
span._jira = data;
|
||||
} else {
|
||||
pill.className = "jira-pill unknown";
|
||||
pill.textContent = data.error === "not_found" ? "?" : "—";
|
||||
}
|
||||
});
|
||||
|
||||
span.addEventListener("mouseenter", () => showJiraCard(span, key));
|
||||
span.addEventListener("mouseleave", hideJiraCard);
|
||||
return span;
|
||||
}
|
||||
|
||||
// header status for a Jira-backed page (title is locked = the Jira summary)
|
||||
async function renderJiraHead(key) {
|
||||
const head = document.getElementById("jirahead");
|
||||
head.innerHTML = "";
|
||||
if (!key) return;
|
||||
const { ok, data } = await fetchJira(key);
|
||||
const k = document.createElement("span"); k.className = "side-key"; k.textContent = key;
|
||||
const pill = document.createElement("span");
|
||||
pill.className = "jira-pill " + ((ok && data.category) ? data.category : "unknown");
|
||||
pill.textContent = (ok && data.category) ? (PILL_LABEL[data.category] || data.status) : "—";
|
||||
const ext = document.createElement("a");
|
||||
ext.className = "jira-ext"; ext.target = "_blank"; ext.rel = "noopener";
|
||||
ext.href = (ok && data.url) ? data.url : "https://metalsoft.atlassian.net/browse/" + key;
|
||||
ext.textContent = "↗"; ext.title = "Open in Jira";
|
||||
head.append(k, pill, ext);
|
||||
}
|
||||
|
||||
function showJiraCard(span, key) {
|
||||
const card = document.getElementById("jira-hovercard");
|
||||
const d = span._jira;
|
||||
if (!d) { card.style.display = "none"; return; }
|
||||
const meta = [d.status, d.type, d.assignee, d.priority].filter(Boolean).join(" · ");
|
||||
card.innerHTML =
|
||||
`<div class="hc-title">${key}: ${d.summary || ""}</div>` +
|
||||
`<div class="hc-meta">${meta}</div>` +
|
||||
(d.preview ? `<div class="hc-preview">${d.preview.replace(/</g, "<")}</div>` : "");
|
||||
const r = span.getBoundingClientRect();
|
||||
card.style.left = (window.scrollX + r.left) + "px";
|
||||
card.style.top = (window.scrollY + r.bottom + 4) + "px";
|
||||
card.style.display = "block";
|
||||
}
|
||||
function hideJiraCard() {
|
||||
document.getElementById("jira-hovercard").style.display = "none";
|
||||
}
|
||||
|
||||
// keep in sync with wikilib.slugify (Python)
|
||||
function slugify(t) {
|
||||
const s = (t || "").trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
return s || "untitled";
|
||||
}
|
||||
|
||||
// --- api -----------------------------------------------------------------
|
||||
const H = { "Content-Type": "application/json", "X-Origin": clientId };
|
||||
const api = {
|
||||
list: () => fetch("/api/notes").then(r => r.json()),
|
||||
sidebar: () => fetch("/api/sidebar").then(r => r.json()),
|
||||
get: (slug) => fetch("/api/notes/" + slug),
|
||||
create: (body) => fetch("/api/notes", { method: "POST", headers: H, body: JSON.stringify(body) }),
|
||||
update: (slug, body) => fetch("/api/notes/" + slug, { method: "PUT", headers: H, body: JSON.stringify(body) }),
|
||||
archive: (slug, archived) => fetch("/api/notes/" + slug + "/archive", { method: "POST", headers: H, body: JSON.stringify({ archived }) }),
|
||||
search: (q) => fetch("/api/search?q=" + encodeURIComponent(q)).then(r => r.json()),
|
||||
};
|
||||
|
||||
// --- sidebar -------------------------------------------------------------
|
||||
function itemEl(e) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "item" + (e.slug === currentSlug ? " active" : "") + (e.has_page === false ? " nopage" : "");
|
||||
const line = document.createElement("div");
|
||||
if (e.key) {
|
||||
const k = document.createElement("span"); k.className = "side-key"; k.textContent = e.key;
|
||||
const t = document.createElement("span"); t.textContent = e.title;
|
||||
line.append(k, t);
|
||||
} else {
|
||||
line.textContent = e.title;
|
||||
}
|
||||
div.appendChild(line);
|
||||
if (e.matched_in) {
|
||||
const m = document.createElement("div"); m.className = "matched";
|
||||
m.textContent = "match: " + e.matched_in; div.appendChild(m);
|
||||
}
|
||||
div.onclick = () => openNote(e.slug);
|
||||
return div;
|
||||
}
|
||||
|
||||
function sectionEl(title, entries) {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "side-group";
|
||||
wrap.dataset.title = title;
|
||||
const expanded = openSection === title;
|
||||
|
||||
const h = document.createElement("div");
|
||||
h.className = "side-section";
|
||||
h.innerHTML = `<span class="caret">${expanded ? "▾" : "▸"}</span><span>${title}</span>` +
|
||||
`<span class="count">${entries.length}</span>`;
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "side-items";
|
||||
entries.forEach(e => body.appendChild(itemEl(e)));
|
||||
body.style.display = expanded ? "" : "none";
|
||||
|
||||
h.onclick = () => {
|
||||
openSection = (openSection === title) ? "" : title; // toggle / accordion
|
||||
localStorage.setItem("openSection", openSection);
|
||||
refreshAccordion();
|
||||
};
|
||||
|
||||
wrap.append(h, body);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
async function renderSidebar() {
|
||||
const data = await api.sidebar();
|
||||
const el = document.getElementById("notelist");
|
||||
el.innerHTML = "";
|
||||
el.append(
|
||||
sectionEl("Notes", data.notes),
|
||||
sectionEl("In Progress", data.inprogress),
|
||||
sectionEl("Backlog", data.backlog),
|
||||
sectionEl("Archive", data.archive),
|
||||
);
|
||||
slugSet = new Set(
|
||||
[...data.notes, ...data.inprogress, ...data.backlog, ...data.archive]
|
||||
.filter(e => e.has_page).map(e => e.slug)
|
||||
);
|
||||
}
|
||||
|
||||
function renderFlat(items) { // flat list, used for search results
|
||||
const el = document.getElementById("notelist");
|
||||
el.innerHTML = "";
|
||||
items.forEach(n => el.appendChild(itemEl({ ...n, key: jiraKeyFromSlug(n.slug), has_page: true })));
|
||||
}
|
||||
|
||||
// --- note load / save ----------------------------------------------------
|
||||
function setBody(md) { // programmatic set: never counts as an edit
|
||||
loading = true;
|
||||
editor.setMarkdown(md || "", false);
|
||||
loading = false;
|
||||
}
|
||||
|
||||
function openNote(slug) {
|
||||
history.pushState({}, "", "/wiki/" + slug);
|
||||
loadNote(slug);
|
||||
}
|
||||
|
||||
async function loadNote(slug) {
|
||||
await flushSave(); // persist the page we're leaving (no version)
|
||||
currentSlug = slug;
|
||||
const res = await api.get(slug);
|
||||
if (res.status === 404) {
|
||||
const jkey = jiraKeyFromSlug(slug);
|
||||
if (jkey) { // defect with no page yet: auto-create silently
|
||||
await api.create({ slug, body_md: "" });
|
||||
return loadNote(slug);
|
||||
}
|
||||
if (confirm(`Note "${slug}" doesn't exist. Create it?`)) {
|
||||
await api.create({ slug, title: slug, body_md: "" });
|
||||
return loadNote(slug);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const note = await res.json();
|
||||
currentNote = note;
|
||||
setTitle(note.title);
|
||||
// Jira notes: title is the locked Jira summary; plain notes stay editable
|
||||
const isJira = !!(note.jira || jiraKeyFromSlug(slug));
|
||||
document.getElementById("title").setAttribute("contenteditable", isJira ? "false" : "true");
|
||||
renderJiraHead(isJira ? (note.jira ? note.jira.key : jiraKeyFromSlug(slug)) : null);
|
||||
setBody(note.body_md);
|
||||
dirtySince = false;
|
||||
setStatus("saved");
|
||||
setArchiveButton(note.archived);
|
||||
slugSet = new Set([...slugSet, ...note.links.filter(l => l.exists).map(l => l.slug)]);
|
||||
renderPanel(note);
|
||||
renderSidebar();
|
||||
}
|
||||
|
||||
// --- save: debounced autosave (no version) + explicit checkpoint (version) ---
|
||||
function scheduleSave() {
|
||||
dirtySince = true;
|
||||
setStatus("●", "#c0392b");
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(flushSave, 700);
|
||||
}
|
||||
|
||||
async function flushSave() {
|
||||
clearTimeout(saveTimer);
|
||||
if (!dirtySince || !currentSlug) return;
|
||||
dirtySince = false;
|
||||
setStatus("saving…");
|
||||
await putNote(null);
|
||||
setStatus("saved");
|
||||
}
|
||||
|
||||
async function putNote(message) {
|
||||
if (!currentSlug) return null;
|
||||
const res = await api.update(currentSlug, {
|
||||
title: titleText(),
|
||||
body_md: editor.getMarkdown(),
|
||||
message,
|
||||
});
|
||||
const note = await res.json();
|
||||
currentNote = note;
|
||||
if (jiraKeyFromSlug(currentSlug)) setTitle(note.title); // keep locked title fresh
|
||||
renderPanel(note);
|
||||
renderSidebar();
|
||||
return note;
|
||||
}
|
||||
|
||||
async function checkpoint() {
|
||||
if (!currentSlug) return;
|
||||
await flushSave();
|
||||
const label = prompt("Checkpoint label (optional):");
|
||||
await putNote(label && label.trim() ? label.trim() : "checkpoint");
|
||||
setStatus("checkpoint saved", "#16a34a");
|
||||
}
|
||||
|
||||
async function toggleArchive() {
|
||||
if (!currentSlug || !currentNote) return;
|
||||
const next = !currentNote.archived;
|
||||
await api.archive(currentSlug, next);
|
||||
currentNote.archived = next;
|
||||
setArchiveButton(next);
|
||||
renderSidebar();
|
||||
}
|
||||
|
||||
// New page. If the name is a Jira id (e.g. MS-7764), the server makes it a
|
||||
// defect page with the title pulled from Jira — no separate action needed.
|
||||
async function newNote() {
|
||||
const name = prompt("New page — title, or a Jira id like MS-7764:");
|
||||
if (!name || !name.trim()) return;
|
||||
const slug = slugify(name);
|
||||
const existing = await api.get(slug);
|
||||
if (existing.ok) { openNote(slug); return; } // already exists → just open
|
||||
const res = await api.create({ slug, title: name.trim(), body_md: "" });
|
||||
if (res.status === 409) { alert("A page with that name already exists."); return; }
|
||||
openNote((await res.json()).slug);
|
||||
}
|
||||
|
||||
// --- right panel: backlinks + attachments --------------------------------
|
||||
function renderPanel(note) {
|
||||
const p = document.getElementById("panel");
|
||||
let html = "<h4>Backlinks</h4>";
|
||||
html += (note.backlinks || []).length
|
||||
? note.backlinks.map(b => `<a href="/wiki/${b.slug}">${b.title}</a>`).join("")
|
||||
: "<em>none</em>";
|
||||
html += "<h4>Attachments</h4>";
|
||||
html += (note.attachments || []).length
|
||||
? note.attachments.map(a =>
|
||||
`<div class="att"><a href="${a.url}" target="_blank">${a.filename}</a>` +
|
||||
`<span class="badge">${a.ocr_status}</span></div>`).join("")
|
||||
: "<em>none</em>";
|
||||
p.innerHTML = html;
|
||||
}
|
||||
|
||||
async function refreshPanel() {
|
||||
if (!currentSlug) return;
|
||||
const r = await api.get(currentSlug);
|
||||
if (r.ok) renderPanel(await r.json());
|
||||
}
|
||||
|
||||
// --- uploads (paste / drop / picker) -------------------------------------
|
||||
async function upload(file) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
if (currentSlug) fd.append("note", currentSlug);
|
||||
const res = await fetch("/attachments", { method: "POST", body: fd });
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// arbitrary (incl. non-image) file: upload + insert a link/image, refresh panel
|
||||
async function handleFile(file) {
|
||||
const a = await upload(file);
|
||||
const md = a.mime_type.startsWith("image/")
|
||||
? `\n\n`
|
||||
: `\n[${a.filename}](${a.url})\n`;
|
||||
editor.insertText(md);
|
||||
refreshPanel();
|
||||
}
|
||||
|
||||
function onPick(e) {
|
||||
const f = e.target.files[0];
|
||||
if (f) handleFile(f);
|
||||
e.target.value = "";
|
||||
}
|
||||
|
||||
// --- boot ----------------------------------------------------------------
|
||||
window.addEventListener("DOMContentLoaded", async () => {
|
||||
editor = new toastui.Editor({
|
||||
el: document.getElementById("editor"),
|
||||
height: "100%",
|
||||
initialEditType: "markdown",
|
||||
previewStyle: "vertical",
|
||||
usageStatistics: false,
|
||||
// render [[wiki links]] as clickable inline widgets (both modes)
|
||||
widgetRules: [
|
||||
{
|
||||
rule: /\[\[([^\]]+)\]\]/,
|
||||
toDOM(text) {
|
||||
const label = text.slice(2, -2);
|
||||
const slug = slugify(label);
|
||||
const jkey = jiraKeyFromSlug(slug);
|
||||
if (jkey) return jiraBadge(jkey); // [[MS-7764]] renders as a status badge
|
||||
const a = document.createElement("a");
|
||||
a.className = "wikilink" + (slugSet.has(slug) ? "" : " missing");
|
||||
a.textContent = label;
|
||||
a.href = "/wiki/" + slug;
|
||||
a.addEventListener("click", (e) => { e.preventDefault(); openNote(slug); });
|
||||
return a;
|
||||
},
|
||||
},
|
||||
{
|
||||
// Jira keys (project MS), but not when embedded in a URL/word
|
||||
rule: /(?<![\w./-])MS-\d+(?![\w-])/,
|
||||
toDOM(text) { return jiraBadge(text); },
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
// fires for pasted / dropped / toolbar images — reuse /attachments
|
||||
addImageBlobHook: async (blob, cb) => {
|
||||
const a = await upload(blob);
|
||||
cb(a.url, a.filename);
|
||||
refreshPanel();
|
||||
},
|
||||
},
|
||||
events: {
|
||||
change: () => { if (!loading) scheduleSave(); },
|
||||
},
|
||||
});
|
||||
|
||||
// editable title (plain notes): autosave on rename; Enter commits (blurs)
|
||||
const titleEl = document.getElementById("title");
|
||||
titleEl.addEventListener("input", () => { if (!loading) scheduleSave(); });
|
||||
titleEl.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") { e.preventDefault(); titleEl.blur(); }
|
||||
});
|
||||
|
||||
// Ctrl/Cmd+S creates a checkpoint
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") { e.preventDefault(); checkpoint(); }
|
||||
});
|
||||
|
||||
// drag & drop arbitrary (non-image) files — TUI's hook already handles images
|
||||
const ed = document.getElementById("editor");
|
||||
ed.addEventListener("dragover", (e) => e.preventDefault());
|
||||
ed.addEventListener("drop", (e) => {
|
||||
const files = [...(e.dataTransfer?.files || [])].filter(f => !f.type.startsWith("image/"));
|
||||
if (!files.length) return; // let TUI handle image drops itself
|
||||
e.preventDefault();
|
||||
files.forEach(handleFile);
|
||||
});
|
||||
|
||||
// flush the last sub-second of edits if the tab is hidden/closed (keepalive PUT)
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "hidden" && dirtySince && currentSlug) {
|
||||
dirtySince = false;
|
||||
fetch("/api/notes/" + currentSlug, {
|
||||
method: "PUT", headers: H, keepalive: true,
|
||||
body: JSON.stringify({ title: titleText(), body_md: editor.getMarkdown(), message: null }),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("search").addEventListener("input", async (e) => {
|
||||
const q = e.target.value.trim();
|
||||
if (!q) return renderSidebar();
|
||||
renderFlat(await api.search(q));
|
||||
});
|
||||
|
||||
applyPanelState();
|
||||
connectWS();
|
||||
window.addEventListener("popstate", boot);
|
||||
await boot();
|
||||
});
|
||||
|
||||
// live sync: refresh when another instance changes a note
|
||||
function editorFocused() {
|
||||
const ed = document.getElementById("editor");
|
||||
return document.activeElement && ed.contains(document.activeElement);
|
||||
}
|
||||
function connectWS() {
|
||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||
const ws = new WebSocket(`${proto}://${location.host}/ws`);
|
||||
ws.onmessage = (ev) => {
|
||||
let msg; try { msg = JSON.parse(ev.data); } catch (e) { return; }
|
||||
if (msg.origin === clientId) return; // ignore our own echo
|
||||
renderSidebar();
|
||||
if (msg.slug === currentSlug && !dirtySince && !editorFocused()) {
|
||||
loadNote(currentSlug); // reload an idle, current page
|
||||
}
|
||||
};
|
||||
ws.onclose = () => setTimeout(connectWS, 2000); // auto-reconnect
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
await renderSidebar();
|
||||
const m = location.pathname.match(/^\/wiki\/(.+)$/);
|
||||
if (m) { loadNote(decodeURIComponent(m[1])); return; }
|
||||
const notes = await api.list();
|
||||
if (notes.length) loadNote(notes[0].slug);
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user