From c39dde319825b666ec401f0dee429231ca1b108f Mon Sep 17 00:00:00 2001 From: zwitschi Date: Sun, 9 Nov 2025 17:48:55 +0100 Subject: [PATCH] feat: enhance UI with responsive sidebar toggle and filter functionality for projects and scenarios --- changelog.md | 1 + static/css/main.css | 117 +++++++++++++++++++++++++++++--- static/css/scenarios.css | 83 ++++++++++++++++++++++ static/js/projects.js | 109 +++++++++++++++++++++++++++-- templates/base.html | 1 + templates/projects/list.html | 5 -- templates/scenarios/detail.html | 80 +++++++++++----------- 7 files changed, 337 insertions(+), 59 deletions(-) diff --git a/changelog.md b/changelog.md index 3f0f5e3..189f9ad 100644 --- a/changelog.md +++ b/changelog.md @@ -8,3 +8,4 @@ - Added SQLite-backed pytest coverage for repository and unit-of-work behaviours to validate persistence interactions. - Exposed project and scenario CRUD APIs with validated schemas and integrated them into the FastAPI application. - Connected project and scenario routers to new Jinja2 list/detail/edit views with HTML forms and redirects. +- Implemented FR-009 client-side enhancements with responsive navigation toggle, mobile-first scenario tables, and shared asset loading across templates. diff --git a/static/css/main.css b/static/css/main.css index 3b22daa..7848704 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -52,8 +52,8 @@ body { body { margin: 0; - font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', 'Roboto', - Helvetica, Arial, 'Apple Color Emoji', 'Segoe UI Emoji'; + font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", "Roboto", + Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"; color: var(--text); background: linear-gradient(180deg, var(--bg) 0%, var(--bg-2) 100%); line-height: 1.45; @@ -337,7 +337,7 @@ a { gap: var(--space-sm); font-weight: 600; color: var(--text); - font-family: 'Fira Code', 'Consolas', 'Courier New', monospace; + font-family: "Fira Code", "Consolas", "Courier New", monospace; font-size: 0.85rem; } @@ -366,7 +366,7 @@ a { } .color-value-input { - font-family: 'Fira Code', 'Consolas', 'Courier New', monospace; + font-family: "Fira Code", "Consolas", "Courier New", monospace; } .color-value-input[disabled] { @@ -395,7 +395,7 @@ a { } .env-overrides-table code { - font-family: 'Fira Code', 'Consolas', 'Courier New', monospace; + font-family: "Fira Code", "Consolas", "Courier New", monospace; font-size: 0.85rem; } @@ -550,7 +550,7 @@ a { } .btn.is-loading::after { - content: ''; + content: ""; width: 0.85rem; height: 0.85rem; border: 2px solid rgba(255, 255, 255, 0.6); @@ -656,14 +656,14 @@ a { color: var(--color-surface-alt); padding: 1rem; border-radius: 8px; - font-family: 'Fira Code', 'Consolas', 'Courier New', monospace; + font-family: "Fira Code", "Consolas", "Courier New", monospace; overflow-x: auto; margin-top: 1.5rem; } .monospace-input { width: 100%; - font-family: 'Fira Code', 'Consolas', 'Courier New', monospace; + font-family: "Fira Code", "Consolas", "Courier New", monospace; min-height: 120px; } @@ -740,6 +740,72 @@ tbody tr:nth-child(even) { font-size: 0.9rem; } +.sidebar-toggle { + display: none; + align-items: center; + gap: 0.6rem; + padding: 0.55rem 1rem; + border-radius: 999px; + border: none; + background: linear-gradient(135deg, var(--brand-2), var(--brand)); + color: var(--color-text-dark); + font-weight: 600; + cursor: pointer; + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.sidebar-toggle:hover, +.sidebar-toggle:focus-visible { + transform: translateY(-1px); + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3); +} + +.sidebar-toggle:focus-visible { + outline: 2px solid rgba(255, 255, 255, 0.65); + outline-offset: 3px; +} + +.sidebar-toggle-icon { + position: relative; + display: inline-block; + width: 18px; + height: 2px; + background-color: currentColor; +} + +.sidebar-toggle-icon::before, +.sidebar-toggle-icon::after { + content: ""; + position: absolute; + left: 0; + width: 18px; + height: 2px; + background-color: currentColor; +} + +.sidebar-toggle-icon::before { + top: -6px; +} + +.sidebar-toggle-icon::after { + top: 6px; +} + +.sidebar-toggle-label { + font-size: 0.95rem; +} + +.sidebar-overlay { + position: fixed; + inset: 0; + background: rgba(7, 11, 17, 0.6); + z-index: 800; + opacity: 0; + pointer-events: none; + transition: opacity 0.25s ease; +} + @media (max-width: 1024px) { .app-sidebar { width: 240px; @@ -790,4 +856,39 @@ tbody tr:nth-child(even) { .dashboard-columns { grid-template-columns: 1fr; } + + .sidebar-toggle { + display: inline-flex; + margin: 1rem auto 1.5rem; + } + + body.sidebar-collapsed .app-sidebar { + display: none; + } + + body.sidebar-open { + overflow: hidden; + } + + body.sidebar-open .app-sidebar { + display: block; + position: fixed; + top: 0; + left: 0; + width: min(320px, 82vw); + height: 100vh; + overflow-y: auto; + z-index: 900; + box-shadow: 0 12px 30px rgba(8, 14, 25, 0.4); + } + + body.sidebar-open .sidebar-overlay { + opacity: 1; + pointer-events: auto; + } + + body.sidebar-open .app-main { + position: relative; + z-index: 950; + } } diff --git a/static/css/scenarios.css b/static/css/scenarios.css index 27efe04..981f7a3 100644 --- a/static/css/scenarios.css +++ b/static/css/scenarios.css @@ -76,3 +76,86 @@ transform: translateY(-1px); box-shadow: 0 10px 24px rgba(0, 0, 0, 0.25); } + +.scenario-filters { + display: grid; + gap: 0.75rem; + margin-bottom: 1.5rem; +} + +.scenario-filters .filter-field { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.scenario-filters .filter-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + align-items: center; +} + +.scenario-filters input, +.scenario-filters select { + width: 100%; + padding: 0.6rem 0.75rem; + border-radius: var(--radius-sm); + border: 1px solid var(--color-border); + background: rgba(8, 12, 19, 0.75); + color: var(--text); +} + +.table-responsive { + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + border-radius: var(--table-radius); + margin: 0; +} + +.table-responsive .table { + min-width: 640px; +} + +.table-responsive::-webkit-scrollbar { + height: 6px; +} + +.table-responsive::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 999px; +} + +@media (min-width: 720px) { + .scenario-filters { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + align-items: end; + } + + .scenario-filters .filter-actions { + justify-content: flex-end; + } + + .table-responsive .table { + min-width: 100%; + } +} + +@media (max-width: 640px) { + .breadcrumb { + flex-wrap: wrap; + gap: 0.35rem; + } + + .table th, + .table td { + padding: 0.55rem 0.65rem; + font-size: 0.9rem; + white-space: nowrap; + } + + .table tbody tr { + border-radius: var(--radius-sm); + } +} diff --git a/static/js/projects.js b/static/js/projects.js index ebad17a..c2fbbbe 100644 --- a/static/js/projects.js +++ b/static/js/projects.js @@ -1,13 +1,9 @@ document.addEventListener("DOMContentLoaded", () => { const table = document.querySelector("[data-project-table]"); - if (!table) { - return; - } - - const rows = Array.from(table.querySelectorAll("tbody tr")); + const rows = table ? Array.from(table.querySelectorAll("tbody tr")) : []; const filterInput = document.querySelector("[data-project-filter]"); - if (filterInput) { + if (table && filterInput) { filterInput.addEventListener("input", () => { const query = filterInput.value.trim().toLowerCase(); rows.forEach((row) => { @@ -16,4 +12,105 @@ document.addEventListener("DOMContentLoaded", () => { }); }); } + + const sidebar = document.querySelector(".app-sidebar"); + const appMain = document.querySelector(".app-main"); + if (!sidebar || !appMain) { + return; + } + + const body = document.body; + const mobileQuery = window.matchMedia("(max-width: 900px)"); + let toggleButton = document.querySelector("[data-sidebar-toggle]"); + + if (!toggleButton) { + toggleButton = document.createElement("button"); + toggleButton.type = "button"; + toggleButton.className = "sidebar-toggle"; + toggleButton.setAttribute("data-sidebar-toggle", ""); + toggleButton.setAttribute("aria-expanded", "false"); + toggleButton.setAttribute("aria-label", "Toggle primary navigation"); + toggleButton.hidden = true; + toggleButton.innerHTML = [ + '', + 'Menu', + ].join(""); + appMain.insertBefore(toggleButton, appMain.firstChild); + } + + let overlay = document.querySelector("[data-sidebar-overlay]"); + if (!overlay) { + overlay = document.createElement("div"); + overlay.className = "sidebar-overlay"; + overlay.setAttribute("data-sidebar-overlay", ""); + overlay.setAttribute("aria-hidden", "true"); + document.body.appendChild(overlay); + } + + const primaryNav = document.querySelector(".sidebar-nav"); + if (primaryNav) { + if (!primaryNav.id) { + primaryNav.id = "primary-navigation"; + } + toggleButton.setAttribute("aria-controls", primaryNav.id); + } + + const openSidebar = () => { + body.classList.remove("sidebar-collapsed"); + body.classList.add("sidebar-open"); + toggleButton.setAttribute("aria-expanded", "true"); + overlay.setAttribute("aria-hidden", "false"); + }; + + const closeSidebar = (focusToggle = false) => { + body.classList.add("sidebar-collapsed"); + body.classList.remove("sidebar-open"); + toggleButton.setAttribute("aria-expanded", "false"); + overlay.setAttribute("aria-hidden", "true"); + if (focusToggle) { + toggleButton.focus({ preventScroll: true }); + } + }; + + const toggleSidebar = () => { + if (body.classList.contains("sidebar-open")) { + closeSidebar(); + } else { + openSidebar(); + sidebar.setAttribute("aria-hidden", "false"); + } + }; + + const applyResponsiveState = (mql) => { + if (!mql.matches) { + toggleButton.hidden = true; + body.classList.remove("sidebar-open", "sidebar-collapsed"); + sidebar.setAttribute("aria-hidden", "true"); + overlay.setAttribute("aria-hidden", "true"); + sidebar.removeAttribute("aria-hidden"); + return; + } + + toggleButton.hidden = false; + if (!body.classList.contains("sidebar-open")) { + body.classList.add("sidebar-collapsed"); + sidebar.setAttribute("aria-hidden", "true"); + } + }; + + toggleButton.addEventListener("click", toggleSidebar); + overlay.addEventListener("click", () => closeSidebar()); + + document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && body.classList.contains("sidebar-open")) { + closeSidebar(true); + } + }); + + applyResponsiveState(mobileQuery); + if (typeof mobileQuery.addEventListener === "function") { + mobileQuery.addEventListener("change", applyResponsiveState); + } else if (typeof mobileQuery.addListener === "function") { + mobileQuery.addListener(applyResponsiveState); + } }); diff --git a/templates/base.html b/templates/base.html index 53722db..b159e20 100644 --- a/templates/base.html +++ b/templates/base.html @@ -20,6 +20,7 @@ {% block scripts %}{% endblock %} + diff --git a/templates/projects/list.html b/templates/projects/list.html index 709e3e8..a6dfb8c 100644 --- a/templates/projects/list.html +++ b/templates/projects/list.html @@ -52,8 +52,3 @@

No projects yet. Create your first project.

{% endif %} {% endblock %} - -{% block scripts %} - {{ super() }} - -{% endblock %} diff --git a/templates/scenarios/detail.html b/templates/scenarios/detail.html index d66216f..eb6980a 100644 --- a/templates/scenarios/detail.html +++ b/templates/scenarios/detail.html @@ -5,10 +5,6 @@ {% endblock %} -{% block head_extra %} - -{% endblock %} - {% block content %}