diff --git a/static/js/06-pages.js b/static/js/06-pages.js index 4b37978..67cccfb 100644 --- a/static/js/06-pages.js +++ b/static/js/06-pages.js @@ -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 || "
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; @@ -276,9 +501,11 @@ Glow.renderPageDetail = () => { const context = document.createElement("p"); context.className = "page-detail-context"; - context.textContent = page.channel_name - ? `${page.team_name} · #${page.channel_name}` - : page.team_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; header.append(meta, title, context); box.append(header); @@ -286,13 +513,35 @@ Glow.renderPageDetail = () => { const body = document.createElement("article"); body.className = "page-body markdown-body"; body.innerHTML = page.body_html || "No content yet.
"; - box.append(body); - if (page.can_edit) { - const actions = document.createElement("div"); - actions.className = "page-detail-actions"; + 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,25 +686,21 @@ 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) { Glow.showToast(error.message || "Could not delete page.", "error"); } -}; +}; \ No newline at end of file