web: start: multiple pages
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
This commit is contained in:
parent
6e976ac6e3
commit
2e06cd85bc
18 changed files with 309 additions and 52 deletions
97
web/build.sh
97
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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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 <name>.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('<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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
54
web/core/router.js
Normal file
54
web/core/router.js
Normal 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()))
|
||||
|
|
@ -92,48 +92,22 @@
|
|||
<div class="layout">
|
||||
<side-nav></side-nav>
|
||||
|
||||
<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>
|
||||
<main></main>
|
||||
</div>
|
||||
|
||||
<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 => {
|
||||
const el = document.getElementById('error')
|
||||
el.style.display = 'block'
|
||||
el.textContent = `Error: ${e.reason?.message ?? e.reason}`
|
||||
if (el) {
|
||||
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>
|
||||
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
34
web/pages.json
Normal file
34
web/pages.json
Normal 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
|
||||
}
|
||||
]
|
||||
2
web/pages/dashboard/dashboard.css
Normal file
2
web/pages/dashboard/dashboard.css
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/* Styles scoped to the dashboard page.
|
||||
Applied when the route is active, removed on navigation. */
|
||||
8
web/pages/dashboard/dashboard.js
Normal file
8
web/pages/dashboard/dashboard.js
Normal 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.
|
||||
}
|
||||
33
web/pages/dashboard/index.html
Normal file
33
web/pages/dashboard/index.html
Normal 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>
|
||||
6
web/pages/dashboard/manifest.json
Normal file
6
web/pages/dashboard/manifest.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"route": "dashboard",
|
||||
"label": "Dashboard",
|
||||
"icon": "dashboard",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
1
web/pages/subnets/index.html
Normal file
1
web/pages/subnets/index.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<p>Subnets — à venir</p>
|
||||
1
web/pages/subnets/manifest.json
Normal file
1
web/pages/subnets/manifest.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{ "route": "subnets", "label": "Subnets", "icon": "subnet", "version": "0.1.0" }
|
||||
1
web/pages/vms/index.html
Normal file
1
web/pages/vms/index.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<p>VMs — à venir</p>
|
||||
1
web/pages/vms/manifest.json
Normal file
1
web/pages/vms/manifest.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{ "route": "vms", "label": "VMs", "icon": "vm", "version": "0.1.0" }
|
||||
1
web/pages/vpcs/index.html
Normal file
1
web/pages/vpcs/index.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<p>VPCs — à venir</p>
|
||||
1
web/pages/vpcs/manifest.json
Normal file
1
web/pages/vpcs/manifest.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{ "route": "vpcs", "label": "VPCs", "icon": "vpc", "version": "0.1.0" }
|
||||
Loading…
Add table
Add a link
Reference in a new issue