445 lines
14 KiB
Plaintext
445 lines
14 KiB
Plaintext
<%- include("partials/layout-top", { title }) %>
|
|
<style>
|
|
.nav-builder {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1.5rem;
|
|
}
|
|
.nav-builder-body {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
|
|
gap: 1.5rem;
|
|
}
|
|
.nav-builder-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 1rem;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
.nav-section-card,
|
|
.nav-pool-card {
|
|
background: var(--surface-2);
|
|
border: 1px solid var(--border);
|
|
border-radius: 16px;
|
|
padding: 1rem;
|
|
}
|
|
.nav-actions {
|
|
margin-top: 1.5rem;
|
|
}
|
|
.nav-actions .inline-form {
|
|
margin: 0;
|
|
}
|
|
.nav-section-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
.nav-section-header {
|
|
display: grid;
|
|
grid-template-columns: 1.2fr 1fr 1fr auto;
|
|
gap: 0.75rem;
|
|
align-items: center;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
.nav-section-header input,
|
|
.nav-section-header select {
|
|
width: 100%;
|
|
}
|
|
.nav-dd-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
min-height: 2.5rem;
|
|
padding: 0.5rem;
|
|
border-radius: 12px;
|
|
border: 1px dashed transparent;
|
|
background: var(--surface-3);
|
|
}
|
|
.nav-dd-list.drag-over {
|
|
border-color: var(--sea);
|
|
background: rgba(79, 182, 194, 0.08);
|
|
}
|
|
.nav-dd-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
padding: 0.6rem 0.8rem;
|
|
border-radius: 12px;
|
|
border: 1px solid var(--border);
|
|
background: var(--card);
|
|
cursor: grab;
|
|
}
|
|
.nav-dd-item.dragging {
|
|
opacity: 0.55;
|
|
}
|
|
.nav-dd-handle {
|
|
font-family: "Courier New", monospace;
|
|
opacity: 0.65;
|
|
}
|
|
.nav-dd-meta {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.15rem;
|
|
}
|
|
.nav-dd-meta span {
|
|
font-size: 0.85rem;
|
|
color: var(--ink-soft);
|
|
}
|
|
.nav-advanced {
|
|
display: none;
|
|
margin-top: 1rem;
|
|
}
|
|
.nav-advanced.open {
|
|
display: block;
|
|
}
|
|
.nav-toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.nav-toolbar .button {
|
|
white-space: nowrap;
|
|
}
|
|
@media (min-width: 901px) {
|
|
.nav-pool-card {
|
|
position: sticky;
|
|
top: 1rem;
|
|
align-self: start;
|
|
max-height: calc(100vh - 2rem);
|
|
overflow: auto;
|
|
}
|
|
}
|
|
@media (max-width: 900px) {
|
|
.nav-builder-body {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
.nav-section-header {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|
|
|
|
<section class="card">
|
|
<h1>Navigation</h1>
|
|
<p class="hint">Drag items between sections to build the sidebar layout.</p>
|
|
<form method="post" action="/admin/navigation" class="form-grid" id="nav-form">
|
|
<div class="field">
|
|
<label>Enable custom navigation</label>
|
|
<label class="switch">
|
|
<input
|
|
type="checkbox"
|
|
class="switch-input"
|
|
name="nav_enabled"
|
|
<%= navStructure.enabled ? "checked" : "" %>
|
|
/>
|
|
<span class="switch-track" aria-hidden="true"></span>
|
|
<span class="switch-text"><%= navStructure.enabled ? "Enabled" : "Disabled" %></span>
|
|
</label>
|
|
</div>
|
|
<div class="field">
|
|
<label>Include unassigned items</label>
|
|
<label class="switch">
|
|
<input
|
|
type="checkbox"
|
|
class="switch-input"
|
|
name="nav_include_unassigned"
|
|
<%= navStructure.includeUnassigned ? "checked" : "" %>
|
|
/>
|
|
<span class="switch-track" aria-hidden="true"></span>
|
|
<span class="switch-text"><%= navStructure.includeUnassigned ? "Enabled" : "Disabled" %></span>
|
|
</label>
|
|
</div>
|
|
<div class="field">
|
|
<label>Unassigned section label</label>
|
|
<input name="nav_unassigned_label" value="<%= navStructure.unassignedLabel || 'Other' %>" />
|
|
</div>
|
|
<div class="field">
|
|
<label>Unassigned section id</label>
|
|
<input name="nav_unassigned_id" value="<%= navStructure.unassignedId || 'other' %>" />
|
|
</div>
|
|
<div class="field">
|
|
<label>Unassigned section icon</label>
|
|
<select name="nav_unassigned_icon">
|
|
<option value="">Default (blocks)</option>
|
|
<% (sectionIcons || []).forEach((icon) => { %>
|
|
<option value="<%= icon %>" <%= navStructure.unassignedIcon === icon ? 'selected' : '' %>><%= icon %></option>
|
|
<% }) %>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="field full nav-builder">
|
|
<div class="nav-toolbar">
|
|
<button type="button" class="button subtle" data-add-section>Add section</button>
|
|
<button type="button" class="button subtle" data-advanced-toggle>Advanced</button>
|
|
</div>
|
|
<div class="nav-builder-body">
|
|
<div>
|
|
<div class="nav-builder-header">
|
|
<h2>Sections</h2>
|
|
</div>
|
|
<div class="nav-section-list" data-section-list></div>
|
|
</div>
|
|
<div class="nav-pool-card">
|
|
<div class="nav-builder-header">
|
|
<h2>Unassigned items</h2>
|
|
</div>
|
|
<div class="nav-dd-list" data-unassigned-list></div>
|
|
</div>
|
|
</div>
|
|
<div class="nav-advanced" data-advanced-panel>
|
|
<div class="field full">
|
|
<label>Sections JSON (advanced)</label>
|
|
<textarea name="nav_sections" rows="14" id="nav-sections-json"><%- navSectionsJson %></textarea>
|
|
<p class="hint">Each section needs <code>id</code>, <code>label</code>, <code>icon</code>, and <code>items</code>.</p>
|
|
</div>
|
|
<div class="field full">
|
|
<button type="button" class="button subtle" data-apply-json>Apply JSON to builder</button>
|
|
</div>
|
|
<div class="field full">
|
|
<label>Default structure</label>
|
|
<pre><code><%- defaultSectionsJson %></code></pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</form>
|
|
<div class="button-group centered nav-actions">
|
|
<button type="submit" form="nav-form" class="button">Save navigation</button>
|
|
<form method="post" action="/admin/navigation/reset" class="inline-form" data-confirm-mode="modal" data-confirm-text="Replace the current navigation layout with Lumi's default structure?">
|
|
<button type="submit" class="button subtle">Reset to default</button>
|
|
</form>
|
|
</div>
|
|
</section>
|
|
|
|
<script id="nav-items-data" type="application/json"><%- navItemsJson %></script>
|
|
<script id="nav-sections-data" type="application/json"><%- navSectionsData %></script>
|
|
<script id="nav-icons-data" type="application/json"><%- JSON.stringify(sectionIcons || []) %></script>
|
|
<script>
|
|
(() => {
|
|
const navItems = JSON.parse(document.getElementById("nav-items-data").textContent || "[]");
|
|
const navSections = JSON.parse(document.getElementById("nav-sections-data").textContent || "[]");
|
|
const navIcons = JSON.parse(document.getElementById("nav-icons-data").textContent || "[]");
|
|
const form = document.getElementById("nav-form");
|
|
const sectionList = document.querySelector("[data-section-list]");
|
|
const unassignedList = document.querySelector("[data-unassigned-list]");
|
|
const jsonField = document.getElementById("nav-sections-json");
|
|
const advancedToggle = document.querySelector("[data-advanced-toggle]");
|
|
const advancedPanel = document.querySelector("[data-advanced-panel]");
|
|
const addSectionButton = document.querySelector("[data-add-section]");
|
|
const applyJsonButton = document.querySelector("[data-apply-json]");
|
|
const itemMap = new Map(navItems.map((item) => [item.navId, item]));
|
|
|
|
function buildItem(navId) {
|
|
const data = itemMap.get(navId) || { navId, label: navId, path: "", role: "public" };
|
|
const item = document.createElement("div");
|
|
item.className = "nav-dd-item";
|
|
item.setAttribute("draggable", "true");
|
|
item.dataset.navId = navId;
|
|
item.innerHTML = `
|
|
<span class="nav-dd-handle">||</span>
|
|
<div class="nav-dd-meta">
|
|
<strong>${escapeHtml(data.label || navId)}</strong>
|
|
<span>${escapeHtml(data.path || navId)}</span>
|
|
</div>
|
|
`;
|
|
item.addEventListener("dragstart", () => {
|
|
item.classList.add("dragging");
|
|
});
|
|
item.addEventListener("dragend", () => {
|
|
item.classList.remove("dragging");
|
|
syncJson();
|
|
});
|
|
return item;
|
|
}
|
|
|
|
function buildSection(section) {
|
|
const card = document.createElement("div");
|
|
card.className = "nav-section-card";
|
|
card.innerHTML = `
|
|
<div class="nav-section-header">
|
|
<input type="text" class="nav-section-label" placeholder="Section label" value="${escapeHtml(section.label || "")}">
|
|
<input type="text" class="nav-section-id" placeholder="section-id" value="${escapeHtml(section.id || "")}">
|
|
<select class="nav-section-icon"></select>
|
|
<button type="button" class="button subtle nav-section-remove">Remove</button>
|
|
</div>
|
|
<div class="nav-dd-list" data-section-items></div>
|
|
`;
|
|
const select = card.querySelector(".nav-section-icon");
|
|
const selectedIcon = (section.icon || "").toString().toLowerCase();
|
|
const options = [`<option value="">Icon</option>`].concat(
|
|
navIcons.map((icon) => {
|
|
const selected = icon === selectedIcon ? "selected" : "";
|
|
return `<option value="${icon}" ${selected}>${icon}</option>`;
|
|
})
|
|
);
|
|
select.innerHTML = options.join("");
|
|
const list = card.querySelector("[data-section-items]");
|
|
(section.items || []).forEach((navId) => {
|
|
if (itemMap.has(navId)) {
|
|
list.appendChild(buildItem(navId));
|
|
}
|
|
});
|
|
attachList(list);
|
|
card.querySelector(".nav-section-remove").addEventListener("click", () => {
|
|
const items = Array.from(list.querySelectorAll(".nav-dd-item"));
|
|
items.forEach((item) => unassignedList.appendChild(item));
|
|
card.remove();
|
|
syncJson();
|
|
});
|
|
card.querySelectorAll("input, select").forEach((input) => {
|
|
input.addEventListener("change", syncJson);
|
|
});
|
|
return card;
|
|
}
|
|
|
|
function attachList(list) {
|
|
list.addEventListener("dragover", (event) => {
|
|
event.preventDefault();
|
|
list.classList.add("drag-over");
|
|
const afterElement = getDragAfterElement(list, event.clientY);
|
|
const dragging = document.querySelector(".nav-dd-item.dragging");
|
|
if (!dragging) return;
|
|
if (afterElement == null) {
|
|
list.appendChild(dragging);
|
|
} else {
|
|
list.insertBefore(dragging, afterElement);
|
|
}
|
|
});
|
|
list.addEventListener("dragleave", () => {
|
|
list.classList.remove("drag-over");
|
|
});
|
|
list.addEventListener("drop", (event) => {
|
|
event.preventDefault();
|
|
list.classList.remove("drag-over");
|
|
syncJson();
|
|
});
|
|
}
|
|
|
|
function getDragAfterElement(container, y) {
|
|
const draggableElements = [
|
|
...container.querySelectorAll(".nav-dd-item:not(.dragging)")
|
|
];
|
|
return draggableElements.reduce(
|
|
(closest, child) => {
|
|
const box = child.getBoundingClientRect();
|
|
const offset = y - box.top - box.height / 2;
|
|
if (offset < 0 && offset > closest.offset) {
|
|
return { offset, element: child };
|
|
}
|
|
return closest;
|
|
},
|
|
{ offset: Number.NEGATIVE_INFINITY, element: null }
|
|
).element;
|
|
}
|
|
|
|
function collectSections() {
|
|
const sections = [];
|
|
sectionList.querySelectorAll(".nav-section-card").forEach((card, index) => {
|
|
const label = card.querySelector(".nav-section-label").value.trim();
|
|
const idInput = card.querySelector(".nav-section-id").value.trim();
|
|
const id = idInput || label || `section-${index + 1}`;
|
|
const icon = card.querySelector(".nav-section-icon").value.trim();
|
|
const items = Array.from(card.querySelectorAll(".nav-dd-item")).map(
|
|
(item) => item.dataset.navId
|
|
);
|
|
sections.push({
|
|
id,
|
|
label: label || id,
|
|
icon,
|
|
items
|
|
});
|
|
});
|
|
return sections;
|
|
}
|
|
|
|
function syncJson() {
|
|
if (!jsonField) return;
|
|
jsonField.value = JSON.stringify(collectSections(), null, 2);
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return (value || "").replace(/[&<>"']/g, (char) => {
|
|
switch (char) {
|
|
case "&": return "&";
|
|
case "<": return "<";
|
|
case ">": return ">";
|
|
case "\"": return """;
|
|
case "'": return "'";
|
|
default: return char;
|
|
}
|
|
});
|
|
}
|
|
|
|
function buildInitial() {
|
|
rebuildFromSections(navSections);
|
|
}
|
|
|
|
function rebuildFromSections(sections) {
|
|
sectionList.innerHTML = "";
|
|
unassignedList.innerHTML = "";
|
|
const assigned = new Set();
|
|
(sections || []).forEach((section) => {
|
|
const card = buildSection(section);
|
|
sectionList.appendChild(card);
|
|
(section.items || []).forEach((navId) => assigned.add(navId));
|
|
});
|
|
navItems.forEach((item) => {
|
|
if (!assigned.has(item.navId)) {
|
|
unassignedList.appendChild(buildItem(item.navId));
|
|
}
|
|
});
|
|
attachList(unassignedList);
|
|
syncJson();
|
|
}
|
|
|
|
addSectionButton.addEventListener("click", () => {
|
|
const existingIds = new Set(
|
|
Array.from(sectionList.querySelectorAll(".nav-section-id")).map((input) =>
|
|
input.value.trim().toLowerCase()
|
|
)
|
|
);
|
|
let index = sectionList.querySelectorAll(".nav-section-card").length + 1;
|
|
let nextId = `section-${index}`;
|
|
while (existingIds.has(nextId)) {
|
|
index += 1;
|
|
nextId = `section-${index}`;
|
|
}
|
|
const newSection = buildSection({
|
|
id: nextId,
|
|
label: `Section ${index}`,
|
|
icon: "",
|
|
items: []
|
|
});
|
|
sectionList.appendChild(newSection);
|
|
syncJson();
|
|
});
|
|
|
|
advancedToggle.addEventListener("click", () => {
|
|
advancedPanel.classList.toggle("open");
|
|
});
|
|
|
|
applyJsonButton.addEventListener("click", () => {
|
|
if (!jsonField) return;
|
|
try {
|
|
const parsed = JSON.parse(jsonField.value || "[]");
|
|
if (!Array.isArray(parsed)) {
|
|
window.alert("JSON must be an array of sections.");
|
|
return;
|
|
}
|
|
rebuildFromSections(parsed);
|
|
} catch (error) {
|
|
window.alert("JSON is invalid.");
|
|
}
|
|
});
|
|
|
|
form.addEventListener("submit", () => {
|
|
syncJson();
|
|
});
|
|
|
|
buildInitial();
|
|
})();
|
|
</script>
|
|
<%- include("partials/layout-bottom") %>
|