web: start: multiple pages

Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
This commit is contained in:
GnomeZworc 2026-06-01 21:40:26 +02:00
commit b479493ac1
Signed by: nicolas.boufideline
GPG key ID: 4406BBBF8845D632
18 changed files with 309 additions and 52 deletions

View file

@ -109,8 +109,12 @@ download_path() {
# ── Read manifest ──────────────────────────────────────────────────────────────── # ── Read manifest ────────────────────────────────────────────────────────────────
mapfile -t LOCALS < <(yq '(.local // [])[]' "$MANIFEST") PAGES_DIR="${WEB_DIR}/pages"
mapfile -t REMOTES < <(yq '(.components // [])[] | [.name, .repo, (.ref // "main")] | join("|")' "$MANIFEST")
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 # Ordered list of load paths for components.json
declare -a LOAD_PATHS=() declare -a LOAD_PATHS=()
@ -175,3 +179,92 @@ echo "$LOCK_ENTRIES" | jq '.' > "$LOCKFILE"
log "wrote ${OUTPUT} (${#LOAD_PATHS[@]} components)" log "wrote ${OUTPUT} (${#LOAD_PATHS[@]} components)"
log "wrote ${LOCKFILE} ($(jq 'length' <<<"$LOCK_ENTRIES") remote pins)" 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

View file

@ -2,7 +2,7 @@
"components/login-gate/login-gate.js", "components/login-gate/login-gate.js",
"components/api-client/api-client.js", "components/api-client/api-client.js",
"components/side-nav/side-nav.js", "components/side-nav/side-nav.js",
"components/side-panel/side-panel.js",
"components/logout-button/logout-button.js", "components/logout-button/logout-button.js",
"components/side-panel/side-panel.js",
"components/date-display/date-display.js" "components/date-display/date-display.js"
] ]

View file

@ -9,13 +9,13 @@
# Local components shipped inside this repo. Not downloaded — listed here only # Local components shipped inside this repo. Not downloaded — listed here only
# so build.sh can place them in the generated load order. # so build.sh can place them in the generated load order.
local: local_components:
- login-gate - login-gate
- api-client - api-client
- side-nav - side-nav
- logout-button - logout-button
- date-display
- side-panel - side-panel
- date-display
# Remote components fetched from git at build time. # Remote components fetched from git at build time.
# Each is its own repo; its root must contain <name>.js and manifest.json. # Each is its own repo; its root must contain <name>.js and manifest.json.
@ -24,3 +24,34 @@ local:
# repo: https://git.g3e.fr/team-reseau/vpc-panel # repo: https://git.g3e.fr/team-reseau/vpc-panel
# ref: v1.2.0 # tag, branch or commit (default: main) # ref: v1.2.0 # tag, branch or commit (default: main)
components: [] 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('<tag>', ...) 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

View file

