Files
pagerite/frontend/src/StructureTree.vue
T

382 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
// Recursive site-structure tree with drag-and-drop ordering (vue-draggable).
// Nodes come from the server (GET /_api/pages via StructureEditor.vue) as
// {slug, path, title, translated, order, published, has_content, language,
// primary, children}. The row's flag
// (LangSelect) sets the node's primary language (language; '' = inherit —
// dimmed, showing the resolved flag); the setting covers the whole subtree.
// With a `lang` prop (StructureEditor's language strip) the titles shown
// are that language's; `translated` marks rows with an actual translation
// (untranslated rows show the original title, dimmed).
// 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 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,
// and while dragging it doubles as the list's "end of list" drop target.
// Leaf pages have no row of their own, but every row's child list is a
// drop target: its (invisible) container box reaches up over the bottom of
// its row, so dropping ON a row makes it a child (first position), while
// dropping on the row's top edge inserts a sibling before it. See the
// `.node > .treelist` style.
// The front page is the root row with an empty slug: renaming it away
// leaves no front page, and giving another top-level row the empty slug
// makes it the front page. Delete is a two-step inline button (no dialog).
// Actions are injected from the parent editor (StructureEditor.vue) to
// avoid per-level event forwarding.
import { inject } from 'vue'
import draggable from 'vuedraggable'
import { slugify } from './slugify'
import LangSelect from './LangSelect.vue'
defineOptions({ name: 'StructureTree' })
const props = defineProps({
nodes: { type: Array, required: true },
parentPath: { type: String, default: '' },
depth: { type: Number, default: 0 },
// StructureEditor's selected language ('' = original). Only used for the
// untranslated-title styling here; the fetch and title edits live in the
// parent (handlers.titleInput posts the lang with the op).
lang: { type: String, default: '' },
})
const handlers = inject('structureHandlers')
// Slug inputs accept free typing; the only live rewrites are turning
// spaces into hyphens and lowercasing (both keep the length for ASCII,
// so the cursor stays put). Anything else (unicode folding, stripping,
// collapsing) is left for commit time, where the value is run through
// slugify before talking to the server (StructureEditor). `element` is
// the pending row (v-modeled), null for existing rows (plain :value
// binding, read back on commit).
function onSlugInput(element, ev) {
const v = ev.target.value.replace(/\s/g, '-').toLowerCase()
ev.target.value = v
if (element) element.slug = v
}
// Focus the title input of a fresh pending row.
const vFocus = { mounted: (el) => el.focus() }
function onChange(evt) {
handlers.reorder(props.parentPath, props.nodes, evt)
}
// Drag guard: the front page (slug "") is a top-level item — it cannot be
// dropped into a section (its empty slug is only valid at the root). And
// nothing may be dropped under itself or one of its own descendants.
function onMove(evt) {
const el = evt.draggedContext.element
if (el.pending) return true // unsaved row: position it anywhere
const targetParent = evt.to.dataset.parent || ''
if (el.slug === '') return targetParent === ''
if (targetParent === el.path || targetParent.startsWith(`${el.path}/`)) return false
return true
}
// While dragging, the child-list overlap strips become hit-testable (see
// styles). Purely functional — nothing is shown or resized.
function onStart() {
document.body.classList.add('tree-dragging')
}
function onEnd() {
document.body.classList.remove('tree-dragging')
}
</script>
<template>
<draggable
class="treelist"
:data-parent="parentPath"
:list="nodes"
item-key="path"
group="sitetree"
ghost-class="ghost"
:move="onMove"
@change="onChange"
@start="onStart"
@end="onEnd"
>
<template #item="{ element }">
<div class="node">
<!-- Indentation is structural: each nested treelist margin-indents
itself relative to its parent (see styles), so a dragged row
previews its whole subtree at the target list's depth. -->
<div
class="row"
:class="{ current: element.path === handlers.current() }"
>
<span class="drag" title="drag to reorder/move">⠿</span>
<template v-if="element.pending">
<input
v-model="element.title"
v-focus
class="edit title-edit"
title="Page title"
@keyup.enter="handlers.commitPending()"
@keyup.esc="handlers.discardPending()"
/>
<input
v-model="element.slug"
class="edit slug-edit"
:placeholder="slugify(element.title)"
title="Slug (last path segment) — empty: derived from the title"
@input="onSlugInput(element, $event)"
@keyup.enter="handlers.commitPending()"
@keyup.esc="handlers.discardPending()"
/>
<span class="acts">
<button type="button" class="act" title="create page" @click="handlers.commitPending()">✓</button>
<button type="button" class="act del" title="discard" @click="handlers.discardPending()">✕</button>
</span>
</template>
<template v-else>
<input
class="edit title-edit"
:class="{ untranslated: lang && !element.translated }"
:value="element.title"
:title="lang && !element.translated
? 'No translation yet showing the original; typing creates the translated title'
: 'Label in the navigation saves while typing; click opens the page'"
@input="handlers.titleInput(element, $event)"
@focus="handlers.open(element.path)"
/>
<input
class="edit slug-edit"
:value="element.slug"
placeholder="front page"
title="Slug (last path segment) — renames move the whole subtree. Empty at top level = front page"
@input="onSlugInput(null, $event)"
@change="handlers.commitSlug(element, $event)"
/>
<span class="acts">
<span
class="row-lang"
:class="{ inherited: !element.language }"
><LangSelect
:model-value="element.language"
:options="handlers.langOptions(element)"
:title="element.language
? `primary language: set on this page (subtree inherits)`
: `primary language: inherited — set it here (subtree inherits)`"
@update:model-value="handlers.setLanguage(element, $event)"
/></span>
<span v-if="!element.published" class="draft">draft</span>
<button
v-if="element.has_content || !element.children.length"
type="button"
class="act del"
:title="element.children.length
? 'delete the landing page (the category keeps its subpages)'
: 'delete page'"
@click="handlers.removePage(element)"
>✕</button>
</span>
</template>
</div>
<StructureTree
v-if="element.slug !== '' && !element.pending"
:nodes="element.children"
:parent-path="element.path"
:depth="depth + 1"
:lang="lang"
/>
</div>
</template>
<!-- Non-draggable footer (vuedraggable slot): a row at the end of
the list. Clicking adds a pending page at this level; while
dragging it is the list's "end of list" drop target. Only shown
where the list has items (or at the root, to add the first page). -->
<template #footer>
<div v-if="nodes.length || !depth" class="add-row">
<button
type="button"
class="add"
title="new page here — fill in title and slug, then drag into place if needed"
@click="handlers.newPage(nodes)"
></button>
</div>
</template>
</draggable>
</template>
<style scoped>
.treelist {
min-height: 0;
/* Flex column so the footer can render last via `order` even when
Sortable's end-of-list insertion appends the preview after it in the
DOM (order only affects painting; Sortable's item rect math and
vuedraggable's index mapping still see the logical DOM order). */
display: flex;
flex-direction: column;
/* Child lists overlap the bottom of their own row (below); the overlap
strip must not block clicks on the row's inputs, so containers are
hit-test transparent and only the rows re-enable pointer events. */
pointer-events: none;
}
/* While dragging, the overlap strips become hit-testable so a row's child
list can be targeted. No visual change. */
body.tree-dragging .treelist {
pointer-events: auto;
}
/* A node's child list indents itself (structural indentation: a dragged
row's subtree follows its head to the target depth automatically) and
reaches up over the bottom ~60% of its row: the negative top margin
pulls the container up, the equal padding pushes its children back
down — zero layout or visual effect, but while dragging Sortable sees
the lower part of the row as inside the child list and natively inserts
there as the first child (a leaf's empty child list becomes a sublist
this way). The exposed top strip of the row remains the parent list's
target = sibling before this row. */
.node > .treelist {
margin-top: -0.95rem;
padding-top: 0.95rem;
margin-left: 1.1rem;
}
.node {
margin-left: 0.2rem;
}
/* Grid rows: handle / title / slug / actions line up as columns within a
level (nesting indents the whole list container, so columns align per
level, not across levels). */
.row {
display: grid;
grid-template-columns: 1.2em minmax(3rem, 1fr) 7rem auto;
align-items: baseline;
gap: 0.35rem;
/* Vertical spacing widens the drop zones: the exposed top strip is the
"sibling before" target, the overlapped bottom is "child of". */
padding-top: 0.15rem;
padding-bottom: 0.15rem;
/* Rows (and the row) stay clickable despite .treelist's pointer-events: none. */
pointer-events: auto;
}
.row.current .title-edit {
color: var(--accent);
font-weight: 500;
}
.drag {
color: var(--muted);
cursor: grab;
}
/* Rows are always editable: inputs stay borderless until interacted with. */
.edit {
font: inherit;
font-size: 0.85rem;
padding: 0.1rem 0.4rem;
background: transparent;
color: var(--text);
border: 1px solid transparent;
border-radius: 4px;
min-width: 0;
}
.edit:hover {
border-color: var(--line);
}
.edit:focus {
background: var(--bg);
border-color: var(--accent);
outline: none;
}
.title-edit {
cursor: pointer;
}
.title-edit:focus {
cursor: text;
}
/* With a language selected (StructureEditor's strip), rows without an
actual translation show the original title dimmed and italic. */
.title-edit.untranslated {
color: var(--muted);
font-style: italic;
}
.slug-edit {
font-family: var(--font-code);
}
.acts {
display: flex;
align-items: baseline;
gap: 0.25rem;
justify-content: end;
}
/* Row language selector (LangSelect): the effective primary language's
flag; dimmed while the setting is inherited rather than set on the row. */
.row-lang {
display: inline-flex;
}
.row-lang.inherited :deep(.lang-current) {
opacity: 0.45;
}
.row-lang.inherited:hover :deep(.lang-current) {
opacity: 0.85;
}
.draft {
color: var(--muted);
font-size: 0.75rem;
}
.act {
padding: 0 0.25rem;
background: none;
border: none;
color: var(--muted);
font-size: 0.8rem;
cursor: pointer;
white-space: nowrap;
}
.del:hover {
color: #e06c75;
}
.ghost {
opacity: 0.4;
}
/* The footer row: subtle always-visible "add here" button at the end of
a list; while dragging it is the list's "end of list" drop target. */
.add-row {
min-height: 1.1rem;
display: flex;
align-items: center;
pointer-events: auto; /* see .row */
order: 1; /* always render after the rows, even mid-drag (see .treelist) */
}
.add {
margin-left: 1.2em; /* align with the row titles, past the drag handle */
padding: 0 0.3rem;
background: none;
border: none;
font-size: 0.9rem;
cursor: pointer;
opacity: 0.5;
}
.add:hover {
opacity: 1;
}
</style>