Update static/js/06-pages.js

This commit is contained in:
admin 2026-10-08 00:32:38 +00:00
parent d2792f32ec
commit fd808d681b

View File

@ -1,18 +1,14 @@
// static/js/06-pages.js
"use strict";
window.Glow = window.Glow || {};
Glow.renderPagesEmptyState = message => {
Glow.els.pagesBox.replaceChildren();
const state = document.createElement("div");
state.className = "empty-state";
const card = document.createElement("div");
card.className = "empty-card";
card.textContent = message;
state.append(card);
Glow.els.pagesBox.append(state);
};
@ -24,29 +20,51 @@ Glow.pageTypeLabel = type => {
update: "Update",
resource: "Resource"
};
return labels[type] || "Page";
};
Glow.apiListPageVersions = (teamSlug, pageSlug) =>
Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}/pages/${encodeURIComponent(pageSlug)}/versions`);
Glow.apiGetPageVersion = versionId =>
Glow.apiRequest(`/api/page-versions/${encodeURIComponent(versionId)}`);
Glow.apiRestorePageVersion = versionId =>
Glow.apiRequest(`/api/page-versions/${encodeURIComponent(versionId)}/restore`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
});
Glow.apiListPageComments = (teamSlug, pageSlug) =>
Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}/pages/${encodeURIComponent(pageSlug)}/comments`);
Glow.apiCreatePageComment = (teamSlug, pageSlug, payload) =>
Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}/pages/${encodeURIComponent(pageSlug)}/comments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
Glow.apiDeletePageComment = commentId =>
Glow.apiRequest(`/api/page-comments/${encodeURIComponent(commentId)}`, {
method: "DELETE"
});
Glow.apiCreatePageShare = (teamSlug, pageSlug) =>
Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}/pages/${encodeURIComponent(pageSlug)}/share`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
});
Glow.loadPages = async (pageType = Glow.state.pageTypeFilter) => {
const team = Glow.state.activeTeam;
if (!team) {
Glow.renderPagesEmptyState("Select a team to view pages.");
return;
}
Glow.els.pagesBox.setAttribute("aria-busy", "true");
try {
const query = pageType ? `?type=${encodeURIComponent(pageType)}` : "";
const data = await Glow.apiRequest(
`/api/teams/${encodeURIComponent(team.slug)}/pages${query}`
);
if (Glow.state.activeTeam?.slug !== team.slug) return;
Glow.state.pages = data.pages || [];
Glow.state.pageTypeFilter = pageType || "";
Glow.renderPages();
@ -62,20 +80,15 @@ Glow.renderPages = () => {
const box = Glow.els.pagesBox;
const team = Glow.state.activeTeam;
const pages = Glow.state.pages || [];
box.replaceChildren();
const header = document.createElement("div");
header.className = "pages-header";
const copy = document.createElement("div");
const title = document.createElement("h2");
title.textContent = `${team?.name || "Team"} pages`;
const subtitle = document.createElement("p");
subtitle.textContent = "Shared team knowledge and public resources.";
copy.append(title, subtitle);
header.append(copy);
@ -85,15 +98,12 @@ Glow.renderPages = () => {
create.type = "button";
create.textContent = "New page";
create.addEventListener("click", Glow.createPage);
header.append(create);
}
box.append(header);
const filters = document.createElement("div");
filters.className = "page-filters";
const filterOptions = [
["", "All"],
["project", "Projects"],
@ -101,28 +111,23 @@ Glow.renderPages = () => {
["update", "Updates"],
["resource", "Resources"]
];
for (const [value, label] of filterOptions) {
const button = document.createElement("button");
button.className = "page-filter";
button.type = "button";
button.textContent = label;
button.classList.toggle("active", Glow.state.pageTypeFilter === value);
button.addEventListener("click", () => Glow.loadPages(value));
filters.append(button);
}
box.append(filters);
if (!pages.length) {
const empty = document.createElement("div");
empty.className = "empty-state page-empty-state";
const card = document.createElement("div");
card.className = "empty-card";
card.textContent = "No pages match this filter.";
empty.append(card);
box.append(empty);
return;
@ -130,11 +135,9 @@ Glow.renderPages = () => {
const list = document.createElement("div");
list.className = "pages-list";
for (const page of pages) {
list.append(Glow.buildPageCard(page));
}
box.append(list);
};
@ -161,9 +164,7 @@ Glow.buildPageCard = page => {
const excerpt = document.createElement("p");
excerpt.className = "page-card-excerpt";
const compactBody = (page.body || "").replace(/\s+/g, " ").trim();
excerpt.textContent =
compactBody.length > 180
? `${compactBody.slice(0, 180)}…`
@ -171,19 +172,20 @@ Glow.buildPageCard = page => {
const footer = document.createElement("div");
footer.className = "page-card-footer";
const context = document.createElement("span");
context.textContent = page.channel_name ? `#${page.channel_name}` : "Team page";
context.textContent = page.parent_page_title
? `Child of ${page.parent_page_title}`
: page.channel_name
? `#${page.channel_name}`
: "Team page";
const updated = document.createElement("time");
updated.dateTime = page.updated_at;
updated.title = new Date(page.updated_at).toLocaleString();
updated.textContent = `Updated ${Glow.relativeTime(page.updated_at)}`;
footer.append(context, updated);
const open = () => Glow.openPage(page.slug);
article.addEventListener("click", open);
article.addEventListener("keydown", event => {
if (event.key === "Enter" || event.key === " ") {
@ -200,7 +202,6 @@ Glow.openPages = async () => {
Glow.state.activeDm = null;
Glow.state.activeView = "pages";
Glow.state.activePage = null;
Glow.updateViewUI();
await Glow.loadPages();
};
@ -208,18 +209,14 @@ Glow.openPages = async () => {
Glow.openPage = async pageSlug => {
const team = Glow.state.activeTeam;
if (!team) return;
Glow.state.activeView = "page";
Glow.state.activePage = null;
Glow.updateViewUI();
Glow.els.pageDetailBox.setAttribute("aria-busy", "true");
try {
const data = await Glow.apiRequest(
`/api/teams/${encodeURIComponent(team.slug)}/pages/${encodeURIComponent(pageSlug)}`
);
Glow.state.activePage = data.page;
Glow.renderPageDetail();
} catch (error) {
@ -230,20 +227,248 @@ Glow.openPage = async pageSlug => {
}
};
Glow.renderPageComments = page => {
const section = document.createElement("section");
section.className = "profile-section";
const heading = document.createElement("h3");
heading.textContent = "Comments";
section.append(heading);
const list = document.createElement("div");
list.className = "page-comments-list";
const comments = Array.isArray(page.comments) ? page.comments : [];
if (!comments.length) {
const empty = document.createElement("div");
empty.className = "profile-empty";
empty.textContent = "No comments yet.";
list.append(empty);
} else {
for (const comment of comments) {
const item = document.createElement("article");
item.className = "page-comment";
const top = document.createElement("div");
top.className = "page-comment-meta";
const author = document.createElement("strong");
author.textContent = comment.username;
author.style.color = comment.user_name_color || "";
const time = document.createElement("time");
time.dateTime = comment.created_at;
time.textContent = new Date(comment.created_at).toLocaleString();
top.append(author, time);
const body = document.createElement("div");
body.className = "page-comment-body";
body.textContent = comment.body;
item.append(top, body);
const canDelete =
Glow.state.currentUser &&
(Number(Glow.state.currentUser.id) === Number(comment.user_id) ||
Glow.state.currentUser.role === "admin" ||
page.can_edit);
if (canDelete) {
const remove = document.createElement("button");
remove.className = "small-button danger";
remove.type = "button";
remove.textContent = "Delete";
remove.addEventListener("click", async () => {
try {
await Glow.apiDeletePageComment(comment.id);
await Glow.openPage(page.slug);
Glow.showToast("Comment deleted.");
} catch (error) {
Glow.showToast(error.message || "Could not delete comment.", "error");
}
});
item.append(remove);
}
list.append(item);
}
}
section.append(list);
if (Glow.state.currentUser) {
const form = document.createElement("form");
form.className = "page-comment-form";
const area = document.createElement("textarea");
area.className = "field";
area.rows = 4;
area.maxLength = 3000;
area.placeholder = "Add a comment";
const submit = document.createElement("button");
submit.className = "button";
submit.type = "submit";
submit.textContent = "Post comment";
form.append(area, submit);
form.addEventListener("submit", async event => {
event.preventDefault();
const body = area.value.trim();
if (!body) return;
try {
await Glow.apiCreatePageComment(page.team_slug, page.slug, { body });
await Glow.openPage(page.slug);
Glow.showToast("Comment added.");
} catch (error) {
Glow.showToast(error.message || "Could not add comment.", "error");
}
});
section.append(form);
}
return section;
};
Glow.renderPageBacklinks = page => {
const section = document.createElement("section");
section.className = "profile-section";
const heading = document.createElement("h3");
heading.textContent = "Backlinks";
section.append(heading);
const backlinks = Array.isArray(page.backlinks) ? page.backlinks : [];
if (!backlinks.length) {
const empty = document.createElement("div");
empty.className = "profile-empty";
empty.textContent = "No backlinks yet.";
section.append(empty);
return section;
}
const list = document.createElement("div");
list.className = "page-related-list";
for (const link of backlinks) {
const button = document.createElement("button");
button.className = "small-button";
button.type = "button";
button.textContent = link.title;
button.addEventListener("click", () => Glow.openPage(link.slug));
list.append(button);
}
section.append(list);
return section;
};
Glow.renderPageChildren = page => {
const section = document.createElement("section");
section.className = "profile-section";
const heading = document.createElement("h3");
heading.textContent = "Child pages";
section.append(heading);
const children = Array.isArray(page.children) ? page.children : [];
if (!children.length) {
const empty = document.createElement("div");
empty.className = "profile-empty";
empty.textContent = "No child pages.";
section.append(empty);
return section;
}
const list = document.createElement("div");
list.className = "page-related-list";
for (const child of children) {
const button = document.createElement("button");
button.className = "small-button";
button.type = "button";
button.textContent = child.title;
button.addEventListener("click", () => Glow.openPage(child.slug));
list.append(button);
}
section.append(list);
return section;
};
Glow.openPageVersionHistory = async page => {
try {
const data = await Glow.apiListPageVersions(page.team_slug, page.slug);
const container = document.createElement("div");
container.className = "member-list";
const versions = data.versions || [];
if (!versions.length) {
const empty = document.createElement("div");
empty.className = "member-row";
empty.textContent = "No version history yet.";
container.append(empty);
} else {
for (const version of versions) {
const row = document.createElement("div");
row.className = "member-row";
const left = document.createElement("button");
left.className = "small-button";
left.type = "button";
left.textContent = `v${version.version_number} · ${version.title}`;
left.addEventListener("click", async () => {
try {
const detail = await Glow.apiGetPageVersion(version.id);
const body = document.createElement("div");
body.className = "page-body markdown-body";
body.innerHTML = detail.version.body_html || "<p>No content.</p>";
const result = await Glow.openDialog({
title: `Version ${detail.version.version_number}`,
body,
confirmText: page.can_edit ? "Restore version" : "Close",
cancelText: "Close",
danger: page.can_edit
});
if (result && page.can_edit) {
await Glow.apiRestorePageVersion(version.id);
Glow.showToast("Version restored.");
await Glow.openPage(page.slug);
}
} catch (error) {
Glow.showToast(error.message || "Could not load version.", "error");
}
});
const right = document.createElement("span");
right.textContent = new Date(version.created_at).toLocaleString();
row.append(left, right);
container.append(row);
}
}
await Glow.openDialog({
title: "Version history",
body: container,
confirmText: "Close",
cancelText: "Close"
});
} catch (error) {
Glow.showToast(error.message || "Could not load version history.", "error");
}
};
Glow.shareCurrentPage = async page => {
try {
const data = await Glow.apiCreatePageShare(page.team_slug, page.slug);
const url = new URL(data.share.url, window.location.origin).toString();
await navigator.clipboard.writeText(url);
Glow.showToast("Page share link copied.");
} catch (error) {
Glow.showToast(error.message || "Could not create page share.", "error");
}
};
Glow.renderPageDetail = () => {
const box = Glow.els.pageDetailBox;
const page = Glow.state.activePage;
box.replaceChildren();
if (!page) {
const state = document.createElement("div");
state.className = "empty-state";
const card = document.createElement("div");
card.className = "empty-card";
card.textContent = "Page not found.";
state.append(card);
box.append(state);
return;
@ -276,7 +501,9 @@ Glow.renderPageDetail = () => {
const context = document.createElement("p");
context.className = "page-detail-context";
context.textContent = page.channel_name
context.textContent = page.parent_page_title
? `${page.team_name} · Child of ${page.parent_page_title}`
: page.channel_name
? `${page.team_name} · #${page.channel_name}`
: page.team_name;
@ -286,13 +513,35 @@ Glow.renderPageDetail = () => {
const body = document.createElement("article");
body.className = "page-body markdown-body";
body.innerHTML = page.body_html || "<p>No content yet.</p>";
box.append(body);
if (page.can_edit) {
const related = document.createElement("div");
related.className = "profile-grid";
related.append(
Glow.renderPageChildren(page),
Glow.renderPageBacklinks(page),
Glow.renderPageComments(page)
);
box.append(related);
const actions = document.createElement("div");
actions.className = "page-detail-actions";
const history = document.createElement("button");
history.className = "button secondary";
history.type = "button";
history.textContent = "Version history";
history.addEventListener("click", () => Glow.openPageVersionHistory(page));
actions.append(history);
const share = document.createElement("button");
share.className = "button secondary";
share.type = "button";
share.textContent = "Share";
share.addEventListener("click", () => Glow.shareCurrentPage(page));
actions.append(share);
if (page.can_edit) {
const edit = document.createElement("button");
edit.className = "button";
edit.type = "button";
@ -306,8 +555,9 @@ Glow.renderPageDetail = () => {
remove.addEventListener("click", () => Glow.deletePage(page));
actions.append(edit, remove);
box.append(actions);
}
box.append(actions);
};
Glow.pageDialogFields = (page = null) => [
@ -337,6 +587,13 @@ Glow.pageDialogFields = (page = null) => [
placeholder: "general",
maxLength: 48
},
{
name: "parent_page_slug",
label: "Parent page slug (optional)",
value: page?.parent_page_slug || "",
placeholder: "project-overview",
maxLength: 48
},
{
name: "body",
label: "Markdown content",
@ -352,44 +609,38 @@ Glow.validatePageValues = values => {
Glow.showToast("Page title is required.", "error");
return null;
}
const pageType = values.page_type.toLowerCase();
if (!["project", "document", "update", "resource"].includes(pageType)) {
Glow.showToast("Choose a valid page type.", "error");
return null;
}
const visibility = values.visibility.toLowerCase();
if (!["team", "public"].includes(visibility)) {
Glow.showToast("Visibility must be team or public.", "error");
return null;
}
return {
title: values.title,
body: values.body,
page_type: pageType,
visibility,
channel_slug: values.channel_slug.toLowerCase()
channel_slug: values.channel_slug.toLowerCase(),
parent_page_slug: values.parent_page_slug.toLowerCase()
};
};
Glow.createPage = async () => {
const team = Glow.state.activeTeam;
if (!team?.can_manage) return;
const values = await Glow.openDialog({
title: "Create page",
body: "Create a shared team page or a public resource. Markdown is supported.",
confirmText: "Create page",
fields: Glow.pageDialogFields()
});
if (!values) return;
const payload = Glow.validatePageValues(values);
if (!payload) return;
try {
const data = await Glow.apiRequest(
`/api/teams/${encodeURIComponent(team.slug)}/pages`,
@ -399,7 +650,6 @@ Glow.createPage = async () => {
body: JSON.stringify(payload)
}
);
Glow.showToast("Page created.");
await Glow.openPage(data.page.slug);
} catch (error) {
@ -409,18 +659,14 @@ Glow.createPage = async () => {
Glow.editPage = async page => {
if (!page.can_edit) return;
const values = await Glow.openDialog({
title: `Edit ${page.title}`,
confirmText: "Save",
fields: Glow.pageDialogFields(page)
});
if (!values) return;
const payload = Glow.validatePageValues(values);
if (!payload) return;
try {
const data = await Glow.apiRequest(
`/api/teams/${encodeURIComponent(page.team_slug)}/pages/${encodeURIComponent(page.slug)}`,
@ -430,7 +676,6 @@ Glow.editPage = async page => {
body: JSON.stringify(payload)
}
);
Glow.state.activePage = data.page;
Glow.renderPageDetail();
Glow.showToast("Page updated.");
@ -441,22 +686,18 @@ Glow.editPage = async page => {
Glow.deletePage = async page => {
if (!page.can_edit) return;
const result = await Glow.openDialog({
title: `Delete ${page.title}?`,
body: "This page will no longer be available.",
confirmText: "Delete page",
danger: true
});
if (!result) return;
try {
await Glow.apiRequest(
`/api/teams/${encodeURIComponent(page.team_slug)}/pages/${encodeURIComponent(page.slug)}`,
{ method: "DELETE" }
);
Glow.showToast("Page deleted.");
await Glow.openPages();
} catch (error) {