Lumi/src/web/views/admin-navigation.ejs
2026-06-18 15:10:19 +02:00

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 "&amp;";
case "<": return "&lt;";
case ">": return "&gt;";
case "\"": return "&quot;";
case "'": return "&#39;";
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") %>