// 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); }; Glow.pageTypeLabel = type => { const labels = { project: "Project", document: "Document", 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(); } catch (error) { Glow.renderPagesEmptyState(error.message || "Could not load pages."); Glow.showToast(error.message || "Could not load pages.", "error"); } finally { Glow.els.pagesBox.setAttribute("aria-busy", "false"); } }; 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); if (team?.can_manage) { const create = document.createElement("button"); create.className = "button"; 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"], ["document", "Documents"], ["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; } const list = document.createElement("div"); list.className = "pages-list"; for (const page of pages) { list.append(Glow.buildPageCard(page)); } box.append(list); }; Glow.buildPageCard = page => { const article = document.createElement("article"); article.className = "page-card"; article.tabIndex = 0; const meta = document.createElement("div"); meta.className = "page-card-meta"; const type = document.createElement("span"); type.className = "page-badge type"; type.textContent = Glow.pageTypeLabel(page.page_type); const visibility = document.createElement("span"); visibility.className = `page-badge visibility ${page.visibility}`; visibility.textContent = page.visibility; meta.append(type, visibility); const title = document.createElement("h3"); title.textContent = page.title; 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)}…` : compactBody || "No content yet."; const footer = document.createElement("div"); footer.className = "page-card-footer"; const context = document.createElement("span"); 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 === " ") { event.preventDefault(); open(); } }); article.append(meta, title, excerpt, footer); return article; }; Glow.openPages = async () => { Glow.state.activeDm = null; Glow.state.activeView = "pages"; Glow.state.activePage = null; Glow.updateViewUI(); await Glow.loadPages(); }; 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) { Glow.showToast(error.message || "Could not load page.", "error"); Glow.openPages(); } finally { Glow.els.pageDetailBox.setAttribute("aria-busy", "false"); } }; 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 || "
No content.
"; 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; } const header = document.createElement("div"); header.className = "page-detail-header"; const meta = document.createElement("div"); meta.className = "page-detail-meta"; const type = document.createElement("span"); type.className = "page-badge type"; type.textContent = Glow.pageTypeLabel(page.page_type); const visibility = document.createElement("span"); visibility.className = `page-badge visibility ${page.visibility}`; visibility.textContent = page.visibility; const updated = document.createElement("time"); updated.className = "page-updated"; updated.dateTime = page.updated_at; updated.title = new Date(page.updated_at).toLocaleString(); updated.textContent = `Updated ${Glow.relativeTime(page.updated_at)}`; meta.append(type, visibility, updated); const title = document.createElement("h1"); title.textContent = page.title; const context = document.createElement("p"); context.className = "page-detail-context"; 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; header.append(meta, title, context); box.append(header); const body = document.createElement("article"); body.className = "page-body markdown-body"; body.innerHTML = page.body_html || "No content yet.
"; box.append(body); 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"; edit.textContent = "Edit"; edit.addEventListener("click", () => Glow.editPage(page)); const remove = document.createElement("button"); remove.className = "danger-button"; remove.type = "button"; remove.textContent = "Delete"; remove.addEventListener("click", () => Glow.deletePage(page)); actions.append(edit, remove); } box.append(actions); }; Glow.pageDialogFields = (page = null) => [ { name: "title", label: "Title", value: page?.title || "", placeholder: "Page title", maxLength: 160 }, { name: "page_type", label: "Type: project, document, update, or resource", value: page?.page_type || "document", maxLength: 12 }, { name: "visibility", label: "Visibility: team or public", value: page?.visibility || "team", maxLength: 6 }, { name: "channel_slug", label: "Channel slug (optional)", value: page?.channel_slug || "", 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", value: page?.body || "", multiline: true, rows: 14, maxLength: 100000 } ]; Glow.validatePageValues = values => { if (!values.title) { 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(), 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`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) } ); Glow.showToast("Page created."); await Glow.openPage(data.page.slug); } catch (error) { Glow.showToast(error.message || "Could not create page.", "error"); } }; 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)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) } ); Glow.state.activePage = data.page; Glow.renderPageDetail(); Glow.showToast("Page updated."); } catch (error) { Glow.showToast(error.message || "Could not update page.", "error"); } }; 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) { Glow.showToast(error.message || "Could not delete page.", "error"); } };