training/dashboard: references scene with PDF viewer + tab strip
New scene 13 (after perf, the last in the deck) renders a tabbed
PDF viewer. Each tab is one .pdf in /opt/cis490/references/; the
active tab swaps the iframe's src to /refs/<encoded-filename>.
Backend
- /api/references — lists pdfs in REFS_DIR, returning
{"name": stem (newlines stripped), "path": "/refs/<urlencoded>"}.
- /refs static mount — serves the PDFs directly. check_dir=False
so the dashboard still boots if the directory is missing.
- REFS_DIR resolves relative to the install root so it works on
/opt/cis490 in production and any dev tree.
Frontend
- Stage view uses metric-stack-wide for the broader card; the
references scene also overrides .stage-view padding-right down
to a small gutter so the iframe takes most of the screen
horizontally — the prose card still sits on the right but the
PDF area is roughly 70% wide on standard viewports.
- Tabs are styled like .db-tab (palette-aware pills) and stop
propagation so they don't trigger the click-to-advance gesture.
- Iframe is lazy-loaded: src isn't set until the user actually
scrolls into the references scene OR clicks a tab, so the
browser doesn't fetch a big PDF the user may never view.
This commit is contained in:
parent
308140c6ce
commit
bee40a6ae9
4 changed files with 190 additions and 2 deletions
|
|
@ -9,6 +9,7 @@ import tarfile
|
|||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from starlette.applications import Starlette
|
||||
from starlette.requests import Request
|
||||
|
|
@ -21,6 +22,11 @@ from starlette.websockets import WebSocket, WebSocketDisconnect
|
|||
log = logging.getLogger("cis490.dashboard")
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
# References (PDFs the deck links to). Resolved relative to the
|
||||
# install root so it works at /opt/cis490 (production) and the
|
||||
# dev tree alike. The /refs URL serves files directly; /api/references
|
||||
# returns the listing the frontend's tab UI iterates.
|
||||
REFS_DIR = Path(__file__).parent.parent.parent / "references"
|
||||
|
||||
# Used to validate URL-supplied host_id / episode_id before they
|
||||
# reach the filesystem. Allows the alphanumeric ULID episode IDs
|
||||
|
|
@ -224,6 +230,34 @@ def make_app(
|
|||
finally:
|
||||
await broadcaster.unregister(q)
|
||||
|
||||
async def references(request: Request) -> JSONResponse:
|
||||
"""List available PDFs in REFS_DIR. Returns
|
||||
``{"references": [{"name": str, "path": str}, ...]}`` sorted
|
||||
alphabetically by name. Empty if the directory doesn't exist
|
||||
or has no PDFs."""
|
||||
if not REFS_DIR.is_dir():
|
||||
return JSONResponse({"references": []})
|
||||
items = []
|
||||
try:
|
||||
for p in REFS_DIR.iterdir():
|
||||
if not p.is_file():
|
||||
continue
|
||||
if p.suffix.lower() != ".pdf":
|
||||
continue
|
||||
# Some downloaded PDFs ship with newlines in the
|
||||
# filename (badly-wrapped DOI titles). Strip them
|
||||
# for display and URL-encode for the path so the
|
||||
# iframe can fetch /refs/<encoded-name>.
|
||||
display_name = " ".join(p.stem.split())
|
||||
items.append({
|
||||
"name": display_name,
|
||||
"path": "/refs/" + quote(p.name, safe=""),
|
||||
})
|
||||
except OSError:
|
||||
log.exception("could not list references in %s", REFS_DIR)
|
||||
items.sort(key=lambda r: r["name"].lower())
|
||||
return JSONResponse({"references": items})
|
||||
|
||||
async def episode(request: Request) -> JSONResponse:
|
||||
host_id = request.path_params["host_id"]
|
||||
episode_id = request.path_params["episode_id"]
|
||||
|
|
@ -243,7 +277,10 @@ def make_app(
|
|||
Route("/healthz", healthz, methods=["GET"]),
|
||||
Route("/publish", publish, methods=["POST"]),
|
||||
Route("/api/episode/{host_id}/{episode_id}", episode, methods=["GET"]),
|
||||
Route("/api/references", references, methods=["GET"]),
|
||||
WebSocketRoute("/ws", ws_endpoint),
|
||||
Mount("/static", app=StaticFiles(directory=str(STATIC_DIR)), name="static"),
|
||||
Mount("/refs", app=StaticFiles(directory=str(REFS_DIR), check_dir=False),
|
||||
name="refs"),
|
||||
]
|
||||
return Starlette(routes=routes, lifespan=lifespan)
|
||||
|
|
|
|||
|
|
@ -286,6 +286,56 @@ body[data-theme="laser"] .bg-laser { display: block; }
|
|||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ─── References scene (PDF viewer + tab strip) ─────────────────────── */
|
||||
.ref-stack { /* metric-stack-wide variant; let viewer take the height */
|
||||
height: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.ref-tabs {
|
||||
display: flex; flex-wrap: wrap; gap: 6px;
|
||||
max-height: clamp(60px, 9vh, 110px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ref-tabs .awaiting {
|
||||
color: var(--fg-mute); font-size: 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.ref-tab {
|
||||
background: transparent; color: var(--fg-dim);
|
||||
border: 1px solid var(--line); border-radius: 16px;
|
||||
padding: 4px 12px; font: inherit; font-size: 12px; cursor: pointer;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
transition: color 120ms, border-color 120ms, background 120ms;
|
||||
max-width: 28em;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.ref-tab:hover { color: var(--fg); border-color: var(--fg-mute); }
|
||||
.ref-tab.active {
|
||||
color: var(--accent); border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.ref-viewer-wrap {
|
||||
flex: 1 1 auto; min-height: 0;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--line); border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ref-viewer {
|
||||
width: 100%; height: 100%;
|
||||
min-height: clamp(360px, 70vh, 900px);
|
||||
border: 0; display: block; background: var(--bg-elev);
|
||||
}
|
||||
|
||||
/* References scene wants more horizontal room than the default
|
||||
metric scenes — the PDF is the point. Drop the right padding
|
||||
that reserves space for the prose column down to a small gutter,
|
||||
so the iframe can stretch most of the way across. The prose card
|
||||
still overlays the right edge with its feathered backdrop. */
|
||||
.stage-view[data-view="references"] {
|
||||
padding-right: clamp(8px, 4vw, 96px);
|
||||
}
|
||||
|
||||
/* ─── Per-theme settings section ───────────────────────────────────── */
|
||||
.theme-bg-section { display: none; }
|
||||
.theme-bg-section.is-active { display: block; }
|
||||
|
|
|
|||
|
|
@ -1632,6 +1632,83 @@ for epoch in range(20):
|
|||
emptyState();
|
||||
})();
|
||||
|
||||
// ── References viewer (scene: references) ────────────────────
|
||||
// Lists PDFs from /api/references; clicking a tab swaps the
|
||||
// iframe's src to /refs/<filename>. List is fetched once at
|
||||
// init; iframe is lazy — src isn't set until the user enters
|
||||
// the references scene OR clicks a tab, so the browser doesn't
|
||||
// download a PDF that never gets viewed.
|
||||
(function () {
|
||||
const tabsEl = document.getElementById('ref-tabs');
|
||||
const viewerEl = document.getElementById('ref-viewer');
|
||||
if (!tabsEl || !viewerEl) return;
|
||||
|
||||
let refs = [];
|
||||
let activeIdx = -1;
|
||||
let pendingFirst = true; // load first PDF only when scene becomes active
|
||||
|
||||
function loadFirstIfReady() {
|
||||
if (!pendingFirst) return;
|
||||
if (refs.length === 0) return;
|
||||
pendingFirst = false;
|
||||
selectRef(0);
|
||||
}
|
||||
|
||||
function rebuildTabs() {
|
||||
tabsEl.innerHTML = '';
|
||||
if (refs.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'awaiting';
|
||||
empty.textContent = 'no PDFs found in /opt/cis490/references/';
|
||||
tabsEl.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
refs.forEach((r, i) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'ref-tab' + (i === activeIdx ? ' active' : '');
|
||||
btn.textContent = r.name;
|
||||
btn.title = r.name;
|
||||
btn.addEventListener('click', e => {
|
||||
e.stopPropagation(); // don't bubble to stage-col click-nav
|
||||
selectRef(i);
|
||||
});
|
||||
tabsEl.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
function selectRef(i) {
|
||||
if (i < 0 || i >= refs.length) return;
|
||||
activeIdx = i;
|
||||
rebuildTabs();
|
||||
// Append a hash so that hitting the same PDF twice in a row
|
||||
// still triggers a reload (helps if the file was updated on
|
||||
// disk; iframes cache aggressively otherwise).
|
||||
viewerEl.src = refs[i].path;
|
||||
}
|
||||
|
||||
fetch('/api/references')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
refs = (data && data.references) || [];
|
||||
rebuildTabs();
|
||||
// If user is already on the references scene at boot, load now.
|
||||
if (document.querySelector('.scene[data-stage="references"][data-active]')) {
|
||||
loadFirstIfReady();
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
tabsEl.innerHTML = '';
|
||||
const e = document.createElement('div');
|
||||
e.className = 'awaiting';
|
||||
e.textContent = `failed to load references list: ${err.message}`;
|
||||
tabsEl.appendChild(e);
|
||||
});
|
||||
|
||||
// Defer the first iframe load until the references scene actually
|
||||
// becomes active (no point fetching a PDF the user may never see).
|
||||
window.dashboard.scene('references', { onEnter: loadFirstIfReady });
|
||||
})();
|
||||
|
||||
// ── Performance scatter — DEMO ONLY ───────────────────────────
|
||||
(function () {
|
||||
const svg = document.getElementById('perf-scatter');
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>CIS490 — live</title>
|
||||
<link rel="stylesheet" href="/static/dashboard.css?v=fbd0584a">
|
||||
<link rel="stylesheet" href="/static/dashboard.css?v=a591789b">
|
||||
</head>
|
||||
<body>
|
||||
<!-- SVG filter defs for the lava-lamp goo effect. Width/height 0
|
||||
|
|
@ -301,6 +301,19 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 13. references — PDF viewer with tabs -->
|
||||
<div class="stage-view" data-view="references">
|
||||
<div class="metric-stack metric-stack-wide ref-stack">
|
||||
<div class="metric-eyebrow">references · papers, notes, prior work</div>
|
||||
<div class="ref-tabs" id="ref-tabs"></div>
|
||||
<div class="ref-viewer-wrap">
|
||||
<iframe class="ref-viewer" id="ref-viewer"
|
||||
title="reference viewer"
|
||||
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 12. perf -->
|
||||
<div class="stage-view" data-view="perf">
|
||||
<div class="metric-stack">
|
||||
|
|
@ -487,10 +500,21 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section class="scene" data-stage="references">
|
||||
<div class="prose">
|
||||
<h2>References</h2>
|
||||
<p>The papers, notes, and prior work this project leans on.
|
||||
Pick a tab on the left to load the document; the viewer
|
||||
takes the bulk of the stage so you can scroll through
|
||||
without leaving the deck.</p>
|
||||
<p class="hint">end of deck · ← to flip back</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="scene-end-spacer"></div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<script src="/static/dashboard.js?v=a00dc514"></script>
|
||||
<script src="/static/dashboard.js?v=b1cb9f39"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue