1231 lines
57 KiB
JavaScript
1231 lines
57 KiB
JavaScript
|
|
(() => {
|
||
|
|
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: `
|
||
|
|
<div class="ps-legal">
|
||
|
|
<p><strong>This OpenManage Cockpit is not an official Dell Technologies product, service, or offering.</strong>
|
||
|
|
It is an independent ATC demonstration built to show what is possible when OpenManage Enterprise is combined with modern AI tooling.</p>
|
||
|
|
<ul>
|
||
|
|
<li>Not endorsed, certified, warranted, or supported by Dell Technologies as a shipping product.</li>
|
||
|
|
<li>Use is <strong>at your own risk</strong>. No liability for loss, downtime, data loss, or decisions made from this UI.</li>
|
||
|
|
<li>Always verify critical actions in official Dell / OME consoles and your change processes.</li>
|
||
|
|
<li>By continuing you acknowledge that Dell Technologies has <strong>no responsibility or obligation</strong> regarding this cockpit.</li>
|
||
|
|
</ul>
|
||
|
|
<p class="ps-legal-ack">Press <kbd>→</kbd> or <strong>Next</strong> to continue.</p>
|
||
|
|
</div>`,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "agenda",
|
||
|
|
kicker: "Customer briefing",
|
||
|
|
title: "What we will cover",
|
||
|
|
anim: "rise",
|
||
|
|
html: `
|
||
|
|
<p class="ps-lead">ATC admins 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.</p>
|
||
|
|
<ol class="ps-steps ps-stagger">
|
||
|
|
<li><strong>Why</strong> — the gap around OME and why AI alone is not enough.</li>
|
||
|
|
<li><strong>Architecture</strong> — one design: Browser → Cockpit BFF → OME / AI (with logos).</li>
|
||
|
|
<li><strong>Apps</strong> — map, network, reports, ops desk, copilots.</li>
|
||
|
|
<li><strong>People</strong> — who asked, who built, who runs it.</li>
|
||
|
|
<li><strong>Value</strong> — what customers can take away.</li>
|
||
|
|
</ol>
|
||
|
|
<p class="ps-sub">Requested by <strong>Jody van Dongen</strong> & <strong>Laurens Rammers</strong> · delivered by the FDE team (<strong>Mohamed El Kadi</strong> & <strong>Bart Sjerps</strong>).</p>`,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "problem",
|
||
|
|
kicker: "The ask",
|
||
|
|
title: "OME is strong — operators still lose time",
|
||
|
|
anim: "slide",
|
||
|
|
html: `
|
||
|
|
<p class="ps-lead">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.</p>
|
||
|
|
<div class="ps-grid3">
|
||
|
|
<div class="ps-card"><h4>Console sprawl</h4><p>OME, iDRAC, sheets, chat, tickets — same Service Tag, five places.</p></div>
|
||
|
|
<div class="ps-card"><h4>Context loss</h4><p>Warranty, firmware, and offline state are related — rarely one story.</p></div>
|
||
|
|
<div class="ps-card"><h4>Ungrounded AI</h4><p>Generic chatbots guess. Useful AI must start from OME.</p></div>
|
||
|
|
</div>
|
||
|
|
<p class="ps-sub">Jody and Laurens asked the FDE team to assist — build a practical ops lens on top of OME, not replace OME.</p>`,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "value",
|
||
|
|
kicker: "Value",
|
||
|
|
title: "Why this cockpit adds value",
|
||
|
|
anim: "rise",
|
||
|
|
html: `
|
||
|
|
<div class="ps-grid3">
|
||
|
|
<div class="ps-card"><h4>One Service Tag story</h4><p>Map, inventory, compliance, warranty, fabric, and tickets around the same ST.</p></div>
|
||
|
|
<div class="ps-card"><h4>AI that cites the fleet</h4><p>Copilot answers from live OME facts — or says it does not know.</p></div>
|
||
|
|
<div class="ps-card"><h4>Faster briefings</h4><p>Click a chart → named systems → hand off in Ops desk.</p></div>
|
||
|
|
<div class="ps-card"><h4>Admin ↔ Data FDE</h4><p>Clear roles: ATC admins own the estate; Data FDEs deliver the AI/ops surface.</p></div>
|
||
|
|
<div class="ps-card"><h4>Demo = daily tool</h4><p>Same stack you present is the stack you can keep using after the meeting.</p></div>
|
||
|
|
<div class="ps-card"><h4>Copyable pattern</h4><p>OME API + BFF + grounded AI — a blueprint, not a Dell SKU.</p></div>
|
||
|
|
</div>`,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "arch-design",
|
||
|
|
kicker: "Architecture design",
|
||
|
|
title: "One design · how everything connects",
|
||
|
|
anim: "zoom",
|
||
|
|
html: `
|
||
|
|
<p class="ps-lead">Every app talks only to the <strong>Cockpit BFF</strong>. The BFF talks to <strong>OME</strong> (truth) and <strong>Open WebUI / vLLM</strong> (AI). Pulse lines show that path.</p>
|
||
|
|
|
||
|
|
<div class="ps-arch ps-arch-logos" aria-label="Architecture design">
|
||
|
|
<svg class="ps-arch-svg" viewBox="0 0 920 420" preserveAspectRatio="xMidYMid meet">
|
||
|
|
<defs>
|
||
|
|
<linearGradient id="archLineBlue" x1="0" y1="0" x2="1" y2="0">
|
||
|
|
<stop offset="0%" stop-color="#0076ce" stop-opacity="0.2"/>
|
||
|
|
<stop offset="50%" stop-color="#4db8ff" stop-opacity="1"/>
|
||
|
|
<stop offset="100%" stop-color="#7ef0d8" stop-opacity="0.35"/>
|
||
|
|
</linearGradient>
|
||
|
|
<linearGradient id="archLineAmber" x1="0" y1="0" x2="0" y2="1">
|
||
|
|
<stop offset="0%" stop-color="#ff9a3c" stop-opacity="0.25"/>
|
||
|
|
<stop offset="50%" stop-color="#ffb84d" stop-opacity="1"/>
|
||
|
|
<stop offset="100%" stop-color="#ff9a3c" stop-opacity="0.25"/>
|
||
|
|
</linearGradient>
|
||
|
|
<filter id="archGlow"><feGaussianBlur stdDeviation="2.8" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
|
||
|
|
</defs>
|
||
|
|
<path class="arch-pulse arch-p1" filter="url(#archGlow)" stroke="url(#archLineAmber)" d="M460 58 L460 360"/>
|
||
|
|
<path class="arch-pulse arch-p2" filter="url(#archGlow)" stroke="url(#archLineBlue)" d="M460 250 C 360 250, 250 250, 160 310"/>
|
||
|
|
<path class="arch-pulse arch-p3" filter="url(#archGlow)" stroke="url(#archLineBlue)" d="M460 250 C 460 270, 460 290, 460 330"/>
|
||
|
|
<path class="arch-pulse arch-p4" filter="url(#archGlow)" stroke="url(#archLineBlue)" d="M460 250 C 560 250, 670 250, 760 310"/>
|
||
|
|
<path class="arch-pulse arch-p5" filter="url(#archGlow)" stroke="url(#archLineBlue)" d="M160 150 C 250 170, 360 190, 460 200"/>
|
||
|
|
<path class="arch-pulse arch-p6" filter="url(#archGlow)" stroke="url(#archLineBlue)" d="M300 150 C 360 170, 410 185, 460 200"/>
|
||
|
|
<path class="arch-pulse arch-p7" filter="url(#archGlow)" stroke="url(#archLineBlue)" d="M460 150 L460 200"/>
|
||
|
|
<path class="arch-pulse arch-p8" filter="url(#archGlow)" stroke="url(#archLineBlue)" d="M620 150 C 560 170, 510 185, 460 200"/>
|
||
|
|
<path class="arch-pulse arch-p9" filter="url(#archGlow)" stroke="url(#archLineBlue)" d="M760 150 C 670 170, 560 190, 460 200"/>
|
||
|
|
<circle class="arch-dot d1" r="4" cx="460" cy="80"><animate attributeName="cy" values="70;230;70" dur="2.4s" repeatCount="indefinite"/></circle>
|
||
|
|
<circle class="arch-dot d2" r="3.5" cx="300" cy="170" style="fill:#7ef0d8"><animate attributeName="cx" values="200;430;200" dur="2.8s" repeatCount="indefinite"/><animate attributeName="cy" values="155;195;155" dur="2.8s" repeatCount="indefinite"/></circle>
|
||
|
|
<circle class="arch-dot d3" r="3.5" cx="620" cy="170" style="fill:#ffb84d"><animate attributeName="cx" values="720;490;720" dur="2.6s" repeatCount="indefinite"/><animate attributeName="cy" values="155;195;155" dur="2.6s" repeatCount="indefinite"/></circle>
|
||
|
|
<circle class="arch-dot d4" r="3.5" cx="300" cy="290" style="fill:#4db8ff"><animate attributeName="cx" values="460;180;460" dur="3s" repeatCount="indefinite"/><animate attributeName="cy" values="250;310;250" dur="3s" repeatCount="indefinite"/></circle>
|
||
|
|
<circle class="arch-dot d5" r="3.5" cx="620" cy="290" style="fill:#ff9a3c"><animate attributeName="cx" values="460;740;460" dur="2.9s" repeatCount="indefinite"/><animate attributeName="cy" values="250;310;250" dur="2.9s" repeatCount="indefinite"/></circle>
|
||
|
|
</svg>
|
||
|
|
<div class="ps-arch-layer top">
|
||
|
|
<div class="ps-arch-node operator">
|
||
|
|
<div class="ps-logo-row"><img class="ps-logo" src="/logos/browser.svg" alt="Browser"/></div>
|
||
|
|
<strong>Operator</strong><span>Browser · HTTPS · no secrets</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="ps-arch-layer apps">
|
||
|
|
<div class="ps-arch-node app"><strong>Map</strong><span>Topology</span></div>
|
||
|
|
<div class="ps-arch-node app"><strong>Reports</strong><span>Analytics</span></div>
|
||
|
|
<div class="ps-arch-node app core-app"><strong>Cockpit UI</strong><span>Drawers · KPIs</span></div>
|
||
|
|
<div class="ps-arch-node app"><strong>Network</strong><span>Fabric · Racks</span></div>
|
||
|
|
<div class="ps-arch-node app"><strong>Ops / AI</strong><span>Tickets · Copilot</span></div>
|
||
|
|
</div>
|
||
|
|
<div class="ps-arch-layer mid">
|
||
|
|
<div class="ps-arch-node bff">
|
||
|
|
<div class="ps-logo-row">
|
||
|
|
<img class="ps-logo" src="/logos/docker.svg" alt="Docker" title="Docker"/>
|
||
|
|
<img class="ps-logo" src="/logos/fastapi.svg" alt="FastAPI" title="FastAPI"/>
|
||
|
|
<img class="ps-logo" src="/logos/sqlite.svg" alt="SQLite" title="SQLite ops.db"/>
|
||
|
|
</div>
|
||
|
|
<strong>BFF · OME Cockpit API</strong>
|
||
|
|
<span>Docker · FastAPI · secrets · cache · ground AI · :3090</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="ps-arch-layer bottom">
|
||
|
|
<div class="ps-arch-node ome">
|
||
|
|
<div class="ps-logo-row"><img class="ps-logo" src="/logos/ome.svg" alt="OME"/><img class="ps-logo" src="/dell.png" alt="Dell"/></div>
|
||
|
|
<strong>Dell OME</strong><span>Truth · inventory · warranty</span>
|
||
|
|
</div>
|
||
|
|
<div class="ps-arch-node owui">
|
||
|
|
<div class="ps-logo-row"><img class="ps-logo" src="/logos/docker.svg" alt="Docker"/><img class="ps-logo" src="/logos/openwebui.svg" alt="Open WebUI"/></div>
|
||
|
|
<strong>Open WebUI</strong><span>OpenManage AI · :3080</span>
|
||
|
|
</div>
|
||
|
|
<div class="ps-arch-node vllm">
|
||
|
|
<div class="ps-logo-row"><img class="ps-logo" src="/logos/vllm.svg" alt="vLLM"/></div>
|
||
|
|
<strong>vLLM</strong><span>FDE inference host</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="ps-arch-legend">
|
||
|
|
<span class="lg-blue">Blue pulse · app / API traffic</span>
|
||
|
|
<span class="lg-amber">Amber pulse · BFF spine</span>
|
||
|
|
<span class="lg-teal">Packets · live request path</span>
|
||
|
|
</div>
|
||
|
|
<p class="ps-sub">Browser never holds OME or AI secrets. Docker hosts Cockpit (:3090) and Open WebUI (:3080) on the portal.</p>`,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "apps",
|
||
|
|
kicker: "Applications",
|
||
|
|
title: "What you use day to day",
|
||
|
|
anim: "rise",
|
||
|
|
html: `
|
||
|
|
<div class="ps-apps">
|
||
|
|
<div class="ps-app"><i></i><strong>Live map</strong><span>Fleet topology · power · inspector</span></div>
|
||
|
|
<div class="ps-app"><i></i><strong>Network</strong><span>Fabric · 42U racks · VLANs</span></div>
|
||
|
|
<div class="ps-app"><i></i><strong>Reports</strong><span>Compliance · warranty analytics</span></div>
|
||
|
|
<div class="ps-app"><i></i><strong>Ops desk</strong><span>Admin ↔ Data FDE tickets</span></div>
|
||
|
|
<div class="ps-app"><i></i><strong>Copilot</strong><span>Grounded chat on Service Tags</span></div>
|
||
|
|
<div class="ps-app"><i></i><strong>OpenManage AI</strong><span>Full Open WebUI beside the cockpit</span></div>
|
||
|
|
</div>
|
||
|
|
<p class="ps-sub">One join key everywhere: <strong>Service Tag</strong>. No second inventory source.</p>`,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "ai",
|
||
|
|
kicker: "AI + OME",
|
||
|
|
title: "AI that starts from OME facts",
|
||
|
|
anim: "slide",
|
||
|
|
html: `
|
||
|
|
<p class="ps-lead">Ask → Cockpit enriches with live OME context → Open WebUI and/or vLLM → answer → act in map, reports, or Ops.</p>
|
||
|
|
<div class="ps-seq">
|
||
|
|
<div class="ps-seq-step"><span>01</span><strong>Ask</strong><p>Fleet question in Copilot</p></div>
|
||
|
|
<div class="ps-seq-step"><span>02</span><strong>Enrich</strong><p>BFF attaches Service Tag facts</p></div>
|
||
|
|
<div class="ps-seq-step"><span>03</span><strong>Route</strong><p>Open WebUI / vLLM completion</p></div>
|
||
|
|
<div class="ps-seq-step"><span>04</span><strong>Answer</strong><p>Still tied to the live map</p></div>
|
||
|
|
<div class="ps-seq-step"><span>05</span><strong>Act</strong><p>Inspector · report · ticket</p></div>
|
||
|
|
</div>
|
||
|
|
<p class="ps-sub"><strong>OME for truth · AI for speed · Cockpit for the operator experience.</strong></p>`,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "origin",
|
||
|
|
kicker: "How this started",
|
||
|
|
title: "Admins set the need · Data FDEs delivered the surface",
|
||
|
|
anim: "rise",
|
||
|
|
html: `
|
||
|
|
<p class="ps-lead"><strong>Jody van Dongen</strong> and <strong>Laurens Rammers</strong> 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.</p>
|
||
|
|
<div class="ps-cols">
|
||
|
|
<div>
|
||
|
|
<h4>ATC Admins · the ask</h4>
|
||
|
|
<ul class="ps-bullets ps-stagger">
|
||
|
|
<li><strong>Jody van Dongen</strong> — fleet, racks, warranty / compliance</li>
|
||
|
|
<li><strong>Laurens Rammers</strong> — datacenter ops & escalation</li>
|
||
|
|
<li>Better visibility and faster OME storytelling</li>
|
||
|
|
</ul>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<h4>Data FDEs · the delivery</h4>
|
||
|
|
<ul class="ps-bullets ps-stagger">
|
||
|
|
<li><strong>Mohamed El Kadi</strong> — cockpit UX/API, OME integration, Copilot, Present</li>
|
||
|
|
<li><strong>Bart Sjerps</strong> — FDE cluster, Open WebUI / AI workloads, hosting</li>
|
||
|
|
</ul>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<p class="ps-sub">Engineered against live OME data; refined for demos and daily operations.</p>`,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "benefits",
|
||
|
|
kicker: "Takeaways",
|
||
|
|
title: "What customers walk away with",
|
||
|
|
anim: "rise",
|
||
|
|
html: `
|
||
|
|
<div class="ps-grid3">
|
||
|
|
<div class="ps-card"><h4>Clarity</h4><p>One place for connectivity, compliance, warranty, and hardware depth.</p></div>
|
||
|
|
<div class="ps-card"><h4>Credibility</h4><p>Claims trace to a Service Tag from OME.</p></div>
|
||
|
|
<div class="ps-card"><h4>Speed</h4><p>AI drafts explanations; humans own change windows.</p></div>
|
||
|
|
<div class="ps-card"><h4>Collaboration</h4><p>Admins and Data FDEs share one Ops language.</p></div>
|
||
|
|
<div class="ps-card"><h4>Pattern</h4><p>OME + Docker BFF + grounded AI — repeatable.</p></div>
|
||
|
|
<div class="ps-card"><h4>Honesty</h4><p>Demo power with an explicit non-product disclaimer.</p></div>
|
||
|
|
</div>`,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "close",
|
||
|
|
kicker: "Next steps",
|
||
|
|
title: "See it live",
|
||
|
|
anim: "zoom",
|
||
|
|
html: `
|
||
|
|
<ol class="ps-steps ps-stagger">
|
||
|
|
<li>Pick a Service Tag on the map and open the inspector.</li>
|
||
|
|
<li>Open Reports · Analytics and click a colored segment.</li>
|
||
|
|
<li>Ask Copilot a question that must cite Service Tags.</li>
|
||
|
|
<li>Switch to <strong>Technical architecture</strong> for API and trust-boundary depth.</li>
|
||
|
|
</ol>
|
||
|
|
<p class="ps-sub">Not an official Dell product — verify production decisions in OME. Esc closes · F fullscreen.</p>`,
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
|
||
|
|
const TECH_SLIDES = [
|
||
|
|
{
|
||
|
|
id: "disclaimer",
|
||
|
|
kicker: "Important notice",
|
||
|
|
title: "Disclaimer — please read first",
|
||
|
|
anim: "fade",
|
||
|
|
html: `
|
||
|
|
<div class="ps-legal">
|
||
|
|
<p><strong>This OpenManage Cockpit is not an official Dell Technologies product.</strong>
|
||
|
|
Technical details describe an ATC demonstration architecture — not a supported Dell reference design SKU.</p>
|
||
|
|
<ul>
|
||
|
|
<li>Not endorsed or supported by Dell as a shipping product.</li>
|
||
|
|
<li>Verify production decisions in official OME / iDRAC tooling.</li>
|
||
|
|
<li>By continuing you accept Dell has no obligation regarding this cockpit.</li>
|
||
|
|
</ul>
|
||
|
|
<p class="ps-legal-ack">Press <kbd>→</kbd> for the technical deep dive.</p>
|
||
|
|
</div>`,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "tech-agenda",
|
||
|
|
kicker: "Technical deck",
|
||
|
|
title: "Architecture for engineers",
|
||
|
|
anim: "rise",
|
||
|
|
html: `
|
||
|
|
<p class="ps-lead">How OME, the Cockpit BFF, Open WebUI, and vLLM are wired — and why that wiring keeps AI grounded.</p>
|
||
|
|
<ol class="ps-steps ps-stagger">
|
||
|
|
<li>Reference design with logos & pulse paths</li>
|
||
|
|
<li>Why a BFF (secrets, normalize, ground AI)</li>
|
||
|
|
<li>OME session / cache behaviour</li>
|
||
|
|
<li>AI completion path</li>
|
||
|
|
<li>Portal containers & key <code>/api/*</code> contracts</li>
|
||
|
|
</ol>
|
||
|
|
<p class="ps-sub">Delivered for ATC admins Jody & Laurens by Data FDEs Mo & Bart.</p>`,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "arch-design",
|
||
|
|
kicker: "Architecture design",
|
||
|
|
title: "Reference design · containers & pulse paths",
|
||
|
|
anim: "zoom",
|
||
|
|
html: `
|
||
|
|
<p class="ps-lead">Thin UI apps over a FastAPI BFF in Docker. OME is hardware truth. Open WebUI / vLLM are the AI plane.</p>
|
||
|
|
|
||
|
|
<div class="ps-arch ps-arch-logos" aria-label="Architecture design">
|
||
|
|
<svg class="ps-arch-svg" viewBox="0 0 920 420" preserveAspectRatio="xMidYMid meet">
|
||
|
|
<defs>
|
||
|
|
<linearGradient id="archLineBlue" x1="0" y1="0" x2="1" y2="0">
|
||
|
|
<stop offset="0%" stop-color="#0076ce" stop-opacity="0.2"/>
|
||
|
|
<stop offset="50%" stop-color="#4db8ff" stop-opacity="1"/>
|
||
|
|
<stop offset="100%" stop-color="#7ef0d8" stop-opacity="0.35"/>
|
||
|
|
</linearGradient>
|
||
|
|
<linearGradient id="archLineAmber" x1="0" y1="0" x2="0" y2="1">
|
||
|
|
<stop offset="0%" stop-color="#ff9a3c" stop-opacity="0.25"/>
|
||
|
|
<stop offset="50%" stop-color="#ffb84d" stop-opacity="1"/>
|
||
|
|
<stop offset="100%" stop-color="#ff9a3c" stop-opacity="0.25"/>
|
||
|
|
</linearGradient>
|
||
|
|
<filter id="archGlow"><feGaussianBlur stdDeviation="2.8" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
|
||
|
|
</defs>
|
||
|
|
<path class="arch-pulse arch-p1" filter="url(#archGlow)" stroke="url(#archLineAmber)" d="M460 58 L460 360"/>
|
||
|
|
<path class="arch-pulse arch-p2" filter="url(#archGlow)" stroke="url(#archLineBlue)" d="M460 250 C 360 250, 250 250, 160 310"/>
|
||
|
|
<path class="arch-pulse arch-p3" filter="url(#archGlow)" stroke="url(#archLineBlue)" d="M460 250 C 460 270, 460 290, 460 330"/>
|
||
|
|
<path class="arch-pulse arch-p4" filter="url(#archGlow)" stroke="url(#archLineBlue)" d="M460 250 C 560 250, 670 250, 760 310"/>
|
||
|
|
<path class="arch-pulse arch-p5" filter="url(#archGlow)" stroke="url(#archLineBlue)" d="M160 150 C 250 170, 360 190, 460 200"/>
|
||
|
|
<path class="arch-pulse arch-p6" filter="url(#archGlow)" stroke="url(#archLineBlue)" d="M300 150 C 360 170, 410 185, 460 200"/>
|
||
|
|
<path class="arch-pulse arch-p7" filter="url(#archGlow)" stroke="url(#archLineBlue)" d="M460 150 L460 200"/>
|
||
|
|
<path class="arch-pulse arch-p8" filter="url(#archGlow)" stroke="url(#archLineBlue)" d="M620 150 C 560 170, 510 185, 460 200"/>
|
||
|
|
<path class="arch-pulse arch-p9" filter="url(#archGlow)" stroke="url(#archLineBlue)" d="M760 150 C 670 170, 560 190, 460 200"/>
|
||
|
|
<circle class="arch-dot d1" r="4" cx="460" cy="80"><animate attributeName="cy" values="70;230;70" dur="2.4s" repeatCount="indefinite"/></circle>
|
||
|
|
<circle class="arch-dot d2" r="3.5" cx="300" cy="170" style="fill:#7ef0d8"><animate attributeName="cx" values="200;430;200" dur="2.8s" repeatCount="indefinite"/><animate attributeName="cy" values="155;195;155" dur="2.8s" repeatCount="indefinite"/></circle>
|
||
|
|
<circle class="arch-dot d3" r="3.5" cx="620" cy="170" style="fill:#ffb84d"><animate attributeName="cx" values="720;490;720" dur="2.6s" repeatCount="indefinite"/><animate attributeName="cy" values="155;195;155" dur="2.6s" repeatCount="indefinite"/></circle>
|
||
|
|
<circle class="arch-dot d4" r="3.5" cx="300" cy="290" style="fill:#4db8ff"><animate attributeName="cx" values="460;180;460" dur="3s" repeatCount="indefinite"/><animate attributeName="cy" values="250;310;250" dur="3s" repeatCount="indefinite"/></circle>
|
||
|
|
<circle class="arch-dot d5" r="3.5" cx="620" cy="290" style="fill:#ff9a3c"><animate attributeName="cx" values="460;740;460" dur="2.9s" repeatCount="indefinite"/><animate attributeName="cy" values="250;310;250" dur="2.9s" repeatCount="indefinite"/></circle>
|
||
|
|
</svg>
|
||
|
|
<div class="ps-arch-layer top">
|
||
|
|
<div class="ps-arch-node operator">
|
||
|
|
<div class="ps-logo-row"><img class="ps-logo" src="/logos/browser.svg" alt="Browser"/></div>
|
||
|
|
<strong>Operator</strong><span>Browser · HTTPS · no secrets</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="ps-arch-layer apps">
|
||
|
|
<div class="ps-arch-node app"><strong>Map</strong><span>Topology</span></div>
|
||
|
|
<div class="ps-arch-node app"><strong>Reports</strong><span>Analytics</span></div>
|
||
|
|
<div class="ps-arch-node app core-app"><strong>Cockpit UI</strong><span>Drawers · KPIs</span></div>
|
||
|
|
<div class="ps-arch-node app"><strong>Network</strong><span>Fabric · Racks</span></div>
|
||
|
|
<div class="ps-arch-node app"><strong>Ops / AI</strong><span>Tickets · Copilot</span></div>
|
||
|
|
</div>
|
||
|
|
<div class="ps-arch-layer mid">
|
||
|
|
<div class="ps-arch-node bff">
|
||
|
|
<div class="ps-logo-row">
|
||
|
|
<img class="ps-logo" src="/logos/docker.svg" alt="Docker" title="Docker"/>
|
||
|
|
<img class="ps-logo" src="/logos/fastapi.svg" alt="FastAPI" title="FastAPI"/>
|
||
|
|
<img class="ps-logo" src="/logos/sqlite.svg" alt="SQLite" title="SQLite ops.db"/>
|
||
|
|
</div>
|
||
|
|
<strong>BFF · OME Cockpit API</strong>
|
||
|
|
<span>Docker · FastAPI · secrets · cache · ground AI · :3090</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="ps-arch-layer bottom">
|
||
|
|
<div class="ps-arch-node ome">
|
||
|
|
<div class="ps-logo-row"><img class="ps-logo" src="/logos/ome.svg" alt="OME"/><img class="ps-logo" src="/dell.png" alt="Dell"/></div>
|
||
|
|
<strong>Dell OME</strong><span>Truth · inventory · warranty</span>
|
||
|
|
</div>
|
||
|
|
<div class="ps-arch-node owui">
|
||
|
|
<div class="ps-logo-row"><img class="ps-logo" src="/logos/docker.svg" alt="Docker"/><img class="ps-logo" src="/logos/openwebui.svg" alt="Open WebUI"/></div>
|
||
|
|
<strong>Open WebUI</strong><span>OpenManage AI · :3080</span>
|
||
|
|
</div>
|
||
|
|
<div class="ps-arch-node vllm">
|
||
|
|
<div class="ps-logo-row"><img class="ps-logo" src="/logos/vllm.svg" alt="vLLM"/></div>
|
||
|
|
<strong>vLLM</strong><span>FDE inference host</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="ps-arch-legend">
|
||
|
|
<span class="lg-blue">Blue pulse · app / API traffic</span>
|
||
|
|
<span class="lg-amber">Amber pulse · BFF spine</span>
|
||
|
|
<span class="lg-teal">Packets · live request path</span>
|
||
|
|
</div>`,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "why-bff",
|
||
|
|
kicker: "Design choice",
|
||
|
|
title: "Why the BFF sits in the middle",
|
||
|
|
anim: "rise",
|
||
|
|
html: `
|
||
|
|
<p class="ps-lead">The browser must never hold OME passwords. The Cockpit API is the only component allowed to call AI with enriched Service Tag context.</p>
|
||
|
|
<div class="ps-grid3">
|
||
|
|
<div class="ps-card tech"><h4>Normalize</h4><p>OME JSON → stable fleet nodes, NIC PortIds, compliance rows.</p></div>
|
||
|
|
<div class="ps-card tech"><h4>Cache</h4><p>Fleet & report caches keep demos responsive.</p></div>
|
||
|
|
<div class="ps-card tech"><h4>Ground AI</h4><p>Inject facts before completions — no invented BIOS levels.</p></div>
|
||
|
|
</div>
|
||
|
|
<ul class="ps-why-list">
|
||
|
|
<li><b>1</b><span><strong>Security</strong> — secrets stay on the portal host.</span></li>
|
||
|
|
<li><b>2</b><span><strong>Clarity</strong> — one Service Tag model for every app.</span></li>
|
||
|
|
<li><b>3</b><span><strong>Speed</strong> — cache for live briefings.</span></li>
|
||
|
|
<li><b>4</b><span><strong>Trust</strong> — AI only speaks from radar (OME) data.</span></li>
|
||
|
|
</ul>`,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "ome-plane",
|
||
|
|
kicker: "Data plane · OME",
|
||
|
|
title: "OME remains the only hardware truth",
|
||
|
|
anim: "slide",
|
||
|
|
html: `
|
||
|
|
<div class="ps-cols">
|
||
|
|
<div>
|
||
|
|
<h4>Session lifecycle</h4>
|
||
|
|
<ol class="ps-steps ps-stagger">
|
||
|
|
<li><code>POST /api/SessionService/Sessions</code> → token</li>
|
||
|
|
<li>Devices · power · baselines · warranties</li>
|
||
|
|
<li>InventoryDetails (memory, NICs, disks, …)</li>
|
||
|
|
<li>DELETE session when the batch ends</li>
|
||
|
|
</ol>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<h4>Cached for the UI</h4>
|
||
|
|
<ul class="ps-bullets ps-stagger">
|
||
|
|
<li><code>/api/fleet</code> — topology + ConnectionState</li>
|
||
|
|
<li>Compliance / warranty / expansion</li>
|
||
|
|
<li>Reports vs Dell Online Baseline</li>
|
||
|
|
<li>Service Tag index for Copilot</li>
|
||
|
|
</ul>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<p class="ps-sub">Rule: if OME did not return it, UI and Copilot must not invent it.</p>`,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "ai-plane",
|
||
|
|
kicker: "AI plane",
|
||
|
|
title: "Copilot → Open WebUI / vLLM",
|
||
|
|
anim: "slide",
|
||
|
|
html: `
|
||
|
|
<div class="ps-seq">
|
||
|
|
<div class="ps-seq-step"><span>01</span><strong>Browser</strong><p>Copilot drawer</p></div>
|
||
|
|
<div class="ps-seq-step"><span>02</span><strong>/api/chat</strong><p>BFF + OME facts</p></div>
|
||
|
|
<div class="ps-seq-step"><span>03a</span><strong>Open WebUI</strong><p>Docker · :3080</p></div>
|
||
|
|
<div class="ps-seq-step"><span>03b</span><strong>vLLM</strong><p>OpenAI-compatible</p></div>
|
||
|
|
<div class="ps-seq-step"><span>04</span><strong>Reply</strong><p>Back to the drawer</p></div>
|
||
|
|
</div>
|
||
|
|
<p class="ps-sub">Two experiences on purpose: grounded Copilot inside Cockpit, and full OpenManage AI (Open WebUI) beside it.</p>`,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "portal-host",
|
||
|
|
kicker: "Deployment",
|
||
|
|
title: "What runs on the portal",
|
||
|
|
anim: "slide",
|
||
|
|
html: `
|
||
|
|
<div class="ps-deploy">
|
||
|
|
<div class="ps-deploy-card">
|
||
|
|
<div class="ps-logo-row"><img class="ps-logo lg" src="/logos/docker.svg" alt="Docker"/><img class="ps-logo lg" src="/logos/fastapi.svg" alt="FastAPI"/></div>
|
||
|
|
<h4>ome-cockpit</h4>
|
||
|
|
<p>Static UI + FastAPI BFF · <code>:3090</code> · ops.db volume</p>
|
||
|
|
</div>
|
||
|
|
<div class="ps-deploy-card">
|
||
|
|
<div class="ps-logo-row"><img class="ps-logo lg" src="/logos/docker.svg" alt="Docker"/><img class="ps-logo lg" src="/logos/openwebui.svg" alt="Open WebUI"/></div>
|
||
|
|
<h4>openmanage</h4>
|
||
|
|
<p>Open WebUI · OpenManage AI · <code>:3080</code></p>
|
||
|
|
</div>
|
||
|
|
<div class="ps-deploy-card">
|
||
|
|
<div class="ps-logo-row"><img class="ps-logo lg" src="/logos/ome.svg" alt="OME"/><img class="ps-logo lg" src="/dell.png" alt="Dell"/></div>
|
||
|
|
<h4>OME appliance</h4>
|
||
|
|
<p>HTTPS management plane · session API</p>
|
||
|
|
</div>
|
||
|
|
<div class="ps-deploy-card">
|
||
|
|
<div class="ps-logo-row"><img class="ps-logo lg" src="/logos/vllm.svg" alt="vLLM"/></div>
|
||
|
|
<h4>vLLM (optional)</h4>
|
||
|
|
<p>FDE inference · OpenAI schema</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<p class="ps-sub">Users hit the portal; the portal speaks to OME and AI backends.</p>`,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "apis",
|
||
|
|
kicker: "API surface",
|
||
|
|
title: "Contracts behind the apps",
|
||
|
|
anim: "rise",
|
||
|
|
html: `
|
||
|
|
<div class="ps-grid3">
|
||
|
|
<div class="ps-card tech"><h4>/api/fleet</h4><p>Cached device graph for canvas & KPIs.</p></div>
|
||
|
|
<div class="ps-card tech"><h4>/api/devices/…</h4><p>Inventory, landscape, expanded NICs.</p></div>
|
||
|
|
<div class="ps-card tech"><h4>/api/reports/*</h4><p>Analytics, firmware, warranty, brief.</p></div>
|
||
|
|
<div class="ps-card tech"><h4>/api/chat · /api/models</h4><p>Grounded completions · model list.</p></div>
|
||
|
|
<div class="ps-card tech"><h4>/api/tickets</h4><p>Ops desk — local SQLite, not OME write-back.</p></div>
|
||
|
|
<div class="ps-card tech"><h4>/api/network/*</h4><p>Fabric ports · racks · VLANs.</p></div>
|
||
|
|
</div>`,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "trust",
|
||
|
|
kicker: "Trust & data",
|
||
|
|
title: "Boundaries to call out",
|
||
|
|
anim: "fade",
|
||
|
|
html: `
|
||
|
|
<div class="ps-cols">
|
||
|
|
<div>
|
||
|
|
<h4>From OME (read)</h4>
|
||
|
|
<ul class="ps-bullets ps-stagger">
|
||
|
|
<li>Devices, power, inventory, warranty, baselines</li>
|
||
|
|
</ul>
|
||
|
|
<h4 style="margin-top:0.85rem">Cockpit-local (write)</h4>
|
||
|
|
<ul class="ps-bullets ps-stagger">
|
||
|
|
<li>Fabric / racks / tickets / presentation decks</li>
|
||
|
|
</ul>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<h4>Security notes</h4>
|
||
|
|
<ul class="ps-bullets ps-stagger">
|
||
|
|
<li>Secrets only in portal environment variables</li>
|
||
|
|
<li>Primary OME usage is read for awareness & demos</li>
|
||
|
|
<li>Production change windows stay in official OME / iDRAC</li>
|
||
|
|
</ul>
|
||
|
|
</div>
|
||
|
|
</div>`,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "close",
|
||
|
|
kicker: "Next steps",
|
||
|
|
title: "Back to the live estate",
|
||
|
|
anim: "zoom",
|
||
|
|
html: `
|
||
|
|
<ol class="ps-steps ps-stagger">
|
||
|
|
<li>Return to the map and pick a Service Tag.</li>
|
||
|
|
<li>Open Reports and click a compliance segment.</li>
|
||
|
|
<li>Ask Copilot a question that must cite that ST.</li>
|
||
|
|
<li>Use <strong>Customer story</strong> for business / ops audiences.</li>
|
||
|
|
</ol>
|
||
|
|
<p class="ps-sub">Not an official Dell product. Esc closes · F fullscreen.</p>`,
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
const BUILTIN_VERSION = 8;
|
||
|
|
|
||
|
|
const BUILTIN_DECKS = [
|
||
|
|
{
|
||
|
|
id: "story",
|
||
|
|
name: "Customer story",
|
||
|
|
description: "Customer briefing — ask, architecture, apps, value",
|
||
|
|
builtin: true,
|
||
|
|
slides: structuredClone(STORY_SLIDES),
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "technical",
|
||
|
|
name: "Technical architecture",
|
||
|
|
description: "Engineer deep dive — design, BFF, APIs, 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: '<p class="ps-lead">Write your first slide. Use Edit to change content.</p>',
|
||
|
|
},
|
||
|
|
],
|
||
|
|
};
|
||
|
|
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: '<p class="ps-lead">Blank deck — edit this slide.</p>',
|
||
|
|
},
|
||
|
|
];
|
||
|
|
}
|
||
|
|
slides = d.slides;
|
||
|
|
index = 0;
|
||
|
|
editing = false;
|
||
|
|
saveStore();
|
||
|
|
render();
|
||
|
|
}
|
||
|
|
|
||
|
|
function escapeHtml(s) {
|
||
|
|
return String(s ?? "")
|
||
|
|
.replace(/&/g, "&")
|
||
|
|
.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 = `
|
||
|
|
<div class="present-scrim" data-present-close></div>
|
||
|
|
<div class="present-stage theme-dark" id="present-stage" role="dialog" aria-modal="true" aria-labelledby="present-title">
|
||
|
|
|
||
|
|
<div class="present-ai-bg" aria-hidden="true">
|
||
|
|
<div class="present-ai-aurora"></div>
|
||
|
|
<div class="present-ai-grid"></div>
|
||
|
|
<div class="present-ai-mesh"></div>
|
||
|
|
<div class="present-ai-orbs">
|
||
|
|
<span class="orb o1"></span>
|
||
|
|
<span class="orb o2"></span>
|
||
|
|
<span class="orb o3"></span>
|
||
|
|
<span class="orb o4"></span>
|
||
|
|
</div>
|
||
|
|
<div class="present-ai-particles">
|
||
|
|
<i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i>
|
||
|
|
</div>
|
||
|
|
<svg class="present-pulse-svg" viewBox="0 0 1100 780" preserveAspectRatio="none">
|
||
|
|
<defs>
|
||
|
|
<linearGradient id="pgCyan" x1="0%" y1="0%" x2="100%" y2="0%">
|
||
|
|
<stop offset="0%" stop-color="#0076ce" stop-opacity="0"/>
|
||
|
|
<stop offset="35%" stop-color="#4db8ff" stop-opacity="1"/>
|
||
|
|
<stop offset="70%" stop-color="#7ef0d8" stop-opacity="1"/>
|
||
|
|
<stop offset="100%" stop-color="#0076ce" stop-opacity="0"/>
|
||
|
|
</linearGradient>
|
||
|
|
<linearGradient id="pgAmber" x1="0%" y1="0%" x2="100%" y2="0%">
|
||
|
|
<stop offset="0%" stop-color="#ff9a3c" stop-opacity="0"/>
|
||
|
|
<stop offset="40%" stop-color="#ffb84d" stop-opacity="1"/>
|
||
|
|
<stop offset="100%" stop-color="#ff9a3c" stop-opacity="0"/>
|
||
|
|
</linearGradient>
|
||
|
|
<linearGradient id="pgTeal" x1="0%" y1="0%" x2="100%" y2="0%">
|
||
|
|
<stop offset="0%" stop-color="#3dffe0" stop-opacity="0"/>
|
||
|
|
<stop offset="50%" stop-color="#3dffe0" stop-opacity="1"/>
|
||
|
|
<stop offset="100%" stop-color="#00a8e8" stop-opacity="0"/>
|
||
|
|
</linearGradient>
|
||
|
|
<filter id="pgGlow" x="-20%" y="-20%" width="140%" height="140%">
|
||
|
|
<feGaussianBlur stdDeviation="3.5" result="b"/>
|
||
|
|
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||
|
|
</filter>
|
||
|
|
</defs>
|
||
|
|
<path class="pl pl1" filter="url(#pgGlow)" stroke="url(#pgCyan)" d="M-20 140 C 180 40, 360 220, 540 110 S 860 40, 1120 180"/>
|
||
|
|
<path class="pl pl2" filter="url(#pgGlow)" stroke="url(#pgTeal)" d="M-40 360 C 200 280, 340 460, 560 340 S 820 260, 1140 420"/>
|
||
|
|
<path class="pl pl3" filter="url(#pgGlow)" stroke="url(#pgAmber)" d="M-10 580 C 240 500, 420 680, 650 560 S 920 480, 1120 620"/>
|
||
|
|
<path class="pl pl4" filter="url(#pgGlow)" stroke="url(#pgCyan)" d="M80 -10 C 200 120, 280 260, 360 400 S 520 640, 640 800"/>
|
||
|
|
<path class="pl pl5" filter="url(#pgGlow)" stroke="url(#pgAmber)" d="M980 -20 C 900 140, 860 300, 780 460 S 620 680, 540 820"/>
|
||
|
|
<circle class="pn pn1" cx="180" cy="160" r="3.5"/>
|
||
|
|
<circle class="pn pn2" cx="540" cy="120" r="4"/>
|
||
|
|
<circle class="pn pn3" cx="860" cy="200" r="3"/>
|
||
|
|
<circle class="pn pn4" cx="320" cy="400" r="3.5"/>
|
||
|
|
<circle class="pn pn5" cx="700" cy="360" r="4"/>
|
||
|
|
<circle class="pn pn6" cx="480" cy="600" r="3"/>
|
||
|
|
<circle class="pn pn7" cx="900" cy="560" r="3.5"/>
|
||
|
|
</svg>
|
||
|
|
<div class="present-ai-scan"></div>
|
||
|
|
<div class="present-ai-vignette"></div>
|
||
|
|
</div>
|
||
|
|
<div class="present-chrome">
|
||
|
|
<div class="present-bar present-bar-mainui">
|
||
|
|
<div class="present-brand">
|
||
|
|
<img src="/dell.png" alt="" width="28" height="28" />
|
||
|
|
<div class="present-brand-text">
|
||
|
|
<strong>OME Cockpit · Present</strong>
|
||
|
|
<span id="present-progress">1 / 1</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="present-actions top-actions">
|
||
|
|
<label class="present-deck-inline" title="Choose presentation deck">
|
||
|
|
<select id="present-deck" class="present-deck-select"></select>
|
||
|
|
</label>
|
||
|
|
<button type="button" class="btn ghost" id="present-mode-mid" title="Mid-screen overlay">Mid</button>
|
||
|
|
<button type="button" class="btn ghost" id="present-mode-full" title="Fullscreen">Full</button>
|
||
|
|
<button type="button" class="btn ghost" id="present-theme-light" title="Light theme">Light</button>
|
||
|
|
<button type="button" class="btn ghost" id="present-theme-dark" title="Dark theme">Dark</button>
|
||
|
|
<button type="button" class="btn ghost" id="present-edit" title="Edit slide content">Edit</button>
|
||
|
|
<button type="button" class="btn ghost" id="present-reset" title="Reset this deck to defaults">Reset</button>
|
||
|
|
<button type="button" class="btn ghost" id="present-export" title="Download deck JSON">Export</button>
|
||
|
|
<button type="button" class="btn ghost" id="present-import" title="Import slides JSON">Import</button>
|
||
|
|
<button type="button" class="btn ghost" id="present-add-media" title="Add image slides">Images</button>
|
||
|
|
<button type="button" class="btn ghost" id="present-deck-new" title="Create a new empty deck">New</button>
|
||
|
|
<button type="button" class="btn ghost" id="present-deck-dup" title="Duplicate current deck">Duplicate</button>
|
||
|
|
<button type="button" class="btn ghost" id="present-deck-del" title="Delete custom deck">Delete</button>
|
||
|
|
<button type="button" class="btn danger" id="present-close" title="Close (Esc)">Close</button>
|
||
|
|
<input type="file" id="present-import-file" accept="application/json,.json" hidden />
|
||
|
|
<input type="file" id="present-media-file" accept="image/*,.pptx,application/vnd.openxmlformats-officedocument.presentationml.presentation" multiple hidden />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<p class="present-deck-desc" id="present-deck-desc"></p>
|
||
|
|
</div>
|
||
|
|
<div class="present-progress-bar"><i id="present-bar"></i></div>
|
||
|
|
<div class="present-slide-wrap">
|
||
|
|
<article class="present-slide" id="present-slide"></article>
|
||
|
|
</div>
|
||
|
|
<div class="present-nav">
|
||
|
|
<button type="button" class="btn ghost" id="present-prev">Previous</button>
|
||
|
|
<div class="present-dots" id="present-dots"></div>
|
||
|
|
<button type="button" class="btn primary" id="present-next">Next</button>
|
||
|
|
</div>
|
||
|
|
<div class="present-edit-panel hidden" id="present-edit-panel">
|
||
|
|
<label>Kicker <input type="text" id="pe-kicker" /></label>
|
||
|
|
<label>Title <input type="text" id="pe-title" /></label>
|
||
|
|
<label>Body (HTML allowed)
|
||
|
|
<textarea id="pe-html" rows="10"></textarea>
|
||
|
|
</label>
|
||
|
|
<div class="present-edit-actions">
|
||
|
|
<button type="button" class="btn" id="pe-add">Add slide after</button>
|
||
|
|
<button type="button" class="btn danger" id="pe-del">Delete slide</button>
|
||
|
|
<button type="button" class="btn primary" id="pe-save">Save changes</button>
|
||
|
|
</div>
|
||
|
|
<p class="present-edit-hint">Decks & edits stay in this browser (localStorage). Export JSON to share or back up.</p>
|
||
|
|
</div>
|
||
|
|
<footer class="present-credit">OME Cockpit · Data Forward Deployed Engineers Mohamed El Kadi & Bart Sjerps · Not an official Dell product</footer>
|
||
|
|
</div>`;
|
||
|
|
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: '<p class="ps-lead">Write your content here.</p>',
|
||
|
|
});
|
||
|
|
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) =>
|
||
|
|
`<option value="${escapeHtml(d.id)}" ${d.id === activeId ? "selected" : ""}>${escapeHtml(d.name)}${d.builtin ? "" : " · custom"}</option>`
|
||
|
|
)
|
||
|
|
.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 = `
|
||
|
|
<div class="ps-image-frame">
|
||
|
|
<img src="${s.image}" alt="${escapeHtml(s.title || "Slide")}" />
|
||
|
|
<div class="ps-image-caption">
|
||
|
|
<p class="ps-kicker">${escapeHtml(s.kicker || "Imported")}</p>
|
||
|
|
<h2 id="present-title">${escapeHtml(s.title || "")}</h2>
|
||
|
|
</div>
|
||
|
|
</div>`;
|
||
|
|
} else {
|
||
|
|
slideEl.classList.remove("is-image");
|
||
|
|
slideEl.innerHTML = `
|
||
|
|
<p class="ps-kicker">${escapeHtml(s.kicker)}</p>
|
||
|
|
<h2 id="present-title">${escapeHtml(s.title)}</h2>
|
||
|
|
<div class="ps-body">${s.html}</div>`;
|
||
|
|
}
|
||
|
|
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) =>
|
||
|
|
`<button type="button" class="present-dot ${i === index ? "active" : ""}" data-dot="${i}" aria-label="Go to slide ${i + 1}"></button>`
|
||
|
|
)
|
||
|
|
.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();
|
||
|
|
})();
|