From b479493ac1956f7e72ed028abbc5aaa15a9e5e10 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Mon, 1 Jun 2026 21:40:26 +0200 Subject: [PATCH] web: start: multiple pages Signed-off-by: GnomeZworc --- web/build.sh | 97 ++++++++++++++++++++++++++++- web/components.json | 2 +- web/components.yml | 35 ++++++++++- web/components/side-nav/side-nav.js | 30 ++++++--- web/core/router.js | 54 ++++++++++++++++ web/index.html | 46 +++----------- web/navigation.json | 8 +-- web/pages.json | 34 ++++++++++ web/pages/dashboard/dashboard.css | 2 + web/pages/dashboard/dashboard.js | 8 +++ web/pages/dashboard/index.html | 33 ++++++++++ web/pages/dashboard/manifest.json | 6 ++ web/pages/subnets/index.html | 1 + web/pages/subnets/manifest.json | 1 + web/pages/vms/index.html | 1 + web/pages/vms/manifest.json | 1 + web/pages/vpcs/index.html | 1 + web/pages/vpcs/manifest.json | 1 + 18 files changed, 309 insertions(+), 52 deletions(-) create mode 100644 web/core/router.js create mode 100644 web/pages.json create mode 100644 web/pages/dashboard/dashboard.css create mode 100644 web/pages/dashboard/dashboard.js create mode 100644 web/pages/dashboard/index.html create mode 100644 web/pages/dashboard/manifest.json create mode 100644 web/pages/subnets/index.html create mode 100644 web/pages/subnets/manifest.json create mode 100644 web/pages/vms/index.html create mode 100644 web/pages/vms/manifest.json create mode 100644 web/pages/vpcs/index.html create mode 100644 web/pages/vpcs/manifest.json diff --git a/web/build.sh b/web/build.sh index 6a33d4f..e882219 100755 --- a/web/build.sh +++ b/web/build.sh @@ -109,8 +109,12 @@ download_path() { # ── Read manifest ──────────────────────────────────────────────────────────────── -mapfile -t LOCALS < <(yq '(.local // [])[]' "$MANIFEST") -mapfile -t REMOTES < <(yq '(.components // [])[] | [.name, .repo, (.ref // "main")] | join("|")' "$MANIFEST") +PAGES_DIR="${WEB_DIR}/pages" + +mapfile -t LOCALS < <(yq '(.local_components // [])[]' "$MANIFEST") +mapfile -t REMOTES < <(yq '(.components // [])[] | [.name, .repo, (.ref // "main")] | join("|")' "$MANIFEST") +mapfile -t LOCAL_PAGES < <(yq '(.local_pages // [])[]' "$MANIFEST") +mapfile -t REMOTE_PAGES < <(yq '(.remote_pages // [])[] | [.name, .repo, (.ref // "main")] | join("|")' "$MANIFEST") # Ordered list of load paths for components.json declare -a LOAD_PATHS=() @@ -175,3 +179,92 @@ echo "$LOCK_ENTRIES" | jq '.' > "$LOCKFILE" log "wrote ${OUTPUT} (${#LOAD_PATHS[@]} components)" log "wrote ${LOCKFILE} ($(jq 'length' <<<"$LOCK_ENTRIES") remote pins)" + +# ── Process pages ──────────────────────────────────────────────────────────────── + +PAGE_ENTRIES="[]" + +# Collect page metadata from a directory into PAGE_ENTRIES. +# Detects css/js presence automatically — both are optional. +add_page_entry() { + local name="$1" dir="$2" + local manifest="${dir}/manifest.json" + local html="${dir}/index.html" + + [[ -f "$manifest" ]] || die "page '${name}' missing manifest.json in ${dir}" + [[ -f "$html" ]] || die "page '${name}' missing index.html in ${dir}" + + local route label icon css_arg js_arg + route=$(jq -r '.route' "$manifest") + label=$(jq -r '.label' "$manifest") + icon=$(jq -r '.icon' "$manifest") + css_arg="null"; js_arg="null" + [[ -f "${dir}/${name}.css" ]] && css_arg="\"pages/${name}/${name}.css\"" + [[ -f "${dir}/${name}.js" ]] && js_arg="\"pages/${name}/${name}.js\"" + + PAGE_ENTRIES="$(jq \ + --arg route "$route" --arg label "$label" --arg icon "$icon" \ + --arg html "pages/${name}/index.html" \ + --argjson css "$css_arg" --argjson js "$js_arg" \ + '. + [{route:$route, label:$label, icon:$icon, html:$html, css:$css, js:$js}]' \ + <<<"$PAGE_ENTRIES")" + info " page '${name}' → /#/${route}$([ "$css_arg" != "null" ] && echo " +css")$([ "$js_arg" != "null" ] && echo " +js")" +} + +# Local pages +for name in "${LOCAL_PAGES[@]}"; do + [[ -z "$name" ]] && continue + add_page_entry "$name" "${PAGES_DIR}/${name}" +done + +# Remote pages — download then register +for spec in "${REMOTE_PAGES[@]}"; do + [[ -z "$spec" ]] && continue + IFS='|' read -r name repo ref <<<"$spec" + [[ -z "$name" || -z "$repo" ]] && die "remote_pages entry missing name or repo" + + read -r server owner gitrepo <<<"$(parse_repo_url "$repo")" + dest="${PAGES_DIR}/${name}" + + if [[ "$CHECK_ONLY" == true ]]; then + info "would fetch page ${name} from ${repo}@${ref}" + continue + fi + + log "fetching page ${name} ← ${owner}/${gitrepo}@${ref}" + commit="$(resolve_commit "$server" "$owner" "$gitrepo" "$ref")" + [[ -z "$commit" ]] && die "cannot resolve ref '${ref}' in ${owner}/${gitrepo}" + + rm -rf "$dest" + download_path "$server" "$owner" "$gitrepo" "$ref" "" "$dest" + add_page_entry "$name" "$dest" + info " pinned ${commit:0:12}" +done + +if [[ "$CHECK_ONLY" == false ]]; then + # Apply menu order if defined — reorder PAGE_ENTRIES and filter nav visibility + mapfile -t MENU_ORDER < <(yq '(.menu // [])[]' "$MANIFEST") + + if [[ ${#MENU_ORDER[@]} -gt 0 ]]; then + ORDERED="[]" + for route in "${MENU_ORDER[@]}"; do + [[ -z "$route" ]] && continue + entry="$(echo "$PAGE_ENTRIES" | jq --arg r "$route" '.[] | select(.route == $r)')" + [[ -z "$entry" ]] && warn "menu: route '${route}' not found in pages, skipping" + [[ -n "$entry" ]] && ORDERED="$(echo "$ORDERED" | jq --argjson e "$entry" '. + [$e]')" + done + NAV_ENTRIES="$ORDERED" + else + NAV_ENTRIES="$PAGE_ENTRIES" + fi + + # pages.json — full list (all pages, original discovery order) + echo "$PAGE_ENTRIES" | jq '.' > "${WEB_DIR}/pages.json" + + # navigation.json — ordered + filtered by menu: + echo "$NAV_ENTRIES" | \ + jq '[.[] | {label: .label, href: ("#/" + .route), icon: .icon}]' \ + > "${WEB_DIR}/navigation.json" + + log "wrote pages.json ($(echo "$PAGE_ENTRIES" | jq 'length') pages) + navigation.json ($(echo "$NAV_ENTRIES" | jq 'length') in menu)" +fi diff --git a/web/components.json b/web/components.json index 36d2ab3..c4e8217 100644 --- a/web/components.json +++ b/web/components.json @@ -2,7 +2,7 @@ "components/login-gate/login-gate.js", "components/api-client/api-client.js", "components/side-nav/side-nav.js", - "components/side-panel/side-panel.js", "components/logout-button/logout-button.js", + "components/side-panel/side-panel.js", "components/date-display/date-display.js" ] diff --git a/web/components.yml b/web/components.yml index f2878e3..dcdb15a 100644 --- a/web/components.yml +++ b/web/components.yml @@ -9,13 +9,13 @@ # Local components shipped inside this repo. Not downloaded — listed here only # so build.sh can place them in the generated load order. -local: +local_components: - login-gate - api-client - side-nav - logout-button - - date-display - side-panel + - date-display # Remote components fetched from git at build time. # Each is its own repo; its root must contain .js and manifest.json. @@ -24,3 +24,34 @@ local: # repo: https://git.g3e.fr/team-reseau/vpc-panel # ref: v1.2.0 # tag, branch or commit (default: main) components: [] + +# Pages — each page is a self-contained directory (JS + CSS + manifest.json). +# Same model as components: local pages ship in this repo, remote pages are +# downloaded from git at build time. build.sh reads each manifest.json to +# generate navigation.json and pages.json. +# +# Page manifest.json must contain: tag, route, label, icon. +# Page JS must: customElements.define('', ...) and use display:contents. + +local_pages: + - dashboard + - vpcs + - subnets + - vms + +# Remote pages fetched from git — same mechanism as components. +# +# - name: vpcs +# repo: https://git.g3e.fr/team-reseau/page-vpcs +# ref: v1.0.0 +remote_pages: [] + +# Menu — order and visibility in the side-nav. +# List routes in desired display order. +# A page not listed here is accessible via /#/route but hidden from the nav. +menu: + - dashboard + - vpcs + - subnets + - vms + diff --git a/web/components/side-nav/side-nav.js b/web/components/side-nav/side-nav.js index 8eb9e61..3c7f7db 100644 --- a/web/components/side-nav/side-nav.js +++ b/web/components/side-nav/side-nav.js @@ -27,11 +27,12 @@ function resolveIcon(name) { } class SideNav extends HTMLElement { - #items = [] - #hamburger = null - #backdrop = null - #mqHandler = null - #isMobile = false + #items = [] + #hamburger = null + #backdrop = null + #mqHandler = null + #hashHandler = null + #isMobile = false async connectedCallback() { this.attachShadow({ mode: 'open' }) @@ -43,21 +44,36 @@ class SideNav extends HTMLElement { this.#render() - this.#mqHandler = () => this.#applyMode() + this.#mqHandler = () => this.#applyMode() + this.#hashHandler = () => this.#updateActive() MQ.addEventListener('change', this.#mqHandler) + window.addEventListener('hashchange', this.#hashHandler) this.#applyMode() } disconnectedCallback() { MQ.removeEventListener('change', this.#mqHandler) + window.removeEventListener('hashchange', this.#hashHandler) this.#removeHamburger() this.#removeBackdrop() } // ── Rendu des items ───────────────────────────────────────────────────────── + #currentHref() { + return window.location.hash || `#/${this.#items[0]?.href?.replace(/^#\//, '') ?? 'dashboard'}` + } + + // Update active class without re-rendering the whole nav. + #updateActive() { + const current = this.#currentHref() + this.shadowRoot?.querySelectorAll('.item').forEach(a => + a.classList.toggle('active', a.getAttribute('href') === current) + ) + } + #render() { - const current = window.location.pathname.split('/').pop() || 'index.html' + const current = this.#currentHref() const links = this.#items.map(item => { const active = item.href === current diff --git a/web/core/router.js b/web/core/router.js new file mode 100644 index 0000000..06dbe9c --- /dev/null +++ b/web/core/router.js @@ -0,0 +1,54 @@ +// Hash-based SPA router. +// Reads pages.json (generated by build.sh from components.yml). +// Each page is an HTML fragment (index.html) + optional CSS + optional JS module. +// +// Load order enforced by index.html: +// await import('./core/registry.js') ← defines all components +// await import('./core/router.js') ← this file + +const response = await fetch('./pages.json') +if (!response.ok) throw new Error('router: failed to load pages.json — run build.sh') +const PAGES = await response.json() + +// Cache of imported JS modules — modules are only fetched once per session. +const MODULE_CACHE = new Map() + +// Currently active page CSS — removed on navigation. +let activeCSS = null + +function currentRoute() { + return window.location.hash.replace(/^#\//, '') || PAGES[0]?.route || '' +} + +async function render(routeName) { + const page = PAGES.find(p => p.route === routeName) ?? PAGES[0] + const main = document.querySelector('main') + if (!main || !page) return + + // ── HTML ──────────────────────────────────────────────────────────────────── + const htmlRes = await fetch(page.html) + if (!htmlRes.ok) throw new Error(`router: failed to load ${page.html}`) + main.innerHTML = await htmlRes.text() + + // ── CSS ───────────────────────────────────────────────────────────────────── + activeCSS?.remove() + activeCSS = null + if (page.css) { + const link = document.createElement('link') + link.rel = 'stylesheet' + link.href = page.css + document.head.appendChild(link) + activeCSS = link + } + + // ── JS ────────────────────────────────────────────────────────────────────── + if (page.js) { + if (!MODULE_CACHE.has(page.js)) { + MODULE_CACHE.set(page.js, await import(`../${page.js}`)) + } + MODULE_CACHE.get(page.js)?.init?.(main) + } +} + +await render(currentRoute()) +window.addEventListener('hashchange', () => render(currentRoute())) diff --git a/web/index.html b/web/index.html index 0961b64..021fb07 100644 --- a/web/index.html +++ b/web/index.html @@ -92,48 +92,22 @@
-
-
- - - -
- - -
-
+
diff --git a/web/navigation.json b/web/navigation.json index 96a2b94..2845612 100644 --- a/web/navigation.json +++ b/web/navigation.json @@ -1,22 +1,22 @@ [ { "label": "Dashboard", - "href": "index.html", + "href": "#/dashboard", "icon": "dashboard" }, { "label": "VPCs", - "href": "vpc.html", + "href": "#/vpcs", "icon": "vpc" }, { "label": "Subnets", - "href": "subnet.html", + "href": "#/subnets", "icon": "subnet" }, { "label": "VMs", - "href": "vm.html", + "href": "#/vms", "icon": "vm" } ] diff --git a/web/pages.json b/web/pages.json new file mode 100644 index 0000000..e018e51 --- /dev/null +++ b/web/pages.json @@ -0,0 +1,34 @@ +[ + { + "route": "dashboard", + "label": "Dashboard", + "icon": "dashboard", + "html": "pages/dashboard/index.html", + "css": "pages/dashboard/dashboard.css", + "js": "pages/dashboard/dashboard.js" + }, + { + "route": "vpcs", + "label": "VPCs", + "icon": "vpc", + "html": "pages/vpcs/index.html", + "css": null, + "js": null + }, + { + "route": "subnets", + "label": "Subnets", + "icon": "subnet", + "html": "pages/subnets/index.html", + "css": null, + "js": null + }, + { + "route": "vms", + "label": "VMs", + "icon": "vm", + "html": "pages/vms/index.html", + "css": null, + "js": null + } +] diff --git a/web/pages/dashboard/dashboard.css b/web/pages/dashboard/dashboard.css new file mode 100644 index 0000000..204e2c0 --- /dev/null +++ b/web/pages/dashboard/dashboard.css @@ -0,0 +1,2 @@ +/* Styles scoped to the dashboard page. + Applied when the route is active, removed on navigation. */ diff --git a/web/pages/dashboard/dashboard.js b/web/pages/dashboard/dashboard.js new file mode 100644 index 0000000..43d7fea --- /dev/null +++ b/web/pages/dashboard/dashboard.js @@ -0,0 +1,8 @@ +// Dashboard page — called by the router each time this route is activated. +// Export init(main) to run logic after the HTML fragment is injected. +// +// `main` is the
DOM element containing the injected HTML. + +export function init(main) { + // Page is ready — add event listeners, fetch data, etc. +} diff --git a/web/pages/dashboard/index.html b/web/pages/dashboard/index.html new file mode 100644 index 0000000..15da108 --- /dev/null +++ b/web/pages/dashboard/index.html @@ -0,0 +1,33 @@ + + + +
+ + +
+ + + diff --git a/web/pages/dashboard/manifest.json b/web/pages/dashboard/manifest.json new file mode 100644 index 0000000..1a0f7ba --- /dev/null +++ b/web/pages/dashboard/manifest.json @@ -0,0 +1,6 @@ +{ + "route": "dashboard", + "label": "Dashboard", + "icon": "dashboard", + "version": "0.1.0" +} diff --git a/web/pages/subnets/index.html b/web/pages/subnets/index.html new file mode 100644 index 0000000..3ff00dd --- /dev/null +++ b/web/pages/subnets/index.html @@ -0,0 +1 @@ +

Subnets — à venir

diff --git a/web/pages/subnets/manifest.json b/web/pages/subnets/manifest.json new file mode 100644 index 0000000..189d0c2 --- /dev/null +++ b/web/pages/subnets/manifest.json @@ -0,0 +1 @@ +{ "route": "subnets", "label": "Subnets", "icon": "subnet", "version": "0.1.0" } diff --git a/web/pages/vms/index.html b/web/pages/vms/index.html new file mode 100644 index 0000000..ffe3861 --- /dev/null +++ b/web/pages/vms/index.html @@ -0,0 +1 @@ +

VMs — à venir

diff --git a/web/pages/vms/manifest.json b/web/pages/vms/manifest.json new file mode 100644 index 0000000..597d281 --- /dev/null +++ b/web/pages/vms/manifest.json @@ -0,0 +1 @@ +{ "route": "vms", "label": "VMs", "icon": "vm", "version": "0.1.0" } diff --git a/web/pages/vpcs/index.html b/web/pages/vpcs/index.html new file mode 100644 index 0000000..6de5126 --- /dev/null +++ b/web/pages/vpcs/index.html @@ -0,0 +1 @@ +

VPCs — à venir

diff --git a/web/pages/vpcs/manifest.json b/web/pages/vpcs/manifest.json new file mode 100644 index 0000000..7dde73b --- /dev/null +++ b/web/pages/vpcs/manifest.json @@ -0,0 +1 @@ +{ "route": "vpcs", "label": "VPCs", "icon": "vpc", "version": "0.1.0" }