@ -27,11 +27,12 @@ function resolveIcon(name) {
} }
class SideNav extends HTMLElement { class SideNav extends HTMLElement {
#items = [] #items = []
#hamburger = null #hamburger = null
#backdrop = null #backdrop = null
#mqHandler = null #mqHandler = null
#isMobile = false #hashHandler = null
#isMobile = false
async connectedCallback() { async connectedCallback() {
this.attachShadow({ mode: 'open' }) this.attachShadow({ mode: 'open' })
@ -43,21 +44,36 @@ class SideNav extends HTMLElement {
this.#render() this.#render()
this.#mqHandler = () => this.#applyMode() this.#mqHandler = () => this.#applyMode()
this.#hashHandler = () => this.#updateActive()
MQ.addEventListener('change', this.#mqHandler) MQ.addEventListener('change', this.#mqHandler)
window.addEventListener('hashchange', this.#hashHandler)
this.#applyMode() this.#applyMode()
} }
disconnectedCallback() { disconnectedCallback() {
MQ.removeEventListener('change', this.#mqHandler) MQ.removeEventListener('change', this.#mqHandler)
window.removeEventListener('hashchange', this.#hashHandler)
this.#removeHamburger() this.#removeHamburger()
this.#removeBackdrop() this.#removeBackdrop()
} }
// ── Rendu des items ───────────────────────────────────────────────────────── // ── 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() { #render() {
const current = window.location.pathname.split('/').pop() || 'index.html' const current = this.#currentHref()
const links = this.#items.map(item => { const links = this.#items.map(item => {
const active = item.href === current const active = item.href === current

54
web/core/router.js Normal file
View file

@ -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 <link> — 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()))

View file

@ -92,48 +92,22 @@
<div class="layout"> <div class="layout">
<side-nav></side-nav> <side-nav></side-nav>
<main> <main></main>
<div id="error"></div>
<date-display></date-display>
<!-- Dev test: open stacked panels -->
<div style="display:flex;gap:8px;margin-top:8px;">
<button onclick="openTestPanel('Panel A', '400px')"
style="background:#313244;border:1px solid #45475a;border-radius:6px;
padding:7px 14px;color:#89b4fa;font-size:13px;cursor:pointer;">
Open panel A
</button>
<button onclick="openTestPanel('Panel B', '520px')"
style="background:#313244;border:1px solid #45475a;border-radius:6px;
padding:7px 14px;color:#a6e3a1;font-size:13px;cursor:pointer;">
Open panel B
</button>
</div>
</main>
</div> </div>
<script type="module"> <script type="module">
import './core/registry.js'
import { SidePanel } from './components/side-panel/side-panel.js'
window.openTestPanel = (title, width) => {
SidePanel.open({
title,
width,
content: `<p style="color:#a6adc8;font-size:14px;line-height:1.6">
Contenu du panneau <strong style="color:#cdd6f4">${title}</strong>.<br>
Largeur : ${width}.<br><br>
Tu peux ouvrir plusieurs panneaux — ils s'empilent vers la gauche.
Ferme avec ✕, Échap, ou en cliquant le fond.
</p>`,
})
}
window.addEventListener('unhandledrejection', e => { window.addEventListener('unhandledrejection', e => {
const el = document.getElementById('error') const el = document.getElementById('error')
el.style.display = 'block' if (el) {
el.textContent = `Error: ${e.reason?.message ?? e.reason}` el.style.display = 'block'
el.textContent = `Error: ${e.reason?.message ?? e.reason}`
}
}) })
// Sequential: registry defines all custom elements,
// then router renders the current route.
await import('./core/registry.js')
await import('./core/router.js')
</script> </script>
</body> </body>

View file

@ -1,22 +1,22 @@
[ [
{ {
"label": "Dashboard", "label": "Dashboard",
"href": "index.html", "href": "#/dashboard",
"icon": "dashboard" "icon": "dashboard"
}, },
{ {
"label": "VPCs", "label": "VPCs",
"href": "vpc.html", "href": "#/vpcs",
"icon": "vpc" "icon": "vpc"
}, },
{ {
"label": "Subnets", "label": "Subnets",
"href": "subnet.html", "href": "#/subnets",
"icon": "subnet" "icon": "subnet"
}, },
{ {
"label": "VMs", "label": "VMs",
"href": "vm.html", "href": "#/vms",
"icon": "vm" "icon": "vm"
} }
] ]

34
web/pages.json Normal file
View file

@ -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
}
]

View file

@ -0,0 +1,2 @@
/* Styles scoped to the dashboard page.
Applied when the route is active, removed on navigation. */

View file

@ -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 <main> DOM element containing the injected HTML.
export function init(main) {
// Page is ready — add event listeners, fetch data, etc.
}

View file

@ -0,0 +1,33 @@
<date-display></date-display>
<!-- Dev test: open stacked panels -->
<div style="display:flex;gap:8px;margin-top:8px;">
<button onclick="openTestPanel('Panel A', '400px')"
style="background:#313244;border:1px solid #45475a;border-radius:6px;
padding:7px 14px;color:#89b4fa;font-size:13px;cursor:pointer;">
Open panel A
</button>
<button onclick="openTestPanel('Panel B', '520px')"
style="background:#313244;border:1px solid #45475a;border-radius:6px;
padding:7px 14px;color:#a6e3a1;font-size:13px;cursor:pointer;">
Open panel B
</button>
</div>
<script type="module">
import { SidePanel } from '../components/side-panel/side-panel.js'
window.openTestPanel = (title, width) => {
SidePanel.open({
title,
width,
content: `<p style="color:#a6adc8;font-size:14px;line-height:1.6">
Contenu du panneau <strong style="color:#cdd6f4">${title}</strong>.<br>
Largeur : ${width}.<br><br>
Tu peux ouvrir plusieurs panneaux — ils s'empilent vers la gauche.
Ferme avec ✕, Échap, ou en cliquant le fond.
</p>`,
})
}
</script>

View file

@ -0,0 +1,6 @@
{
"route": "dashboard",
"label": "Dashboard",
"icon": "dashboard",
"version": "0.1.0"
}

View file

@ -0,0 +1 @@
<p>Subnets — à venir</p>

View file

@ -0,0 +1 @@
{ "route": "subnets", "label": "Subnets", "icon": "subnet", "version": "0.1.0" }

1
web/pages/vms/index.html Normal file
View file

@ -0,0 +1 @@
<p>VMs — à venir</p>

View file

@ -0,0 +1 @@
{ "route": "vms", "label": "VMs", "icon": "vm", "version": "0.1.0" }

View file

@ -0,0 +1 @@
<p>VPCs — à venir</p>

View file

@ -0,0 +1 @@
{ "route": "vpcs", "label": "VPCs", "icon": "vpc", "version": "0.1.0" }