diff --git a/CHANGELOG.md b/CHANGELOG.md index a6dda1d..8257c65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,16 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/). +## [0.2.32] - 2026-07-22 + +### Added +- **Responsive mobile admin console**: Added a hamburger navigation drawer, touch-friendly controls, phone-sized forms and cards, scrollable tables, compact toolbars, safe-area support, and mobile bottom-sheet dialogs across the admin interface. +- **Accessible mobile interactions**: Navigation and dialogs now support backdrop dismissal, Escape-key closing, reduced-motion preferences, and explicit accessibility attributes. + +### Fixed +- **Add model dialog**: Restored the missing `opt` helper import that prevented the Add model button from opening its form. +- **Local model settings on phones**: Reflowed managed CMF server controls to avoid clipped fields and actions on narrow screens. + ## [0.2.31] - 2026-07-22 ### Added diff --git a/Cargo.lock b/Cargo.lock index 09a6171..c56daae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -285,7 +285,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "cortiq-gateway" -version = "0.2.30" +version = "0.2.32" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index d002cf5..95fce2f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cortiq-gateway" -version = "0.2.31" +version = "0.2.32" edition = "2021" rust-version = "1.75" license = "Apache-2.0" diff --git a/web/css/styles.css b/web/css/styles.css index 0d4c04e..818471c 100644 --- a/web/css/styles.css +++ b/web/css/styles.css @@ -397,3 +397,170 @@ tbody tr:hover td { background: var(--hover); } .mono.kv { display: flex; gap: 6px; padding: 1px 0; white-space: normal; overflow-wrap: anywhere; word-break: break-word; } .mono.kv .kv-k { color: var(--muted, #9aa4b2); flex: 0 0 auto; } .mono.kv .kv-v { font-weight: 600; overflow-wrap: anywhere; word-break: break-word; min-width: 0; } + +/* ---- responsive shell + touch layout ----------------------------------- */ +.menu-toggle, .nav-close, .mobile-nav-backdrop { display: none; } +.hamburger { width: 18px; display: flex; flex-direction: column; gap: 4px; } +.hamburger i { display: block; height: 2px; border-radius: 2px; background: currentColor; } +body.modal-open { overflow: hidden; } + +@media (max-width: 980px) { + .app { display: block; min-height: 100dvh; } + .main { min-height: 100dvh; } + .menu-toggle { display: inline-flex; flex: 0 0 auto; } + .nav-close { display: inline-flex; margin-left: auto; flex: 0 0 auto; font-size: 24px; line-height: 1; } + .brand-copy { min-width: 0; } + + .sidebar { + display: flex; + position: fixed; + inset: 0 auto 0 0; + width: min(84vw, 310px); + height: 100dvh; + padding: max(18px, env(safe-area-inset-top)) 14px max(14px, env(safe-area-inset-bottom)); + z-index: 70; + box-shadow: 18px 0 45px rgba(0, 0, 0, .42); + transform: translateX(-104%); + visibility: hidden; + transition: transform .22s ease, visibility 0s linear .22s; + overflow-y: auto; + } + .app.nav-open .sidebar { + transform: translateX(0); + visibility: visible; + transition: transform .22s ease; + } + .mobile-nav-backdrop { + display: block; + position: fixed; + inset: 0; + z-index: 65; + width: 100%; + height: 100%; + border: 0; + background: rgba(3, 7, 18, .62); + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: opacity .22s ease, visibility 0s linear .22s; + } + .app.nav-open .mobile-nav-backdrop { + opacity: 1; + visibility: visible; + pointer-events: auto; + transition: opacity .22s ease; + } + body.mobile-nav-open { overflow: hidden; } + + .topbar { padding: 10px 18px; min-height: 58px; gap: 10px; } + .topbar .title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .topbar .right { gap: 8px; min-width: 0; } + .content { max-width: none; padding: 22px 20px 52px; } +} + +@media (max-width: 720px) { + body { font-size: 14px; } + .status-pills { display: none; } + .topbar { padding: 9px 12px; } + .content { padding: 18px 14px 42px; } + + .page-head { margin-bottom: 16px; } + .page-head > .flex { align-items: stretch; flex-direction: column; } + .page-head > .flex > .btn { width: 100%; } + .page-head p { line-height: 1.45; } + + .card { padding: 15px; margin-bottom: 14px; border-radius: 12px; } + .card .card-head { align-items: flex-start; flex-wrap: wrap; } + .card .card-head .right { width: 100%; margin-left: 0; flex-wrap: wrap; } + .grid { gap: 14px; } + .kpis { gap: 10px; margin-bottom: 14px; } + .kpi { padding: 12px; border-radius: 11px; } + .kpi .big { font-size: 21px; } + + .row { display: grid; grid-template-columns: minmax(0, 1fr); gap: 0; } + .row > * { min-width: 0; width: 100%; } + input, select, textarea { min-height: 44px; font-size: 16px; } + input[type="checkbox"], input[type="radio"] { min-height: 0; } + .check { min-height: 36px; margin-bottom: 8px; } + .btn, .icon-btn { min-height: 44px; } + .btn { padding: 10px 14px; } + .btn.sm, .btn.small { min-height: 38px; padding: 8px 11px; } + + .table-wrap { + width: 100%; + overflow-x: auto; + overscroll-behavior-x: contain; + -webkit-overflow-scrolling: touch; + scrollbar-width: thin; + } + .table-wrap table { min-width: max-content; } + th, td { padding: 10px; } + + .tierrow { align-items: flex-start; flex-direction: column; gap: 8px; } + .tierrow .tname { width: auto; } + .chips { width: 100%; } + .togglerow { align-items: flex-start; } + + .pg { gap: 14px; } + .routecard .kv { flex-wrap: wrap; } + .routecard .kv .v { max-width: 100%; overflow-wrap: anywhere; } + .answer { padding: 12px; } + .seg { max-width: 100%; overflow-x: auto; } + .seg button { min-height: 40px; white-space: nowrap; } + + .hf-grid { grid-template-columns: 1fr; max-height: 56dvh; } + .job-head, .actions { align-items: stretch; flex-wrap: wrap; } + .actions .btn { flex: 1 1 150px; } + .key-row { flex-direction: column; } + .key-row .btn { width: 100%; } + .cmf-server-row { align-items: stretch !important; padding: 10px; border: 1px solid var(--border-soft); border-radius: 10px; } + .cmf-server-row > input, .cmf-server-row > select { width: 100% !important; min-width: 0 !important; flex: 1 1 100% !important; } + .cmf-server-row > .btn { flex: 1 1 calc(50% - 6px); } + .cmf-server-row > .small { flex: 1 1 100%; } + + .modal-backdrop { align-items: flex-end; padding: 8px 0 0; } + .modal { + max-width: none; + max-height: calc(100dvh - 8px); + overflow-y: auto; + padding: 18px 15px max(14px, env(safe-area-inset-bottom)); + border-radius: 18px 18px 0 0; + } + .modal .modal-foot { + position: sticky; + bottom: calc(-1 * max(14px, env(safe-area-inset-bottom))); + margin: 12px -15px 0; + padding: 12px 15px max(14px, env(safe-area-inset-bottom)); + background: color-mix(in srgb, var(--card) 94%, transparent); + backdrop-filter: blur(10px); + border-top: 1px solid var(--border-soft); + } + .modal .modal-foot .btn { flex: 1; } + + .toasts { left: 12px; right: 12px; bottom: max(12px, env(safe-area-inset-bottom)); } + .toast { min-width: 0; width: 100%; } + .login-wrap { align-items: flex-start; padding: max(20px, env(safe-area-inset-top)) 14px; } + .login-card { padding: 22px 18px; margin-top: 8dvh; } +} + +@media (max-width: 480px) { + .topbar .title { font-size: 14px; } + .topbar .right { gap: 6px; } + .lang-select { width: 54px; min-height: 44px; padding: 6px; } + .icon-btn { width: 42px; height: 42px; } + .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .kpi .lbl { line-height: 1.25; } + .barlist .b { grid-template-columns: minmax(0, 1fr) auto; gap: 6px 10px; } + .barlist .b .track { grid-column: 1 / -1; grid-row: 2; } + .barlist .b .val { min-width: 0; } + .empty { padding: 28px 12px; } +} + +@media (max-width: 360px) { + .topbar .title { display: none; } + .kpis { grid-template-columns: 1fr; } +} + +@media (prefers-reduced-motion: reduce) { + .sidebar, .mobile-nav-backdrop { transition: none; } +} diff --git a/web/js/app.js b/web/js/app.js index 7999cdf..2466005 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -28,6 +28,17 @@ export const appState = { meta: null, health: null, version: "" }; // allaigate site — where router keys are issued and topped up export const SITE_URL = "https://api.allaigate.com/"; +let mobileNavOpen = false; + +function setMobileNav(open) { + mobileNavOpen = !!open; + const app = document.querySelector(".app"); + if (app) app.classList.toggle("nav-open", mobileNavOpen); + document.body.classList.toggle("mobile-nav-open", mobileNavOpen); + const toggle = document.querySelector(".menu-toggle"); + if (toggle) toggle.setAttribute("aria-expanded", String(mobileNavOpen)); +} + function currentRoute() { const id = location.hash.replace(/^#\/?/, "") || "dashboard"; return ROUTES.find((r) => r.id === id) || ROUTES[0]; @@ -42,17 +53,18 @@ const FAVICON = function sidebar(activeId) { return h( "aside", - { class: "sidebar", id: "sidebar" }, + { class: "sidebar", id: "sidebar", "aria-label": "Main navigation" }, h( "div", { class: "brand" }, h("img", { src: FAVICON, alt: "" }), - h("div", {}, h("b", {}, "allaigate"), h("div", { class: "sub" }, t("brand.sub"))) + h("div", { class: "brand-copy" }, h("b", {}, "allaigate"), h("div", { class: "sub" }, t("brand.sub"))), + h("button", { class: "icon-btn nav-close", type: "button", title: "Close menu", "aria-label": "Close menu", onClick: () => setMobileNav(false) }, "×") ), ...ROUTES.map((r) => h( "a", - { class: "nav-item" + (r.id === activeId ? " active" : ""), href: "#/" + r.id }, + { class: "nav-item" + (r.id === activeId ? " active" : ""), href: "#/" + r.id, onClick: () => setMobileNav(false) }, h("span", { class: "ico" }, r.icon), t("nav." + r.id) ) @@ -85,7 +97,7 @@ function statusPills() { : t("status.checking"); return h( "div", - { class: "flex" }, + { class: "flex status-pills" }, h( "span", { class: "pill" }, @@ -136,6 +148,19 @@ function topbar(activeId) { return h( "header", { class: "topbar" }, + h( + "button", + { + class: "icon-btn menu-toggle", + type: "button", + title: "Menu", + "aria-label": "Open menu", + "aria-controls": "sidebar", + "aria-expanded": String(mobileNavOpen), + onClick: () => setMobileNav(!mobileNavOpen), + }, + h("span", { class: "hamburger", "aria-hidden": "true" }, h("i"), h("i"), h("i")) + ), h("div", { class: "title" }, t("nav." + activeId)), h( "div", @@ -175,12 +200,15 @@ async function renderView() { function renderShell() { const route = currentRoute(); + mobileNavOpen = false; + document.body.classList.remove("mobile-nav-open"); mount( document.getElementById("app"), h( "div", { class: "app" }, sidebar(route.id), + h("button", { class: "mobile-nav-backdrop", type: "button", tabindex: "-1", "aria-label": "Close menu", onClick: () => setMobileNav(false) }), h("main", { class: "main" }, topbar(route.id), h("div", { class: "content", id: "view" })) ) ); @@ -243,6 +271,12 @@ onLangChange(() => { window.addEventListener("hashchange", () => { if (document.querySelector(".app")) renderShell(); }); +window.addEventListener("keydown", (e) => { + if (e.key === "Escape" && mobileNavOpen) setMobileNav(false); +}); +window.addEventListener("resize", () => { + if (window.innerWidth > 980 && mobileNavOpen) setMobileNav(false); +}); setInterval(() => { if (document.querySelector(".app")) refreshHealth(); }, 20000); diff --git a/web/js/ui.js b/web/js/ui.js index 3e86fa6..38f3402 100644 --- a/web/js/ui.js +++ b/web/js/ui.js @@ -50,7 +50,14 @@ export function toast(msg, kind = "info", ms = 3200) { // Modal dialog. `body` is a DOM node; `onSave` returns a Promise; returns nothing. export function modal(title, body, onSave, saveLabel) { const backdrop = h("div", { class: "modal-backdrop" }); - const close = () => backdrop.remove(); + const onKeydown = (e) => { + if (e.key === "Escape") close(); + }; + const close = () => { + backdrop.remove(); + document.body.classList.remove("modal-open"); + document.removeEventListener("keydown", onKeydown); + }; backdrop.addEventListener("click", (e) => { if (e.target === backdrop) close(); }); @@ -77,6 +84,8 @@ export function modal(title, body, onSave, saveLabel) { ) ); backdrop.appendChild(card); + document.body.classList.add("modal-open"); + document.addEventListener("keydown", onKeydown); document.body.appendChild(backdrop); return { close }; } diff --git a/web/js/views/models.js b/web/js/views/models.js index 25d4ed6..eaa93c3 100644 --- a/web/js/views/models.js +++ b/web/js/views/models.js @@ -1,5 +1,5 @@ // Models — pool table with add/edit modal, probe, and per-model secret entry. -import { h, mount, modal, toast, confirmDialog, money } from "../ui.js"; +import { h, mount, modal, toast, confirmDialog, money, opt } from "../ui.js"; import { t } from "../i18n.js"; import { api } from "../api.js"; import { appState } from "../app.js"; diff --git a/web/js/views/settings.js b/web/js/views/settings.js index eb02e98..7d6993a 100644 --- a/web/js/views/settings.js +++ b/web/js/views/settings.js @@ -331,7 +331,7 @@ export async function renderSettings() { drawer.style.display = drawer.style.display === "none" ? "block" : "none"; }); - const row = h("div", { class: "flex wrap", style: "gap:6px;align-items:center;margin:6px 0" }, + const row = h("div", { class: "flex wrap cmf-server-row", style: "gap:6px;align-items:center;margin:6px 0" }, idIn, modelSel, portIn, checkBtn, threadsIn, h("label", { class: "flex", style: "gap:4px" }, gpuIn, "GPU"), gearBtn, removeBtn, st, drawer); const entry = { node: row, read: () => ({ id: idIn.value.trim() || autoId(modelSel.value),