Beter layouting

This commit is contained in:
2026-08-14 22:54:00 +02:00
parent 40ad35b418
commit c54216fae2
9 changed files with 463 additions and 83 deletions
+129 -48
View File
@@ -9,7 +9,9 @@
newPageSlug: null,
previousView: "page-view",
translations: {},
translationPage: "wiki-translations-en"
translationPage: "wiki-translations",
translationTemplate: "",
siteTitle: "Racket Wiki"
};
let easyMDE = null;
@@ -17,9 +19,10 @@
const $ = (id) => document.getElementById(id);
function show(viewId) {
for (const id of ["page-view", "not-found-view", "editor-view", "search-view", "todo-view", "history-view", "admin-view"]) {
for (const id of ["page-view", "not-found-view", "editor-view", "search-view", "todo-view", "history-view", "admin-view", "user-admin-view"]) {
$(id).classList.toggle("hidden", id !== viewId);
}
$("page-action-links").classList.toggle("hidden", viewId !== "page-view");
document.body.classList.toggle("editor-mode", viewId === "editor-view");
}
@@ -98,8 +101,45 @@
return body;
}
function applyImageWidthMarkup(html) {
const imageOptionsSuffix = /(<img\b[^>]*>)\s*\{\s*width\s*=\s*([0-9]+(?:\.[0-9]+)?)(%|px)?(?:\s+(left|center|right))?(?:\s+(float))?(?:\s+float\s*=\s*(left|right))?\s*\}/gi;
return (html || "").replace(imageOptionsSuffix, (_match, imageTag, amount, unit, align, floatFlag, floatSide) => {
const width = `${amount}${unit || "px"}`;
const closing = imageTag.endsWith("/>") ? "/>" : ">";
let tagBody = imageTag.slice(0, imageTag.length - closing.length);
const classes = [];
const effectiveFloatSide = floatSide || (floatFlag && (align === "left" || align === "right") ? align : null);
if (effectiveFloatSide) {
classes.push(`wiki-image-float-${effectiveFloatSide}`);
} else if (align) {
classes.push(`wiki-image-${align}`);
}
if (/\sstyle=(['"])(.*?)\1/i.test(tagBody)) {
tagBody = tagBody.replace(/\sstyle=(['"])(.*?)\1/i, (_style, quote, value) =>
` style=${quote}${value}; width: ${width}${quote}`);
} else {
tagBody += ` style="width: ${width}"`;
}
if (classes.length > 0) {
if (/\sclass=(['"])(.*?)\1/i.test(tagBody)) {
tagBody = tagBody.replace(/\sclass=(['"])(.*?)\1/i, (_class, quote, value) =>
` class=${quote}${value} ${classes.join(" ")}${quote}`);
} else {
tagBody += ` class="${classes.join(" ")}"`;
}
}
return `${tagBody}${closing}`;
});
}
function renderMarkdown(markdown) {
return easyMDE.markdown(expandTodoMarkup(markdown || ""));
const html = easyMDE.markdown(expandTodoMarkup(markdown || ""));
return DOMPurify.sanitize(applyImageWidthMarkup(html));
}
function slugTitle(slug) {
@@ -261,7 +301,7 @@
.then(onSuccess)
.catch((error) => onError(error.message));
},
previewRender: (plainText) => easyMDE ? easyMDE.markdown(expandTodoMarkup(plainText)) : "",
previewRender: (plainText) => easyMDE ? renderMarkdown(plainText) : "",
renderingConfig: {
codeSyntaxHighlighting: true,
hljs: window.hljs,
@@ -474,7 +514,7 @@
function pageBreadcrumbs(page, suffix = null) {
const items = [
{ label: "Racket Wiki", href: "/" },
{ label: state.siteTitle, href: "/" },
{ label: page?.title || page?.slug || "New page" }
];
@@ -486,6 +526,25 @@
renderBreadcrumbs(items);
}
function startPage() {
const namedStartPage = state.pages.find((page) => page.slug === "start") || null;
if (namedStartPage) return namedStartPage;
if (state.pages.length === 0) return null;
return state.pages.reduce((first, page) => {
if (!first) return page;
return Number(page.createdAt) < Number(first.createdAt) ? page : first;
}, null);
}
function updateWikiIdentity() {
const firstPage = startPage();
state.siteTitle = firstPage?.title || "Racket Wiki";
$("wiki-brand").textContent = state.siteTitle;
$("wiki-brand").href = firstPage ? `#/${encodeURIComponent(firstPage.slug)}` : "#";
document.title = state.siteTitle;
}
function renderPageList() {
const list = $("page-list");
list.replaceChildren();
@@ -506,6 +565,7 @@
async function loadPages() {
const result = await api("/api/pages");
state.pages = result.pages;
updateWikiIdentity();
renderPageList();
}
@@ -545,14 +605,7 @@
$("editor-title").value = translationPage ? tr("translations", "Translations") : "";
$("editor-tags").value = "";
if (translationPage) {
const lines = [
"# Translation overrides",
"",
"Edit only the values after =. Unknown keys are ignored by the UI until they are used.",
""
];
Object.keys(state.translations).sort().forEach((key) => lines.push(`${key} = ${state.translations[key]}`));
easyMDE.value(lines.join("\n"));
easyMDE.value(state.translationTemplate || "");
} else {
easyMDE.value("");
}
@@ -620,6 +673,14 @@
state.editingNew = false;
state.newPageSlug = null;
await loadPages();
if (page.slug === state.translationPage) {
const translationData = await api("/api/translations");
state.language = translationData.language;
state.translationTemplate = translationData.template || state.translationTemplate;
state.translations = translationData.translations || {};
applyTranslations();
$("account-role").textContent = tr(`role-${state.session.user.role}`, state.session.user.role);
}
location.hash = `#/${encodeURIComponent(page.slug)}`;
await openPage(page.slug);
$("save-status").textContent = tr("saved", "Saved");
@@ -730,11 +791,12 @@
state.currentPage = null;
await loadPages();
if (state.pages.length > 0) {
location.hash = `#/${encodeURIComponent(state.pages[0].slug)}`;
const firstPage = startPage();
location.hash = `#/${encodeURIComponent(firstPage.slug)}`;
} else {
$("page-title").textContent = tr("no-pages", "No pages yet");
$("markdown-preview").innerHTML = "";
renderBreadcrumbs([{ label: "Racket Wiki" }]);
renderBreadcrumbs([{ label: state.siteTitle }]);
renderToc([], () => {});
show("page-view");
}
@@ -819,7 +881,7 @@
const result = await api(`/api/search?q=${encodeURIComponent(text)}`);
renderBreadcrumbs([
{ label: "Racket Wiki", href: "/" },
{ label: state.siteTitle, href: "/" },
{ label: tr("search", "Search") }
]);
renderToc([], () => {});
@@ -899,10 +961,22 @@
});
}
async function loadAdmin() {
function showAdmin() {
state.previousView = state.currentPage ? "page-view" : "admin-view";
renderBreadcrumbs([]);
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("admin", "Admin") }
]);
show("admin-view");
}
async function loadUsersAdmin() {
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("admin", "Admin") },
{ label: tr("users", "Users") }
]);
show("user-admin-view");
const result = await api("/api/admin/users");
const list = $("user-list");
list.replaceChildren();
@@ -917,7 +991,7 @@
for (const roleName of ["reader", "editor", "admin"]) {
const option = document.createElement("option");
option.value = roleName;
option.textContent = roleName;
option.textContent = tr(`role-${roleName}`, roleName);
option.selected = roleName === user.role;
role.append(option);
}
@@ -949,7 +1023,7 @@
remove.addEventListener("click", async () => {
if (!confirm(`Delete user ${user.username}?`)) return;
await api(`/api/admin/users/${user.id}`, { method: "DELETE" });
await loadAdmin();
await loadUsersAdmin();
});
actions.append(save, remove);
row.append(username, display, role, enabled, password, actions);
@@ -963,7 +1037,7 @@
state.newPageSlug = null;
$("not-found-message").textContent = `${tr("page-does-not-exist", "The page does not exist.")} (${slug})`;
renderBreadcrumbs([
{ label: "Racket Wiki", href: "/" },
{ label: state.siteTitle, href: "/" },
{ label: slug }
]);
show("not-found-view");
@@ -980,7 +1054,7 @@
$("markdown-preview").innerHTML = "";
$("page-details").replaceChildren();
renderBreadcrumbs([
{ label: "Racket Wiki", href: "/" },
{ label: state.siteTitle, href: "/" },
{ label: slugTitle(slug) || slug }
]);
show("page-view");
@@ -1008,7 +1082,8 @@
return;
}
if (state.pages.length > 0) {
location.hash = `#/${encodeURIComponent(state.pages[0].slug)}`;
const firstPage = startPage();
location.hash = `#/${encodeURIComponent(firstPage.slug)}`;
} else {
if (can("editor")) {
showMissingEditablePage("start");
@@ -1017,7 +1092,7 @@
$("page-title").textContent = tr("no-pages", "No pages yet");
$("page-meta").textContent = "";
$("markdown-preview").innerHTML = "";
renderBreadcrumbs([{ label: "Racket Wiki" }]);
renderBreadcrumbs([{ label: state.siteTitle }]);
renderToc([], () => {});
show("page-view");
}
@@ -1027,7 +1102,7 @@
async function showTodos() {
state.previousView = state.currentPage ? "page-view" : "todo-view";
renderBreadcrumbs([
{ label: "Racket Wiki", href: "/" },
{ label: state.siteTitle, href: "/" },
{ label: tr("todo", "Todo") }
]);
renderToc([], () => {});
@@ -1097,24 +1172,12 @@
const translationData = await api("/api/translations");
state.language = translationData.language;
state.translationPage = translationData.page;
state.translationTemplate = translationData.template || "";
state.translations = translationData.translations || {};
applyTranslations();
initializeEditor();
const userBox = $("user-box");
userBox.replaceChildren();
const displayName = document.createTextNode(state.session.user.displayName);
const breakNode = document.createElement("br");
const role = document.createElement("span");
role.className = "muted";
role.textContent = state.session.user.role;
const logout = document.createElement("button");
logout.id = "logout";
logout.textContent = tr("sign-out", "Sign out");
userBox.append(displayName, breakNode, role, " ", logout);
logout.addEventListener("click", async () => {
await api("/api/logout", { method: "POST", body: "{}" });
window.location.replace("/login");
});
$("account-name").textContent = state.session.user.displayName;
$("account-role").textContent = tr(`role-${state.session.user.role}`, state.session.user.role);
updateRoleUi();
await loadPages();
await route();
@@ -1128,14 +1191,32 @@
searchWiki($("search-input").value).catch((error) => console.error(error));
});
$("edit-page").addEventListener("click", beginEditPage);
$("delete-page").addEventListener("click", deleteCurrentPage);
$("history-page").addEventListener("click", showHistory);
$("todo-button").addEventListener("click", () => showTodos().catch((error) => console.error(error)));
$("translations-button").addEventListener("click", () => { location.hash = `#/${encodeURIComponent(state.translationPage)}`; });
$("admin-button").addEventListener("click", loadAdmin);
$("edit-page").addEventListener("click", (event) => { event.preventDefault(); beginEditPage(); });
$("delete-page").addEventListener("click", (event) => { event.preventDefault(); deleteCurrentPage(); });
$("history-page").addEventListener("click", (event) => { event.preventDefault(); showHistory(); });
$("todo-link").addEventListener("click", (event) => { event.preventDefault(); showTodos().catch((error) => console.error(error)); });
$("logout-link").addEventListener("click", async (event) => {
event.preventDefault();
await api("/api/logout", { method: "POST", body: "{}" });
window.location.replace("/login");
});
$("admin-link").addEventListener("click", (event) => { event.preventDefault(); showAdmin(); });
$("admin-users-link").addEventListener("click", (event) => { event.preventDefault(); loadUsersAdmin().catch(console.error); });
$("admin-translations-link").addEventListener("click", (event) => {
event.preventDefault();
const page = state.pages.find((item) => item.slug === state.translationPage);
if (page) {
location.hash = `#/${encodeURIComponent(state.translationPage)}`;
return;
}
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("translations", "Translations") }
]);
beginNewPage(state.translationPage);
});
$("close-history").addEventListener("click", () => show("page-view"));
$("close-admin").addEventListener("click", () => state.currentPage ? show("page-view") : route());
$("close-user-admin").addEventListener("click", (event) => { event.preventDefault(); showAdmin(); });
$("cancel-edit").addEventListener("click", () => {
if (state.currentPage) {
openPage(state.currentPage.slug);
@@ -1167,7 +1248,7 @@
})
});
event.target.reset();
await loadAdmin();
await loadUsersAdmin();
});
window.addEventListener("hashchange", () => route().catch(console.error));