(() => { const $ = (sel, root = document) => root.querySelector(sel); const STORE_KEY = "cockpit_present_decks_v2"; const STORY_SLIDES = [ { id: "disclaimer", kicker: "Important notice", title: "Disclaimer — please read first", anim: "fade", html: ` `, }, { id: "agenda", kicker: "Customer briefing", title: "What we will cover", anim: "rise", html: `

Datacenter Engineers asked us to help them see OME data faster and explain it with AI. This briefing shows the result — and why a cockpit like this adds value.

  1. Why — the gap around OME and why AI alone is not enough.
  2. Architecture — one design: Browser → Cockpit BFF → OME / AI (with logos).
  3. Apps — map, network, reports, Console wall, Ops + Team chat, copilots.
  4. Remote ops — power via OME→iDRAC and live consoles in-panel.
  5. Team — soft identity, chat, files, avatars, live unread alerts.
  6. People — Datacenter Engineers & Data FDEs ("Data Plumbers" 😉).
  7. Value — what customers can take away.

Requested by Jody van Dongen & Laurens Rammers · delivered by the FDE team (Mo & Bart"Data Plumbers" 😉).

`, }, { id: "problem", kicker: "The ask", title: "OME is strong — operators still lose time", anim: "slide", html: `

OME remains the management plane of record. The friction is around it: too many screens, slow answers to “what is wrong on this Service Tag?”, and AI that invents facts without live inventory.

Console sprawl

OME, iDRAC, sheets, chat, tickets — same Service Tag, five places.

Context loss

Warranty, firmware, and offline state are related — rarely one story.

Ungrounded AI

Generic chatbots guess. Useful AI must start from OME.

Jody and Laurens (Datacenter Engineers) asked the FDE team to assist — build a practical ops lens on top of OME, not replace OME.

`, }, { id: "value", kicker: "Value", title: "Why this cockpit adds value", anim: "rise", html: `

One Service Tag story

Map, inventory, compliance, warranty, fabric, and tickets around the same ST.

Remote ops in UI

Power via OME jobs · live iDRAC Console wall (4/8) without tab sprawl.

AI that cites the fleet

Copilot answers from live OME facts — or says it does not know.

Ops + Team chat

Tickets and live team / DM chat with files, avatars, and unread alerts.

DC Engineer ↔ Data FDE

Jody & Laurens own the estate; Mo & Bart ("Data Plumbers" 😉) deliver the surface.

Copyable pattern

OME API + BFF + grounded AI — a blueprint, not a Dell SKU.

`, }, { id: "arch-design", kicker: "Architecture design", title: "One design · how everything connects", anim: "zoom", html: `

Every app talks only to the Cockpit BFF. The BFF talks to OME (truth) and Open WebUI / vLLM (AI). Pulse lines show that path.

OperatorBrowser · HTTPS · no secrets
MapTopology
ConsoleiDRAC wall
Cockpit UIDrawers · KPIs
NetworkFabric · Racks
Ops / TeamTickets · chat
BFF · OME Cockpit API Docker · FastAPI · secrets · cache · ground AI · :3090
Dell OMETruth · inventory · warranty
Open WebUIOpenManage AI · :3080
vLLMFDE inference host
Blue pulse · app / API traffic Amber pulse · BFF spine Packets · live request path

Browser never holds OME or AI secrets. Docker hosts Cockpit (:3090) and Open WebUI (:3080) on the portal.

`, }, { id: "apps", kicker: "Applications", title: "What you use day to day", anim: "rise", html: `
Live mapFleet topology · power · inspector
Console wall4 / 8 live iDRAC embeds · drag
NetworkFabric · 42U racks · VLANs
ReportsCompliance · warranty analytics
Ops + TeamTickets · chat · files · avatars
CopilotGrounded chat on Service Tags

One join key everywhere: Service Tag. Soft identity on open so Jody, Laurens, Mo, Bart (or Guest) act as themselves.

`, }, { id: "console-wall", kicker: "Remote ops", title: "Console wall · many iDRACs, one screen", anim: "zoom", html: `

Operators asked to see systems without tab-hopping. The Console tab is a live iDRAC wall: network-aware, draggable, and embedded through Cockpit so browsers are not blocked by X-Frame-Options.

4 or 8 screens

Pick wall size · Small / Medium density · Fullscreen popup per tile.

Drag & + Wall

Fleet rail grouped by VLAN · drop onto slots · Auto-fill live BMCs.

Network badges

VLAN · subnet · LIVE/OFF on every tile — know where you are looking.

Same-origin proxy

/api/idrac-proxy/{id}/… strips framing headers · WebSocket bridge for HTML5 console.

Real iDRACs only

OOB / BMC endpoints — never bare OS host IPs that cannot speak iDRAC.

Still OME truth

Device list and connectivity come from the live fleet snapshot.

`, }, { id: "power-idrac", kicker: "Remote ops", title: "Power on / off · without leaving the map", anim: "rise", html: `

Offline servers with POWER N/A are common in the lab. From Servers KPI, inspector, or Quick Connect you can submit an OME POWER_CONTROL job — on, graceful off, or cycle — with confirm for destructive actions.

01Select

Server / iDRAC on map or KPI list

02Power

⏻ On · Off · Cycle via OME JobService

03Console

iDRAC HTML5 in-panel (proxied)

04Verify

Next fleet poll updates powered state

05Escalate

Ops desk ticket if change needs a trail

Demo power with care — production change windows still belong in official OME / iDRAC process.

`, }, { id: "team-chat", kicker: "Collaboration", title: "Team chat · who you are, what you share", anim: "rise", html: `

First popup on open: pick yourself (or Guest). Then Ops desk and Team chat share one soft identity — tickets, DMs, and files stay attributable without SSO.

Identity gate

Jody · Laurens · Mo · Bart · Guest — profile photo upload optional.

Team + DMs

Shared ATC room and 1:1 chats · WebSocket live presence.

Files & avatars

Drop files into chat · avatars on bubbles, presence, and the Team chip.

Unread alerts

Badge on Ops|Team · room counters · browser notify when the tab is in the background.

Ops|Team combo

One topbar control: tickets left, Team + who-you-are right — KPIs keep a single row.

Roles that match reality

Datacenter Engineers own rack & stack; FDEs are "Data Plumbers" 😉.

`, }, { id: "ai", kicker: "AI + OME", title: "AI that starts from OME facts", anim: "slide", html: `

Ask → Cockpit enriches with live OME context → Open WebUI and/or vLLM → answer → act in map, reports, Console, or Ops.

01Ask

Fleet question in Copilot

02Enrich

BFF attaches Service Tag facts

03Route

Open WebUI / vLLM completion

04Answer

Still tied to the live map

05Act

Inspector · console · ticket

OME for truth · AI for speed · Cockpit for the operator experience.

`, }, { id: "origin", kicker: "How this started", title: "Datacenter Engineers set the need · Data FDEs delivered the surface", anim: "rise", html: `

Jody van Dongen and Laurens Rammers (Datacenter Engineers — storage, servers, network, rack & stack, installs) defined the operational need and asked the FDE team to help. Together they produced a cockpit that sits on OME: topology, reports, network context, tickets, and AI — for ATC use and customer conversations, explicitly not a product SKU.

Datacenter Engineers · the ask

Data FDEs · the delivery

Engineered against live OME data; refined for demos and daily operations.

`, }, { id: "benefits", kicker: "Takeaways", title: "What customers walk away with", anim: "rise", html: `

Clarity

One place for connectivity, compliance, warranty, and hardware depth.

Credibility

Claims trace to a Service Tag from OME.

Speed

AI drafts explanations; humans own change windows.

Collaboration

Datacenter Engineers and Data FDEs ("Data Plumbers" 😉) share Ops + Team chat.

Pattern

OME + Docker BFF + grounded AI — repeatable.

Honesty

Demo power with an explicit non-product disclaimer.

`, }, { id: "close", kicker: "Next steps", title: "See it live", anim: "zoom", html: `
  1. Pick yourself on the identity gate (or Guest) when the UI opens.
  2. Pick a Service Tag on the map and open the inspector.
  3. Open Console — Auto-fill live iDRACs (4 or 8 screens).
  4. From Servers KPI: Power on a cold node · open iDRAC console in-panel.
  5. Open Ops|Team — create a ticket, then send a Team or DM message with a file.
  6. Ask Copilot a question that must cite Service Tags.
  7. Switch to Technical architecture for API and trust-boundary depth.

Not an official Dell product — verify production decisions in OME. Esc closes · F fullscreen.

`, }, ]; const TECH_SLIDES = [ { id: "disclaimer", kicker: "Important notice", title: "Disclaimer — please read first", anim: "fade", html: ` `, }, { id: "tech-agenda", kicker: "Technical deck", title: "Architecture for engineers", anim: "rise", html: `

How OME, the Cockpit BFF, Open WebUI, and vLLM are wired — and why that wiring keeps AI grounded.

  1. Reference design with logos & pulse paths
  2. Why a BFF (secrets, normalize, ground AI)
  3. OME session / cache behaviour
  4. AI completion path
  5. Portal containers & key /api/* contracts
  6. iDRAC proxy · power jobs · Console wall
  7. Team chat · soft identity · WebSocket · file / avatar store

Delivered for Datacenter Engineers Jody & Laurens by Data FDEs Mo & Bart ("Data Plumbers" 😉).

`, }, { id: "arch-design", kicker: "Architecture design", title: "Reference design · containers & pulse paths", anim: "zoom", html: `

Thin UI apps over a FastAPI BFF in Docker. OME is hardware truth. Open WebUI / vLLM are the AI plane.

OperatorBrowser · HTTPS · no secrets
MapTopology
ConsoleiDRAC wall
Cockpit UIDrawers · KPIs
NetworkFabric · Racks
Ops / TeamTickets · chat
BFF · OME Cockpit API Docker · FastAPI · secrets · cache · ground AI · :3090
Dell OMETruth · inventory · warranty
Open WebUIOpenManage AI · :3080
vLLMFDE inference host
Blue pulse · app / API traffic Amber pulse · BFF spine Packets · live request path
`, }, { id: "why-bff", kicker: "Design choice", title: "Why the BFF sits in the middle", anim: "rise", html: `

The browser must never hold OME passwords. The Cockpit API is the only component allowed to call AI with enriched Service Tag context.

Normalize

OME JSON → stable fleet nodes, NIC PortIds, compliance rows.

Cache

Fleet & report caches keep demos responsive.

Ground AI

Inject facts before completions — no invented BIOS levels.

`, }, { id: "ome-plane", kicker: "Data plane · OME", title: "OME remains the only hardware truth", anim: "slide", html: `

Session lifecycle

  1. POST /api/SessionService/Sessions → token
  2. Devices · power · baselines · warranties
  3. InventoryDetails (memory, NICs, disks, …)
  4. DELETE session when the batch ends

Cached for the UI

Rule: if OME did not return it, UI and Copilot must not invent it.

`, }, { id: "ai-plane", kicker: "AI plane", title: "Copilot → Open WebUI / vLLM", anim: "slide", html: `
01Browser

Copilot drawer

02/api/chat

BFF + OME facts

03aOpen WebUI

Docker · :3080

03bvLLM

OpenAI-compatible

04Reply

Back to the drawer

Two experiences on purpose: grounded Copilot inside Cockpit, and full OpenManage AI (Open WebUI) beside it.

`, }, { id: "portal-host", kicker: "Deployment", title: "What runs on the portal", anim: "slide", html: `

ome-cockpit

Static UI + FastAPI BFF · :3090 · ops.db volume

openmanage

Open WebUI · OpenManage AI · :3080

OME appliance

HTTPS management plane · session API

vLLM (optional)

FDE inference · OpenAI schema

Users hit the portal; the portal speaks to OME and AI backends.

`, }, { id: "apis", kicker: "API surface", title: "Contracts behind the apps", anim: "rise", html: `

/api/fleet

Cached device graph for canvas & KPIs.

/api/devices/…/power

OME JobService POWER_CONTROL · on / off / cycle.

/api/idrac-proxy/…

Same-origin HTML + WebSocket bridge · strips XFO.

/api/team/* · /ws/team-chat

Rooms, DMs, files, avatars · live presence & messages.

/api/chat · /api/models

Grounded completions · model list.

/api/network/*

Fabric ports · racks · VLANs.

`, }, { id: "idrac-proxy", kicker: "Remote console", title: "Why the iDRAC proxy exists", anim: "slide", html: `

Browsers refuse to iframe most iDRAC UIs (X-Frame-Options: SAMEORIGIN|DENY). Cockpit proxies the session so the Console wall and in-panel viewer stay on :3090.

HTTP reverse proxy

Gzip passthrough · cookie Path rewrite · CSP frame-ancestors *.

JS bootstrap

Patches fetch / XHR / WebSocket so absolute /sysmgmt paths stay on the proxy prefix.

SSRF guard

Only fleet management IPs · prefers idrac_ip · rejects bare OS hosts.

Operators still authenticate to iDRAC with their own credentials inside the embed.

`, }, { id: "trust", kicker: "Trust & data", title: "Boundaries to call out", anim: "fade", html: `

From OME (read + jobs)

Cockpit-local (write)

Security notes

`, }, { id: "close", kicker: "Next steps", title: "Back to the live estate", anim: "zoom", html: `
  1. Return to the map and pick a Service Tag.
  2. Open Console and drop two iDRACs onto the wall.
  3. Open Ops|Team — ticket + a chat message with unread badge.
  4. Ask Copilot a question that must cite that ST.
  5. Use Customer story for business / ops audiences.

Not an official Dell product. Esc closes · F fullscreen.

`, }, ]; const BUILTIN_VERSION = 10; const BUILTIN_DECKS = [ { id: "story", name: "Customer story", description: "Customer briefing — Console, power, Team chat, roles, value", builtin: true, slides: structuredClone(STORY_SLIDES), }, { id: "technical", name: "Technical architecture", description: "Engineer deep dive — design, BFF, APIs, Team chat, trust", builtin: true, slides: structuredClone(TECH_SLIDES), }, ]; let store = loadStore(); let activeId = store.activeId || "story"; let slides = currentDeck().slides; let index = 0; let mode = "mid"; let theme = (() => { try { if (!localStorage.getItem("present_theme_migrated_v1")) { localStorage.setItem("cockpit_present_theme", "dark"); localStorage.setItem("present_theme_migrated_v1", "1"); } } catch (_) {} return localStorage.getItem("cockpit_present_theme") || "dark"; })(); let editing = false; let open = false; function builtinTemplate(id) { return BUILTIN_DECKS.find((d) => d.id === id) || null; } function defaultStore() { return { activeId: "story", decks: BUILTIN_DECKS.map((d) => ({ id: d.id, name: d.name, description: d.description, builtin: true, version: BUILTIN_VERSION, slides: structuredClone(d.slides), })), }; } function loadStore() { try { const raw = JSON.parse(localStorage.getItem(STORE_KEY) || "null"); if (raw && Array.isArray(raw.decks) && raw.decks.length) { // ensure builtin decks exist (merge missing) const byId = new Map(raw.decks.map((d) => [d.id, d])); for (const b of BUILTIN_DECKS) { if (!byId.has(b.id)) { raw.decks.unshift({ id: b.id, name: b.name, description: b.description, builtin: true, version: BUILTIN_VERSION, slides: structuredClone(b.slides), }); } else { const cur = byId.get(b.id); cur.builtin = true; cur.name = b.name; cur.description = b.description; if ((cur.version || 0) < BUILTIN_VERSION) { cur.slides = structuredClone(b.slides); cur.version = BUILTIN_VERSION; } } } if (!raw.activeId || !raw.decks.some((d) => d.id === raw.activeId)) { raw.activeId = raw.decks[0].id; } return raw; } } catch (_) {} // migrate old single-deck keys if present try { for (const k of ["cockpit_present_slides_v2", "cockpit_present_slides_v1"]) { const old = JSON.parse(localStorage.getItem(k) || "null"); if (Array.isArray(old) && old.length) { const s = defaultStore(); s.decks.find((d) => d.id === "technical").slides = old; s.activeId = "technical"; return s; } } } catch (_) {} return defaultStore(); } function saveStore() { // sync current slides into store const deck = store.decks.find((d) => d.id === activeId); if (deck) deck.slides = slides; store.activeId = activeId; localStorage.setItem(STORE_KEY, JSON.stringify(store)); } function currentDeck() { return store.decks.find((d) => d.id === activeId) || store.decks[0]; } function selectDeck(id) { if (!store.decks.some((d) => d.id === id)) return; // persist current const prev = store.decks.find((d) => d.id === activeId); if (prev) prev.slides = slides; activeId = id; slides = currentDeck().slides; index = 0; editing = false; saveStore(); render(); } function createDeck() { const name = prompt("New deck name", "Custom deck"); if (!name) return; const id = `custom-${Date.now()}`; const deck = { id, name: name.trim().slice(0, 64), description: "Custom presentation", builtin: false, slides: [ { id: "slide-1", kicker: "Custom", title: name.trim().slice(0, 64), anim: "slide", html: '

Write your first slide. Use Edit to change content.

', }, ], }; store.decks.push(deck); selectDeck(id); } function duplicateDeck() { const src = currentDeck(); const id = `custom-${Date.now()}`; store.decks.push({ id, name: `${src.name} (copy)`.slice(0, 64), description: src.description || "", builtin: false, slides: structuredClone(src.slides), }); selectDeck(id); } function deleteDeck() { const d = currentDeck(); if (d.builtin) { alert("Built-in decks cannot be deleted. Reset restores their default slides."); return; } if (store.decks.length <= 1) { alert("Keep at least one deck."); return; } if (!confirm(`Delete deck “${d.name}”?`)) return; store.decks = store.decks.filter((x) => x.id !== d.id); activeId = store.decks[0].id; slides = currentDeck().slides; index = 0; saveStore(); render(); } function resetCurrentDeck() { const d = currentDeck(); const tpl = builtinTemplate(d.id); if (tpl) { if (!confirm(`Reset “${d.name}” to the built-in default slides?`)) return; d.slides = structuredClone(tpl.slides); d.name = tpl.name; d.description = tpl.description; d.version = BUILTIN_VERSION; } else { if (!confirm(`Clear custom deck “${d.name}” to a single blank slide?`)) return; d.slides = [ { id: "slide-1", kicker: "Custom", title: d.name, anim: "slide", html: '

Blank deck — edit this slide.

', }, ]; } slides = d.slides; index = 0; editing = false; saveStore(); render(); } function escapeHtml(s) { return String(s ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function fileToDataUrl(file) { return new Promise((resolve, reject) => { const r = new FileReader(); r.onload = () => resolve(r.result); r.onerror = () => reject(new Error("Failed to read " + file.name)); r.readAsDataURL(file); }); } async function loadJsZip() { if (window.JSZip) return window.JSZip; await new Promise((resolve, reject) => { const s = document.createElement("script"); s.src = "https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js"; s.onload = resolve; s.onerror = () => reject(new Error("Could not load JSZip for PPTX import")); document.head.appendChild(s); }); return window.JSZip; } async function slidesFromPptx(file) { const JSZip = await loadJsZip(); const zip = await JSZip.loadAsync(file); const media = Object.keys(zip.files) .filter((p) => /^ppt\/media\//i.test(p) && !zip.files[p].dir) .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); const out = []; let n = 0; for (const path of media) { const lower = path.toLowerCase(); if (!/\.(png|jpe?g|gif|webp|bmp)$/i.test(lower)) continue; n += 1; const blob = await zip.files[path].async("blob"); const type = blob.type || (lower.endsWith(".png") ? "image/png" : "image/jpeg"); const dataUrl = await new Promise((resolve, reject) => { const r = new FileReader(); r.onload = () => resolve(r.result); r.onerror = reject; r.readAsDataURL(new Blob([blob], { type })); }); out.push({ id: `pptx-${Date.now()}-${n}`, kicker: "Imported · PPTX", title: `Slide ${n}`, anim: "fade", layout: "image", image: dataUrl, html: "", }); } if (!out.length) { throw new Error("No images found inside this PPTX. Export slides as images or use a PPTX with embedded pictures."); } return out; } async function importMediaFiles(files) { const added = []; for (const file of files) { const name = (file.name || "").toLowerCase(); if (name.endsWith(".pptx") || file.type.includes("presentationml")) { added.push(...(await slidesFromPptx(file))); } else if (file.type.startsWith("image/") || /\.(png|jpe?g|gif|webp|bmp)$/i.test(name)) { const dataUrl = await fileToDataUrl(file); added.push({ id: `img-${Date.now()}-${added.length}`, kicker: "Imported · image", title: file.name.replace(/\.[^.]+$/, ""), anim: "fade", layout: "image", image: dataUrl, html: "", }); } } if (!added.length) throw new Error("No supported files (use .pptx or images)."); slides.push(...added); index = slides.length - added.length; saveStore(); render(); } function ensureDom() { if ($("#present-root")) return; const root = document.createElement("div"); root.id = "present-root"; root.className = "present-root hidden"; root.setAttribute("aria-hidden", "true"); root.innerHTML = `
`; document.body.appendChild(root); $("#present-close", root)?.addEventListener("click", closePresent); root.querySelector("[data-present-close]")?.addEventListener("click", closePresent); $("#present-prev", root)?.addEventListener("click", () => go(-1)); $("#present-next", root)?.addEventListener("click", () => go(1)); $("#present-mode-mid", root)?.addEventListener("click", () => setMode("mid")); $("#present-mode-full", root)?.addEventListener("click", () => setMode("full")); $("#present-theme-light", root)?.addEventListener("click", () => setTheme("light")); $("#present-theme-dark", root)?.addEventListener("click", () => setTheme("dark")); $("#present-edit", root)?.addEventListener("click", toggleEdit); $("#present-deck", root)?.addEventListener("change", (e) => selectDeck(e.target.value)); $("#present-deck-new", root)?.addEventListener("click", createDeck); $("#present-deck-dup", root)?.addEventListener("click", duplicateDeck); $("#present-deck-del", root)?.addEventListener("click", deleteDeck); $("#present-reset", root)?.addEventListener("click", resetCurrentDeck); $("#present-export", root)?.addEventListener("click", () => { const deck = currentDeck(); const payload = { id: deck.id, name: deck.name, description: deck.description, slides, }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = `ome-cockpit-deck-${deck.id}.json`; a.click(); URL.revokeObjectURL(a.href); }); $("#present-import", root)?.addEventListener("click", () => $("#present-import-file")?.click()); $("#present-import-file", root)?.addEventListener("change", async (e) => { const file = e.target.files?.[0]; if (!file) return; try { const text = await file.text(); const data = JSON.parse(text); const list = Array.isArray(data) ? data : data.slides; if (!Array.isArray(list) || !list.length) throw new Error("Expected slides array or { slides: [...] }"); slides = list.map((s, i) => ({ id: s.id || `slide-${i + 1}`, kicker: s.kicker || "", title: s.title || `Slide ${i + 1}`, html: s.html || s.body || "", anim: s.anim || "slide", })); if (data.name && !currentDeck().builtin) currentDeck().name = String(data.name).slice(0, 64); if (data.description && !currentDeck().builtin) currentDeck().description = String(data.description); index = 0; saveStore(); render(); } catch (err) { alert("Import failed: " + err.message); } e.target.value = ""; }); $("#present-add-media", root)?.addEventListener("click", () => $("#present-media-file")?.click()); $("#present-media-file", root)?.addEventListener("change", async (e) => { const files = [...(e.target.files || [])]; if (!files.length) return; try { await importMediaFiles(files); } catch (err) { alert("Media import failed: " + err.message); } e.target.value = ""; }); $("#pe-save", root)?.addEventListener("click", () => { const s = slides[index]; if (!s) return; s.kicker = $("#pe-kicker")?.value || ""; s.title = $("#pe-title")?.value || ""; s.html = $("#pe-html")?.value || ""; saveStore(); editing = false; render(); }); $("#pe-add", root)?.addEventListener("click", () => { slides.splice(index + 1, 0, { id: `custom-${Date.now()}`, kicker: "Custom", title: "New slide", anim: "slide", html: '

Write your content here.

', }); index += 1; saveStore(); editing = true; render(); }); $("#pe-del", root)?.addEventListener("click", () => { if (slides.length <= 1) { alert("Keep at least one slide."); return; } if (!confirm("Delete this slide?")) return; slides.splice(index, 1); index = Math.min(index, slides.length - 1); saveStore(); render(); }); } function renderDeckSelect() { const sel = $("#present-deck"); if (!sel) return; sel.innerHTML = store.decks .map( (d) => `` ) .join(""); const desc = $("#present-deck-desc"); if (desc) desc.textContent = currentDeck().description || ""; $("#present-deck-del")?.toggleAttribute("disabled", !!currentDeck().builtin); } function render() { ensureDom(); const root = $("#present-root"); const stage = $("#present-stage"); const slideEl = $("#present-slide"); const s = slides[index] || slides[0]; if (!s || !slideEl) return; root.classList.toggle("hidden", !open); root.setAttribute("aria-hidden", open ? "false" : "true"); document.body.classList.toggle("present-open", open); stage?.classList.toggle("mode-mid", mode === "mid"); stage?.classList.toggle("mode-full", mode === "full"); stage?.classList.toggle("theme-light", theme === "light"); stage?.classList.toggle("theme-dark", theme === "dark"); const setBtnState = (id, on) => { const el = $(id); if (!el) return; el.classList.toggle("primary", on); el.classList.toggle("ghost", !on); }; setBtnState("#present-mode-mid", mode === "mid"); setBtnState("#present-mode-full", mode === "full"); setBtnState("#present-theme-light", theme === "light"); setBtnState("#present-theme-dark", theme === "dark"); setBtnState("#present-edit", editing); renderDeckSelect(); slideEl.classList.remove("enter", "anim-fade", "anim-zoom", "anim-rise", "anim-slide"); void slideEl.offsetWidth; const anim = s.anim || "slide"; if (s.layout === "image" && s.image) { slideEl.classList.add("is-image"); slideEl.innerHTML = `
${escapeHtml(s.title ||

${escapeHtml(s.kicker || "Imported")}

${escapeHtml(s.title || "")}

`; } else { slideEl.classList.remove("is-image"); slideEl.innerHTML = `

${escapeHtml(s.kicker)}

${escapeHtml(s.title)}

${s.html}
`; } slideEl.classList.add("enter", `anim-${anim}`); const prog = $("#present-progress"); if (prog) prog.textContent = `${currentDeck().name} · ${index + 1} / ${slides.length}`; const bar = $("#present-bar"); if (bar) bar.style.width = `${((index + 1) / slides.length) * 100}%`; const dots = $("#present-dots"); if (dots) { dots.innerHTML = slides .map( (_, i) => `` ) .join(""); dots.querySelectorAll("[data-dot]").forEach((btn) => { btn.addEventListener("click", () => { index = Number(btn.dataset.dot); render(); }); }); } $("#present-prev").disabled = index <= 0; const nextBtn = $("#present-next"); if (nextBtn) nextBtn.textContent = index >= slides.length - 1 ? "Done" : "Next"; const panel = $("#present-edit-panel"); panel?.classList.toggle("hidden", !editing); $("#present-edit")?.classList.toggle("is-on", editing); if (editing) { $("#pe-kicker").value = s.kicker || ""; $("#pe-title").value = s.title || ""; $("#pe-html").value = s.html || ""; } } function go(delta) { if (delta > 0 && index >= slides.length - 1) { closePresent(); return; } index = Math.max(0, Math.min(slides.length - 1, index + delta)); render(); } function setTheme(next) { theme = next === "dark" ? "dark" : "light"; try { localStorage.setItem("cockpit_present_theme", theme); } catch (_) {} render(); } async function setMode(next) { mode = next === "full" ? "full" : "mid"; const stage = $("#present-stage"); if (mode === "full") { try { if (stage && !document.fullscreenElement) await stage.requestFullscreen(); } catch (_) {} } else if (document.fullscreenElement) { try { await document.exitFullscreen(); } catch (_) {} } render(); } function toggleEdit() { editing = !editing; render(); } function openPresent() { ensureDom(); open = true; index = 0; editing = false; mode = "mid"; if (!localStorage.getItem("cockpit_present_theme")) theme = "dark"; slides = currentDeck().slides; render(); } function closePresent() { open = false; editing = false; saveStore(); if (document.fullscreenElement) { document.exitFullscreen().catch(() => {}); } render(); } document.addEventListener("fullscreenchange", () => { if (!open) return; mode = document.fullscreenElement ? "full" : "mid"; render(); }); document.addEventListener("keydown", (e) => { if (!open) return; if (e.key === "Escape") { e.preventDefault(); closePresent(); return; } if (editing && e.target?.closest?.("#present-edit-panel")) return; if (e.target?.closest?.("#present-deck")) return; if (e.key === "ArrowRight" || e.key === "PageDown" || e.key === " ") { e.preventDefault(); go(1); } else if (e.key === "ArrowLeft" || e.key === "PageUp") { e.preventDefault(); go(-1); } else if (e.key === "f" || e.key === "F") { e.preventDefault(); setMode(mode === "full" ? "mid" : "full"); } else if (e.key === "e" || e.key === "E") { e.preventDefault(); toggleEdit(); } }); function init() { $("#btn-present")?.addEventListener("click", openPresent); window.cockpitPresent = { open: openPresent, close: closePresent, decks: () => store.decks }; } if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init); else init(); })();