Rework category labels, slug validation, and task-list checkboxes
- Remove the section-header ➕ "add landing page" button; landing pages are
now created via the page editor pen.
- Treat saving a page with empty (after stripping) Markdown as deleting it:
childless nodes are removed, nodes with children become content-less labels.
- Restrict server-side slugs to [a-z0-9_-] with no leading underscore or dot;
drop the reserved-filename blacklist.
- Render placeholder pages for content-less category labels and link them to
their first published child in the navigation.
- Make task-list checkboxes live, non-disabled inputs: toggling updates the
Markdown source, updates the open CodeMirror document when the page editor is
open, and persists to the server when no editor is open. Errors revert the UI.
- Update docs and AGENTS.md conventions accordingly.
This commit is contained in:
@@ -120,8 +120,10 @@ function openPath(p) {
|
||||
send({ type: 'open', path: p })
|
||||
}
|
||||
|
||||
function setDocument(text) {
|
||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } })
|
||||
function setDocument(text, preserveSelection = false) {
|
||||
const tr = { changes: { from: 0, to: view.state.doc.length, insert: text } }
|
||||
if (preserveSelection) tr.selection = view.state.selection
|
||||
view.dispatch(tr)
|
||||
}
|
||||
|
||||
function onHashChange() {
|
||||
@@ -272,6 +274,11 @@ onMounted(() => {
|
||||
parent: editorEl.value,
|
||||
})
|
||||
view.scrollDOM.addEventListener('scroll', syncScroll)
|
||||
window.__pageritePageEditor = {
|
||||
getMarkdown: () => view.state.doc.toString(),
|
||||
setMarkdown: (text) => setDocument(text, true),
|
||||
path: () => path.value,
|
||||
}
|
||||
if (props.standalone) addEventListener('hashchange', onHashChange)
|
||||
addEventListener('keydown', onKeydown)
|
||||
})
|
||||
@@ -283,6 +290,7 @@ onUnmounted(() => {
|
||||
ws.close()
|
||||
}
|
||||
view?.destroy()
|
||||
delete window.__pageritePageEditor
|
||||
if (props.standalone) removeEventListener('hashchange', onHashChange)
|
||||
removeEventListener('keydown', onKeydown)
|
||||
})
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
//
|
||||
// The tree comes from the server nested (GET /_/api/pages); every node is
|
||||
// real — a label with a title and slug, with content (landing page) or
|
||||
// without (category redirecting to its first child). The front page is a
|
||||
// top-level row with an empty slug, not the parent of the others.
|
||||
// without (category whose URL renders a placeholder page). The front page
|
||||
// is a top-level row with an empty slug, not the parent of the others.
|
||||
import { computed, onMounted, onUnmounted, provide, ref } from 'vue'
|
||||
import StructureTree from './StructureTree.vue'
|
||||
import { EditorView, basicSetup } from 'codemirror'
|
||||
@@ -125,8 +125,8 @@ async function loadPlain(p) {
|
||||
const res = await fetch(finalUrl)
|
||||
const type = res.headers.get('content-type') || ''
|
||||
if (!type.includes('text/html')) return
|
||||
// Category URLs redirect to their first child; reflect that. A 404
|
||||
// layout is fine too (new pages are created by editing them).
|
||||
// Category and missing URLs render a placeholder 404 page — fine to
|
||||
// swap in (new pages are created by editing them).
|
||||
if (res.redirected) finalUrl = res.url
|
||||
doc = new DOMParser().parseFromString(await res.text(), 'text/html')
|
||||
} catch { return }
|
||||
@@ -239,22 +239,6 @@ async function commitPending() {
|
||||
navigate(newPath)
|
||||
}
|
||||
|
||||
// Give a content-less category a landing page (empty page at its path).
|
||||
async function addContent(node) {
|
||||
const res = await fetch(`/_/api/pages/${node.path}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ title: node.title, markdown: '', published: node.published }),
|
||||
})
|
||||
if (res.ok) {
|
||||
saveError.value = ''
|
||||
await refreshPages()
|
||||
navigate(node.path)
|
||||
} else {
|
||||
saveError.value = '⚠️ changes could not be saved'
|
||||
}
|
||||
}
|
||||
|
||||
// --- Site-wide brand (header link + <title> suffix) ----------------------
|
||||
// Edits apply to the live page immediately and save while typing. An
|
||||
// empty brand removes the header link and the title suffix entirely.
|
||||
@@ -330,8 +314,8 @@ async function removePage(node) {
|
||||
refreshPages()
|
||||
const p = node.path
|
||||
if (p === path.value || (p && path.value.startsWith(`${p}/`))) {
|
||||
// The current page was deleted — or reduced to a category that now
|
||||
// redirects to its first child. Either way, re-render from the server.
|
||||
// The current page was deleted — or reduced to a category, which now
|
||||
// renders a placeholder page. Either way, re-render from the server.
|
||||
if (node.children.length) loadPlain(path.value)
|
||||
else navigate('')
|
||||
} else {
|
||||
@@ -429,7 +413,6 @@ provide('structureHandlers', {
|
||||
reorder: onReorder,
|
||||
titleInput: onTitleInput,
|
||||
commitSlug,
|
||||
addContent,
|
||||
commitPending,
|
||||
discardPending,
|
||||
newPage,
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
// Every node is real: a label whose title and slug are always editable
|
||||
// inline — the title saves while typing (and focusing it opens the page),
|
||||
// the slug commits on blur/Enter since it renames the path, moving the
|
||||
// whole subtree. Nodes without content are category labels that redirect
|
||||
// to their first child; the ➕ on their row gives them a landing page.
|
||||
// whole subtree. Nodes without content are category labels whose URL
|
||||
// renders a placeholder page; the ➕ on their row gives them a landing page.
|
||||
// Every non-empty list (and the root list) ends with a ➕ footer row:
|
||||
// clicking it adds a *pending* row (a local-only item persisted to the
|
||||
// server only on commit, ✓/Enter, Esc discards) at the end of that list,
|
||||
@@ -140,13 +140,6 @@ function onEnd() {
|
||||
/>
|
||||
<span class="acts">
|
||||
<span v-if="!element.published" class="draft">draft</span>
|
||||
<button
|
||||
v-if="!element.has_content"
|
||||
type="button"
|
||||
class="act"
|
||||
title="add a landing page (currently redirects to the first child)"
|
||||
@click="handlers.addContent(element)"
|
||||
>➕</button>
|
||||
<button
|
||||
type="button"
|
||||
class="act del"
|
||||
|
||||
@@ -316,11 +316,17 @@ article ul ul ul li::before {
|
||||
content: "🔹";
|
||||
}
|
||||
|
||||
/* Task lists render emoji checkmarks (see markdown.py), no diamond. */
|
||||
/* Task lists: real checkboxes (not emoji), clickable in the public page. */
|
||||
article .task-list-item::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
article .task-list-item-checkbox {
|
||||
cursor: pointer;
|
||||
margin-inline-end: 0.35em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
article {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ import "./assets/style.css";
|
||||
const res = await fetch(url);
|
||||
const type = res.headers.get("content-type") || "";
|
||||
if (!res.ok || !type.includes("text/html")) throw new Error("not a page");
|
||||
// Section URLs redirect to their first child; reflect that.
|
||||
// Reflect any redirect the server issued.
|
||||
if (res.redirected) finalUrl = res.url;
|
||||
doc = new DOMParser().parseFromString(await res.text(), "text/html");
|
||||
} catch {
|
||||
@@ -255,5 +255,41 @@ import "./assets/style.css";
|
||||
|
||||
addEventListener("popstate", () => load(location.href, false, true));
|
||||
|
||||
// --- Task-list checkboxes ------------------------------------------------
|
||||
// Checkboxes in the rendered article are live: toggling them edits the
|
||||
// Markdown source. If the page editor is open, its CodeMirror document is
|
||||
// updated directly; otherwise the server copy is toggled and saved.
|
||||
async function toggleTask(checkbox, index) {
|
||||
const editor = window.__pageritePageEditor;
|
||||
const pagePath = editor ? editor.path() : currentPath;
|
||||
const path = pagePath.replace(/^\/+|\/+$/g, "");
|
||||
const originalChecked = !checkbox.checked;
|
||||
try {
|
||||
const body = { path, index };
|
||||
if (editor) body.markdown = editor.getMarkdown();
|
||||
const res = await fetch("/_/api/toggle-task", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = await res.json().catch(() => ({}));
|
||||
throw new Error(detail.detail || res.statusText);
|
||||
}
|
||||
const { markdown } = await res.json();
|
||||
if (editor) editor.setMarkdown(markdown);
|
||||
} catch {
|
||||
checkbox.checked = originalChecked;
|
||||
}
|
||||
}
|
||||
|
||||
addEventListener("change", (ev) => {
|
||||
const checkbox = ev.target.closest(".task-list-item-checkbox");
|
||||
if (!checkbox) return;
|
||||
const index = Number(checkbox.dataset.taskIndex);
|
||||
if (Number.isNaN(index)) return;
|
||||
toggleTask(checkbox, index);
|
||||
});
|
||||
|
||||
applyEffects();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user